diff --git a/.editorconfig b/.editorconfig
index badeda06..61305601 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -4,7 +4,7 @@ root = true
end_of_line = lf
insert_final_newline = true
-[*.{js,ts,svelte,html,json,mjs,cjs}]
+[*.{js,ts,html,json,mjs,cjs,vue}]
charset = utf-8
indent_style = space
indent_size = 2
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 146883a6..812d32b4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -67,31 +67,59 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
+ targets: aarch64-linux-android
- name: Install cargo-nextest
run: cargo install cargo-nextest --locked --force
+ # Before host golden/nextest: linux serialport/nusb need -ludev (and GTK for tauri later).
- name: Install system dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
- sudo apt-get install -y pkg-config libglib2.0-dev libudev-dev libgtk-3-dev libgdk-pixbuf2.0-dev libwebkit2gtk-4.1-dev
+ sudo apt-get install -y openjdk-17-jdk pkg-config libglib2.0-dev libudev-dev libgtk-3-dev libgdk-pixbuf2.0-dev libwebkit2gtk-4.1-dev
- name: Install system dependencies (macOS)
if: matrix.os == 'macos-latest'
run: |
brew install pkg-config
+ - name: Check android-usb-serial (Android target)
+ run: cargo check -p android-usb-serial --target aarch64-linux-android
+ - name: Check plugin (Android target)
+ run: cargo check -p tauri-plugin-serialplugin --target aarch64-linux-android
+
+ - name: Golden parity (host)
+ run: cargo test -p android-usb-serial --features fake-transport
+
+ - name: Clippy android-usb-serial (Android target)
+ run: cargo clippy -p android-usb-serial --target aarch64-linux-android -- -D warnings
+
+ - name: Robolectric (Android module)
+ if: matrix.os == 'ubuntu-latest'
+ run: cd android && ./gradlew test
- name: Install dependencies
run: |
pnpm install
+ - name: Rustfmt
+ run: cargo fmt --all -- --check
+
+ - name: Clippy
+ run: cargo clippy --workspace --all-targets -- -D warnings
+
+ - name: TypeScript check
+ run: pnpm check
+
+ - name: Build JS
+ run: pnpm build
+
- name: Run Rust tests
env:
NEXTEST_EXPERIMENTAL_LIBTEST_JSON: 1
- run: cargo nextest run --message-format=libtest-json-plus > test-results.jsonx
+ run: cargo nextest run --workspace --message-format=libtest-json-plus > test-results.jsonx
- name: Run JavaScript tests
run: pnpm test
@@ -106,3 +134,99 @@ jobs:
test-results/
test-results.jsonx
if-no-files-found: warn
+
+ android-integration:
+ # Build/sync JNI integration harness only — no emulator (device tests stay local).
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20.x
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 9.15.4
+
+ - name: Setup Java 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: x86_64-linux-android
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Install Android SDK packages
+ run: |
+ yes | sdkmanager --licenses
+ sdkmanager \
+ "platform-tools" \
+ "platforms;android-34" \
+ "build-tools;34.0.0" \
+ "ndk;27.2.12479018"
+
+ - name: Install cargo-ndk
+ run: cargo install cargo-ndk --locked
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build JS
+ run: pnpm build
+
+ # file: link is snapshotted at install; refresh hardlinks after dist-js appears
+ - name: Relink workspace packages
+ run: pnpm install
+
+ - name: Prebuild Android integration artifacts
+ env:
+ ANDROID_INTEGRATION_NDK_ARCH: x86_64
+ ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.2.12479018
+ NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.2.12479018
+ run: chmod +x scripts/android-integration-ci.sh && ./scripts/android-integration-ci.sh prebuild
+
+ windows:
+ runs-on: windows-latest
+ timeout-minutes: 30
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+
+ - name: Rustfmt
+ run: cargo fmt --all -- --check
+
+ - name: Clippy (Windows target)
+ run: cargo clippy --all-targets -- -D warnings
+
+ - name: Cargo check Windows
+ run: cargo check --target x86_64-pc-windows-msvc
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 9.15.4
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: TypeScript check
+ run: pnpm check
+
+ - name: Run JavaScript tests
+ run: pnpm test
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b81..00000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index fca4bf00..00000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/process.iml b/.idea/process.iml
deleted file mode 100644
index 404de742..00000000
--- a/.idea/process.iml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7f..00000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94c0d6d3..28e1dae1 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,85 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
+
+## [3.0.0](https://github.com/s00d/tauri-plugin-serialplugin/compare/v2.24.1...v3.0.0) (2026-07-08)
+
+Major v3 release: Channel-based **`watch()`** API, native AT **`exchange`** / FIFO queue, unified **RX hub** on desktop and Android, and a **hard cut** from vendored Java `usb-serial-for-android` to the pure-Rust **`android-usb-serial`** crate (nusb).
+
+### Breaking Changes
+
+* **Streaming API:** `startListening()` / `listen()` / `disconnected()` / `cancelListen()` removed. Use **`watch({ onData, onDisconnect?, onError?, onUrc? })`** → `SerialEvent` over Tauri [`Channel`](https://v2.tauri.app/develop/calling-frontend/) (desktop + Android).
+* **Capabilities:** `SerialPort.getCapabilities()` (`transport`, `platform`, `version`); commands `capabilities`, `watch`, `unwatch` replace `start_listening` / `stop_listening`.
+* **Removed:** `available_ports_direct` — use `available_ports()`.
+* **Removed `AtCommandQueue` / `port.at`:** Use **`sendAt()`**, **`sendAtPhases()`**, **`sendSmsPdu()`**, **`cancelAt()`**, **`configureAtSession()`**.
+* **`onUrc` removed from `AtSessionOptions`:** Use **`watch({ onUrc })`** for live URC lines.
+* **`exchange()`** returns **`ExchangeResponse`**, not `Uint8Array`. Use `.raw` for bytes.
+* **`clearRx: true`** maps to **`purge`**; default is now **`drain`** when unset.
+* **Android USB stack:** Vendored `android/usbserial` Java tree **removed**. Kotlin no longer runs serial I/O commands on the main thread — only **`UsbFdBridge`** (enumerate, permission, fd). All drivers and bulk I/O live in Rust (`android-usb-serial` + nusb).
+* **Rust module layout:** Removed compat top-level modules (`desktop_api`, `mobile_api`, `port_rx_hub`, `exchange_runtime`, …). Use `api::serial`, `hub`, `exchange`, `port`, `state`, `android`.
+* **guest-js:** `AtCommandResult.raw` / `ExchangeResponse.raw` are `Uint8Array`; `timedOut` is a native `boolean`.
+* **`rust-version`:** **1.79** minimum.
+
+### Migration (v2 → v3)
+
+| v2 | v3 |
+|----|-----|
+| `startListening()` + `listen(fn)` | `watch({ onData: fn })` |
+| `disconnected(fn)` | `watch({ onDisconnect: fn })` |
+| `cancelListen()` / `stopListening()` | `handle.unwatch()` |
+| `port.at.enable()` / `enqueue()` | `sendAt()` / `sendAtPhases()` |
+| Kotlin `UsbBridge` + Java drivers | `UsbFdBridge` fd → Rust `driver_host` |
+
+**Android app requirements:** `uses-feature android.hardware.usb.host`, `device_filter.xml`, runtime USB permission before `openDevice()`. Do **not** `claimInterface()` in Kotlin before handing fd to Rust. See [`crates/android-usb-serial/README.md`](crates/android-usb-serial/README.md).
+
+### Features
+
+* **`android-usb-serial` crate:** Pure Rust USB serial on Android via nusb; **567** golden parity fixtures; drivers for FTDI, CP21xx, CH34x, PL2303, CDC-ACM, GSM modem, Chrome CCD ([20b54c0](https://github.com/s00d/tauri-plugin-serialplugin/commit/20b54c0bbc17227465da71728333cf93d88103aa)).
+* **`UsbFdBridge`:** Kotlin provides USB fd only; Rust `driver_host` owns probe, claim, drivers, RX reader, and I/O.
+* **Unified `api::serial`:** Single `serialport::SerialPort` facade on desktop and Android; `PortRxHub` poll-loop on both platforms ([4071ff6](https://github.com/s00d/tauri-plugin-serialplugin/commit/4071ff6)).
+* **Rust-first Android JNI** ([29faa29](https://github.com/s00d/tauri-plugin-serialplugin/commit/29faa29)): bulk-IN reader thread, chunked write, CMUX virtual paths through Rust session.
+* **Unified RX hub (desktop + Android):** Single consumer per port; `watch`, `exchange`, `read`, and drain share one hub ([7024fc0](https://github.com/s00d/tauri-plugin-serialplugin/commit/7024fc0), [caa5700](https://github.com/s00d/tauri-plugin-serialplugin/commit/caa5700)).
+* **`take_idle_bytes`:** Stale RX in the hub idle buffer is replayed into the next `exchange` after write.
+* **Native FIFO queue (Rust + Android):** All `exchange` / AT jobs on one port serialize in FIFO order; parallel invokes **wait** instead of `"Exchange already in progress"` ([e1eb63f](https://github.com/s00d/tauri-plugin-serialplugin/commit/e1eb63f)).
+* **Native `exchange` / `cancel_exchange`:** Write + read-until terminators, idle silence, wall timeout, max response size; structured `ExchangeResponse` (`status`, `lines`, `solicitedBody`, `urcLines`, `raw`).
+* **Line-framed AT completion:** Final line `OK` / `ERROR` / `+CME ERROR` / `+CMS ERROR`; `completionMode: 'substring'` for legacy/binary.
+* **`rxPrepare`:** Default **`drain`**; **`purge`** opt-in; **`none`** unchanged.
+* **`watchAvailablePorts()` / `watch_ports`:** Hotplug via Channel — `snapshot`, then `added` / `removed` (desktop poll; Android USB attach/detach).
+* **`open()` canonical path:** Returns session key (Android device path / `device#N` for multi-port).
+* **CMUX virtual `exchange`:** Paths `physical#dlci=N` routed through Rust CMUX session like desktop.
+* **`usb-driver-tester`:** Standalone hardware self-test app under `examples/usb-driver-tester/`.
+* **guest-js:** Modular v3 SDK; auto-reconnect restores **`open()` + `watch()`** after disconnect.
+* **macOS:** `available_ports({ singlePortPerDevice: true })` — one path per device (prefers `/dev/cu.*`).
+* **Extended AT grammar:** Vendor prefixes, V.250 finals, `derive_solicited_prefixes(command)`; `ExchangeDemux` for live URC before echo.
+* **`pauseWatch` default `false`** — watch stays on during AT; pass `pauseWatch: true` for legacy behavior.
+
+### Bug Fixes
+
+* **Android CH340 / weak OTG:** Bulk IN reader starts after line/DTR setup; in-flight URBs reduced to 2; clearer detach reason in logs.
+* **Android enumerate:** Kotlin exports `interfaces[]`; Rust expands multi-port paths (`device#N`) via `ProbeTable` without opening fd.
+* **Android write after listen:** `EndpointPair::write` no longer re-opens bulk IN owned by `SerialReader` (`endpoint already in use`).
+* **Android Kotlin fd bridge:** Removed pre-`claimInterface` (fixes `io interface is busy` with nusb `detach_and_claim`).
+* **Android logcat:** Rust plugin logs use tag `SerialPlugin` via `__android_log_write`.
+* **Android:** USB permission `PendingIntent` uses `FLAG_MUTABLE` on API 31+; detach + IO errors → `SerialEvent::Disconnect` on Channel ([#27](https://github.com/s00d/tauri-plugin-serialplugin/issues/27)).
+* **Android:** `cancel_exchange` wakes hub waiter; CMUX virtual cancel clears DLCI TX queue ([c6c94dd](https://github.com/s00d/tauri-plugin-serialplugin/commit/c6c94dd)).
+* **Android:** JNI exceptions, session path re-key, teardown leaks ([e9fc307](https://github.com/s00d/tauri-plugin-serialplugin/commit/e9fc307)).
+* **Android:** Serialize fail/shutdown on dedicated usb-io thread ([cd74b84](https://github.com/s00d/tauri-plugin-serialplugin/commit/cd74b84)).
+* **desktop:** `write` / `write_binary` flush via `write_all` ([#29](https://github.com/s00d/tauri-plugin-serialplugin/issues/29)).
+* **desktop:** Unblock hub drain when watch is active ([55f1cb3](https://github.com/s00d/tauri-plugin-serialplugin/commit/55f1cb3)).
+* **desktop:** Lock order, Opening-state open, async `enable_mux` ([3a4bd88](https://github.com/s00d/tauri-plugin-serialplugin/commit/3a4bd88)).
+* **desktop (Windows):** Enrich truncated USB `serial_number` from WMI ([#23](https://github.com/s00d/tauri-plugin-serialplugin/issues/23)).
+* **Hub / watch:** `lock_or_recover` in watch_registry and hub channel paths ([2071e15](https://github.com/s00d/tauri-plugin-serialplugin/commit/2071e15)); panic/poison hardening ([8672de9](https://github.com/s00d/tauri-plugin-serialplugin/commit/8672de9)).
+* **TX queue:** Errors no longer halt the port queue until reopen ([e1eb63f](https://github.com/s00d/tauri-plugin-serialplugin/commit/e1eb63f)).
+* **guest-js:** `readBinary` requires open port; watch-preserving `change()` ([9b22af7](https://github.com/s00d/tauri-plugin-serialplugin/commit/9b22af7)).
+
+### Build / CI / Docs
+
+* **CI:** `cargo fmt`, `clippy`, `pnpm check` / `build`, Windows `cargo check`, Android integration emulator job ([1aa1682](https://github.com/s00d/tauri-plugin-serialplugin/commit/1aa1682), [2e70ecf](https://github.com/s00d/tauri-plugin-serialplugin/commit/2e70ecf)).
+* **Tests:** Kotlin↔JNI↔Rust instrumented integration tests with `FakeTransport` ([412b4a5](https://github.com/s00d/tauri-plugin-serialplugin/commit/412b4a5), [be77e2a](https://github.com/s00d/tauri-plugin-serialplugin/commit/be77e2a)); Jest v3 lifecycle + Rust contract tests.
+* **Docs:** v3 migration guide, Android bridge docs, [`examples/serialport-test/ANDROID.md`](examples/serialport-test/ANDROID.md), [`android-usb-serial` Android usage](crates/android-usb-serial/README.md#using-on-android) ([c2d1e23](https://github.com/s00d/tauri-plugin-serialplugin/commit/c2d1e23), [b3cc7d2](https://github.com/s00d/tauri-plugin-serialplugin/commit/b3cc7d2)).
+* **Workspace:** `android-test-harness` feature wires `FakeTransport` for instrumented tests.
+
+
### [2.24.1](https://github.com/s00d/tauri-plugin-serialplugin/compare/v2.24.0...v2.24.1) (2026-07-08)
## [2.24.0](https://github.com/s00d/tauri-plugin-serialplugin/compare/v2.21.1...v2.24.0) (2026-07-08)
@@ -41,7 +120,7 @@ All notable changes to this project will be documented in this file. See [standa
* **Lifecycle:** `SerialPlugin` registers `Application.ActivityLifecycleCallbacks` and runs `SerialPortManager.cleanup()` when the host `Activity` is destroyed (close USB ports, unregister permission receiver, shut down IO executor).
* **Listening:** Incoming data is coalesced in `BufferedEmitter` / `SerialByteAccumulator` before `serialData` events; flush interval via `serialDataFlushIntervalMs` (native clamp typically 10–2000 ms).
* **USB serial (usb-serial-for-android):** Read/write use configured timeouts; `clearBuffer` maps to `purgeHwBuffers` when supported; `setFlowControl` (RTS/CTS, XON/XOFF); `SerialInputOutputManager` errors trigger `serialError` and port cleanup.
-* **`bytesToRead` / `bytesToWrite` (Android):** `bytesToRead` returns bytes buffered **in the plugin** only while `startListening` is active (not the kernel queue — not exposed by `UsbSerialPort`). `bytesToWrite` is `0` (writes complete synchronously from the app’s perspective). Documented in JSDoc on the JS API.
+* **`bytesToRead` / `bytesToWrite` (Android):** With active **`watch`**, `bytesToRead` is the plugin-side buffer before the next Channel flush; `bytesToWrite` is typically `0`.
* **Tooling:** Gradle wrapper and `android/` settings for local `./gradlew test`; JVM unit tests use a real `org.json` artifact (Android JSON stubs break `JSONObject.put` in tests). Kotlin tests cover models, JSON helpers, emit pipeline, `BufferedEmitter`.
### Features
diff --git a/Cargo.lock b/Cargo.lock
index 84edf08d..92693e48 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -47,6 +47,19 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+[[package]]
+name = "android-usb-serial"
+version = "0.1.0"
+dependencies = [
+ "base64 0.22.1",
+ "libc",
+ "log",
+ "nusb",
+ "serde",
+ "serde_json",
+ "serialport",
+]
+
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -364,9 +377,9 @@ dependencies = [
[[package]]
name = "core-foundation"
-version = "0.10.0"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
@@ -693,6 +706,16 @@ dependencies = [
"typeid",
]
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1462,7 +1485,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
- "mach2",
+ "mach2 0.4.3",
+]
+
+[[package]]
+name = "io-kit-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06d3a048d09fbb6597dbf7c69f40d14df4a49487db1487191618c893fc3b1c26"
+dependencies = [
+ "core-foundation-sys",
+ "mach2 0.5.0",
]
[[package]]
@@ -1619,9 +1652,9 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.174"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
@@ -1663,6 +1696,12 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
[[package]]
name = "litemap"
version = "0.8.0"
@@ -1700,6 +1739,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "mach2"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -1884,6 +1932,24 @@ dependencies = [
"syn 2.0.104",
]
+[[package]]
+name = "nusb"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "215a49c10e8cff30a0da93b258b21846c4e9242f7683748cfa8474fdc2e4d22b"
+dependencies = [
+ "core-foundation",
+ "core-foundation-sys",
+ "futures-core",
+ "io-kit-sys 0.5.0",
+ "linux-raw-sys",
+ "log",
+ "once_cell",
+ "rustix",
+ "slab",
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -2678,6 +2744,19 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.9.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "rustversion"
version = "1.0.21"
@@ -2933,9 +3012,9 @@ dependencies = [
"cfg-if",
"core-foundation",
"core-foundation-sys",
- "io-kit-sys",
+ "io-kit-sys 0.4.1",
"libudev",
- "mach2",
+ "mach2 0.4.3",
"nix",
"scopeguard",
"unescaper",
@@ -3348,14 +3427,16 @@ dependencies = [
[[package]]
name = "tauri-plugin-serialplugin"
-version = "2.24.1"
+version = "3.0.0"
dependencies = [
+ "android-usb-serial",
+ "jni",
+ "log",
"serde",
"serde_json",
"serialport",
"tauri",
"tauri-plugin",
- "thiserror 2.0.12",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 661bcb11..4828ee4b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,14 +1,25 @@
+[workspace]
+members = ["crates/android-usb-serial"]
+exclude = [
+ "examples/serialport-test/src-tauri",
+ "examples/usb-driver-tester/rust",
+]
+resolver = "2"
+
[package]
name = "tauri-plugin-serialplugin"
-version = "2.24.1"
-description = "Access the current process of your Tauri application."
+version = "3.0.0"
+description = "Tauri plugin for serial port access (desktop and Android)."
edition = "2021"
authors = ["Tauri Programme within The Commons Conservancy"]
license = "Apache-2.0 OR MIT"
-rust-version = "1.70"
+rust-version = "1.79"
links = "tauri-plugin-serialplugin"
repository = "https://github.com/s00d/tauri-plugin-serialplugin"
+[features]
+android-test-harness = ["android-usb-serial/fake-transport"]
+
[package.metadata.docs.rs]
rustc-args = [ "--cfg", "docsrs" ]
rustdoc-args = [ "--cfg", "docsrs" ]
@@ -26,14 +37,18 @@ tauri-plugin = { version = "2.3.0", features = [ "build" ] }
[dependencies]
tauri = { version = "2.6.2", features = ["test"] }
serde = { version = "1.0.219", features = ["derive"] }
-thiserror = "2.0.12"
serde_json = "1.0.140"
+[target.'cfg(target_os = "android")'.dependencies]
+jni = "0.21"
+android-usb-serial = { path = "crates/android-usb-serial", version = "0.1.0" }
+serialport = "4.9.0"
+log = { version = "0.4", features = ["std"] }
+
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
serialport = "4.9.0"
[profile.release]
-panic = "abort"
codegen-units = 1
lto = true
incremental = false
diff --git a/README.md b/README.md
index 8ce986ac..966d4a9d 100755
--- a/README.md
+++ b/README.md
@@ -9,7 +9,49 @@
A comprehensive plugin for Tauri applications to communicate with serial ports. This plugin provides a complete API for reading from and writing to serial devices, with support for various configuration options and control signals.
-> **⚠️ Important Notice:** The JavaScript dependency has been renamed from `tauri-plugin-serialplugin` to `tauri-plugin-serialplugin-api`. Please update your `package.json` before updating to the latest version, following the same pattern used by other Tauri plugins.
+> **v3.0 breaking change:** `listen` / `startListening` / `disconnected` were removed. Use **`watch()`** and **`SerialPort.getCapabilities()`**. See [Migrating to v3](#migrating-to-v3) below.
+
+---
+
+## Migrating to v3
+
+| v2 | v3 |
+|----|-----|
+| `startListening()` + `listen(fn)` | `const handle = await port.watch({ onData: fn })` |
+| `disconnected(fn)` | `watch({ onDisconnect: fn })` |
+| `cancelListen()` / `stopListening()` | `await handle.unwatch()` |
+
+Legacy Tauri events (`plugin-serialplugin-read-*`) and Android plugin triggers (`serialData`, `serialError`) are **no longer part of the public API**.
+
+**New in v3:** `SerialPort.getCapabilities()` — `{ transport, platform, version }` from Rust (`cfg!`), if the app needs to branch by platform (replaces ad-hoc `window` / `@tauri-apps/plugin-os` probing on the app side).
+
+**Breaking API changes (v3):**
+
+| Field / behavior | v2 | v3 |
+|------------------|-----|-----|
+| `AtCommandResult.raw` / `ExchangeResponse.raw` | `number[]` | `Uint8Array` |
+| `AtCommandResult.timedOut` | literal `false` | `boolean` (timeouts surfaced by native layer) |
+| `open()` | `void` | returns **canonical path** (Android re-keys to `UsbPath.sessionKey`; desktop echoes input) |
+| `cancel_read` | also detached watch on some builds | **does not** unwatch — use `close()` or `handle.unwatch()` |
+| TX queue after error | port could stay halted until reopen | next `exchange` / `sendAt` job runs normally (`stopOnError` only stops remaining phases in one `sendAtPhases` batch) |
+| Early RX before `exchange` | often lost or raced with wait | native hub keeps **idle** bytes; `exchange` replays them via `take_idle_bytes` after write (no extra line response needed) |
+
+### Watch events (`SerialEvent`)
+
+| `kind` | Meaning | Port stays open? |
+|--------|---------|------------------|
+| `data` | Incoming bytes (decoded to `string` in JS when `decode !== false`) | Yes |
+| `error` | Non-fatal notification (e.g. emitter glitch); watch continues | Yes |
+| `disconnect` | Fatal end of stream (unplug, IO manager stopped); triggers auto-reconnect if enabled | No (`isOpen = false`) |
+
+### Watch options
+
+| Option | Desktop | Android |
+|--------|---------|---------|
+| `timeout` | Batch coalescing window (ms) | Coalescing / flush hint where supported |
+| `serialDataFlushIntervalMs` | Preferred batch interval; falls back to `timeout` | `BufferedEmitter` flush (10–2000 ms) |
+| `size` | Read chunk size per syscall | Reserved |
+| `decode` | JS-only: `TextDecoder` on `onData` | JS-only |
---
@@ -28,7 +70,8 @@ A comprehensive plugin for Tauri applications to communicate with serial ports.
7.4. [Port Configuration](#port-configuration)
7.5. [Control Signals](#control-signals)
7.6. [Buffer Management](#buffer-management)
- 7.7. [Log Control](#log-control)
+ 7.7. [Log Control](#log-control)
+ 7.8. [Auto-Reconnect](#auto-reconnect-management)
8. [Common Use Cases](#common-use-cases)
9. [Android Setup](#android-setup)
10. [Contributing](#contributing)
@@ -47,11 +90,6 @@ A comprehensive plugin for Tauri applications to communicate with serial ports.
- **Tauri** 2.0 or higher
- **Node.js** and an npm-compatible package manager (npm, yarn, pnpm)
-
-
-
-## Installation
-
### Automatic Installation (Recommended)
Use the Tauri CLI to automatically install both the Rust and JavaScript parts of the plugin:
@@ -97,45 +135,9 @@ npm install tauri-plugin-serialplugin-api
pnpm add tauri-plugin-serialplugin-api
```
-### Android Setup (Required for Android builds)
+### Android
-> **⚠️ Important:** If you're building for Android, you **must** configure the JitPack repository before building. The plugin will not compile without this step.
-
-The plugin depends on `com.github.mik3y:usb-serial-for-android:3.8.1`, which is hosted on JitPack. Add the following to your `/src-tauri/gen/android/build.gradle.kts` file:
-
-```kotlin
-buildscript {
- repositories {
- google()
- mavenCentral()
- maven { url = uri("https://jitpack.io") }
- }
- dependencies {
- classpath("com.android.tools.build:gradle:8.11.0")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
- // ... your other dependencies
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- maven { url = uri("https://jitpack.io") }
- }
-}
-
-tasks.register("clean").configure {
- delete("build")
-}
-```
-
-**Without this configuration, you will get an error:**
-```
-could not find com.github.mik3y:usb-serial-for-android:3.8.1
-```
-
-For more details and troubleshooting, see the [Android Setup](#android-setup) section.
+USB serial on Android uses pure Rust drivers in [`crates/android-usb-serial`](crates/android-usb-serial/) (nusb). Kotlin provides USB permission, enumeration, and a dup'd fd; **no** vendored Java stack or JitPack dependency.
---
@@ -185,16 +187,14 @@ For more details and troubleshooting, see the [Android Setup](#android-setup) se
// Write data
await port.write("Hello, Serial Port!");
- // Start port listening
- await port.startListening();
-
- // Start port listening
- const unsubscribe = await port.listen((data) => {
- console.log("Received:", data);
+ // Stream incoming data (desktop + Android)
+ const handle = await port.watch({
+ onData: (data) => console.log("Received:", data),
+ onDisconnect: (reason) => console.log("Disconnected:", reason),
});
- // Stop listening when done
- await port.cancelListen();
+ // Stop streaming when done
+ await handle.unwatch();
// Close port
await port.close();
@@ -242,13 +242,12 @@ For more details and troubleshooting, see the [Android Setup](#android-setup) se
}
try {
- // Start listening
- await port.startListening();
- await port.listen((data) => {
- console.log("Received:", data);
+ const handle = await port.watch({
+ onData: (data) => console.log("Received:", data),
});
+ // ... use handle.unwatch() in cleanup
} catch (error) {
- throw new Error(`Failed to start listening: ${error}`);
+ throw new Error(`Failed to start watch: ${error}`);
}
try {
@@ -270,7 +269,6 @@ For more details and troubleshooting, see the [Android Setup](#android-setup) se
// Clean up
if (port) {
try {
- await port.cancelListen();
await port.close();
} catch (error) {
console.error("Error during cleanup:", error);
@@ -346,8 +344,8 @@ await port.setTimeout(500);
```typescript
// Set control signals
-await port.setRequestToSend(true);
-await port.setDataTerminalReady(true);
+await port.writeRequestToSend(true);
+await port.writeDataTerminalReady(true);
// Alternative methods (writeRequestToSend and writeDataTerminalReady)
await port.writeRequestToSend(true);
@@ -437,7 +435,7 @@ use tauri::{AppHandle, State};
#[tauri::command]
async fn configure_production_logging(
app: AppHandle,
- serial: State<'_, tauri_plugin_serialplugin::desktop_api::SerialPort>
+ serial: State<'_, tauri_plugin_serialplugin::api::desktop::SerialPort>
) -> Result<(), String> {
// Only show errors in production
set_log_level(app, serial, LogLevel::Error)
@@ -463,7 +461,7 @@ await SerialPort.setLogLevel(LogLevel.None);
setInterval(async () => {
const ports = await SerialPort.available_ports();
- // No "listen event: plugin-serialplugin-disconnected-COM6" logs
+ // No extra console noise from the plugin while polling
}, 1000);
```
@@ -514,7 +512,7 @@ use std::collections::HashMap;
#[tauri::command]
async fn rust_serial_example(
app: AppHandle,
- serial: State<'_, tauri_plugin_serialplugin::desktop_api::SerialPort>
+ serial: State<'_, tauri_plugin_serialplugin::api::desktop::SerialPort>
) -> Result<(), String> {
// Get available ports
let ports = available_ports(app.clone(), serial.clone())
@@ -621,7 +619,7 @@ async fn rust_serial_example(
```rust
use tauri_plugin_serialplugin::commands::{
- available_ports, open, write, read, close, force_close, managed_ports, start_listening
+ available_ports, open, write, read, close, force_close, managed_ports, watch, unwatch
};
use tauri_plugin_serialplugin::state::{DataBits, FlowControl, Parity, StopBits};
use tauri::{AppHandle, State};
@@ -630,7 +628,7 @@ use std::collections::HashMap;
#[tauri::command]
async fn advanced_serial_example(
app: AppHandle,
- serial: State<'_, tauri_plugin_serialplugin::desktop_api::SerialPort>
+ serial: State<'_, tauri_plugin_serialplugin::api::desktop::SerialPort>
) -> Result<(), String> {
// Get available ports with error handling
let ports = match available_ports(app.clone(), serial.clone()) {
@@ -670,22 +668,11 @@ async fn advanced_serial_example(
}
}
- // Start listening for data
- match start_listening(
- app.clone(),
- serial.clone(),
- port_path.clone(),
- Some(1000u64), // timeout
- Some(1024usize) // max bytes
- ) {
- Ok(_) => println!("Started listening"),
- Err(e) => {
- eprintln!("Failed to start listening: {}", e);
- // Continue anyway, we can still read manually
- }
+ // Poll read (for streaming use `watch` + Channel from the frontend)
+ match read(app.clone(), serial.clone(), port_path.clone(), Some(1000u64), Some(1024usize)) {
+ Ok(data) => println!("Read: {}", data),
+ Err(e) => eprintln!("Read failed: {}", e),
}
-
- // Send a command and read response
let command = "AT\r\n".to_string();
match write(app.clone(), serial.clone(), port_path.clone(), command) {
Ok(bytes) => println!("Sent {} bytes", bytes),
@@ -747,7 +734,7 @@ use tauri::{AppHandle, State};
#[tauri::command]
async fn binary_data_example(
app: AppHandle,
- serial: State<'_, tauri_plugin_serialplugin::desktop_api::SerialPort>
+ serial: State<'_, tauri_plugin_serialplugin::api::desktop::SerialPort>
) -> Result<(), String> {
let port_path = "COM1".to_string();
@@ -804,7 +791,7 @@ use tauri::{AppHandle, State};
#[tauri::command]
async fn my_serial_function(
app: AppHandle,
- serial: State<'_, tauri_plugin_serialplugin::desktop_api::SerialPort>
+ serial: State<'_, tauri_plugin_serialplugin::api::desktop::SerialPort>
) -> Result<(), String> {
// Use command functions
let ports = available_ports(app.clone(), serial.clone())?;
@@ -819,14 +806,14 @@ Use the SerialPort methods directly:
```rust
use tauri::State;
-use tauri_plugin_serialplugin::desktop_api::SerialPort;
+use tauri_plugin_serialplugin::api::desktop::SerialPort;
#[tauri::command]
async fn my_serial_function(
serial: State<'_, SerialPort>
) -> Result<(), String> {
// Use serial methods directly
- let ports = serial.available_ports()?;
+ let ports = serial.available_ports(false)?;
// ... rest of your code
}
```
@@ -853,7 +840,6 @@ Here are all the available command functions you can import and use. For detaile
use tauri_plugin_serialplugin::commands::{
// Port discovery
available_ports, // Get list of available ports
- available_ports_direct, // Get ports using platform-specific commands
managed_ports, // Get list of currently managed ports
// Connection management
@@ -868,10 +854,11 @@ use tauri_plugin_serialplugin::commands::{
read, // Read string data
read_binary, // Read binary data
- // Listening
- start_listening, // Start listening for data
- stop_listening, // Stop listening
- cancel_read, // Cancel read operations
+ // Listening (streaming)
+ capabilities, // Runtime info
+ watch, // Stream events via Channel
+ unwatch, // Stop watch session
+ cancel_read, // Cancel poll read or active watch (shared stop channel)
// Port configuration
set_baud_rate, // Set baud rate
@@ -950,7 +937,7 @@ pub fn write(
- "Failed to open serial port: {error}" - Error opening port
- "Failed to clone serial port: {error}" - Error cloning port
- "Failed to set short timeout: {error}" - Error setting timeout
-- "Failed to stop existing listener: {error}" - Error stopping existing listener
+- "Failed to cancel serial port watch: {error}" - Error stopping watch thread
- "Failed to join thread: {error}" - Error waiting for thread completion
- "Failed to cancel serial port data reading: {error}" - Error canceling data reading
@@ -1011,8 +998,6 @@ Below is a list of all permissions the plugin supports. Granting or denying them
| `serialplugin:deny-write` | Denies writing data to serial ports |
| `serialplugin:allow-write-binary` | Allows writing binary data to serial ports |
| `serialplugin:deny-write-binary` | Denies writing binary data to serial ports |
-| `serialplugin:allow-available-ports-direct` | Enables the `available_ports_direct` command without any pre-configured scope |
-| `serialplugin:deny-available-ports-direct` | Denies the `available_ports_direct` command without any pre-configured scope |
| `serialplugin:allow-set-baud-rate` | Allows changing the baud rate of serial ports |
| `serialplugin:deny-set-baud-rate` | Denies changing the baud rate of serial ports |
| `serialplugin:allow-set-data-bits` | Allows changing the data bits configuration |
@@ -1047,10 +1032,12 @@ Below is a list of all permissions the plugin supports. Granting or denying them
| `serialplugin:deny-set-break` | Denies starting break signal transmission |
| `serialplugin:allow-clear-break` | Allows stopping break signal transmission |
| `serialplugin:deny-clear-break` | Denies stopping break signal transmission |
-| `serialplugin:allow-start-listening` | Allows starting automatic port monitoring and data listening |
-| `serialplugin:deny-start-listening` | Denies starting automatic port monitoring and data listening |
-| `serialplugin:allow-stop-listening` | Allows stopping automatic port monitoring and data listening |
-| `serialplugin:deny-stop-listening` | Denies stopping automatic port monitoring and data listening |
+| `serialplugin:allow-capabilities` | Allows reading runtime plugin capabilities |
+| `serialplugin:deny-capabilities` | Denies reading runtime plugin capabilities |
+| `serialplugin:allow-watch` | Allows streaming port data through a Tauri Channel |
+| `serialplugin:deny-watch` | Denies streaming port data through a Tauri Channel |
+| `serialplugin:allow-unwatch` | Allows stopping an active watch session |
+| `serialplugin:deny-unwatch` | Denies stopping an active watch session |
| `serialplugin:allow-set-log-level` | Allows setting the global log level |
| `serialplugin:deny-set-log-level` | Denies setting the global log level |
| `serialplugin:allow-get-log-level` | Allows getting the current log level |
@@ -1071,7 +1058,6 @@ Below is a list of all permissions the plugin supports. Granting or denying them
"serialplugin:allow-read",
"serialplugin:allow-write",
"serialplugin:allow-write-binary",
- "serialplugin:allow-available-ports-direct",
"serialplugin:allow-set-baud-rate",
"serialplugin:allow-set-data-bits",
"serialplugin:allow-set-flow-control",
@@ -1089,8 +1075,9 @@ Below is a list of all permissions the plugin supports. Granting or denying them
"serialplugin:allow-clear-buffer",
"serialplugin:allow-set-break",
"serialplugin:allow-clear-break",
- "serialplugin:allow-start-listening",
- "serialplugin:allow-stop-listening",
+ "serialplugin:allow-capabilities",
+ "serialplugin:allow-watch",
+ "serialplugin:allow-unwatch",
"serialplugin:allow-set-log-level",
"serialplugin:allow-get-log-level"
]
@@ -1102,24 +1089,41 @@ Below is a list of all permissions the plugin supports. Granting or denying them
### Port Discovery
+> **Removed in 3.0.0:** `available_ports_direct` — use `available_ports()`.
+
+> **macOS duplicates:** `serialport-rs` lists both `/dev/cu.*` (callout) and `/dev/tty.*` (dial-in) per device. Pass `{ singlePortPerDevice: true }` to keep one path per device (prefers `/dev/cu.*`, like Node.js `SerialPort.list()`). Default returns all paths.
+
```typescript
class SerialPort {
/**
* Lists all available serial ports on the system
+ * @param options.macOS `singlePortPerDevice` — see note above
* @returns {Promise<{[key: string]: PortInfo}>} Map of port names to port information
* @example
* const ports = await SerialPort.available_ports();
+ * const onePerDevice = await SerialPort.available_ports({ singlePortPerDevice: true });
* console.log(ports);
*/
- static async available_ports(): Promise<{ [key: string]: PortInfo }>;
+ static async available_ports(options?: AvailablePortsOptions): Promise<{ [key: string]: PortInfo }>;
/**
- * Lists ports using platform-specific commands for enhanced detection
- * @returns {Promise<{[key: string]: PortInfo}>} Map of port names to port information
+ * Subscribe to available-port hotplug (attach/detach). Sends an initial snapshot,
+ * then `added` / `removed` events through a Tauri Channel.
+ * @param handlers Callbacks for snapshot / added / removed
+ * @param options `singlePortPerDevice`, `pollIntervalMs` (desktop default 2000)
+ * @returns Handle with `unwatch()` to stop
* @example
- * const ports = await SerialPort.available_ports_direct();
+ * const handle = await SerialPort.watchAvailablePorts({
+ * onSnapshot: (ports) => console.log('ports', ports),
+ * onAdded: (path, info) => console.log('plugged', path, info.type),
+ * onRemoved: (path) => console.log('unplugged', path),
+ * }, { singlePortPerDevice: true });
+ * // later: await handle.unwatch();
*/
- static async available_ports_direct(): Promise<{ [key: string]: PortInfo }>;
+ static async watchAvailablePorts(
+ handlers: WatchPortsHandlers,
+ options?: WatchPortsOptions,
+ ): Promise;
/**
* @description Lists all managed serial ports (ports that are currently open and managed by the application).
@@ -1153,27 +1157,24 @@ class SerialPort {
async close(): Promise;
/**
- * Starts listening for data on the serial port
- * @returns {Promise} A promise that resolves when listening starts
- * @throws {Error} If starting listener fails or port is not open
+ * Streams serial port events through a Tauri Channel.
+ * @returns {Promise} Handle with `channelId` and `unwatch()`
* @example
- * await port.startListening();
- *
- * // Listen for data events
- * port.listen((data) => {
- * console.log("Data received:", data);
+ * const handle = await port.watch({
+ * onData: (data) => console.log("Data:", data),
+ * onError: (message) => console.warn("Non-fatal:", message),
+ * onDisconnect: (reason) => console.log("Disconnected:", reason),
* });
+ * await handle.unwatch();
*/
- async startListening(): Promise;
+ async watch(handlers: WatchHandlers, options?: WatchOptions): Promise;
/**
- * Stops listening for data on the serial port
- * @returns {Promise} A promise that resolves when listening stops
- * @throws {Error} If stopping listener fails or port is not open
+ * Runtime plugin info (transport, platform, version).
* @example
- * await port.stopListening();
+ * const caps = await SerialPort.getCapabilities();
*/
- async stopListening(): Promise;
+ static getCapabilities(): Promise;
/**
* Forces a serial port to close regardless of its state
@@ -1232,29 +1233,6 @@ class SerialPort {
* const bytesWritten = await port.writeBinary(data);
*/
async writeBinary(data: Uint8Array | number[]): Promise;
-
- /**
- * Sets up a listener for incoming data
- * @param {(data: string | Uint8Array) => void} callback Function to handle received data
- * @param {boolean} [decode=true] Whether to decode data as string (true) or return raw bytes (false)
- * @returns {Promise} A promise that resolves to an unlisten function
- * @example
- * const unsubscribe = await port.listen((data) => {
- * console.log("Received:", data);
- * });
- *
- * // Later, to stop listening:
- * unsubscribe();
- */
- async listen(callback: (data: string | Uint8Array) => void, decode?: boolean): Promise;
-
- /**
- * Cancels listening for serial port data (does not affect disconnect listeners)
- * @returns {Promise} A promise that resolves when listening is cancelled
- * @example
- * await port.cancelListen();
- */
- async cancelListen(): Promise;
}
```
@@ -1340,24 +1318,6 @@ class SerialPort {
*/
async writeDataTerminalReady(level: boolean): Promise;
- /**
- * Alternative method to set RTS signal
- * @param {boolean} value Signal level (true = high, false = low)
- * @returns {Promise}
- * @example
- * await port.setRequestToSend(true);
- */
- async setRequestToSend(value: boolean): Promise;
-
- /**
- * Alternative method to set DTR signal
- * @param {boolean} value Signal level (true = high, false = low)
- * @returns {Promise}
- * @example
- * await port.setDataTerminalReady(true);
- */
- async setDataTerminalReady(value: boolean): Promise;
-
/**
* Reads the CTS (Clear to Send) signal state
* @returns {Promise} Signal state
@@ -1489,9 +1449,9 @@ class SerialPort {
* @param {number} [options.interval=5000] Reconnection interval in milliseconds
* @param {number | null} [options.maxAttempts=10] Maximum number of reconnection attempts (null for infinite)
* @param {Function} [options.onReconnect] Callback function called on each reconnection attempt
- * @returns {Promise}
+ * @returns {void}
* @example
- * await port.enableAutoReconnect({
+ * port.enableAutoReconnect({
* interval: 3000,
* maxAttempts: 5,
* onReconnect: (success, attempt) => {
@@ -1499,19 +1459,19 @@ class SerialPort {
* }
* });
*/
- async enableAutoReconnect(options?: {
+ enableAutoReconnect(options?: {
interval?: number;
maxAttempts?: number | null;
onReconnect?: (success: boolean, attempt: number) => void;
- }): Promise;
+ }): void;
/**
* Disables auto-reconnect functionality
- * @returns {Promise}
+ * @returns {void}
* @example
* await port.disableAutoReconnect();
*/
- async disableAutoReconnect(): Promise;
+ disableAutoReconnect(): void;
/**
* Gets auto-reconnect status and configuration
@@ -1553,11 +1513,13 @@ const port = new SerialPort({
});
await port.open();
-await port.startListening();
-await port.listen((data) => {
- const sensorValue = parseFloat(data);
- console.log("Sensor reading:", sensorValue);
+const handle = await port.watch({
+ onData: (data) => {
+ const sensorValue = parseFloat(String(data));
+ console.log("Sensor reading:", sensorValue);
+ },
});
+// await handle.unwatch() when done
```
### Binary Protocol Communication
@@ -1574,14 +1536,12 @@ await port.open();
const command = new Uint8Array([0x02, 0x01, 0x03]);
await port.writeBinary(command);
-// Start listening for response
-await port.startListening();
-
-// Read response (raw bytes)
-await port.listen((data) => {
- const response = data instanceof Uint8Array ? data : new Uint8Array();
- console.log("Response:", response);
-}, false);
+const handle = await port.watch({
+ onData: (data) => {
+ const response = data instanceof Uint8Array ? data : new Uint8Array();
+ console.log("Response:", response);
+ },
+}, { decode: false });
```
### Modbus Communication
@@ -1621,27 +1581,21 @@ const port = new SerialPort({
await port.open();
-// Enable auto-reconnect with custom settings
+// Enable auto-reconnect: restores both open() and watch() after disconnect
+// Reconnection uses a fixed interval (options.interval); exponential backoff is not implemented.
await port.enableAutoReconnect({
- interval: 3000, // Try to reconnect every 3 seconds
- maxAttempts: 5, // Maximum 5 attempts
+ interval: 3000,
+ maxAttempts: 5,
onReconnect: (success, attempt) => {
- if (success) {
- console.log(`Reconnected successfully on attempt ${attempt}`);
- } else {
- console.log(`Reconnection attempt ${attempt} failed`);
- }
- }
+ console.log(success ? `Reconnected on attempt ${attempt}` : `Attempt ${attempt} failed`);
+ },
});
-// Set up data listener
-await port.startListening();
-const unsubscribe = await port.listen((data) => {
- console.log("Received data:", data);
+const handle = await port.watch({
+ onData: (data) => console.log("Received data:", data),
+ onDisconnect: () => console.log("Port disconnected — auto-reconnect will reopen and re-watch"),
});
-
-// The port will automatically reconnect if disconnected
-// You can also manually trigger reconnection
+// Manual reconnect also re-establishes watch when a session was active before disconnect
const success = await port.manualReconnect();
if (success) {
console.log("Manual reconnection successful");
@@ -1656,58 +1610,68 @@ console.log("Current attempts:", info.currentAttempts);
await port.disableAutoReconnect();
```
----
-
-## Android Setup
-
-To use this plugin on Android, you **must** add the JitPack repository to your project's `build.gradle.kts` file located at `/src-tauri/gen/android/build.gradle.kts`. This is required because the plugin depends on `com.github.mik3y:usb-serial-for-android:3.8.1`, which is hosted on JitPack.
+### AT commands (native FIFO queue)
+
+For modems and AT devices, use **`sendAt()`** / **`sendAtPhases()`** / **`sendSmsPdu()`** — native FIFO queue over **`exchange`** with **line-framed AT completion** (`OK` / `ERROR` / `+CME ERROR` as the final line).
+
+| Mode | RX | Use when |
+|------|-----|----------|
+| `watch()` | Streaming Channel events + optional **`onUrc`** | General I/O, live URC |
+| `sendAt()` | One structured response per command | AT modems, request/response scripts |
+
+**Capabilities (v3.0):**
+
+| Feature | Description |
+|---------|-------------|
+| `AtCommandResult` | Structured result: `command`, `response`, `status`, `lines`, `solicitedBody`, `urcLines`, `raw` |
+| Native queue | Parallel `exchange()` / `sendAt()` **wait in FIFO** (no `"Exchange already in progress"`) |
+| `configureAtSession()` | Session defaults: `expectOk`, `stopOnError`, `appendCr`, timeouts, `resultFormat` |
+| Default `rxPrepare: 'drain'` | Soft idle drain before each command; use **`purge`** only for recovery. On **Android**, drain uses the same hub `drain()` path as desktop (Rust reader → `PortRxHub`). |
+| `expectOk`, `solicitedPrefixes` | Per-command control via session + `AtCommandOptions` |
+| Watch during AT | Watch stays active; live **`SerialEvent::Urc`** via `watch({ onUrc })` |
+| Vendor grammar | Auto **`solicitedPrefixes`** from command (`^`, `#`, `$`, `%`, `*`) |
+| `resultFormat: 'numeric'` | `ATV0` line codes (`0`/`3`/`4`…) |
+| `completionMode: 'atIntermediate'` | CMGS `>` prompt and other intermediate lines |
+| `sendAtPhases()` / `sendSmsPdu()` | Multi-phase SMS (prompt → PDU → `SEND OK`) |
+| `exchangeBinary()` | Binary write + read-until (PDU + Ctrl+Z) |
+| CMUX | `enableMux()`, `openMuxChannel(dlci)`, virtual paths `physical#dlci=N` |
+
+**Migration (v3.0 major):**
+
+| Was | Now |
+|-----|-----|
+| `port.at.enqueue('AT')` | `port.sendAt('AT')` |
+| `port.at.enqueuePhases(...)` | `port.sendAtPhases(...)` |
+| `port.at.sendSmsPdu(...)` | `port.sendSmsPdu(...)` |
+| `port.at.cancel()` | `port.cancelAt()` |
+| `new SerialPort({ atSession })` | `atSession` still applies on `open()` via `configureAtSession` |
+| Parallel `exchange()` throws | `exchange()` awaits turn in native queue |
-### Required Configuration
-
-Add the JitPack repository to both `buildscript` and `allprojects` sections:
-
-```kotlin
-buildscript {
- repositories {
- google()
- mavenCentral()
- maven { url = uri("https://jitpack.io") }
- }
- dependencies {
- classpath("com.android.tools.build:gradle:8.11.0")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
- // ... your other dependencies
- }
-}
+```typescript
+// Session defaults (optional — also via constructor `atSession`)
+await port.configureAtSession({ expectOk: true, defaultTimeoutMs: 5000 });
-allprojects {
- repositories {
- google()
- mavenCentral()
- maven { url = uri("https://jitpack.io") }
- }
-}
+await port.sendAt('AT^SYSCFG?');
+await port.sendAt('ATV0', { resultFormat: 'numeric' });
+await port.sendSmsPdu(pduLength, pduBytes);
-tasks.register("clean").configure {
- delete("build")
-}
+// CMUX: second logical channel on same USB port
+await port.enableMux({ command: 'AT+CMUX=0,0,5,31,10,2' });
+const dataPort = await port.openMuxChannel(2);
+await dataPort.sendAt('AT');
```
-### Why is this needed?
+Low-level **`exchange`** / **`exchangeBinary`** return **`ExchangeResponse`** (also queued); **`cancel_exchange`** / **`cancelAt()`** cancels in-flight work and rejects queued waiters.
-The plugin uses the [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) library, which is published on JitPack rather than Maven Central. Without adding JitPack to your repositories, Gradle will fail with an error like:
+---
-```
-could not find com.github.mik3y:usb-serial-for-android:3.8.1
-```
+## Android Setup
-### Troubleshooting
+Android USB serial runs in Rust (`android-usb-serial` + nusb). Kotlin holds the `UsbDeviceConnection` fd; the plugin duplicates it and claims interfaces in native code. Add a `device_filter.xml` in your app for your VID/PID and grant USB permission at runtime.
-If you still encounter issues after adding JitPack:
+If you previously added `maven { url = uri("https://jitpack.io") }` only for usb-serial-for-android, you can remove it.
-1. Make sure you've added it to **both** `buildscript.repositories` and `allprojects.repositories`
-2. Sync your Gradle files in your IDE
-3. Clean and rebuild your project: `./gradlew clean build`
+See [`android/README.md`](android/README.md) and [`android/BUILD_INSTRUCTIONS.md`](android/BUILD_INSTRUCTIONS.md).
---
@@ -1730,173 +1694,40 @@ pnpm run playground
## Testing
-For testing applications without physical hardware, you can use a mock implementation of the serial port. The mock port emulates all functions of a real port and allows testing the application without physical devices.
-
-### Using Mock Port
-
-```rust
-use tauri_plugin_serialplugin::tests::mock::MockSerialPort;
-
-// Create a mock port
-let mock_port = MockSerialPort::new();
-
-// Configure port settings
-mock_port.set_baud_rate(9600).unwrap();
-mock_port.set_data_bits(serialport::DataBits::Eight).unwrap();
-mock_port.set_flow_control(serialport::FlowControl::None).unwrap();
-mock_port.set_parity(serialport::Parity::None).unwrap();
-mock_port.set_stop_bits(serialport::StopBits::One).unwrap();
-
-// Write data
-mock_port.write("Test data".as_bytes()).unwrap();
+Run the full suite locally:
-// Read data
-let mut buffer = [0u8; 1024];
-let bytes_read = mock_port.read(&mut buffer).unwrap();
-let data = String::from_utf8_lossy(&buffer[..bytes_read]);
-assert_eq!(data, "Test data");
+```bash
+./scripts/verify-android-usb-migration.sh # full Android USB migration gate
+cargo fmt --all -- --check
+cargo clippy --all-targets -- -D warnings
+cargo test
+pnpm install && pnpm check && pnpm test && pnpm build
+cd android && ./gradlew test
```
-### Mock Port Features
-
-- Complete emulation of all real port functions
-- Built-in buffer for data storage
-- Control signal emulation (RTS, DTR, CTS, DSR)
-- Support for parallel operation testing
-- No additional software required
-- Works on all platforms
+### Virtual serial port (desktop integration)
-### Application Testing Example
-
-```rust
-#[test]
-fn test_serial_communication() {
- let app = create_test_app();
- let serial_port = SerialPort::new(app.handle().clone());
- app.manage(serial_port);
-
- // Open mock port
- app.state::>().open(
- "COM1".to_string(),
- 9600,
- Some(DataBits::Eight),
- Some(FlowControl::None),
- Some(Parity::None),
- Some(StopBits::One),
- Some(1000),
- ).unwrap();
-
- // Test write and read operations
- app.state::>().write(
- "COM1".to_string(),
- "Test data".to_string(),
- ).unwrap();
-
- let data = app.state::>().read(
- "COM1".to_string(),
- Some(1000),
- Some(1024),
- ).unwrap();
- assert_eq!(data, "Test data");
-
- // Test port settings
- app.state::>().set_baud_rate(
- "COM1".to_string(),
- 115200,
- ).unwrap();
+For manual or integration testing without hardware, pair two PTY endpoints with **socat**:
- // Close port
- app.state::>().close("COM1".to_string()).unwrap();
-}
+```bash
+socat -d -d pty,raw,echo=0 pty,raw,echo=0
+# open the printed /dev/tty* path (or COM* on Windows with com0com)
+pnpm playground
```
-### Implementing Your Own Mock Port
+Use `watch()`, `exchange()`, and `sendAt()` against that path; unplugging the peer exercises disconnect fail-fast and hub restart behavior.
-You can implement your own mock port by implementing the `SerialPort` trait. Here's a basic example of how to create a custom mock port:
-
-```rust
-use std::io::{self, Read, Write};
-use serialport::{self, SerialPort};
-use std::time::Duration;
+### Unit tests (no hardware)
-struct CustomMockPort {
- buffer: Vec,
- baud_rate: u32,
- data_bits: serialport::DataBits,
- flow_control: serialport::FlowControl,
- parity: serialport::Parity,
- stop_bits: serialport::StopBits,
- timeout: Duration,
-}
-
-impl CustomMockPort {
- fn new() -> Self {
- Self {
- buffer: Vec::new(),
- baud_rate: 9600,
- data_bits: serialport::DataBits::Eight,
- flow_control: serialport::FlowControl::None,
- parity: serialport::Parity::None,
- stop_bits: serialport::StopBits::One,
- timeout: Duration::from_millis(1000),
- }
- }
-}
-
-// Implement Read trait for reading data
-impl Read for CustomMockPort {
- fn read(&mut self, buf: &mut [u8]) -> io::Result {
- let len = std::cmp::min(buf.len(), self.buffer.len());
- if len > 0 {
- buf[..len].copy_from_slice(&self.buffer[..len]);
- self.buffer.drain(..len);
- }
- Ok(len)
- }
-}
-
-// Implement Write trait for writing data
-impl Write for CustomMockPort {
- fn write(&mut self, buf: &[u8]) -> io::Result {
- self.buffer.extend_from_slice(buf);
- Ok(buf.len())
- }
-
- fn flush(&mut self) -> io::Result<()> {
- Ok(())
- }
-}
-
-// Implement SerialPort trait for port configuration
-impl SerialPort for CustomMockPort {
- fn name(&self) -> Option {
- Some("CUSTOM_PORT".to_string())
- }
-
- fn baud_rate(&self) -> serialport::Result {
- Ok(self.baud_rate)
- }
-
- fn data_bits(&self) -> serialport::Result {
- Ok(self.data_bits)
- }
-
- // ... implement other required methods ...
-}
-```
+- **Rust:** `src/tests/mock_serial.rs` — scripted RX/TX mock used by `cargo test`
+- **JS:** `tests/*.test.ts` — Jest mocks for `invoke` / `Channel`
+- **Android:** `android/src/test/...` — Robolectric for `UsbFdBridge`; Rust driver tests in `crates/android-usb-serial` (`fake-transport`)
-For a complete implementation example, see the mock port implementation in the plugin's test directory:
-[`src/tests/mock.rs`](https://github.com/s00d/tauri-plugin-serialplugin/blob/main/src/tests/mock.rs)
+> **Note:** `bytesToWrite()` returns **0 on Android** (writes are synchronous over JNI). Desktop returns the driver queue depth when available.
-The example includes:
-- Full implementation of all required traits
-- Buffer management for read/write operations
-- Control signal emulation
-- Port configuration handling
-- Error handling
-- Thread safety considerations
+### Known limitations
-You can use this implementation as a reference when creating your own mock port with custom behavior for specific testing scenarios.
+* **Windows port enumeration** uses `wmic` for supplemental metadata on some builds. This path is **not exercised in CI** and may behave differently on Windows 11 if `wmic` is unavailable or restricted.
---
diff --git a/android/BUILD_INSTRUCTIONS.md b/android/BUILD_INSTRUCTIONS.md
index 6addac95..79895297 100644
--- a/android/BUILD_INSTRUCTIONS.md
+++ b/android/BUILD_INSTRUCTIONS.md
@@ -2,105 +2,67 @@
## Prerequisites
-1. **Android Studio** (latest version)
-2. **Android SDK** (API 24+)
-3. **Kotlin** (1.8+)
-4. **Gradle** (7.0+)
-5. **Tauri CLI** (`npm install -g @tauri-apps/cli`)
+1. **Android Studio** (latest)
+2. **Android SDK** (API 24+, compile 34)
+3. **Kotlin** 1.9+
+4. **Gradle** 8.5+
+5. **Rust** 1.79+ with Android targets (`aarch64-linux-android`, etc.)
+6. **Tauri CLI** 2.x
-## Building the Plugin
-
-### 1. Environment Setup
+## Building the library module
```bash
-# Make sure you have all dependencies installed
cd android
-./gradlew clean
-```
-
-### 2. Building the Library
-
-```bash
-# Build AAR file
-./gradlew assembleRelease
-
-# Or for debugging
+export JAVA_HOME=$(/usr/libexec/java_home -v 17) # macOS
./gradlew assembleDebug
```
-### 3. Build Verification
+Outputs: `build/outputs/aar/`
-Built files will be located in:
-- `build/outputs/aar/` - AAR library
-- `build/intermediates/aar_main_jar/` - JAR files
+## Rust Android check
-## Testing
+```bash
+rustup target add aarch64-linux-android
+cargo check -p android-usb-serial --target aarch64-linux-android
+cargo check -p tauri-plugin-serialplugin --target aarch64-linux-android
+```
-### 1. Running the Example Application
+## Example app (Tauri)
```bash
cd examples/serialport-test
-npm install
-npm run tauri android dev
+pnpm install
+pnpm tauri android dev
```
-### 2. Connecting USB Device
+## USB on device
-1. Connect USB Serial device to Android device
-2. Allow USB access in the application
-3. Device should appear in the list of available ports
+1. Add `device_filter.xml` entries for your VID/PID in the **app** manifest.
+2. Grant USB permission when prompted.
+3. Port paths look like `/dev/bus/usb/001/002` (multi-interface FTDI: `#1`, `#2`, …).
-### 3. Checking Logs
+## Logs
```bash
-# View Android logs
-adb logcat | grep -E "(SerialPlugin|SerialPortManager)"
-
-# Or through Android Studio
-# View -> Tool Windows -> Logcat
+adb logcat -v time -s UsbFdBridge SerialPlugin RustStdoutStderr
```
-## Troubleshooting
-
-### Build Issues
+## Project structure
-1. **Gradle Error**: Check Gradle and Android Gradle Plugin versions
-2. **Kotlin Error**: Make sure Kotlin version is 1.8+
-3. **Dependency Error**: Check repository availability
-
-### Runtime Issues
-
-1. **USB permissions not requested**: Check AndroidManifest.xml
-2. **Device not detected**: Check device_filter.xml
-3. **Connection errors**: Check logs and USB drivers
-
-### Debugging
-
-1. **Enable detailed logging** in SerialPortManager
-2. **Check USB permissions** in Android settings
-3. **Test with different devices** (FTDI, CH340, CP210x)
-
-## Project Structure
-
-```
+```text
android/
-├── src/main/kotlin/
-│ ├── SerialPlugin.kt # Main plugin
-│ ├── SerialPortManager.kt # USB port manager
-│ └── models/ # Data models
-├── src/main/AndroidManifest.xml # Application manifest
-├── src/main/res/xml/
-│ └── device_filter.xml # USB device filter
-├── build.gradle # Build configuration
-└── README.md # Documentation
+├── src/main/kotlin/app/tauri/serialplugin/
+│ ├── SerialPlugin.kt
+│ ├── UsbNative.kt
+│ ├── MobileBridge.kt
+│ └── manager/UsbFdBridge.kt
+├── src/main/res/xml/device_filter.xml
+└── build.gradle
+
+crates/android-usb-serial/ # published-quality driver crate (nusb)
+src/android/ # fd_bridge, driver_host, registry JNI
```
-## Dependencies
-
-- **usb-serial-for-android**: Library for USB Serial operations
-- **tauri-android**: Tauri Android runtime
-- **AndroidX**: Modern Android libraries
-
-## License
+## Attribution
-MIT License - see LICENSE file in the project root.
+Driver logic is ported from [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) into Rust (`android-usb-serial`). See `crates/android-usb-serial/NOTICE`.
diff --git a/android/README.md b/android/README.md
index 2cc3bda1..a3f547b0 100644
--- a/android/README.md
+++ b/android/README.md
@@ -1,43 +1,67 @@
-# Android module (Tauri serial plugin)
+# Android serial plugin
-## Dependency
+## Architecture (fd + Rust drivers)
-- [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) (`com.github.mik3y:usb-serial-for-android`) — the `UsbSerialPort` interface: `read` / `write` with timeout, `purgeHwBuffers`, `setFlowControl`, RTS/DTR/CTS lines, etc.
+```text
+SerialPlugin → UsbFdBridge (Kotlin) → openDeviceFd / enumerateJson
+UsbNative (JNI) ↔ fd_bridge.rs ↔ driver_host.rs ↔ android-usb-serial (nusb)
+RX: Rust reader thread → PortRxHub poll-loop (same as desktop)
+```
+
+Kotlin keeps `UsbDeviceConnection` and the raw fd. Rust duplicates the fd (`dup`), opens `nusb::Device::from_fd`, claims interfaces, and runs vendor drivers from the `android-usb-serial` crate. There is **no** vendored `usb-serial-for-android` Java tree in this repo.
-## Lifecycle / cleanup
+## Consumer app requirements
-`SerialPlugin` registers `Application.ActivityLifecycleCallbacks` in `load()` and calls `SerialPortManager.cleanup()` when the host `Activity` is destroyed (USB ports closed, broadcast receiver unregistered, IO executor shut down). This complements JS `close` / `closeAll` and reduces leaks or stale native work after the WebView/activity is gone.
+1. Declare USB host in the app manifest (`uses-feature android.hardware.usb.host` optional).
+2. Ship a `device_filter.xml` aligned with [`device_filter.xml`](src/main/res/xml/device_filter.xml) / `ProbeTable::default_table()`.
+3. Request runtime USB permission before open (the plugin uses `PendingIntent.FLAG_MUTABLE` + `setPackage()`).
-## Unit tests (Kotlin)
+## JVM unit tests (Robolectric)
-Tests live under `src/test/kotlin/`. Pure JVM tests (no Robolectric required):
+Requires **JDK 17**:
-- `SerialModelsTest` — model enums / defaults (`Parity` / `StopBits` round-trip, invalid `fromValue` fallbacks)
-- `SerialByteAccumulatorTest` — thread-safe byte coalescing for `BufferedEmitter`
-- `SerialDataEmitFieldsTest` — `serialDataPayloadFromChunk` / `flushAccumulatorToEmit` / `applyToJSObject` (binary + UTF-8)
-- `SerialPluginConversionTest` — `Map.toJSObject` / `List.toJSArray` helpers from `SerialPlugin.kt`
-- `BufferedEmitterTest` — `pendingByteCount()` before flush
+```bash
+export JAVA_HOME=$(/usr/libexec/java_home -v 17) # macOS
+cd android && ./gradlew test
+```
-JVM unit tests use **`testImplementation("org.json:json:…")`** so `JSONObject.put` is not the Android stub that throws (“Method … not mocked”).
+## Kotlin ↔ Rust JNI integration tests (instrumented)
-`BufferedEmitter` itself still schedules timers and builds `JSObject` on device; the buffer + flush pipeline is covered by the tests above.
+Stable sources: [`examples/serialport-test/android-integration/`](../examples/serialport-test/android-integration/).
-Run from the `android/` directory (Android SDK, **JDK 17+** for Gradle; use **17** if Gradle errors on JDK 25):
+Uses `FakeTransport` via `android-test-harness` (no Kotlin USB fakes).
```bash
-cd android
-export JAVA_HOME=$(/usr/libexec/java_home -v 17) # macOS; use JDK 17 on Linux/Windows
-./gradlew test
+cd examples/serialport-test
+pnpm android:integration-test
```
-This repo includes a Gradle wrapper (`gradlew`, `gradle/wrapper/`). The module depends on `:tauri-android` from `.tauri/tauri-api` (bundled under `android/.tauri/tauri-api` or generated by Tauri).
+Debug harness JNI (`test_harness.rs`): `testHarnessReset`, `testOpenFakePort`, `testFakeInjectRx`, `testFakeTakeTx`, `testFakeInjectError`, `testHubBufferedLen`, `testInvokeWrite`, `testRegistryHasPort`.
+
+## Layout
+
+| File | Role |
+|------|------|
+| `SerialPlugin` | Tauri plugin load → `UsbFdBridge` |
+| `UsbFdBridge` | enumerate, permission, fd open/close, attach/detach |
+| `UsbNative` | JNI for enumerate + fd lifecycle |
+| `MobileBridge` | JNI callbacks (`onUsbError`, `onDeviceDetached`, port list change, …) |
+
+## Golden fixture regen
+
+Rust drivers are verified against frozen JSON fixtures (`crates/android-usb-serial/tests/fixtures/`).
+
+```bash
+cargo run -p android-usb-serial --features fake-transport --bin golden_record
+cargo test -p android-usb-serial --features fake-transport --test golden_parity
+```
+
+See [`docs/golden-recorder-archive/README.md`](../docs/golden-recorder-archive/README.md) for the retired JVM recorder workflow.
+
+Logcat: `adb logcat -s UsbFdBridge SerialPlugin RustStdoutStderr`
-## Behavior tied to the library
+## CMUX virtual paths
-| Plugin API | Implementation |
-|------------|----------------|
-| `setTimeout` | Stored in `SerialPortConfig.timeout` and used for `write()` and as a fallback for `read()` when the call passes `timeout == 0`. |
-| `clearBuffer` | `UsbSerialPort.purgeHwBuffers()` (when supported by the driver). |
-| `setFlowControl` | `UsbSerialPort.setFlowControl(RTS_CTS / XON_XOFF / NONE)`. |
-| `bytesToRead` | With **listening** (`startListening`): bytes in the plugin’s [BufferedEmitter] before the next `serialData` flush (not the kernel queue). Without listening: `0`. |
-| `bytesToWrite` | Always `0`: writes are synchronous; usb-serial exposes no TX backlog. |
+* **`exchange` / `at` on `physical#dlci=N`:** Routed through the Rust CMUX session (same as desktop).
+* **`cancel_exchange`:** Sets the virtual cancel flag, fails the active DLCI waiter, and clears the virtual TX queue.
+* **`rx_prepare: drain`:** Uses the shared RX hub drain before write (idle bytes may still be replayed via `take_idle_bytes`).
diff --git a/android/build.gradle b/android/build.gradle
index 16b1be90..7c189fd9 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -17,7 +17,6 @@ android {
buildTypes {
release {
-// isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -25,11 +24,16 @@ android {
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = "11"
+ }
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
}
}
@@ -37,13 +41,16 @@ dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
- implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3")
+ testImplementation("androidx.test:core:1.5.0")
testImplementation("junit:junit:4.13.2")
- // Real org.json for JVM unit tests (Android stubs throw on JSONObject.put — see not-mocked)
- testImplementation("org.json:json:20240303")
+ testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
+ testImplementation("org.robolectric:robolectric:4.14.1")
+ testImplementation("com.google.code.gson:gson:2.10.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
implementation(project(":tauri-android"))
implementation("com.google.code.gson:gson:2.10.1")
- implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
-}
\ No newline at end of file
+ implementation("androidx.annotation:annotation:1.9.1")
+}
+
diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro
new file mode 100644
index 00000000..78c88fb4
--- /dev/null
+++ b/android/consumer-rules.pro
@@ -0,0 +1,3 @@
+# Consumer ProGuard rules for tauri-plugin-serialplugin Android module.
+-keep class app.tauri.serialplugin.UsbNative { *; }
+-keep class app.tauri.serialplugin.MobileBridge { *; }
diff --git a/android/settings.gradle b/android/settings.gradle
index aa26926f..22c74029 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -15,7 +15,6 @@ dependencyResolutionManagement {
repositories {
mavenCentral()
google()
- maven { url 'https://jitpack.io' }
}
}
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 70f85715..14ffb286 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -4,12 +4,7 @@
android:hardwareAccelerated="true">
-
-
-
-
-
-
+
diff --git a/android/src/main/kotlin/app/tauri/serialplugin/MobileBridge.kt b/android/src/main/kotlin/app/tauri/serialplugin/MobileBridge.kt
new file mode 100644
index 00000000..123be022
--- /dev/null
+++ b/android/src/main/kotlin/app/tauri/serialplugin/MobileBridge.kt
@@ -0,0 +1,59 @@
+package app.tauri.serialplugin
+
+/**
+ * JNI entry points into the Tauri Rust library (loaded by the host app).
+ * RX is polled via Rust USB reader → [PortRxHub] (see [android/jni.rs]).
+ */
+object MobileBridge {
+ @JvmStatic
+ external fun onUsbError(path: String, reason: String)
+
+ @JvmStatic
+ external fun onDeviceDetached(deviceName: String)
+
+ @JvmStatic
+ external fun onPortListChange()
+
+ @JvmStatic
+ external fun onAppDestroy()
+
+ /** Debug-only: reset Rust port registry between integration tests. */
+ @JvmStatic
+ external fun testHarnessReset()
+
+ /** Debug-only: register path in Rust registry with a fresh PortRxHub. */
+ @JvmStatic
+ external fun testRegisterPort(path: String)
+
+ /** Debug-only: idle + read-slot bytes buffered in the Rust hub for [path]. */
+ @JvmStatic
+ external fun testHubBufferedLen(path: String): Long
+
+ /** Debug-only: take idle bytes from the Rust hub (exchange replay path). */
+ @JvmStatic
+ external fun testHubTakeIdle(path: String): ByteArray
+
+ /** Debug-only: whether [path] is still registered after USB teardown. */
+ @JvmStatic
+ external fun testRegistryHasPort(path: String): Boolean
+
+ /** Debug-only: invoke Rust TX path (driver_host write). Returns bytes written or -1. */
+ @JvmStatic
+ external fun testInvokeWrite(path: String, data: ByteArray): Long
+
+ /** Debug-only: inject CDC fake device, open port, register hub. Returns session path. */
+ @JvmStatic
+ external fun testOpenFakePort(deviceName: String): String
+
+ /** Debug-only: push scripted bulk-IN bytes into the fake transport. */
+ @JvmStatic
+ external fun testFakeInjectRx(deviceName: String, data: ByteArray): Boolean
+
+ /** Debug-only: take bytes written to the fake bulk-OUT endpoint. */
+ @JvmStatic
+ external fun testFakeTakeTx(deviceName: String): ByteArray
+
+ /** Debug-only: fail the next bulk-IN read (reader → onUsbError). */
+ @JvmStatic
+ external fun testFakeInjectError(deviceName: String, reason: String): Boolean
+}
diff --git a/android/src/main/kotlin/app/tauri/serialplugin/SerialPlugin.kt b/android/src/main/kotlin/app/tauri/serialplugin/SerialPlugin.kt
index cf842181..a16e47e7 100644
--- a/android/src/main/kotlin/app/tauri/serialplugin/SerialPlugin.kt
+++ b/android/src/main/kotlin/app/tauri/serialplugin/SerialPlugin.kt
@@ -5,693 +5,43 @@ package app.tauri.serialplugin
import android.app.Activity
import android.app.Application
import android.os.Bundle
-import app.tauri.annotation.Command
-import app.tauri.annotation.InvokeArg
+import android.util.Log
+import android.webkit.WebView
import app.tauri.annotation.TauriPlugin
-import app.tauri.plugin.Invoke
-import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
-import app.tauri.serialplugin.manager.SerialPortManager
-import app.tauri.serialplugin.models.*
-import android.webkit.WebView
-import android.util.Log
-import app.tauri.plugin.JSArray
-import java.util.concurrent.ExecutorService
-import java.util.concurrent.Executors
-import java.util.concurrent.RejectedExecutionException
-// --- Reused from previous answer (Converts a Map to a JSObject) ---
-fun Map.toJSObject(): JSObject {
- val jsObject = JSObject()
- for ((key, value) in this) {
- val convertedValue: Any? = when (value) {
- is Map<*, *> -> @Suppress("UNCHECKED_CAST") (value as Map).toJSObject()
- is List<*> -> @Suppress("UNCHECKED_CAST") value.toJSArray() // Call the new list utility
- else -> value
- }
- jsObject.put(key, convertedValue)
- }
- return jsObject
-}
-
-// --- NEW Utility: Converts a List to a JSArray ---
-fun List.toJSArray(): JSArray {
- val jsArray = JSArray()
-
- for (item in this) {
- val convertedItem: Any? = when (item) {
- is Map<*, *> -> {
- // If the item is a Map, convert it to a JSObject
- @Suppress("UNCHECKED_CAST")
- (item as Map).toJSObject()
- }
- is List<*> -> {
- // If the item is a nested List, convert it to a JSArray (for list of lists)
- @Suppress("UNCHECKED_CAST")
- item.toJSArray()
- }
- else -> item // Primitives (String, Int, Boolean, etc.) can be put directly
- }
- // Add the converted item to the JSArray
- jsArray.put(convertedItem)
- }
-
- return jsArray
-}
-@InvokeArg
-class PortConfigArgs {
- lateinit var path: String
- var baudRate: Int = 9600
- var dataBits: Any? = null
- val size: Int? = null
- var flowControl: Any? = null
- var parity: Any? = null
- var stopBits: Any? = null
- var timeout: Int = 1000
-}
-
-@InvokeArg
-class WriteArgs {
- lateinit var path: String
- lateinit var value: String
-}
-
-@InvokeArg
-class WriteBinaryArgs {
- lateinit var path: String
- lateinit var value: List
-}
-
-@InvokeArg
-class CloseArgs {
- lateinit var path: String
-}
-
-@InvokeArg
-class StartListenArgs {
- lateinit var path: String
- /** Flush interval for batched serialData events (ms). Default 100; clamped 10–2000 on native side. */
- var serialDataFlushIntervalMs: Long = 100L
-}
+import app.tauri.serialplugin.manager.UsbFdBridge
@TauriPlugin
class SerialPlugin(private val activity: Activity) : Plugin(activity) {
- private var webView: WebView? = null
- private lateinit var serialPortManager: SerialPortManager
- /** Unregistered after [activity] is destroyed so we do not leak the callback. */
- private var activityDestroyCallback: Application.ActivityLifecycleCallbacks? = null
-
- /**
- * Plugin commands are dispatched synchronously on the Android main looper, so a
- * blocking `UsbSerialPort.read`/`write`/permission wait inside a command freezes the
- * UI (polled reads block the main thread for up to the read timeout, per chunk).
- * Every command that touches [SerialPortManager] runs here instead: its internal
- * state (`portMap`) is not thread-safe, so funneling all access through one thread
- * both keeps the main thread free and preserves command ordering.
- */
- private val ioExecutor: ExecutorService = Executors.newSingleThreadExecutor { r ->
- Thread(r, "serialplugin-io")
- }
-
- private fun runOnIoThread(invoke: Invoke, block: () -> Unit) {
- try {
- ioExecutor.execute(block)
- } catch (e: RejectedExecutionException) {
- invoke.reject("Serial IO executor is shut down")
- }
- }
+ private lateinit var usb: UsbFdBridge
+ private var destroyCb: Application.ActivityLifecycleCallbacks? = null
override fun load(webView: WebView) {
super.load(webView)
- serialPortManager = SerialPortManager(activity) { path, message ->
- try {
- val errorData = JSObject()
- errorData.put("path", path)
- errorData.put("error", message)
- trigger("serialError", errorData)
- } catch (e: Exception) {
- Log.e("SerialPlugin", "serialError trigger failed: ${e.message}", e)
- }
- }
- this.webView = webView
- registerActivityDestroyCleanup()
-
- Log.d("SerialPlugin", "SerialPlugin loaded successfully")
- }
-
- /**
- * [Plugin] has no `onDestroy()`. When the host [Activity] is destroyed (back, process kill path),
- * release USB threads/receiver/ports so we do not leak or fire stale events after WebView is gone.
- */
- private fun registerActivityDestroyCleanup() {
- unregisterActivityDestroyCleanup()
+ Log.i(TAG, "load: binding UsbFdBridge")
+ usb = UsbFdBridge(activity.applicationContext)
+ UsbNative.bind(usb)
val app = activity.application
- val cb = object : Application.ActivityLifecycleCallbacks {
- override fun onActivityDestroyed(destroyed: Activity) {
- if (destroyed !== activity) return
- try {
- Log.d("SerialPlugin", "Activity destroyed — releasing USB serial resources")
- // Enqueue cleanup behind any pending IO so queued read/write tasks
- // do not run against already-released ports, then stop the executor.
- ioExecutor.execute {
- try {
- serialPortManager.cleanup()
- } catch (e: Exception) {
- Log.e("SerialPlugin", "cleanup on activity destroy failed: ${e.message}", e)
- }
- }
- } catch (e: RejectedExecutionException) {
- // Executor already stopped; release directly.
- serialPortManager.cleanup()
- } finally {
- ioExecutor.shutdown()
- unregisterActivityDestroyCleanup()
- }
- }
-
- override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
- override fun onActivityStarted(activity: Activity) {}
- override fun onActivityResumed(activity: Activity) {}
- override fun onActivityPaused(activity: Activity) {}
- override fun onActivityStopped(activity: Activity) {}
- override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
- }
- activityDestroyCallback = cb
- app.registerActivityLifecycleCallbacks(cb)
- }
-
- private fun unregisterActivityDestroyCleanup() {
- activityDestroyCallback?.let { cb ->
- try {
- activity.application.unregisterActivityLifecycleCallbacks(cb)
- } catch (_: Exception) {
- }
- activityDestroyCallback = null
- }
- }
-
- @Command
- fun availablePorts(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- Log.d("SerialPlugin", "Fetching available ports")
- val ports = serialPortManager.getAvailablePorts()
- Log.d("SerialPlugin", "Available ports fetched successfully: ${ports.size} ports")
- val result = JSObject()
-
- result.put("ports", ports.toJSObject())
- invoke.resolve(result)
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to get available ports: ${e.message}", e)
- invoke.reject("Failed to get available ports: ${e.message}")
- }
- }
-
- @Command
- fun managedPorts(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val managedPorts = serialPortManager.getManagedPorts()
- Log.d("SerialPlugin", "Managed ports: ${managedPorts.size} ports")
- val result = JSObject()
- result.put("ports", managedPorts)
- invoke.resolve(result)
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to get managed ports: ${e.message}", e)
- invoke.reject("Failed to get managed ports: ${e.message}")
- }
- }
-
- @Command
- fun open(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- Log.d("SerialPlugin", "Opening port: ${args.path}")
-
- val dataBits = when (args.dataBits) {
- is String -> DataBits.valueOf(args.dataBits as String)
- is Number -> DataBits.fromValue((args.dataBits as Number).toInt())
- null -> DataBits.EIGHT
- else -> throw IllegalArgumentException("Invalid data bits type")
- }
-
- val flowControl = when (args.flowControl) {
- is String -> FlowControl.valueOf(args.flowControl as String)
- is Number -> FlowControl.fromValue((args.flowControl as Number).toInt())
- null -> FlowControl.NONE
- else -> throw IllegalArgumentException("Invalid flow control type")
- }
-
- val parity = when (args.parity) {
- is String -> Parity.valueOf(args.parity as String)
- is Number -> Parity.fromValue((args.parity as Number).toInt())
- null -> Parity.NONE
- else -> throw IllegalArgumentException("Invalid parity type")
- }
-
- val stopBits = when (args.stopBits) {
- is String -> StopBits.valueOf(args.stopBits as String)
- is Number -> StopBits.fromValue((args.stopBits as Number).toInt())
- null -> StopBits.ONE
- else -> throw IllegalArgumentException("Invalid stop bits type")
- }
-
- val serialConfig = SerialPortConfig(
- path = args.path,
- baudRate = args.baudRate,
- dataBits = dataBits,
- flowControl = flowControl,
- parity = parity,
- stopBits = stopBits,
- timeout = args.timeout
- )
-
- val success = serialPortManager.openPort(serialConfig)
- if (success) {
- Log.d("SerialPlugin", "Port opened successfully: ${args.path}")
- invoke.resolve()
- } else {
- Log.e("SerialPlugin", "Failed to open port: ${args.path}")
- invoke.reject("Failed to open port")
- }
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to open port: ${e.message}", e)
- invoke.reject("Failed to open port: ${e.message}")
- }
- }
-
- @Command
- fun write(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(WriteArgs::class.java)
- Log.d("SerialPlugin", "Writing to port: ${args.path}, data: ${args.value}")
- val bytesWritten = serialPortManager.writeToPort(args.path, args.value.toByteArray())
- val result = JSObject()
- result.put("bytesWritten", bytesWritten)
- Log.d("SerialPlugin", "Write successful: $bytesWritten bytes written")
- invoke.resolve(result)
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to write data: ${e.message}", e)
- invoke.reject("Failed to write data: ${e.message}")
- }
- }
-
- @Command
- fun close(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- Log.d("SerialPlugin", "Closing port: ${args.path}")
- serialPortManager.closePort(args.path)
- Log.d("SerialPlugin", "Port closed successfully: ${args.path}")
- invoke.resolve()
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to close port: ${e.message}", e)
- invoke.reject("Failed to close port: ${e.message}")
- }
- }
-
- @Command
- fun closeAll(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- Log.d("SerialPlugin", "Closing all ports")
- serialPortManager.closeAllPorts()
- Log.d("SerialPlugin", "All ports closed successfully")
- invoke.resolve()
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to close all ports: ${e.message}", e)
- invoke.reject("Failed to close all ports: ${e.message}")
- }
- }
-
- @Command
- fun forceClose(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- Log.d("SerialPlugin", "Force closing port: ${args.path}")
- serialPortManager.closePort(args.path)
- Log.d("SerialPlugin", "Port force closed successfully: ${args.path}")
- invoke.resolve()
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to force close port: ${e.message}", e)
- invoke.reject("Failed to force close port: ${e.message}")
- }
- }
-
- @Command
- fun writeBinary(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(WriteBinaryArgs::class.java)
- Log.d("SerialPlugin", "Writing binary to port: ${args.path}")
- val bytesToSend = args.value.map { it.toByte() }.toByteArray()
- val bytesWritten = serialPortManager.writeToPort(args.path, bytesToSend)
- val result = JSObject()
- result.put("bytesWritten", bytesWritten)
- Log.d("SerialPlugin", "Binary write successful: $bytesWritten bytes written")
- invoke.resolve(result)
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to write binary data: ${e.message}", e)
- invoke.reject("Failed to write binary data: ${e.message}")
- }
- }
-
- @Command
- fun read(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- Log.d("SerialPlugin", "Reading from port: ${args.path}")
- val data = serialPortManager.readFromPort(args.path, args.timeout, args.size)
- val result = JSObject()
- result.put("data", String(data))
- Log.d("SerialPlugin", "Read successful: ${data.size} bytes read")
- invoke.resolve(result)
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to read data: ${e.message}", e)
- invoke.reject("Failed to read data: ${e.message}")
- }
- }
-
- @Command
- fun readBinary(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- Log.d("SerialPlugin", "Reading binary from port: ${args.path}")
- val data = serialPortManager.readFromPort(args.path, args.timeout, args.size)
- val result = JSObject().apply {
- put("data", data.toList().map { it.toInt() and 0xFF }.toJSArray())
- }
- Log.d("SerialPlugin", "Binary read successful: ${data.size} bytes read")
- invoke.resolve(result)
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to read binary data: ${e.message}", e)
- invoke.reject("Failed to read binary data: ${e.message}")
- }
- }
-
- @Command
- fun startListening(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(StartListenArgs::class.java)
- val path = args.path
- val flushMs = args.serialDataFlushIntervalMs.coerceIn(10L, 2000L)
- Log.d("SerialPlugin", "Starting listening on port: $path (flush ${flushMs}ms)")
-
- serialPortManager.startListening(path, flushMs) { eventData: JSObject ->
- try {
- Log.d("SerialPlugin", "Emitting serialData batch for $path")
- trigger("serialData", eventData)
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Error in serialData emit: ${e.message}", e)
- }
- }
- Log.d("SerialPlugin", "Listening started successfully on port: $path")
- invoke.resolve()
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to start listening: ${e.message}", e)
- invoke.reject("Failed to start listening: ${e.message}")
- }
- }
-
- @Command
- fun stopListening(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- Log.d("SerialPlugin", "Stopping listening on port: ${args.path}")
- serialPortManager.stopListening(args.path)
- Log.d("SerialPlugin", "Listening stopped successfully on port: ${args.path}")
- invoke.resolve()
- } catch (e: Exception) {
- Log.e("SerialPlugin", "Failed to stop listening: ${e.message}", e)
- invoke.reject("Failed to stop listening: ${e.message}")
- }
- }
-
- @Command
- fun setBaudRate(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- val success = serialPortManager.setBaudRate(args.path, args.baudRate)
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to set baud rate")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to set baud rate: ${e.message}")
- }
- }
-
- @Command
- fun setDataBits(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- val dataBits = when (args.dataBits) {
- is String -> DataBits.valueOf(args.dataBits as String)
- is Number -> DataBits.fromValue((args.dataBits as Number).toInt())
- null -> DataBits.EIGHT
- else -> throw IllegalArgumentException("Invalid data bits type")
- }
- val success = serialPortManager.setDataBits(args.path, dataBits)
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to set data bits")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to set data bits: ${e.message}")
- }
- }
-
- @Command
- fun setFlowControl(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- val flowControl = when (args.flowControl) {
- is String -> FlowControl.valueOf(args.flowControl as String)
- is Number -> FlowControl.fromValue((args.flowControl as Number).toInt())
- null -> FlowControl.NONE
- else -> throw IllegalArgumentException("Invalid flow control type")
- }
- val success = serialPortManager.setFlowControl(args.path, flowControl)
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to set flow control")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to set flow control: ${e.message}")
- }
- }
-
- @Command
- fun setParity(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- val parity = when (args.parity) {
- is String -> Parity.valueOf(args.parity as String)
- is Number -> Parity.fromValue((args.parity as Number).toInt())
- null -> Parity.NONE
- else -> throw IllegalArgumentException("Invalid parity type")
- }
- val success = serialPortManager.setParity(args.path, parity)
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to set parity")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to set parity: ${e.message}")
- }
- }
-
- @Command
- fun setStopBits(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- val stopBits = when (args.stopBits) {
- is String -> StopBits.valueOf(args.stopBits as String)
- is Number -> StopBits.fromValue((args.stopBits as Number).toInt())
- null -> StopBits.ONE
- else -> throw IllegalArgumentException("Invalid stop bits type")
- }
- val success = serialPortManager.setStopBits(args.path, stopBits)
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to set stop bits")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to set stop bits: ${e.message}")
- }
- }
-
- @Command
- fun setTimeout(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- val success = serialPortManager.setTimeout(args.path, args.timeout)
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to set timeout")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to set timeout: ${e.message}")
- }
- }
-
- @Command
- fun writeRequestToSend(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- val success = serialPortManager.writeRequestToSend(args.path, args.flowControl == "HARDWARE")
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to set RTS")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to set RTS: ${e.message}")
- }
- }
-
- @Command
- fun writeDataTerminalReady(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- val success = serialPortManager.writeDataTerminalReady(args.path, args.flowControl == "HARDWARE")
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to set DTR")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to set DTR: ${e.message}")
- }
- }
-
- @Command
- fun readClearToSend(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- val state = serialPortManager.readClearToSend(args.path)
- val result = JSObject()
- result.put("state", state)
- invoke.resolve(result)
- } catch (e: Exception) {
- invoke.reject("Failed to read CTS: ${e.message}")
- }
- }
-
- @Command
- fun readDataSetReady(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- val state = serialPortManager.readDataSetReady(args.path)
- val result = JSObject()
- result.put("state", state)
- invoke.resolve(result)
- } catch (e: Exception) {
- invoke.reject("Failed to read DSR: ${e.message}")
- }
- }
-
- @Command
- fun readRingIndicator(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- val state = serialPortManager.readRingIndicator(args.path)
- val result = JSObject()
- result.put("state", state)
- invoke.resolve(result)
- } catch (e: Exception) {
- invoke.reject("Failed to read RI: ${e.message}")
- }
- }
-
- @Command
- fun readCarrierDetect(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- val state = serialPortManager.readCarrierDetect(args.path)
- val result = JSObject()
- result.put("state", state)
- invoke.resolve(result)
- } catch (e: Exception) {
- invoke.reject("Failed to read CD: ${e.message}")
- }
- }
-
- @Command
- fun bytesToRead(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- val bytes = serialPortManager.bytesToRead(args.path)
- val result = JSObject()
- result.put("bytes", bytes)
- invoke.resolve(result)
- } catch (e: Exception) {
- invoke.reject("Failed to get bytes to read: ${e.message}")
- }
- }
-
- @Command
- fun bytesToWrite(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- val bytes = serialPortManager.bytesToWrite(args.path)
- val result = JSObject()
- result.put("bytes", bytes)
- invoke.resolve(result)
- } catch (e: Exception) {
- invoke.reject("Failed to get bytes to write: ${e.message}")
- }
- }
-
- @Command
- fun clearBuffer(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(PortConfigArgs::class.java)
- val bufferType = when (args.dataBits) {
- is String -> ClearBuffer.fromValue(args.dataBits as String)
- is Number -> ClearBuffer.INPUT // By default we use INPUT for numeric values
- null -> ClearBuffer.INPUT
- else -> throw IllegalArgumentException("Invalid buffer type")
- }
- val success = serialPortManager.clearBuffer(args.path, bufferType.name.lowercase())
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to clear buffer")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to clear buffer: ${e.message}")
- }
- }
-
- @Command
- fun setBreak(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- val success = serialPortManager.setBreak(args.path)
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to set break")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to set break: ${e.message}")
- }
- }
-
- @Command
- fun clearBreak(invoke: Invoke) = runOnIoThread(invoke) {
- try {
- val args = invoke.parseArgs(CloseArgs::class.java)
- val success = serialPortManager.clearBreak(args.path)
- if (success) {
- invoke.resolve()
- } else {
- invoke.reject("Failed to clear break")
- }
- } catch (e: Exception) {
- invoke.reject("Failed to clear break: ${e.message}")
- }
+ destroyCb = object : Application.ActivityLifecycleCallbacks {
+ override fun onActivityDestroyed(a: Activity) {
+ if (a !== activity) return
+ usb.shutdown()
+ MobileBridge.onAppDestroy()
+ app.unregisterActivityLifecycleCallbacks(this)
+ destroyCb = null
+ }
+ override fun onActivityCreated(a: Activity, s: Bundle?) {}
+ override fun onActivityStarted(a: Activity) {}
+ override fun onActivityResumed(a: Activity) {}
+ override fun onActivityPaused(a: Activity) {}
+ override fun onActivityStopped(a: Activity) {}
+ override fun onActivitySaveInstanceState(a: Activity, s: Bundle) {}
+ }
+ app.registerActivityLifecycleCallbacks(destroyCb!!)
+ Log.i(TAG, "load: ready")
+ }
+
+ companion object {
+ private const val TAG = "SerialPlugin"
}
}
diff --git a/android/src/main/kotlin/app/tauri/serialplugin/UsbNative.kt b/android/src/main/kotlin/app/tauri/serialplugin/UsbNative.kt
new file mode 100644
index 00000000..a6672a2b
--- /dev/null
+++ b/android/src/main/kotlin/app/tauri/serialplugin/UsbNative.kt
@@ -0,0 +1,34 @@
+package app.tauri.serialplugin
+
+import app.tauri.serialplugin.manager.UsbFdBridge
+import org.json.JSONObject
+
+/** Static JNI facade: Rust calls fd + enumerate only. */
+object UsbNative {
+ @Volatile
+ private var bridge: UsbFdBridge? = null
+
+ @JvmStatic
+ fun bind(usb: UsbFdBridge) {
+ bridge = usb
+ nativeInit()
+ }
+
+ @JvmStatic
+ private external fun nativeInit()
+
+ private fun usb(): UsbFdBridge =
+ bridge ?: throw IllegalStateException("UsbNative not bound")
+
+ @JvmStatic
+ fun enumerateJson(): String = usb().runOnIoSync { usb().enumerateJson() }
+
+ @JvmStatic
+ fun openDeviceFd(deviceName: String): Int =
+ usb().runOnIoSync { usb().openDeviceFd(deviceName) }
+
+ @JvmStatic
+ fun closeDeviceFd(deviceName: String) {
+ usb().runOnIoSync { usb().closeDeviceFd(deviceName) }
+ }
+}
diff --git a/android/src/main/kotlin/app/tauri/serialplugin/manager/BufferedEmitter.kt b/android/src/main/kotlin/app/tauri/serialplugin/manager/BufferedEmitter.kt
deleted file mode 100644
index 1581019f..00000000
--- a/android/src/main/kotlin/app/tauri/serialplugin/manager/BufferedEmitter.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package app.tauri.serialplugin.manager
-
-import android.util.Log
-import app.tauri.plugin.JSObject
-import java.util.concurrent.Executors
-import java.util.concurrent.ScheduledFuture
-import java.util.concurrent.TimeUnit
-
-/**
- * Coalesces high-frequency [onNewData] chunks and emits at most once per [flushIntervalMs]
- * to reduce WebView/JS pressure (backpressure).
- */
-internal class BufferedEmitter(
- private val path: String,
- flushIntervalMs: Long,
- private val emit: (JSObject) -> Unit,
-) {
- private val accumulator = SerialByteAccumulator()
- private val scheduler = Executors.newSingleThreadScheduledExecutor { r ->
- Thread(r, "serial-emit-$path").apply { isDaemon = true }
- }
- private val scheduled: ScheduledFuture<*>
-
- init {
- val interval = flushIntervalMs.coerceIn(10L, 2000L)
- scheduled = scheduler.scheduleAtFixedRate(
- {
- try {
- flushOnce()
- } catch (e: Exception) {
- Log.e("BufferedEmitter", "flush: ${e.message}", e)
- }
- },
- interval,
- interval,
- TimeUnit.MILLISECONDS,
- )
- }
-
- private fun flushOnce() {
- flushAccumulatorToEmit(path, accumulator) { fields ->
- val eventData = JSObject()
- fields.applyToJSObject(eventData)
- emit(eventData)
- }
- }
-
- fun addData(data: ByteArray) {
- accumulator.append(data)
- }
-
- /**
- * Bytes received via [addData] but not yet emitted to JS (waiting for the next flush).
- * Does not include data still inside the USB/driver stack — only this plugin buffer.
- */
- fun pendingByteCount(): Int = accumulator.pendingByteCount()
-
- fun stop() {
- scheduled.cancel(false)
- scheduler.shutdown()
- try {
- if (!scheduler.awaitTermination(300, TimeUnit.MILLISECONDS)) {
- scheduler.shutdownNow()
- }
- } catch (_: InterruptedException) {
- scheduler.shutdownNow()
- }
- try {
- flushAccumulatorToEmit(path, accumulator) { fields ->
- val eventData = JSObject()
- fields.applyToJSObject(eventData)
- emit(eventData)
- }
- } catch (e: Exception) {
- Log.w("BufferedEmitter", "final flush: ${e.message}")
- }
- }
-}
diff --git a/android/src/main/kotlin/app/tauri/serialplugin/manager/SerialByteAccumulator.kt b/android/src/main/kotlin/app/tauri/serialplugin/manager/SerialByteAccumulator.kt
deleted file mode 100644
index b52e8221..00000000
--- a/android/src/main/kotlin/app/tauri/serialplugin/manager/SerialByteAccumulator.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package app.tauri.serialplugin.manager
-
-import java.io.ByteArrayOutputStream
-
-/**
- * Thread-safe byte buffer for coalescing serial chunks before emitting to JS.
- * Pure JVM logic — unit-testable without Robolectric.
- */
-internal class SerialByteAccumulator {
- private val lock = Any()
- private val stream = ByteArrayOutputStream()
-
- fun append(data: ByteArray) {
- if (data.isEmpty()) return
- synchronized(lock) {
- stream.write(data)
- }
- }
-
- /**
- * Returns all accumulated bytes and clears the buffer.
- * Returns an empty array if nothing was pending.
- */
- fun drain(): ByteArray {
- synchronized(lock) {
- if (stream.size() == 0) return ByteArray(0)
- val b = stream.toByteArray()
- stream.reset()
- return b
- }
- }
-
- fun pendingByteCount(): Int = synchronized(lock) { stream.size() }
-}
diff --git a/android/src/main/kotlin/app/tauri/serialplugin/manager/SerialDataEmitFields.kt b/android/src/main/kotlin/app/tauri/serialplugin/manager/SerialDataEmitFields.kt
deleted file mode 100644
index 5371c1b8..00000000
--- a/android/src/main/kotlin/app/tauri/serialplugin/manager/SerialDataEmitFields.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package app.tauri.serialplugin.manager
-
-import app.tauri.plugin.JSObject
-
-/**
- * Payload fields for the `serialData` plugin event (mirrors what we put on [JSObject]).
- * Kept as a plain Kotlin type so tests do not need [JSObject].
- */
-internal data class SerialDataEmitFields(
- val path: String,
- val dataAsString: String,
- val size: Int,
-)
-
-internal fun serialDataPayloadFromChunk(path: String, chunk: ByteArray): SerialDataEmitFields =
- SerialDataEmitFields(
- path = path,
- dataAsString = String(chunk),
- size = chunk.size,
- )
-
-internal fun SerialDataEmitFields.applyToJSObject(target: JSObject) {
- target.put("path", path)
- target.put("data", dataAsString)
- target.put("size", size)
-}
-
-/**
- * Drains [accumulator] and invokes [emitFields] once if there was data.
- */
-internal fun flushAccumulatorToEmit(
- path: String,
- accumulator: SerialByteAccumulator,
- emitFields: (SerialDataEmitFields) -> Unit,
-) {
- val chunk = accumulator.drain()
- if (chunk.isEmpty()) return
- emitFields(serialDataPayloadFromChunk(path, chunk))
-}
diff --git a/android/src/main/kotlin/app/tauri/serialplugin/manager/SerialPortManager.kt b/android/src/main/kotlin/app/tauri/serialplugin/manager/SerialPortManager.kt
deleted file mode 100644
index 4b82b7a7..00000000
--- a/android/src/main/kotlin/app/tauri/serialplugin/manager/SerialPortManager.kt
+++ /dev/null
@@ -1,736 +0,0 @@
-package app.tauri.serialplugin.manager
-
-import android.app.PendingIntent
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.hardware.usb.UsbDevice
-import android.hardware.usb.UsbManager
-import android.os.Build
-import com.hoho.android.usbserial.driver.UsbSerialPort
-import com.hoho.android.usbserial.driver.UsbSerialProber
-import com.hoho.android.usbserial.util.SerialInputOutputManager
-import com.hoho.android.usbserial.driver.ProbeTable
-import app.tauri.plugin.JSObject
-import app.tauri.serialplugin.models.*
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.Executors
-import java.io.IOException
-import android.util.Log
-import androidx.core.content.ContextCompat
-import java.util.concurrent.CompletableFuture
-import java.util.concurrent.TimeUnit
-
-data class ManagedPort (
- val port: UsbSerialPort,
- val config: SerialPortConfig
-)
-
-/**
- * @param onIoRunError Optional: invoked on [SerialInputOutputManager.Listener.onRunError] before [closePort]
- * (e.g. emit plugin event so JS can set isOpen = false).
- */
-class SerialPortManager(
- private val context: Context,
- private val onIoRunError: ((path: String, message: String) -> Unit)? = null,
-) {
- private val usbManager: UsbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
- private val portMap = mutableMapOf()
- /** Active [SerialInputOutputManager] per path; must be stopped on close/stop/replace to avoid thread leaks. */
- private val ioManagers = ConcurrentHashMap()
- /** Batched emission to WebView (one per path while listening). */
- private val emitters = ConcurrentHashMap()
- private val executor = Executors.newCachedThreadPool()
- private val permissionFutures = mutableMapOf>()
-
- private val ACTION_USB_PERMISSION = "app.tauri.serialplugin.USB_PERMISSION"
-
- // Custom prober for unknown devices (custom VID/PID only)
- private val customProber: UsbSerialProber by lazy {
- val customTable = ProbeTable()
-
- // Add only devices with custom VID/PID not covered by the default table
- // Example: device with VID=0x1234 and PID=0x0001 compatible with FTDI
- // customTable.addProduct(0x1234, 0x0001, FtdiSerialDriver::class.java)
-
- UsbSerialProber(customTable)
- }
-
- private val usbReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- if (ACTION_USB_PERMISSION == intent.action) {
- synchronized(this) {
- val device: UsbDevice? = if (Build.VERSION.SDK_INT >= 33) {
- intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
- } else {
- @Suppress("DEPRECATION")
- intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) as UsbDevice?
- }
-
- val permissionGranted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
- val deviceName = device?.deviceName
-
- Log.d("SerialPortManager", "USB permission result for $deviceName: $permissionGranted")
-
- deviceName?.let { name ->
- permissionFutures[name]?.complete(permissionGranted)
- permissionFutures.remove(name)
- }
- }
- }
- }
- }
-
- fun registerReceiver() {
- val filter = IntentFilter(ACTION_USB_PERMISSION)
-
- if (Build.VERSION.SDK_INT >= 33) {
- context.registerReceiver(usbReceiver, filter, Context.RECEIVER_EXPORTED)
- } else {
- ContextCompat.registerReceiver(
- context,
- usbReceiver,
- filter,
- ContextCompat.RECEIVER_NOT_EXPORTED
- )
- }
- }
-
- fun unregisterReceiver() {
- try {
- context.unregisterReceiver(usbReceiver)
- } catch (_: IllegalArgumentException) {
- Log.w("SerialPortManager", "Receiver not registered")
- }
- }
-
- init {
- registerReceiver()
- }
-
- fun getAvailablePorts(): Map> {
- val result = mutableMapOf>()
-
- try {
- // Use default prober first
- val availableDrivers = UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
- Log.d("SerialPortManager", "Available drivers (default prober): ${availableDrivers.size}")
-
- availableDrivers.forEach { driver ->
- val device = driver.device
- Log.d("SerialPortManager", "Found device: ${device.deviceName}, Vendor ID: ${device.vendorId}, Product ID: ${device.productId}")
-
- result[device.deviceName] = mapOf(
- "type" to "USB",
- "vid" to device.vendorId.toString(),
- "pid" to device.productId.toString(),
- "manufacturer" to (device.manufacturerName ?: "Unknown"),
- "product" to (device.productName ?: "Unknown"),
- "serial_number" to (device.serialNumber ?: "Unknown")
- )
-
- Log.d("SerialPortManager", "Device Info: ${result[device.deviceName]}")
- }
-
- // Also check for custom prober devices
- val customDrivers = customProber.findAllDrivers(usbManager)
- Log.d("SerialPortManager", "Available drivers (custom prober): ${customDrivers.size}")
-
- customDrivers.forEach { driver ->
- val device = driver.device
- if (!result.containsKey(device.deviceName)) {
- Log.d("SerialPortManager", "Found custom device: ${device.deviceName}, Vendor ID: ${device.vendorId}, Product ID: ${device.productId}")
-
- result[device.deviceName] = mapOf(
- "type" to "USB (Custom)",
- "vid" to device.vendorId.toString(),
- "pid" to device.productId.toString(),
- "manufacturer" to (device.manufacturerName ?: "Unknown"),
- "product" to (device.productName ?: "Unknown"),
- "serial_number" to (device.serialNumber ?: "Unknown")
- )
- }
- }
-
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Error getting available ports: ${e.message}", e)
- }
-
- Log.d("SerialPortManager", "Total available ports: ${result.size}")
- return result
- }
-
- fun getManagedPorts(): List {
- return portMap.keys.toList()
- }
-
- fun openPort(config: SerialPortConfig): Boolean {
- try {
- Log.d("SerialPortManager", "Opening port: ${config.path}")
-
- // Find the device by name
- val device = findDeviceByName(config.path)
- ?: throw IOException("Device not found: ${config.path}")
-
- // Probe for driver using default prober first
- var driver = UsbSerialProber.getDefaultProber().probeDevice(device)
-
- // If no driver found, try custom prober
- if (driver == null) {
- driver = customProber.probeDevice(device)
- Log.d("SerialPortManager", "Device found via custom prober: ${device.deviceName}")
- }
-
- if (driver == null) {
- throw IOException("No driver found for device: ${config.path}")
- }
-
- // Check permissions
- if (!usbManager.hasPermission(device)) {
- Log.d("SerialPortManager", "Requesting USB permission for device: ${device.deviceName}")
-
- val permissionFuture = CompletableFuture()
- permissionFutures[device.deviceName] = permissionFuture
-
- val flags =
- PendingIntent.FLAG_IMMUTABLE
-
- val permissionIntent = PendingIntent.getBroadcast(
- context,
- 0,
- Intent(ACTION_USB_PERMISSION),
- flags
- )
- usbManager.requestPermission(device, permissionIntent)
-
- // Wait for permission result with timeout
- val permissionGranted = permissionFuture.get(10, TimeUnit.SECONDS)
- if (!permissionGranted) {
- throw IOException("USB permission denied for device: ${config.path}")
- }
- }
-
- // Open connection
- val connection = usbManager.openDevice(device)
- ?: throw IOException("Failed to open device: ${config.path}")
-
- // Get port (most devices have just one port)
- val port = driver.ports[0]
-
- // Open port
- port.open(connection)
- Log.d("SerialPortManager", "Setting port parameters: baudRate=${config.baudRate}, dataBits=${config.dataBits.value}, stopBits=${config.stopBits.value}, parity=${config.parity.value}")
-
- try {
- port.setParameters(
- config.baudRate,
- config.dataBits.value,
- config.stopBits.value,
- config.parity.value
- )
- Log.d("SerialPortManager", "Port parameters set successfully")
- } catch (_: UnsupportedOperationException) {
- Log.w("SerialPortManager", "setParameters not supported for this device, using default settings")
- // Some devices don't support parameter changes, continue with defaults
- } catch (e: Exception) {
- Log.w("SerialPortManager", "Failed to set parameters: ${e.message}, using default settings")
- // Continue with default parameters
- }
-
- // Flow control — [UsbSerialPort.setFlowControl](https://github.com/mik3y/usb-serial-for-android)
- when (config.flowControl) {
- FlowControl.NONE -> {
- try {
- port.setFlowControl(UsbSerialPort.FlowControl.NONE)
- Log.d("SerialPortManager", "Flow control: NONE")
- } catch (e: Exception) {
- Log.w("SerialPortManager", "setFlowControl(NONE): ${e.message}")
- }
- }
- FlowControl.HARDWARE -> {
- Log.d("SerialPortManager", "Enabling RTS/CTS flow control")
- try {
- port.setFlowControl(UsbSerialPort.FlowControl.RTS_CTS)
- Log.d("SerialPortManager", "Hardware (RTS/CTS) flow control set")
- } catch (_: UnsupportedOperationException) {
- Log.w("SerialPortManager", "RTS/CTS not supported, falling back to DTR/RTS pins")
- try {
- port.dtr = true
- port.rts = true
- } catch (e: Exception) {
- Log.w("SerialPortManager", "Fallback DTR/RTS failed: ${e.message}")
- }
- } catch (e: Exception) {
- Log.w("SerialPortManager", "Failed to set RTS/CTS: ${e.message}")
- }
- }
- FlowControl.SOFTWARE -> {
- Log.d("SerialPortManager", "Enabling XON/XOFF flow control")
- try {
- port.setFlowControl(UsbSerialPort.FlowControl.XON_XOFF)
- Log.d("SerialPortManager", "Software flow control set")
- } catch (e: Exception) {
- Log.w("SerialPortManager", "XON/XOFF not supported: ${e.message}")
- }
- }
- }
-
- portMap[config.path] = ManagedPort(port, config)
- Log.d("SerialPortManager", "Port opened successfully: ${config.path}")
- return true
-
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to open port: ${e.message}", e)
- throw IOException("Failed to open port: ${e.message}")
- }
- }
-
- private fun findDeviceByName(deviceName: String): UsbDevice? {
- return usbManager.deviceList.values.find { it.deviceName == deviceName }
- }
-
- private fun startIoManager(
- path: String,
- port: UsbSerialPort,
- flushIntervalMs: Long,
- emit: (JSObject) -> Unit,
- ) {
- // Replace existing reader: stop previous manager so threads are not leaked
- emitters.remove(path)?.stop()
- ioManagers.remove(path)?.let { old ->
- try {
- old.stop()
- } catch (e: Exception) {
- Log.w("SerialPortManager", "Failed to stop previous IO manager for $path: ${e.message}")
- }
- }
-
- val emitter = BufferedEmitter(path, flushIntervalMs, emit)
- emitters[path] = emitter
-
- val ioManager = SerialInputOutputManager(port, object : SerialInputOutputManager.Listener {
- override fun onNewData(data: ByteArray) {
- try {
- Log.d("SerialPortManager", "Data received on $path: ${data.size} bytes")
- emitter.addData(data)
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Error in data callback for $path: ${e.message}", e)
- }
- }
-
- override fun onRunError(e: Exception) {
- Log.e("SerialPortManager", "IO Manager error for $path: ${e.message}", e)
- val msg = e.message ?: e.toString()
- try {
- onIoRunError?.invoke(path, msg)
- } catch (cb: Exception) {
- Log.e("SerialPortManager", "onIoRunError callback failed: ${cb.message}", cb)
- }
- closePort(path)
- }
- })
-
- ioManagers[path] = ioManager
-
- try {
- executor.submit {
- try {
- ioManager.start()
- Log.d("SerialPortManager", "IO Manager started successfully for $path")
- } catch (e: Exception) {
- Log.e(
- "SerialPortManager",
- "Failed to start IO Manager for $path: ${e.message}",
- e
- )
- ioManagers.remove(path)
- emitters.remove(path)?.stop()
- closePort(path)
- }
- }
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to submit IO Manager task for $path: ${e.message}", e)
- ioManagers.remove(path)
- emitters.remove(path)?.stop()
- closePort(path)
- }
- }
-
- fun writeToPort(path: String, data: ByteArray): Int {
- try {
- val port = portMap[path] ?: throw IOException("Port not found")
-
- Log.d("SerialPortManager", "Writing to port $path: ${data.size} bytes")
-
- val writeTimeout = port.config.timeout.coerceIn(1, 600_000)
- port.port.write(data, writeTimeout)
- val bytesWritten = data.size
-
- return bytesWritten
- } catch (e: IOException) {
- Log.e("SerialPortManager", "Write failed: ${e.message}")
- throw e
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Unexpected error during write: ${e.message}", e)
- throw IOException("Failed to write data: ${e.message}")
- }
- }
-
- fun closePort(path: String) {
- try {
- emitters.remove(path)?.stop()
- ioManagers[path]?.let { mgr ->
- try {
- mgr.stop()
- } catch (e: Exception) {
- Log.w("SerialPortManager", "Failed to stop IO manager for $path: ${e.message}")
- }
- }
- ioManagers.remove(path)
- portMap[path]?.port?.close()
- portMap.remove(path)
- Log.d("SerialPortManager", "Port closed: $path")
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to close port $path: ${e.message}", e)
- throw IOException("Failed to close port: ${e.message}")
- }
- }
-
- fun closeAllPorts() {
- val exceptions = mutableListOf()
-
- portMap.keys.toList().forEach { path ->
- try {
- closePort(path)
- } catch (e: Exception) {
- exceptions.add(e)
- }
- }
-
- if (exceptions.isNotEmpty()) {
- throw IOException("Failed to close all ports: ${exceptions.joinToString(", ") { it.message ?: "" }}")
- }
- }
-
- fun setPortParameters(path: String, config: SerialPortConfig): Boolean {
- return try {
- portMap[path]?.let { port ->
- port.port.setParameters(
- config.baudRate,
- config.dataBits.value,
- config.stopBits.value,
- config.parity.value
- )
- true
- } ?: false
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set port parameters: ${e.message}", e)
- false
- }
- }
-
- fun readFromPort(path: String, timeout: Int, size: Int?): ByteArray {
- return try {
- val port = portMap[path] ?: throw IOException("Port not found")
-
- val targetSize = size ?: 1024
- val maxPacketSize = port.port.readEndpoint.maxPacketSize
- val bufferSize = minOf(targetSize, maxPacketSize)
-
- val buffer = ByteArray(bufferSize)
- // Prefer invoke timeout; fall back to open/config timeout (usb-serial: read timeout in ms)
- val stored = port.config.timeout
- val adjustedTimeout = (if (timeout > 0) timeout else stored).coerceAtLeast(200)
-
- Log.d("SerialPortManager", "Reading from port $path: bufferSize=$bufferSize, timeout=$adjustedTimeout")
-
- val bytesRead = port.port.read(buffer, adjustedTimeout)
-
- if (bytesRead > 0) {
- Log.d("SerialPortManager", "Read successful: $bytesRead bytes")
- buffer.copyOf(bytesRead)
- } else {
- Log.w("SerialPortManager", "Read timeout: no data received within $adjustedTimeout ms")
- throw IOException("Read timeout: no data received within $adjustedTimeout ms")
- }
- } catch (e: IOException) {
- Log.e("SerialPortManager", "Read failed: ${e.message}")
- throw e
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Unexpected error during read: ${e.message}", e)
- throw IOException("Failed to read data: ${e.message}")
- }
- }
-
- fun readFullyFromPort(path: String, timeout: Int, size: Int?): ByteArray {
- val port = portMap[path] ?: throw IOException("Port not found")
- val buffer = mutableListOf()
- val startTime = System.currentTimeMillis()
-
- val targetSize = size ?: 1024
- val maxPacketSize = port.port.readEndpoint.maxPacketSize
-
- while (buffer.size < targetSize && (System.currentTimeMillis() - startTime) < timeout) {
- val remainingTime = timeout - (System.currentTimeMillis() - startTime).toInt()
- if (remainingTime <= 0) break
-
- val chunkSize = minOf(targetSize - buffer.size, maxPacketSize)
- val tempBuffer = ByteArray(chunkSize)
- val bytesRead = port.port.read(tempBuffer, remainingTime.coerceAtLeast(200))
-
- if (bytesRead > 0) {
- buffer.addAll(tempBuffer.copyOf(bytesRead).toList())
- } else {
- throw IOException("Read timeout: no data received within $timeout ms")
- }
- }
-
- return if (buffer.isEmpty()) {
- throw IOException("Read timeout: no data received within $timeout ms")
- } else {
- buffer.toByteArray()
- }
- }
-
- fun setBaudRate(path: String, baudRate: Int): Boolean {
- return try {
- val port = portMap[path] ?: return false
- port.config.baudRate = baudRate
- port.port.setParameters(port.config.baudRate, port.config.dataBits.value, port.config.stopBits.value, port.config.parity.value)
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set baud rate: ${e.message}", e)
- false
- }
- }
-
- fun setDataBits(path: String, dataBits: DataBits): Boolean {
- return try {
- val port = portMap[path] ?: return false
- port.config.dataBits = dataBits
- port.port.setParameters(port.config.baudRate, port.config.dataBits.value, port.config.stopBits.value, port.config.parity.value)
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set data bits: ${e.message}", e)
- false
- }
- }
-
- fun setFlowControl(path: String, flowControl: FlowControl): Boolean {
- return try {
- val p = portMap[path]?.port ?: return false
- when (flowControl) {
- FlowControl.NONE -> p.setFlowControl(UsbSerialPort.FlowControl.NONE)
- FlowControl.HARDWARE -> p.setFlowControl(UsbSerialPort.FlowControl.RTS_CTS)
- FlowControl.SOFTWARE -> p.setFlowControl(UsbSerialPort.FlowControl.XON_XOFF)
- }
- portMap[path]?.let { it.config.flowControl = flowControl }
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set flow control: ${e.message}", e)
- false
- }
- }
-
- fun setParity(path: String, parity: Parity): Boolean {
- return try {
- val port = portMap[path] ?: return false
- port.config.parity = parity
- port.port.setParameters(port.config.baudRate, port.config.dataBits.value, port.config.stopBits.value, port.config.parity.value)
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set parity: ${e.message}", e)
- false
- }
- }
-
- fun setStopBits(path: String, stopBits: StopBits): Boolean {
- return try {
- val port = portMap[path] ?: return false
- port.config.stopBits = stopBits
- port.port.setParameters(port.config.baudRate, port.config.dataBits.value, port.config.stopBits.value, port.config.parity.value)
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set stop bits: ${e.message}", e)
- false
- }
- }
-
- fun setTimeout(path: String, timeout: Int): Boolean {
- return try {
- val managed = portMap[path] ?: return false
- // No USB-level "read timeout" register — store for read()/write() (see usb-serial read/write timeout args)
- managed.config.timeout = timeout.coerceIn(1, 600_000)
- Log.d("SerialPortManager", "Read/write timeout preference set to ${managed.config.timeout} ms for $path")
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set timeout: ${e.message}", e)
- false
- }
- }
-
- fun writeRequestToSend(path: String, level: Boolean): Boolean {
- return try {
- portMap[path]?.port?.rts = level
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set RTS: ${e.message}", e)
- false
- }
- }
-
- fun writeDataTerminalReady(path: String, level: Boolean): Boolean {
- return try {
- portMap[path]?.port?.dtr = level
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set DTR: ${e.message}", e)
- false
- }
- }
-
- fun readClearToSend(path: String): Boolean {
- return try {
- portMap[path]?.port?.cts ?: false
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to read CTS: ${e.message}", e)
- false
- }
- }
-
- fun readDataSetReady(path: String): Boolean {
- return try {
- portMap[path]?.port?.dsr ?: false
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to read DSR: ${e.message}", e)
- false
- }
- }
-
- fun readRingIndicator(path: String): Boolean {
- return try {
- portMap[path]?.port?.ri ?: false
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to read RI: ${e.message}", e)
- false
- }
- }
-
- fun readCarrierDetect(path: String): Boolean {
- return try {
- portMap[path]?.port?.cd ?: false
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to read CD: ${e.message}", e)
- false
- }
- }
-
- /**
- * When [startListening] is active, returns bytes accumulated in [BufferedEmitter] before the next
- * `serialData` flush — **not** the OS/driver queue (UsbSerialPort exposes no such API).
- * Without an active listener, returns `0`.
- */
- fun bytesToRead(path: String): Int {
- return try {
- if (!portMap.containsKey(path)) throw IOException("Port not found")
- emitters[path]?.pendingByteCount() ?: 0
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to get bytes to read: ${e.message}", e)
- 0
- }
- }
-
- /**
- * [UsbSerialPort] does not expose a pending TX queue; [writeToPort] is synchronous, so there is
- * nothing application-level queued after a successful write — always `0` here.
- */
- fun bytesToWrite(path: String): Int {
- return try {
- if (!portMap.containsKey(path)) throw IOException("Port not found")
- 0
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to get bytes to write: ${e.message}", e)
- 0
- }
- }
-
- /** [UsbSerialPort.purgeHwBuffers](https://github.com/mik3y/usb-serial-for-android) */
- fun clearBuffer(path: String, bufferType: String): Boolean {
- return try {
- val p = portMap[path]?.port ?: return false
- when (bufferType.lowercase()) {
- "input" -> p.purgeHwBuffers(false, true)
- "output" -> p.purgeHwBuffers(true, false)
- "all" -> p.purgeHwBuffers(true, true)
- else -> return false
- }
- true
- } catch (e: UnsupportedOperationException) {
- Log.w("SerialPortManager", "purgeHwBuffers not supported: ${e.message}")
- false
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to clear buffer: ${e.message}", e)
- false
- }
- }
-
- fun setBreak(path: String): Boolean {
- return try {
- portMap[path]?.port?.setBreak(true)
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to set break: ${e.message}", e)
- false
- }
- }
-
- fun clearBreak(path: String): Boolean {
- return try {
- portMap[path]?.port?.setBreak(false)
- true
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to clear break: ${e.message}", e)
- false
- }
- }
-
- /**
- * @param flushIntervalMs how often to flush buffered bytes to [emit] (10–2000 ms).
- */
- fun startListening(
- path: String,
- flushIntervalMs: Long,
- emit: (JSObject) -> Unit,
- ) {
- val port = portMap[path] ?: throw IOException("Port not found")
- startIoManager(path, port.port, flushIntervalMs, emit)
- }
-
- fun stopListening(path: String) {
- try {
- emitters.remove(path)?.stop()
- ioManagers[path]?.let { mgr ->
- try {
- mgr.stop()
- } catch (e: Exception) {
- Log.w("SerialPortManager", "Failed to stop IO manager for $path: ${e.message}")
- }
- }
- ioManagers.remove(path)
- Log.d("SerialPortManager", "Stopped listening on port: $path")
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Failed to stop listening: ${e.message}", e)
- }
- }
-
- fun cleanup() {
- try {
- closeAllPorts()
- unregisterReceiver()
- executor.shutdown()
- } catch (e: Exception) {
- Log.e("SerialPortManager", "Error during cleanup: ${e.message}", e)
- }
- }
-}
diff --git a/android/src/main/kotlin/app/tauri/serialplugin/manager/UsbFdBridge.kt b/android/src/main/kotlin/app/tauri/serialplugin/manager/UsbFdBridge.kt
new file mode 100644
index 00000000..3b0aea6a
--- /dev/null
+++ b/android/src/main/kotlin/app/tauri/serialplugin/manager/UsbFdBridge.kt
@@ -0,0 +1,303 @@
+package app.tauri.serialplugin.manager
+
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbDeviceConnection
+import android.hardware.usb.UsbManager
+import android.os.Build
+import android.util.Log
+import androidx.core.content.ContextCompat
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executor
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+/**
+ * USB fd provider for Rust [android-usb-serial] (no vendored usbserial).
+ */
+class UsbFdBridge private constructor(
+ private val context: Context?,
+ private val nativeSink: NativeSink,
+ private val registerReceiver: Boolean,
+ private val testMode: Boolean,
+) {
+ constructor(context: Context) : this(
+ context,
+ JniNativeSink,
+ true,
+ false,
+ )
+
+ companion object {
+ private const val TAG = "UsbFdBridge"
+ private const val ACTION_USB_PERMISSION = "app.tauri.serialplugin.USB_PERMISSION"
+
+ fun forTesting(sink: NativeSink = object : NativeSink {
+ override fun onPortListChange() {}
+ override fun onDeviceDetached(deviceName: String) {}
+ }): UsbFdBridge = UsbFdBridge(null, sink, false, true)
+
+ /** Robolectric: real [Context] + receiver registration, synchronous IO. */
+ fun forTesting(
+ context: Context,
+ sink: NativeSink = object : NativeSink {
+ override fun onPortListChange() {}
+ override fun onDeviceDetached(deviceName: String) {}
+ },
+ ): UsbFdBridge = UsbFdBridge(context, sink, registerReceiver = true, testMode = true)
+
+ @JvmStatic
+ fun forIntegrationTest(): UsbFdBridge = forTesting(JniNativeSink)
+ }
+
+ interface NativeSink {
+ fun onPortListChange()
+ fun onDeviceDetached(deviceName: String)
+ }
+
+ @Volatile private var shutDown = false
+
+ private val ioExecutor: Executor = if (testMode) {
+ Executor { it.run() }
+ } else {
+ Executors.newSingleThreadExecutor { r -> Thread(r, "usb-fd-io").apply { isDaemon = true } }
+ }
+ private val usbManager = context?.getSystemService(Context.USB_SERVICE) as? UsbManager
+ private val connections = ConcurrentHashMap()
+ private val permissionFutures = ConcurrentHashMap>()
+
+ private val usbReceiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ when (intent.action) {
+ ACTION_USB_PERMISSION -> {
+ val device = deviceFromIntent(intent)
+ val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
+ device?.deviceName?.let { permissionFutures[it]?.complete(granted) }
+ }
+ UsbManager.ACTION_USB_DEVICE_DETACHED -> {
+ val device = deviceFromIntent(intent)
+ val name = device?.deviceName
+ if (name != null) {
+ closeDeviceFd(name)
+ nativeSink.onDeviceDetached(name)
+ }
+ nativeSink.onPortListChange()
+ }
+ UsbManager.ACTION_USB_DEVICE_ATTACHED -> nativeSink.onPortListChange()
+ }
+ }
+ }
+
+ init {
+ if (registerReceiver && context != null) {
+ val filter = IntentFilter(ACTION_USB_PERMISSION).apply {
+ addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
+ addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
+ }
+ if (Build.VERSION.SDK_INT >= 33 || testMode) {
+ context.registerReceiver(usbReceiver, filter, Context.RECEIVER_EXPORTED)
+ } else {
+ ContextCompat.registerReceiver(
+ context, usbReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
+ }
+ }
+ }
+
+ /** Test-only: invoke the USB broadcast receiver directly (Robolectric). */
+ fun deliverBroadcastForTest(intent: Intent) {
+ check(testMode)
+ val ctx = context ?: throw IllegalStateException("no context")
+ usbReceiver.onReceive(ctx, intent)
+ }
+
+ /** Test-only: complete a pending USB permission request without a broadcast. */
+ fun completePermissionForTest(deviceName: String, granted: Boolean) {
+ check(testMode)
+ permissionFutures[deviceName]?.complete(granted)
+ }
+
+ fun shutdown() {
+ if (testMode) {
+ connections.keys.toList().forEach { closeDeviceFd(it) }
+ if (registerReceiver && context != null) {
+ try {
+ context.unregisterReceiver(usbReceiver)
+ } catch (_: IllegalArgumentException) {
+ }
+ }
+ return
+ }
+ shutDown = true
+ val pool = ioExecutor as ExecutorService
+ val ctx = context
+ val shouldUnregister = registerReceiver
+ try {
+ pool.execute {
+ try {
+ connections.keys.toList().forEach { closeDeviceFd(it) }
+ } finally {
+ if (shouldUnregister && ctx != null) {
+ try {
+ ctx.unregisterReceiver(usbReceiver)
+ } catch (_: IllegalArgumentException) {
+ }
+ }
+ pool.shutdown()
+ }
+ }
+ } catch (_: RejectedExecutionException) {
+ connections.keys.toList().forEach { closeDeviceFd(it) }
+ if (shouldUnregister && ctx != null) {
+ try {
+ ctx.unregisterReceiver(usbReceiver)
+ } catch (_: IllegalArgumentException) {
+ }
+ }
+ pool.shutdown()
+ }
+ }
+
+ fun runOnIoSync(block: () -> Unit) {
+ runOnIoSync { block() }
+ }
+
+ fun runOnIoSync(block: () -> T): T {
+ if (shutDown) throw IOException("USB fd bridge shut down")
+ if (testMode) return block()
+ return CompletableFuture.supplyAsync(block, ioExecutor).get()
+ }
+
+ fun enumerateJson(): String {
+ val mgr = usbManager ?: run {
+ Log.w(TAG, "enumerateJson: no UsbManager")
+ return JSONObject().put("ports", JSONObject()).toString()
+ }
+ val ports = JSONObject()
+ mgr.deviceList.values.forEach { device ->
+ val key = device.deviceName
+ try {
+ ports.put(key, deviceInfo(device))
+ } catch (e: Exception) {
+ Log.e(TAG, "enumerateJson: skip $key: ${e.message}", e)
+ }
+ }
+ val json = JSONObject().put("ports", ports).toString()
+ Log.i(TAG, "enumerateJson: ${ports.length()} device(s) json=$json")
+ return json
+ }
+
+ fun openDeviceFd(deviceName: String): Int {
+ connections[deviceName]?.let {
+ Log.i(TAG, "openDeviceFd reuse $deviceName fd=${it.fileDescriptor}")
+ return it.fileDescriptor
+ }
+ val mgr = usbManager ?: throw IOException("no UsbManager")
+ val device = mgr.deviceList.values.find { it.deviceName == deviceName }
+ ?: throw IOException("device not found: $deviceName")
+ if (!mgr.hasPermission(device)) requestPermission(device)
+ // Do NOT claimInterface here — Rust nusb detach_and_claim owns claiming.
+ // Pre-claim makes the same fd report "io interface is busy" on open.
+ val conn = mgr.openDevice(device) ?: throw IOException("open failed: $deviceName")
+ connections[deviceName] = conn
+ Log.i(
+ TAG,
+ "openDeviceFd $deviceName fd=${conn.fileDescriptor} ifaces=${device.interfaceCount} (unclaimed for nusb)",
+ )
+ return conn.fileDescriptor
+ }
+
+ fun closeDeviceFd(deviceName: String) {
+ connections.remove(deviceName)?.close()
+ }
+
+ /** Test-only: inject a pre-opened connection (Robolectric / harness). */
+ fun adoptConnectionForTest(deviceName: String, connection: UsbDeviceConnection) {
+ check(testMode)
+ connections[deviceName] = connection
+ }
+
+ /** Nested [JSONObject] (not Map) — avoids ambiguous `JSONObject.put` wrap. */
+ private fun deviceInfo(device: UsbDevice): JSONObject {
+ fun safeName(block: () -> String?): String = try {
+ block() ?: ""
+ } catch (e: SecurityException) {
+ Log.w(TAG, "deviceInfo string denied for ${device.deviceName}: ${e.message}")
+ ""
+ }
+ val ifaces = JSONArray()
+ for (i in 0 until device.interfaceCount) {
+ val iface = device.getInterface(i)
+ ifaces.put(
+ JSONObject()
+ .put("id", iface.id)
+ .put("class", iface.interfaceClass)
+ .put("subclass", iface.interfaceSubclass)
+ .put("protocol", iface.interfaceProtocol),
+ )
+ }
+ return JSONObject()
+ .put("type", "Usb")
+ .put("vid", "0x%04X".format(device.vendorId))
+ .put("pid", "0x%04X".format(device.productId))
+ .put("manufacturer", safeName { device.manufacturerName })
+ .put("product", safeName { device.productName })
+ .put("serial_number", safeName { device.serialNumber })
+ .put("interfaces", ifaces)
+ }
+
+ private fun requestPermission(device: UsbDevice) {
+ val mgr = usbManager ?: throw IOException("no UsbManager")
+ val ctx = context ?: throw IOException("no context")
+ val name = device.deviceName
+ val fut = CompletableFuture()
+ permissionFutures[name] = fut
+ val intent = Intent(ACTION_USB_PERMISSION).apply {
+ setPackage(ctx.packageName)
+ }
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_MUTABLE
+ } else {
+ 0
+ }
+ val pi = PendingIntent.getBroadcast(ctx, 0, intent, flags)
+ mgr.requestPermission(device, pi)
+ val granted = try {
+ if (testMode) {
+ fut.get(200, TimeUnit.MILLISECONDS)
+ } else {
+ fut.get(30, TimeUnit.SECONDS)
+ }
+ } catch (_: TimeoutException) {
+ false
+ }
+ if (!granted) {
+ throw IOException("USB permission denied for $name")
+ }
+ }
+
+ private fun deviceFromIntent(intent: Intent): UsbDevice? =
+ if (Build.VERSION.SDK_INT >= 33) {
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
+ }
+}
+
+private object JniNativeSink : UsbFdBridge.NativeSink {
+ override fun onPortListChange() = app.tauri.serialplugin.MobileBridge.onPortListChange()
+ override fun onDeviceDetached(deviceName: String) =
+ app.tauri.serialplugin.MobileBridge.onDeviceDetached(deviceName)
+}
diff --git a/android/src/main/kotlin/app/tauri/serialplugin/models/SerialPortConfig.kt b/android/src/main/kotlin/app/tauri/serialplugin/models/SerialPortConfig.kt
deleted file mode 100644
index b1b8927c..00000000
--- a/android/src/main/kotlin/app/tauri/serialplugin/models/SerialPortConfig.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package app.tauri.serialplugin.models
-
-import com.hoho.android.usbserial.driver.UsbSerialPort
-
-enum class DataBits(val value: Int) {
- FIVE(5),
- SIX(6),
- SEVEN(7),
- EIGHT(8);
-
- companion object {
- fun fromValue(value: Int): DataBits {
- return DataBits.entries.find { it.value == value } ?: EIGHT
- }
- }
-}
-
-enum class FlowControl {
- NONE,
- SOFTWARE,
- HARDWARE;
-
- companion object {
- fun fromValue(value: Int): FlowControl {
- return when (value) {
- 1 -> SOFTWARE
- 2 -> HARDWARE
- else -> NONE
- }
- }
- }
-}
-
-enum class Parity(val value: Int) {
- NONE(UsbSerialPort.PARITY_NONE),
- ODD(UsbSerialPort.PARITY_ODD),
- EVEN(UsbSerialPort.PARITY_EVEN);
-
- companion object {
- fun fromValue(value: Int): Parity {
- return Parity.entries.find { it.value == value } ?: NONE
- }
- }
-}
-
-enum class StopBits(val value: Int) {
- ONE(UsbSerialPort.STOPBITS_1),
- TWO(UsbSerialPort.STOPBITS_2);
-
- companion object {
- fun fromValue(value: Int): StopBits {
- return StopBits.entries.find { it.value == value } ?: ONE
- }
- }
-}
-
-enum class ClearBuffer {
- INPUT,
- OUTPUT,
- ALL;
-
- companion object {
- fun fromValue(value: String): ClearBuffer {
- return when (value.lowercase()) {
- "input" -> INPUT
- "output" -> OUTPUT
- "all" -> ALL
- else -> INPUT
- }
- }
- }
-}
-
-data class SerialPortConfig(
- var path: String,
- var baudRate: Int = 9600,
- var dataBits: DataBits = DataBits.EIGHT,
- var flowControl: FlowControl = FlowControl.NONE,
- var parity: Parity = Parity.NONE,
- var stopBits: StopBits = StopBits.ONE,
- var timeout: Int = 1000
-)
diff --git a/android/src/test/AndroidManifest.xml b/android/src/test/AndroidManifest.xml
new file mode 100644
index 00000000..f0251043
--- /dev/null
+++ b/android/src/test/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/src/test/kotlin/app/tauri/serialplugin/SerialPluginConversionTest.kt b/android/src/test/kotlin/app/tauri/serialplugin/SerialPluginConversionTest.kt
deleted file mode 100644
index 1e44f089..00000000
--- a/android/src/test/kotlin/app/tauri/serialplugin/SerialPluginConversionTest.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package app.tauri.serialplugin
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
-import org.junit.Test
-
-/**
- * Tests for package-level [Map.toJSObject] and [List.toJSArray] helpers used by [SerialPlugin].
- */
-class SerialPluginConversionTest {
- @Test
- fun toJSObject_flatPrimitives() {
- val m = mapOf(
- "n" to 42,
- "s" to "hello",
- "b" to true,
- )
- val o = m.toJSObject()
- assertEquals(42, o.getInteger("n"))
- assertEquals("hello", o.getString("s"))
- assertEquals(true, o.getBoolean("b"))
- }
-
- @Test
- fun toJSObject_nestedMap() {
- val inner = mapOf("k" to "v", "x" to 1)
- val m = mapOf("outer" to inner)
- val o = m.toJSObject()
- val innerObj = o.getJSObject("outer")
- assertNotNull(innerObj)
- assertEquals("v", innerObj!!.getString("k"))
- assertEquals(1, innerObj.getInteger("x"))
- }
-
- @Test
- fun toJSArray_listOfMaps() {
- val list: List = listOf(
- mapOf("a" to 1),
- mapOf("b" to 2),
- )
- val arr = list.toJSArray()
- assertEquals(2, arr.length())
- val first = arr.getJSONObject(0)
- assertEquals(1, first.getInt("a"))
- }
-
- @Test
- fun toJSArray_mixedPrimitives() {
- val list: List = listOf("x", 3, false)
- val arr = list.toJSArray()
- assertEquals(3, arr.length())
- assertEquals("x", arr.getString(0))
- assertEquals(3, arr.getInt(1))
- assertEquals(false, arr.getBoolean(2))
- }
-
- @Test
- fun toJSArray_nestedListOfInts() {
- val inner: List = listOf(1, 2)
- val outer: List = listOf(inner)
- val arr = outer.toJSArray()
- assertEquals(1, arr.length())
- val nested = arr.getJSONArray(0)
- assertEquals(2, nested.length())
- assertEquals(1, nested.getInt(0))
- assertEquals(2, nested.getInt(1))
- }
-}
diff --git a/android/src/test/kotlin/app/tauri/serialplugin/manager/BufferedEmitterTest.kt b/android/src/test/kotlin/app/tauri/serialplugin/manager/BufferedEmitterTest.kt
deleted file mode 100644
index e919fc3c..00000000
--- a/android/src/test/kotlin/app/tauri/serialplugin/manager/BufferedEmitterTest.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package app.tauri.serialplugin.manager
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-class BufferedEmitterTest {
- @Test
- fun pendingByteCount_tracks_data_before_flush() {
- // Max flush interval (2000 ms) so the first scheduled flush is late; we only assert immediately.
- val emitter = BufferedEmitter("/dev/usbX", 2000L) { _ -> }
- try {
- assertEquals(0, emitter.pendingByteCount())
- emitter.addData(byteArrayOf(10, 20, 30))
- assertEquals(3, emitter.pendingByteCount())
- } finally {
- emitter.stop()
- }
- }
-}
diff --git a/android/src/test/kotlin/app/tauri/serialplugin/manager/SerialByteAccumulatorTest.kt b/android/src/test/kotlin/app/tauri/serialplugin/manager/SerialByteAccumulatorTest.kt
deleted file mode 100644
index 566adef8..00000000
--- a/android/src/test/kotlin/app/tauri/serialplugin/manager/SerialByteAccumulatorTest.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package app.tauri.serialplugin.manager
-
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-class SerialByteAccumulatorTest {
- @Test
- fun append_empty_is_noop() {
- val acc = SerialByteAccumulator()
- acc.append(ByteArray(0))
- assertEquals(0, acc.pendingByteCount())
- assertArrayEquals(ByteArray(0), acc.drain())
- }
-
- @Test
- fun append_then_drain_preserves_order() {
- val acc = SerialByteAccumulator()
- acc.append(byteArrayOf(1, 2))
- acc.append(byteArrayOf(3))
- assertEquals(3, acc.pendingByteCount())
- assertArrayEquals(byteArrayOf(1, 2, 3), acc.drain())
- assertEquals(0, acc.pendingByteCount())
- assertArrayEquals(ByteArray(0), acc.drain())
- }
-
- @Test
- fun drain_when_empty_returns_empty() {
- val acc = SerialByteAccumulator()
- assertArrayEquals(ByteArray(0), acc.drain())
- }
-
- @Test
- fun concurrent_appends_merge_consistently() {
- val acc = SerialByteAccumulator()
- val threads = List(8) { i ->
- Thread {
- repeat(100) {
- acc.append(byteArrayOf(i.toByte()))
- }
- }
- }
- threads.forEach { it.start() }
- threads.forEach { it.join() }
- val out = acc.drain()
- assertEquals(800, out.size)
- // All bytes are in 0..7 — count occurrences
- val counts = IntArray(8)
- for (b in out) {
- counts[b.toInt() and 0xff]++
- }
- for (i in 0 until 8) {
- assertEquals(100, counts[i])
- }
- }
-}
diff --git a/android/src/test/kotlin/app/tauri/serialplugin/manager/SerialDataEmitFieldsTest.kt b/android/src/test/kotlin/app/tauri/serialplugin/manager/SerialDataEmitFieldsTest.kt
deleted file mode 100644
index b4b46659..00000000
--- a/android/src/test/kotlin/app/tauri/serialplugin/manager/SerialDataEmitFieldsTest.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package app.tauri.serialplugin.manager
-
-import app.tauri.plugin.JSObject
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-class SerialDataEmitFieldsTest {
- @Test
- fun payload_from_chunk_preserves_utf8_string() {
- val chunk = "café 🚀".toByteArray(Charsets.UTF_8)
- val p = serialDataPayloadFromChunk("/dev/usb", chunk)
- assertEquals("/dev/usb", p.path)
- assertEquals("café 🚀", p.dataAsString)
- assertEquals(chunk.size, p.size)
- }
-
- @Test
- fun payload_from_chunk_preserves_nul_and_binary_bytes() {
- val chunk = byteArrayOf(0x48, 0x00, 0x01.toByte())
- val p = serialDataPayloadFromChunk("/dev/usb", chunk)
- assertEquals(3, p.size)
- assertEquals(3, p.dataAsString.length)
- assertEquals('\u0000', p.dataAsString[1])
- assertEquals(1.toChar(), p.dataAsString[2])
- }
-
- @Test
- fun applyToJSObject_sets_path_data_size() {
- val p = SerialDataEmitFields(
- path = "/dev/tty",
- dataAsString = "ab",
- size = 2,
- )
- val o = JSObject()
- p.applyToJSObject(o)
- assertEquals("/dev/tty", o.getString("path"))
- assertEquals("ab", o.getString("data"))
- assertEquals(2, o.getInteger("size"))
- }
-
- @Test
- fun flush_accumulator_invokes_emit_once_with_merged_bytes() {
- val acc = SerialByteAccumulator()
- acc.append(byteArrayOf(65, 66))
- acc.append(byteArrayOf(67))
- val captured = mutableListOf()
- flushAccumulatorToEmit("COM1", acc) { captured.add(it) }
- assertEquals(1, captured.size)
- assertEquals("COM1", captured[0].path)
- assertEquals("ABC", captured[0].dataAsString)
- assertEquals(3, captured[0].size)
- }
-
- @Test
- fun flush_accumulator_skips_emit_when_empty() {
- val acc = SerialByteAccumulator()
- assertEquals(0, acc.pendingByteCount())
- assertEquals(0, acc.drain().size)
- val captured = mutableListOf()
- flushAccumulatorToEmit("COM1", acc) { captured.add(it) }
- assertEquals(0, captured.size)
- }
-}
diff --git a/android/src/test/kotlin/app/tauri/serialplugin/manager/UsbFdBridgeTest.kt b/android/src/test/kotlin/app/tauri/serialplugin/manager/UsbFdBridgeTest.kt
new file mode 100644
index 00000000..bfc59089
--- /dev/null
+++ b/android/src/test/kotlin/app/tauri/serialplugin/manager/UsbFdBridgeTest.kt
@@ -0,0 +1,252 @@
+package app.tauri.serialplugin.manager
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbDeviceConnection
+import android.hardware.usb.UsbManager
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.mockStatic
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.IOException
+import java.util.concurrent.atomic.AtomicInteger
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28])
+class UsbFdBridgeTest {
+
+ private companion object {
+ const val DEVICE = "/dev/bus/usb/001/001"
+ const val ACTION_USB_PERMISSION = "app.tauri.serialplugin.USB_PERMISSION"
+ }
+
+ private class RecordingSink : UsbFdBridge.NativeSink {
+ val portListChanges = AtomicInteger(0)
+ val detached = mutableListOf()
+
+ override fun onPortListChange() {
+ portListChanges.incrementAndGet()
+ }
+
+ override fun onDeviceDetached(deviceName: String) {
+ detached.add(deviceName)
+ }
+ }
+
+ private data class UsbHarness(
+ val bridge: UsbFdBridge,
+ val usbManager: UsbManager,
+ val context: Context,
+ val sink: RecordingSink,
+ )
+
+ private fun usbHarness(sink: RecordingSink = RecordingSink()): UsbHarness {
+ val base = ApplicationProvider.getApplicationContext()
+ val usbManager = mock()
+ val context = object : ContextWrapper(base) {
+ override fun getSystemService(name: String): Any? =
+ if (Context.USB_SERVICE == name) usbManager else super.getSystemService(name)
+ }
+ val bridge = UsbFdBridge.forTesting(context, sink)
+ return UsbHarness(bridge, usbManager, context, sink)
+ }
+
+ private fun mockDevice(name: String = DEVICE): UsbDevice {
+ val device = mock()
+ whenever(device.deviceName).thenReturn(name)
+ whenever(device.vendorId).thenReturn(0x0403)
+ whenever(device.productId).thenReturn(0x6001)
+ whenever(device.interfaceCount).thenReturn(1)
+ val iface = mock()
+ whenever(iface.id).thenReturn(0)
+ whenever(iface.interfaceClass).thenReturn(255)
+ whenever(iface.interfaceSubclass).thenReturn(255)
+ whenever(iface.interfaceProtocol).thenReturn(255)
+ whenever(device.getInterface(0)).thenReturn(iface)
+ return device
+ }
+
+ private fun mockConn(fd: Int): UsbDeviceConnection {
+ val conn = mock()
+ whenever(conn.fileDescriptor).thenReturn(fd)
+ return conn
+ }
+
+ private fun stubDeviceList(usbManager: UsbManager, device: UsbDevice) {
+ whenever(usbManager.deviceList).thenReturn(hashMapOf("key" to device))
+ }
+
+ @Test
+ fun enumerateJson_emptyWithoutUsbManager() {
+ val bridge = UsbFdBridge.forTesting()
+ val json = bridge.enumerateJson()
+ assertTrue(json.contains("\"ports\""))
+ }
+
+ @Test
+ fun enumerateJson_includesInterfaces() {
+ val harness = usbHarness()
+ val device = mockDevice()
+ stubDeviceList(harness.usbManager, device)
+ val json = harness.bridge.enumerateJson()
+ assertTrue(json.contains("\"interfaces\""))
+ assertTrue(json.contains("\"class\":255"))
+ }
+
+ @Test
+ fun adoptConnection_roundtripFd() {
+ val bridge = UsbFdBridge.forTesting()
+ val conn = mockConn(99)
+ bridge.adoptConnectionForTest(DEVICE, conn)
+ assertEquals(99, bridge.openDeviceFd(DEVICE))
+ bridge.closeDeviceFd(DEVICE)
+ }
+
+ @Test
+ fun openDeviceFd_permissionGranted() {
+ val harness = usbHarness()
+ val device = mockDevice()
+ stubDeviceList(harness.usbManager, device)
+ whenever(harness.usbManager.hasPermission(device)).thenReturn(false)
+ val conn = mockConn(42)
+ whenever(harness.usbManager.openDevice(device)).thenReturn(conn)
+ doAnswer {
+ harness.bridge.completePermissionForTest(DEVICE, true)
+ null
+ }.whenever(harness.usbManager).requestPermission(eq(device), any())
+
+ assertEquals(42, harness.bridge.openDeviceFd(DEVICE))
+ }
+
+ @Test
+ fun openDeviceFd_permissionDenied() {
+ val harness = usbHarness()
+ val device = mockDevice()
+ stubDeviceList(harness.usbManager, device)
+ whenever(harness.usbManager.hasPermission(device)).thenReturn(false)
+ doAnswer {
+ harness.bridge.completePermissionForTest(DEVICE, false)
+ null
+ }.whenever(harness.usbManager).requestPermission(eq(device), any())
+
+ val err = assertThrows(IOException::class.java) {
+ harness.bridge.openDeviceFd(DEVICE)
+ }
+ assertTrue(err.message!!.contains("permission denied"))
+ }
+
+ @Test
+ fun openDeviceFd_permissionTimeout() {
+ val harness = usbHarness()
+ val device = mockDevice()
+ stubDeviceList(harness.usbManager, device)
+ whenever(harness.usbManager.hasPermission(device)).thenReturn(false)
+
+ val err = assertThrows(IOException::class.java) {
+ harness.bridge.openDeviceFd(DEVICE)
+ }
+ assertTrue(err.message!!.contains("permission denied"))
+ verify(harness.usbManager, never()).openDevice(device)
+ }
+
+ @Test
+ @Config(sdk = [31])
+ fun requestPermission_usesMutablePendingIntentOnApi31() {
+ val harness = usbHarness()
+ val device = mockDevice()
+ stubDeviceList(harness.usbManager, device)
+ whenever(harness.usbManager.hasPermission(device)).thenReturn(false)
+ val conn = mockConn(7)
+ whenever(harness.usbManager.openDevice(device)).thenReturn(conn)
+
+ mockStatic(PendingIntent::class.java).use { mocked ->
+ mocked.`when` {
+ PendingIntent.getBroadcast(any(), anyInt(), any(), anyInt())
+ }.thenAnswer { inv ->
+ val flags = inv.arguments[3] as Int
+ assertEquals(PendingIntent.FLAG_MUTABLE, flags and PendingIntent.FLAG_MUTABLE)
+ mock()
+ }
+ doAnswer {
+ harness.bridge.completePermissionForTest(DEVICE, true)
+ null
+ }.whenever(harness.usbManager).requestPermission(eq(device), any())
+
+ assertEquals(7, harness.bridge.openDeviceFd(DEVICE))
+ }
+ }
+
+ @Test
+ fun onDeviceDetached_notifiesSinkAndClosesConnection() {
+ val sink = RecordingSink()
+ val harness = usbHarness(sink)
+ val device = mockDevice()
+ val conn = mockConn(11)
+ harness.bridge.adoptConnectionForTest(DEVICE, conn)
+
+ harness.bridge.deliverBroadcastForTest(
+ Intent(UsbManager.ACTION_USB_DEVICE_DETACHED).apply {
+ putExtra(UsbManager.EXTRA_DEVICE, device)
+ },
+ )
+
+ assertEquals(listOf(DEVICE), sink.detached)
+ assertTrue(sink.portListChanges.get() >= 1)
+ verify(conn).close()
+ }
+
+ @Test
+ fun onDeviceAttached_notifiesPortListChange() {
+ val sink = RecordingSink()
+ val harness = usbHarness(sink)
+ val before = sink.portListChanges.get()
+
+ harness.bridge.deliverBroadcastForTest(Intent(UsbManager.ACTION_USB_DEVICE_ATTACHED))
+
+ assertEquals(before + 1, sink.portListChanges.get())
+ assertTrue(sink.detached.isEmpty())
+ }
+
+ @Test
+ fun shutdown_unregistersReceiver() {
+ val sink = RecordingSink()
+ val harness = usbHarness(sink)
+ val before = sink.portListChanges.get()
+
+ harness.bridge.shutdown()
+
+ harness.context.sendBroadcast(Intent(UsbManager.ACTION_USB_DEVICE_ATTACHED))
+ assertEquals(before, sink.portListChanges.get())
+ }
+
+ @Test
+ fun openDeviceFd_reusesExistingConnection() {
+ val harness = usbHarness()
+ val device = mockDevice()
+ stubDeviceList(harness.usbManager, device)
+ whenever(harness.usbManager.hasPermission(device)).thenReturn(true)
+ val conn = mockConn(55)
+ whenever(harness.usbManager.openDevice(device)).thenReturn(conn)
+
+ assertEquals(55, harness.bridge.openDeviceFd(DEVICE))
+ assertEquals(55, harness.bridge.openDeviceFd(DEVICE))
+ verify(harness.usbManager, times(1)).openDevice(device)
+ }
+}
diff --git a/android/src/test/kotlin/app/tauri/serialplugin/models/SerialModelsTest.kt b/android/src/test/kotlin/app/tauri/serialplugin/models/SerialModelsTest.kt
deleted file mode 100644
index 5be20a79..00000000
--- a/android/src/test/kotlin/app/tauri/serialplugin/models/SerialModelsTest.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package app.tauri.serialplugin.models
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-class SerialModelsTest {
- @Test
- fun clearBuffer_fromValue_isCaseInsensitive() {
- assertEquals(ClearBuffer.INPUT, ClearBuffer.fromValue("input"))
- assertEquals(ClearBuffer.OUTPUT, ClearBuffer.fromValue("OUTPUT"))
- assertEquals(ClearBuffer.ALL, ClearBuffer.fromValue("All"))
- }
-
- @Test
- fun dataBits_fromValue_roundTrip() {
- assertEquals(DataBits.EIGHT, DataBits.fromValue(8))
- assertEquals(DataBits.FIVE, DataBits.fromValue(5))
- }
-
- @Test
- fun flowControl_fromValue() {
- assertEquals(FlowControl.NONE, FlowControl.fromValue(0))
- assertEquals(FlowControl.SOFTWARE, FlowControl.fromValue(1))
- assertEquals(FlowControl.HARDWARE, FlowControl.fromValue(2))
- }
-
- @Test
- fun serialPortConfig_defaultTimeout() {
- val c = SerialPortConfig(path = "/dev/usb", baudRate = 115200)
- assertEquals(1000, c.timeout)
- }
-
- @Test
- fun dataBits_fromValue_unknownDefaultsToEight() {
- assertEquals(DataBits.EIGHT, DataBits.fromValue(999))
- }
-
- @Test
- fun parity_roundTrip_and_unknownDefaultsToNone() {
- Parity.entries.forEach { p ->
- assertEquals(p, Parity.fromValue(p.value))
- }
- assertEquals(Parity.NONE, Parity.fromValue(-1))
- }
-
- @Test
- fun stopBits_roundTrip_and_unknownDefaultsToOne() {
- StopBits.entries.forEach { s ->
- assertEquals(s, StopBits.fromValue(s.value))
- }
- assertEquals(StopBits.ONE, StopBits.fromValue(-99))
- }
-
- @Test
- fun clearBuffer_unknownDefaultsToInput() {
- assertEquals(ClearBuffer.INPUT, ClearBuffer.fromValue("not-a-buffer"))
- }
-}
diff --git a/build.rs b/build.rs
index 33b7ee08..a64fe4a5 100755
--- a/build.rs
+++ b/build.rs
@@ -4,7 +4,6 @@
const COMMANDS: &[&str] = &[
"available_ports",
- "available_ports_direct",
"managed_ports",
"cancel_read",
"close",
@@ -13,8 +12,11 @@ const COMMANDS: &[&str] = &[
"open",
"read",
"read_binary",
- "start_listening",
- "stop_listening",
+ "capabilities",
+ "watch",
+ "unwatch",
+ "watch_ports",
+ "unwatch_ports",
"write",
"write_binary",
"set_baud_rate",
@@ -34,17 +36,26 @@ const COMMANDS: &[&str] = &[
"clear_buffer",
"set_break",
"clear_break",
- "write_rts",
- "write_dtr",
- "read_cts",
- "read_dsr",
- "read_ri",
- "read_cd",
"set_log_level",
"get_log_level",
+ "exchange",
+ "exchange_binary",
+ "cancel_exchange",
+ "at",
+ "at_phases",
+ "send_sms_pdu",
+ "configure_at_session",
+ "enable_mux",
+ "open_mux_channel",
+ "disable_mux",
];
fn main() {
+ let target = std::env::var("TARGET").unwrap_or_default();
+ if target.contains("android") {
+ println!("cargo:rustc-link-lib=log");
+ }
+
let result = tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.try_build();
diff --git a/crates/android-usb-serial/CHANGELOG.md b/crates/android-usb-serial/CHANGELOG.md
new file mode 100644
index 00000000..330d565a
--- /dev/null
+++ b/crates/android-usb-serial/CHANGELOG.md
@@ -0,0 +1,8 @@
+# Changelog
+
+## 0.1.0
+
+- Initial release: FTDI, CP21xx, CH34x, Prolific, CDC-ACM, GSM modem, Chrome CCD drivers on `nusb`
+- `from_raw_fd` / `NusbTransport` for Android `UsbDeviceConnection` fds
+- `ProbeTable`, `SerialPortHandle`, optional `serialport-compat` and `fake-transport`
+- Golden parity fixtures (≥560 Java-sourced control sequences)
diff --git a/crates/android-usb-serial/Cargo.toml b/crates/android-usb-serial/Cargo.toml
new file mode 100644
index 00000000..a8f2c366
--- /dev/null
+++ b/crates/android-usb-serial/Cargo.toml
@@ -0,0 +1,66 @@
+[package]
+name = "android-usb-serial"
+version = "0.1.0"
+description = "Pure Rust USB serial drivers for Android (and Linux), built on nusb"
+edition = "2021"
+rust-version = "1.79"
+license = "Apache-2.0 OR MIT"
+readme = "README.md"
+repository = "https://github.com/s00d/tauri-plugin-serialplugin"
+homepage = "https://github.com/s00d/tauri-plugin-serialplugin/tree/master/crates/android-usb-serial"
+documentation = "https://docs.rs/android-usb-serial"
+authors = ["s00d"]
+keywords = ["usb", "serial", "android", "nusb", "serialport"]
+categories = ["hardware-support", "os::android-apis", "embedded"]
+# Library package; lockfile not required for consumers. Golden fixtures stay in crate.
+exclude = [
+ "/target/",
+ "Cargo.lock",
+]
+
+[package.metadata.docs.rs]
+all-features = false
+features = ["serialport-compat", "fake-transport"]
+targets = ["x86_64-unknown-linux-gnu", "x86_64-linux-android"]
+rustdoc-args = ["--cfg", "docsrs"]
+
+[features]
+default = ["serialport-compat"]
+# In-memory FakeTransport + golden_record binary (tests / harnesses).
+fake-transport = ["dep:serde", "dep:serde_json", "dep:base64"]
+# serialport::SerialPort adapter for drop-in desktop/mobile facades.
+serialport-compat = ["dep:serialport"]
+
+[dependencies]
+log = "0.4"
+
+[dependencies.serde]
+version = "1.0"
+features = ["derive"]
+optional = true
+
+[dependencies.serde_json]
+version = "1.0"
+optional = true
+
+[dependencies.base64]
+version = "0.22"
+optional = true
+
+[dependencies.serialport]
+version = "4.9"
+optional = true
+
+[target.'cfg(any(target_os = "android", target_os = "linux"))'.dependencies]
+nusb = "0.2"
+libc = "0.2"
+
+[dev-dependencies]
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+base64 = "0.22"
+
+[[bin]]
+name = "golden_record"
+path = "src/bin/golden_record.rs"
+required-features = ["fake-transport"]
diff --git a/crates/android-usb-serial/LICENSE-APACHE b/crates/android-usb-serial/LICENSE-APACHE
new file mode 100755
index 00000000..4947287f
--- /dev/null
+++ b/crates/android-usb-serial/LICENSE-APACHE
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/crates/android-usb-serial/LICENSE-MIT b/crates/android-usb-serial/LICENSE-MIT
new file mode 100755
index 00000000..4d754725
--- /dev/null
+++ b/crates/android-usb-serial/LICENSE-MIT
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 - Present Tauri Apps Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/crates/android-usb-serial/NOTICE b/crates/android-usb-serial/NOTICE
new file mode 100644
index 00000000..343149f4
--- /dev/null
+++ b/crates/android-usb-serial/NOTICE
@@ -0,0 +1,9 @@
+android-usb-serial
+Copyright (c) s00d and contributors
+
+Portions of driver logic are derived from usb-serial-for-android
+(https://github.com/mik3y/usb-serial-for-android), Copyright (c) Mike Wakerly
+and contributors, licensed under the MIT License.
+
+The MIT License above and the dual Apache-2.0 OR MIT license of this crate
+apply to those derived portions as described in LICENSE-MIT and LICENSE-APACHE.
diff --git a/crates/android-usb-serial/README.md b/crates/android-usb-serial/README.md
new file mode 100644
index 00000000..4dea6e93
--- /dev/null
+++ b/crates/android-usb-serial/README.md
@@ -0,0 +1,356 @@
+# android-usb-serial
+
+Pure Rust USB serial drivers for **Android** (and Linux), built on [nusb](https://docs.rs/nusb).
+
+Ported from [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) driver
+protocol logic — verified byte-for-byte against **567 golden JSON fixtures**.
+
+[](https://crates.io/crates/android-usb-serial)
+[](https://docs.rs/android-usb-serial)
+[](LICENSE-APACHE)
+
+## Supported chips
+
+| Driver | Examples (VID:PID) |
+|--------|--------------------|
+| FTDI | FT232R `0403:6001`, FT2232H `0403:6010` |
+| Silicon Labs CP21xx | CP2102 `10C4:EA60`, CP2105 `10C4:EA70` |
+| WCH CH34x | CH340 `1A86:7523` |
+| Prolific PL2303 | HX / HXN / type 0x01 / TA |
+| CDC ACM | Castrated single-iface, IAD, multi-port |
+| GSM modem | Fibocom-style vendor ports |
+| Chrome CCD / CR50 | `18D1:5014` (3 ports) |
+
+## Install
+
+```toml
+[dependencies]
+android-usb-serial = "0.1"
+
+# Optional: serialport::SerialPort facade (enabled by default)
+# android-usb-serial = { version = "0.1", default-features = false }
+
+# Host / instrumentation tests
+# android-usb-serial = { version = "0.1", features = ["fake-transport"] }
+```
+
+Requires **Rust 1.79+**. Real USB needs `target_os = "android"` or `"linux"` (pulls in `nusb` + `libc`).
+
+## Quick start (Android fd)
+
+Kotlin/Java keeps `UsbDeviceConnection` and grants USB permission. Pass the raw fd into Rust:
+
+```rust
+use android_usb_serial::{
+ from_raw_fd, open_port, DataBits, LineConfig, NusbTransport, Parity, StopBits, Transport,
+};
+use std::os::fd::RawFd;
+use std::sync::Arc;
+
+fn open_android_port(fd: RawFd) -> android_usb_serial::Result<()> {
+ let device = from_raw_fd(fd)?;
+ let transport = Arc::new(NusbTransport::from_device(device)?) as Arc;
+ let mut port = open_port(transport, 0)?;
+ port.set_line_config(LineConfig {
+ baud_rate: 115_200,
+ data_bits: DataBits::Eight,
+ parity: Parity::None,
+ stop_bits: StopBits::One,
+ })?;
+ port.set_dtr(true)?;
+ port.write(b"AT\r\n")?;
+ Ok(())
+}
+```
+
+`from_raw_fd` **`dup`s** the fd so Java can keep owning `UsbDeviceConnection`.
+
+## Using on Android
+
+This crate talks USB only through a **raw file descriptor** from Android's
+`UsbDeviceConnection`. It does **not** call `UsbManager`, does **not** show
+permission dialogs, and does **not** register broadcast receivers — your app
+(or a thin Kotlin bridge) owns all of that.
+
+```text
+App (Kotlin) android-usb-serial (Rust)
+─────────────────────────────────────────────────────────────
+UsbManager.deviceList ProbeTable::find(vid, pid, ifaces)
+enumerateJson + interfaces[] → expand to port keys (device / device#N)
+requestPermission + openDevice → from_raw_fd → NusbTransport → open_port
+keep UsbDeviceConnection alive dup(fd); claim interfaces inside nusb
+closeDeviceFd on detach/close driver I/O (write, reader, modem lines)
+```
+
+### Manifest
+
+Declare USB host support and (optionally) attach a device filter so Android
+can launch your activity when a known adapter is plugged in:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+```
+
+`device_filter.xml` should list VID/PID pairs you care about. Align entries with
+[`ProbeTable::default_table()`](src/probe.rs) — a full filter ships with
+[tauri-plugin-serialplugin](../../../android/src/main/res/xml/device_filter.xml).
+A catch-all `` at the end also matches generic CDC ACM devices.
+
+### USB permission
+
+Android requires **runtime permission per device** before `openDevice()`:
+
+1. Check `usbManager.hasPermission(device)`.
+2. If false, call `usbManager.requestPermission(device, pendingIntent)`.
+3. Handle the result in a `BroadcastReceiver` (`EXTRA_PERMISSION_GRANTED`).
+4. On API 31+, use `PendingIntent.FLAG_MUTABLE` on the permission intent.
+5. Set `intent.setPackage(packageName)` when building the permission `Intent`
+ (see [UsbFdBridge](../../../android/src/main/kotlin/app/tauri/serialplugin/manager/UsbFdBridge.kt)).
+
+Minimal pattern (standalone app):
+
+```kotlin
+private const val ACTION_USB_PERMISSION = "your.app.USB_PERMISSION"
+
+fun openWithPermission(device: UsbDevice) {
+ val mgr = getSystemService(USB_SERVICE) as UsbManager
+ if (!mgr.hasPermission(device)) {
+ val pi = PendingIntent.getBroadcast(
+ this, 0,
+ Intent(ACTION_USB_PERMISSION),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
+ )
+ mgr.requestPermission(device, pi)
+ return
+ }
+ val conn = mgr.openDevice(device) ?: return
+ val fd = conn.fileDescriptor
+ // pass fd to Rust (JNI) — keep `conn` alive until close
+ nativeOpenPort(fd, device.vendorId, device.productId)
+}
+```
+
+Register for `UsbManager.ACTION_USB_DEVICE_ATTACHED` / `DETACHED` to refresh the
+port list and close fds when the cable is unplugged.
+
+### Open fd — do **not** pre-claim interfaces
+
+After permission is granted:
+
+```kotlin
+val conn = usbManager.openDevice(device) ?: throw IOException("open failed")
+// Do NOT call conn.claimInterface() here.
+val fd = conn.fileDescriptor
+```
+
+Rust/nusb calls `detach_and_claim` on the duplicated fd. If Kotlin claims the
+interface first, open fails with **`io interface is busy`**. The reference
+implementation lives in
+[`UsbFdBridge.openDeviceFd`](../../../android/src/main/kotlin/app/tauri/serialplugin/manager/UsbFdBridge.kt).
+
+Keep the `UsbDeviceConnection` open for the whole session; call `close()` only
+after Rust has closed the port and released endpoints.
+
+### Enumerate (no fd, no claim)
+
+List attached devices from Kotlin and pass metadata to Rust for driver probing.
+Include **USB interface descriptors** so multi-port chips (FT2232, dual CDC,
+etc.) expand to separate port keys:
+
+```json
+{
+ "ports": {
+ "/dev/bus/usb/001/002": {
+ "type": "Usb",
+ "vid": "0x1A86",
+ "pid": "0x7523",
+ "manufacturer": "",
+ "product": "",
+ "serial_number": "",
+ "interfaces": [
+ { "id": 0, "class": 255, "subclass": 0, "protocol": 0 }
+ ]
+ }
+ }
+}
+```
+
+Rust maps each entry to one or more paths: `deviceName` or `deviceName#N`
+(port index). `manufacturer` / `product` / `serial_number` may be empty when
+Android denies string reads (`SecurityException`) — probing uses VID/PID +
+`interfaces[]` only.
+
+### JNI / Rust entry
+
+Typical sequence after you have `fd`:
+
+```rust
+let device = from_raw_fd(fd as RawFd)?;
+let transport = Arc::new(NusbTransport::from_device(device)?);
+let mut port = open_port(transport, port_index)?; // 0 for single-port CH340
+port.set_line_config(LineConfig { baud_rate: 115_200, .. })?;
+port.set_dtr(true)?;
+port.start_reader()?; // optional background bulk IN
+port.write(b"AT\r\n")?;
+```
+
+- `write()` opens **bulk OUT** only; bulk IN belongs to the reader (do not
+ reopen IN in `write` — see `EndpointPair::write`).
+- Prefer `start_reader()` **after** line config and DTR/RTS — some chips (CH340)
+ misbehave if bulk IN starts too early.
+- On detach, expect errors like `USB device detached (unplug, power loss, or
+ protocol error on bulk IN)`.
+
+Build the native library for Android:
+
+```bash
+cargo ndk -t arm64-v8a -o app/src/main/jniLibs build --release
+# or: aarch64-linux-android, armeabi-v7a, x86_64 as needed
+```
+
+Link `liblog` if you emit Android logcat from Rust (`__android_log_write`).
+
+### Examples in this repository
+
+| Example | Purpose |
+|---------|---------|
+| [`examples/usb-driver-tester`](../../../examples/usb-driver-tester/) | Minimal standalone app: permission → fd → `from_raw_fd` → probe/open/write/read. **Self-test** runs fake-transport matrix; **Device test** hits real hardware. Use to isolate CH340 power vs plugin bugs. |
+| [`examples/serialport-test`](../../../examples/serialport-test/) | Full Tauri Android app (`pnpm tauri android build`). See [`ANDROID.md`](../../../examples/serialport-test/ANDROID.md) for wireless ADB, install, and logcat tags. |
+| [`android/`](../../../android/) | Tauri plugin Kotlin bridge (`UsbFdBridge`, `UsbNative`, `SerialPlugin`). |
+| [`examples/serialport-test/android-integration/`](../../../examples/serialport-test/android-integration/) | Instrumented JNI tests with `FakeTransport` (no real USB). |
+
+**usb-driver-tester** build:
+
+```bash
+cd examples/usb-driver-tester/rust
+cargo ndk -t arm64-v8a -o ../app/src/main/jniLibs build --release
+cd .. && gradle assembleDebug
+```
+
+**serialport-test** on device:
+
+```bash
+cd examples/serialport-test
+pnpm tauri android build --debug
+adb install -r src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk
+adb logcat -s UsbFdBridge SerialPlugin Console
+```
+
+### Debugging checklist
+
+| Symptom | Likely cause |
+|---------|----------------|
+| `io interface is busy` | Kotlin called `claimInterface` before Rust |
+| `endpoint already in use` | bulk IN opened twice (writer + reader) |
+| `USB permission denied` | user declined dialog or `PendingIntent` not mutable |
+| Device listed, UI empty | enumerate JSON missing `interfaces[]` or wrong port key |
+| CH340 vanishes after open (`URB ep 82 status=-71`) | OTG power / cable — confirm with **usb-driver-tester** on same phone + cable |
+| `enumerateJson: 0` after disconnect | normal after detach; replug or powered USB hub |
+
+### Tauri plugin consumers
+
+If you use [tauri-plugin-serialplugin](https://github.com/s00d/tauri-plugin-serialplugin)
+instead of wiring JNI yourself, merge the plugin's `device_filter.xml`, ensure
+USB host feature in your app manifest, and let the plugin handle enumerate /
+permission / fd. Your frontend calls the same serial API as on desktop.
+
+## Fake transport (tests)
+
+```rust
+#[cfg(feature = "fake-transport")]
+fn example() {
+ use android_usb_serial::{open_port, FakeTransport, Transport};
+ use std::sync::Arc;
+
+ let fake = FakeTransport::cdc_single_iface();
+ let transport: Arc = Arc::new(fake.clone());
+ let mut port = open_port(transport, 0).unwrap();
+ port.write(b"PING").unwrap();
+ assert_eq!(fake.take_tx(), b"PING");
+}
+```
+
+## Features
+
+| Feature | Default | Description |
+|---------|---------|-------------|
+| `serialport-compat` | yes | Implements `serialport::SerialPort` |
+| `fake-transport` | no | `FakeTransport` + `golden_record` binary |
+
+## Architecture
+
+```text
+UsbManager (Kotlin) → permission → UsbDeviceConnection → fd (unclaimed)
+ ↓
+ from_raw_fd / NusbTransport (this crate)
+ ↓
+ ProbeTable → Ftdi / Cp21xx / Ch34x / … drivers
+ ↓
+ SerialPortHandle (write / reader / modem / purge)
+```
+
+See **[Using on Android](#using-on-android)** for manifest, permissions, enumerate
+JSON, and example apps. Kotlin owns USB policy; Rust owns protocol + bulk I/O.
+
+## Golden fixtures
+
+```bash
+# Full parity gate (Java-sourced controls + bulkOut + rx_filter + probe)
+cargo test -p android-usb-serial --features fake-transport --test golden_parity
+
+# All crate tests
+cargo test -p android-usb-serial --features fake-transport
+
+# Optional: regen Rust-only driver fixtures (never overwrites source=java)
+cargo run -p android-usb-serial --features fake-transport --bin golden_record
+```
+
+Breakdown: **560** Java control sequences, **6** RX-filter pairs, **1** probe table → **567** JSON files under `tests/fixtures/`.
+
+## Hardware spike
+
+```bash
+# On device: ANDROID_USB_SPIKE_FD=
+cargo test -p android-usb-serial --features fake-transport spike_fd_hardware -- --ignored
+```
+
+## License
+
+Licensed under either of
+
+- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
+- MIT license ([LICENSE-MIT](LICENSE-MIT))
+
+at your option.
+
+Driver protocols include logic derived from usb-serial-for-android (MIT); see [NOTICE](NOTICE).
+
+## Publish (maintainers)
+
+```bash
+# From workspace root
+cargo test -p android-usb-serial --features fake-transport
+cargo package -p android-usb-serial --allow-dirty # review pack list
+cargo publish -p android-usb-serial --dry-run
+cargo publish -p android-usb-serial
+```
+
+Version bumps: edit `version` in this crate’s `Cargo.toml` and the `android-usb-serial = { …, version = "…" }` entry in the workspace plugin `Cargo.toml`.
+
+## Related
+
+- [tauri-plugin-serialplugin](https://github.com/s00d/tauri-plugin-serialplugin) — Tauri integration (`UsbFdBridge` → this crate)
+- [examples/usb-driver-tester](../../../examples/usb-driver-tester/) — standalone hardware tester
+- [examples/serialport-test/ANDROID.md](../../../examples/serialport-test/ANDROID.md) — Tauri Android dev workflow
+- Standalone apps can depend on this crate alone + a thin Kotlin fd bridge
diff --git a/crates/android-usb-serial/src/bin/golden_record.rs b/crates/android-usb-serial/src/bin/golden_record.rs
new file mode 100644
index 00000000..cde96315
--- /dev/null
+++ b/crates/android-usb-serial/src/bin/golden_record.rs
@@ -0,0 +1,464 @@
+//! Record golden USB control-transfer fixtures from Rust drivers (regen tool).
+//!
+//! Run: `cargo run -p android-usb-serial --features fake-transport --bin golden_record`
+
+use android_usb_serial::config::{DataBits, FlowControl, LineConfig, Parity, PurgeKind, StopBits};
+use android_usb_serial::drivers::create_driver;
+use android_usb_serial::fake::{FakeTransport, RecordedControl};
+use android_usb_serial::probe::DriverType;
+use android_usb_serial::transport::{EndpointInfo, InterfaceInfo, Transport};
+use base64::Engine;
+use serde::Serialize;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+#[derive(Clone)]
+struct Preset {
+ driver: &'static str,
+ driver_type: DriverType,
+ vendor_id: u16,
+ product_id: u16,
+ port_index: usize,
+ interfaces: Vec,
+ build_fake: fn() -> FakeTransport,
+}
+
+#[derive(Serialize)]
+struct GoldenFixture {
+ driver: String,
+ scenario: String,
+ #[serde(rename = "vendorId")]
+ vendor_id: u16,
+ #[serde(rename = "productId")]
+ product_id: u16,
+ #[serde(rename = "portIndex")]
+ port_index: usize,
+ interfaces: Vec,
+ controls: Vec,
+}
+
+#[derive(Serialize, Clone)]
+struct GoldenInterface {
+ id: u8,
+ #[serde(rename = "classId")]
+ class_id: u8,
+ subclass: u8,
+ protocol: u8,
+ #[serde(rename = "bulkIn")]
+ bulk_in: Option,
+ #[serde(rename = "bulkOut")]
+ bulk_out: Option,
+ #[serde(rename = "interruptIn")]
+ interrupt_in: Option,
+ #[serde(rename = "maxPacketSize")]
+ max_packet_size: u16,
+}
+
+#[derive(Serialize)]
+struct GoldenControl {
+ #[serde(rename = "requestType")]
+ request_type: u8,
+ request: u8,
+ value: u16,
+ index: u16,
+ data: String,
+}
+
+fn fixture_dir() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
+}
+
+fn encode_controls(controls: &[RecordedControl]) -> Vec {
+ controls
+ .iter()
+ .map(|c| GoldenControl {
+ request_type: c.request_type,
+ request: c.request,
+ value: c.value,
+ index: c.index,
+ data: base64::engine::general_purpose::STANDARD.encode(&c.data),
+ })
+ .collect()
+}
+
+fn iface_layout(
+ id: u8,
+ class_id: u8,
+ bulk_in: u8,
+ bulk_out: u8,
+ interrupt_in: Option,
+) -> (InterfaceInfo, GoldenInterface, (u8, Vec)) {
+ let info = InterfaceInfo {
+ id,
+ class: class_id,
+ subclass: 0,
+ protocol: 0,
+ };
+ let golden = GoldenInterface {
+ id,
+ class_id,
+ subclass: 0,
+ protocol: 0,
+ bulk_in: Some(bulk_in),
+ bulk_out: Some(bulk_out),
+ interrupt_in,
+ max_packet_size: 64,
+ };
+ let mut eps = vec![
+ EndpointInfo {
+ address: bulk_in,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: bulk_out,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ];
+ if let Some(int_ep) = interrupt_in {
+ eps.insert(
+ 0,
+ EndpointInfo {
+ address: int_ep,
+ attributes: 3,
+ max_packet_size: 64,
+ interval: 1,
+ },
+ );
+ }
+ (info, golden, (id, eps))
+}
+
+fn build_ftdi() -> FakeTransport {
+ let fake = FakeTransport::ftdi_ft232r();
+ let (i0, _, e0) = iface_layout(0, 255, 0x81, 0x02, None);
+ fake.set_interfaces(vec![i0]);
+ fake.configure_endpoints(&[e0]);
+ fake
+}
+
+fn build_cp21xx() -> FakeTransport {
+ let fake = FakeTransport::cp2102();
+ let (i0, _, e0) = iface_layout(0, 255, 0x81, 0x02, None);
+ fake.set_interfaces(vec![i0]);
+ fake.configure_endpoints(&[e0]);
+ fake
+}
+
+fn build_ch34x() -> FakeTransport {
+ let fake = FakeTransport::ch340_dual_iface();
+ for _ in 0..4 {
+ fake.script_control_in_response(vec![0, 0]);
+ }
+ let (i0, _, _) = iface_layout(0, 255, 0x81, 0x02, None);
+ let (_, _, e1) = iface_layout(1, 255, 0x82, 0x03, None);
+ fake.set_interfaces(vec![
+ i0,
+ InterfaceInfo {
+ id: 1,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ },
+ ]);
+ fake.configure_endpoints(&[e1]);
+ fake
+}
+
+fn build_prolific() -> FakeTransport {
+ let fake = FakeTransport::pl2303_hx();
+ fake.script_control_in_response(vec![0]);
+ let (i0, _, e0) = iface_layout(0, 255, 0x83, 0x02, Some(0x81));
+ fake.set_interfaces(vec![i0]);
+ fake.configure_endpoints(&[e0]);
+ fake
+}
+
+fn build_cdc() -> FakeTransport {
+ let fake = FakeTransport::cdc_single_iface();
+ fake.set_vendor_product(0x2341, 0x0043);
+ let i0 = InterfaceInfo {
+ id: 0,
+ class: 2,
+ subclass: 2,
+ protocol: 0,
+ };
+ let i1 = InterfaceInfo {
+ id: 1,
+ class: 10,
+ subclass: 0,
+ protocol: 0,
+ };
+ let (_, _, e0) = iface_layout(0, 2, 0x81, 0x02, None);
+ let (_, _, e1) = iface_layout(1, 10, 0x83, 0x04, None);
+ fake.set_interfaces(vec![i0, i1]);
+ fake.configure_endpoints(&[e0, e1]);
+ fake
+}
+
+fn build_gsm() -> FakeTransport {
+ let fake = FakeTransport::cdc_single_iface();
+ fake.set_vendor_product(0x1782, 0x4D10);
+ let (i0, _, e0) = iface_layout(0, 255, 0x81, 0x02, None);
+ fake.set_interfaces(vec![i0]);
+ fake.configure_endpoints(&[e0]);
+ fake
+}
+
+fn build_ccd() -> FakeTransport {
+ let fake = FakeTransport::cdc_single_iface();
+ fake.set_vendor_product(0x18D1, 0x5014);
+ let mut ifaces = Vec::new();
+ let mut eps = Vec::new();
+ for n in 0..3u8 {
+ let (i, _, e) = iface_layout(n, 255, 0x81 + n * 2, 0x02 + n * 2, None);
+ ifaces.push(i);
+ eps.push(e);
+ }
+ fake.set_interfaces(ifaces);
+ fake.configure_endpoints(&eps);
+ fake
+}
+
+fn presets() -> Vec {
+ let (_, g_ftdi, _) = iface_layout(0, 255, 0x81, 0x02, None);
+ let (_, g_cp, _) = iface_layout(0, 255, 0x81, 0x02, None);
+ let (_, g_ch, _) = iface_layout(1, 255, 0x82, 0x03, None);
+ let (_, g_pl, _) = iface_layout(0, 255, 0x83, 0x02, Some(0x81));
+ let g_cdc0 = GoldenInterface {
+ id: 0,
+ class_id: 2,
+ subclass: 2,
+ protocol: 0,
+ bulk_in: Some(0x81),
+ bulk_out: Some(0x02),
+ interrupt_in: None,
+ max_packet_size: 64,
+ };
+ let (_, g_cdc1, _) = iface_layout(1, 10, 0x83, 0x04, None);
+ let (_, g_gsm, _) = iface_layout(0, 255, 0x81, 0x02, None);
+ let mut ccd_golden = Vec::new();
+ for n in 0..3u8 {
+ let (_, g, _) = iface_layout(n, 255, 0x81 + n * 2, 0x02 + n * 2, None);
+ ccd_golden.push(g);
+ }
+
+ vec![
+ Preset {
+ driver: "ftdi",
+ driver_type: DriverType::Ftdi,
+ vendor_id: 0x0403,
+ product_id: 0x6001,
+ port_index: 0,
+ interfaces: vec![g_ftdi],
+ build_fake: build_ftdi,
+ },
+ Preset {
+ driver: "cp21xx",
+ driver_type: DriverType::Cp21xx,
+ vendor_id: 0x10C4,
+ product_id: 0xEA60,
+ port_index: 0,
+ interfaces: vec![g_cp],
+ build_fake: build_cp21xx,
+ },
+ Preset {
+ driver: "ch34x",
+ driver_type: DriverType::Ch34x,
+ vendor_id: 0x1A86,
+ product_id: 0x7523,
+ port_index: 0,
+ interfaces: vec![g_ch],
+ build_fake: build_ch34x,
+ },
+ Preset {
+ driver: "prolific",
+ driver_type: DriverType::Prolific,
+ vendor_id: 0x067B,
+ product_id: 0x2303,
+ port_index: 0,
+ interfaces: vec![g_pl],
+ build_fake: build_prolific,
+ },
+ Preset {
+ driver: "cdc_acm",
+ driver_type: DriverType::CdcAcm,
+ vendor_id: 0x2341,
+ product_id: 0x0043,
+ port_index: 0,
+ interfaces: vec![g_cdc0, g_cdc1],
+ build_fake: build_cdc,
+ },
+ Preset {
+ driver: "gsm_modem",
+ driver_type: DriverType::GsmModem,
+ vendor_id: 0x1782,
+ product_id: 0x4D10,
+ port_index: 0,
+ interfaces: vec![g_gsm],
+ build_fake: build_gsm,
+ },
+ Preset {
+ driver: "chrome_ccd",
+ driver_type: DriverType::ChromeCcd,
+ vendor_id: 0x18D1,
+ product_id: 0x5014,
+ port_index: 0,
+ interfaces: ccd_golden,
+ build_fake: build_ccd,
+ },
+ ]
+}
+
+fn record_scenario(
+ preset: &Preset,
+ scenario: &str,
+ run: impl FnOnce(&mut dyn android_usb_serial::drivers::Driver, &Preset),
+) {
+ let fake = (preset.build_fake)();
+ let transport: Arc = Arc::new(fake.clone());
+ let mut driver = create_driver(preset.driver_type, preset.port_index);
+ driver.open(&transport).expect("open");
+ run(driver.as_mut(), preset);
+ driver.close().expect("close");
+
+ let fixture = GoldenFixture {
+ driver: preset.driver.to_string(),
+ scenario: scenario.to_string(),
+ vendor_id: preset.vendor_id,
+ product_id: preset.product_id,
+ port_index: preset.port_index,
+ interfaces: preset.interfaces.clone(),
+ controls: encode_controls(&fake.recorded_controls()),
+ };
+
+ let dir = fixture_dir().join(preset.driver);
+ fs::create_dir_all(&dir).expect("mkdir");
+ let path = dir.join(format!("{scenario}.json"));
+ if path.exists() {
+ let existing = fs::read_to_string(&path).unwrap_or_default();
+ if existing.contains("\"source\":\"java\"") || existing.contains("\"source\": \"java\"") {
+ eprintln!("skip (java): {}", path.display());
+ return;
+ }
+ }
+ let json = serde_json::to_string_pretty(&fixture).expect("json");
+ fs::write(&path, json).expect("write");
+ eprintln!("wrote {}", path.display());
+}
+
+fn line_115200() -> LineConfig {
+ LineConfig {
+ baud_rate: 115_200,
+ data_bits: DataBits::Eight,
+ parity: Parity::None,
+ stop_bits: StopBits::One,
+ }
+}
+
+fn supported_flows(driver: &str) -> Vec {
+ match driver {
+ "ftdi" => vec![
+ FlowControl::None,
+ FlowControl::RtsCts,
+ FlowControl::DtrDsr,
+ FlowControl::XonXoffInline,
+ ],
+ "cp21xx" => vec![
+ FlowControl::None,
+ FlowControl::RtsCts,
+ FlowControl::DtrDsr,
+ FlowControl::XonXoff,
+ ],
+ "prolific" => vec![
+ FlowControl::None,
+ FlowControl::RtsCts,
+ FlowControl::XonXoffInline,
+ ],
+ _ => vec![FlowControl::None, FlowControl::RtsCts, FlowControl::DtrDsr],
+ }
+}
+
+fn main() {
+ let baud_rates = [
+ 300, 1200, 9600, 19200, 38400, 57600, 115_200, 230_400, 460_800, 921_600,
+ ];
+
+ for preset in presets() {
+ record_scenario(&preset, "open_default", |d, p| {
+ if p.driver != "gsm_modem" && p.driver != "chrome_ccd" {
+ d.set_line_config(line_115200()).ok();
+ }
+ let _ = d.set_dtr(true);
+ let _ = d.set_rts(true);
+ });
+
+ if preset.driver == "cdc_acm" {
+ record_scenario(&preset, "open_line_config", |d, _| {
+ d.set_line_config(line_115200()).expect("line");
+ let _ = d.set_dtr(true);
+ let _ = d.set_rts(true);
+ });
+ }
+
+ if !matches!(preset.driver, "gsm_modem" | "chrome_ccd") {
+ for &baud in &baud_rates {
+ let name = format!("set_baud_{baud}");
+ record_scenario(&preset, &name, |d, _| {
+ let mut cfg = line_115200();
+ cfg.baud_rate = baud;
+ d.set_line_config(cfg).ok();
+ });
+ }
+
+ for flow in supported_flows(preset.driver) {
+ let name = format!("flow_{flow:?}");
+ record_scenario(&preset, &name, |d, _| {
+ d.set_line_config(line_115200()).ok();
+ d.set_flow_control(flow).ok();
+ });
+ }
+
+ record_scenario(&preset, "dtr_on", |d, _| {
+ d.set_dtr(true).ok();
+ });
+ record_scenario(&preset, "dtr_off", |d, _| {
+ d.set_dtr(false).ok();
+ });
+ record_scenario(&preset, "rts_on", |d, _| {
+ d.set_rts(true).ok();
+ });
+ record_scenario(&preset, "rts_off", |d, _| {
+ d.set_rts(false).ok();
+ });
+ record_scenario(&preset, "break_on", |d, _| {
+ d.set_break(true).ok();
+ });
+ record_scenario(&preset, "break_off", |d, _| {
+ d.set_break(false).ok();
+ });
+ record_scenario(&preset, "purge_rx", |d, _| {
+ d.purge(PurgeKind::Rx).ok();
+ });
+ record_scenario(&preset, "purge_tx", |d, _| {
+ d.purge(PurgeKind::Tx).ok();
+ });
+ record_scenario(&preset, "purge_both", |d, _| {
+ d.purge(PurgeKind::Both).ok();
+ });
+ record_scenario(&preset, "modem_status", |d, _| {
+ let _ = d.modem_status();
+ });
+ }
+
+ record_scenario(&preset, "close", |d, p| {
+ if p.driver != "gsm_modem" && p.driver != "chrome_ccd" {
+ d.set_line_config(line_115200()).ok();
+ }
+ });
+ }
+}
diff --git a/crates/android-usb-serial/src/config.rs b/crates/android-usb-serial/src/config.rs
new file mode 100644
index 00000000..3a91a21e
--- /dev/null
+++ b/crates/android-usb-serial/src/config.rs
@@ -0,0 +1,73 @@
+//! Line configuration types (no plugin dependency).
+
+/// ASCII XON (DC1).
+pub const CHAR_XON: u8 = 17;
+/// ASCII XOFF (DC3).
+pub const CHAR_XOFF: u8 = 19;
+
+/// Number of data bits in the serial frame.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DataBits {
+ Five,
+ Six,
+ Seven,
+ Eight,
+}
+
+/// Parity bit mode.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Parity {
+ None,
+ Odd,
+ Even,
+ Mark,
+ Space,
+}
+
+/// Stop bits.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum StopBits {
+ One,
+ OnePointFive,
+ Two,
+}
+
+/// Hardware / software flow control.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FlowControl {
+ None,
+ RtsCts,
+ DtrDsr,
+ /// Host software XON/XOFF (CP21xx).
+ XonXoff,
+ /// Inline filter on RX (FTDI, PL2303).
+ XonXoffInline,
+}
+
+/// Which UART buffers to purge.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PurgeKind {
+ Rx,
+ Tx,
+ Both,
+}
+
+/// Baud rate and framing for [`crate::SerialPortHandle::set_line_config`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct LineConfig {
+ pub baud_rate: u32,
+ pub data_bits: DataBits,
+ pub parity: Parity,
+ pub stop_bits: StopBits,
+}
+
+impl Default for LineConfig {
+ fn default() -> Self {
+ Self {
+ baud_rate: 115_200,
+ data_bits: DataBits::Eight,
+ parity: Parity::None,
+ stop_bits: StopBits::One,
+ }
+ }
+}
diff --git a/crates/android-usb-serial/src/device.rs b/crates/android-usb-serial/src/device.rs
new file mode 100644
index 00000000..e6aa8e85
--- /dev/null
+++ b/crates/android-usb-serial/src/device.rs
@@ -0,0 +1,73 @@
+//! Device discovery and port enumeration.
+//!
+//! Use [`describe_device`] after the transport is ready (interfaces readable) to learn
+//! how many serial ports the chip exposes, then [`open_port`] with a zero-based index.
+
+use crate::drivers::create_driver;
+use crate::error::{Result, UsbSerialError};
+use crate::probe::{DriverType, ProbeTable};
+use crate::transport::SharedTransport;
+
+/// One logical serial port exposed by a USB composite / multi-interface device.
+#[derive(Debug, Clone)]
+pub struct PortDescriptor {
+ /// Zero-based index passed to [`open_port`].
+ pub port_index: usize,
+ /// Driver selected by [`ProbeTable`] for this product.
+ pub driver: DriverType,
+ pub vendor_id: u16,
+ pub product_id: u16,
+}
+
+/// USB device identify + probed driver and ports.
+#[derive(Debug, Clone)]
+pub struct DeviceDescriptor {
+ pub vendor_id: u16,
+ pub product_id: u16,
+ pub driver: DriverType,
+ /// One entry per openable port (`port_index` 0..n).
+ pub ports: Vec,
+}
+
+/// Probe VID/PID + interface layout without opening a driver session.
+pub fn describe_device(transport: &SharedTransport) -> Result {
+ let desc = transport.raw_device_descriptor();
+ let vendor_id = u16::from_le_bytes([desc[8], desc[9]]);
+ let product_id = u16::from_le_bytes([desc[10], desc[11]]);
+ let table = ProbeTable::default_table();
+ let ifaces = transport.interfaces();
+ let driver = table.find(vendor_id, product_id, &ifaces);
+ let count = table.port_count(driver, &ifaces).max(1);
+ let ports = (0..count)
+ .map(|i| PortDescriptor {
+ port_index: i,
+ driver,
+ vendor_id,
+ product_id,
+ })
+ .collect();
+ Ok(DeviceDescriptor {
+ vendor_id,
+ product_id,
+ driver,
+ ports,
+ })
+}
+
+/// Open port `port_index` (0 for single-port chips like CH340).
+///
+/// Claims / initializes the matching vendor driver on `transport`. The reader is **not**
+/// started here — call [`crate::SerialPortHandle::start_reader`] after line config / DTR.
+pub fn open_port(
+ transport: SharedTransport,
+ port_index: usize,
+) -> Result {
+ let device = describe_device(&transport)?;
+ let port = device
+ .ports
+ .get(port_index)
+ .ok_or_else(|| UsbSerialError::Unsupported(format!("port {port_index}")))?;
+ let mut driver = create_driver(port.driver, port_index);
+ driver.open(&transport)?;
+ Ok(crate::port::SerialPortHandle::new(transport, driver))
+}
diff --git a/crates/android-usb-serial/src/drivers/cdc_acm.rs b/crates/android-usb-serial/src/drivers/cdc_acm.rs
new file mode 100644
index 00000000..e2f539e0
--- /dev/null
+++ b/crates/android-usb-serial/src/drivers/cdc_acm.rs
@@ -0,0 +1,201 @@
+//! CDC ACM driver.
+
+use super::{line_coding_bytes, Driver, EndpointPair, ModemStatus, WRITE_TIMEOUT_MS};
+use crate::config::{FlowControl, LineConfig, PurgeKind};
+use crate::error::{Result, UsbSerialError};
+use crate::reader::SerialReader;
+use crate::transport::{ControlRequest, SharedTransport, USB_RECIP_INTERFACE, USB_TYPE_CLASS};
+
+const USB_SUBCLASS_ACM: u8 = 2;
+const SET_LINE_CODING: u8 = 0x20;
+const SET_CONTROL_LINE_STATE: u8 = 0x22;
+const SEND_BREAK: u8 = 0x23;
+
+pub struct CdcAcmDriver {
+ port_index: usize,
+ control_index: u8,
+ control_iface: u8,
+ data_iface: u8,
+ dtr: bool,
+ rts: bool,
+ endpoints: Option,
+ transport: Option,
+ reader: Option,
+}
+
+impl CdcAcmDriver {
+ pub fn new(port_index: usize) -> Self {
+ Self {
+ port_index,
+ control_index: 0,
+ control_iface: 0,
+ data_iface: 0,
+ dtr: false,
+ rts: false,
+ endpoints: None,
+ transport: None,
+ reader: None,
+ }
+ }
+
+ fn acm_control(&self, request: u8, value: u16, data: Vec) -> Result<()> {
+ let transport = self.transport.as_ref().unwrap();
+ let req = ControlRequest {
+ request_type: USB_TYPE_CLASS | USB_RECIP_INTERFACE,
+ request,
+ value,
+ index: self.control_index as u16,
+ data,
+ timeout_ms: WRITE_TIMEOUT_MS,
+ };
+ transport.control_out(&req)?;
+ Ok(())
+ }
+
+ fn resolve_interfaces(&mut self, transport: &SharedTransport) -> Result<()> {
+ let ifaces = transport.interfaces();
+ let desc = transport.raw_device_descriptor();
+ let is_iad = desc.len() >= 7 && desc[4] == 0xEF && desc[5] == 0x02 && desc[6] == 0x01;
+ if is_iad {
+ if let Some((ctrl, data)) = resolve_iad_pair(transport, self.port_index) {
+ self.control_iface = ctrl;
+ self.data_iface = data;
+ self.control_index = ctrl;
+ return Ok(());
+ }
+ }
+ let comm: Vec = ifaces
+ .iter()
+ .filter(|i| i.class == 2 && i.subclass == USB_SUBCLASS_ACM)
+ .map(|i| i.id)
+ .collect();
+ let data: Vec = ifaces
+ .iter()
+ .filter(|i| i.class == 10)
+ .map(|i| i.id)
+ .collect();
+ if comm.is_empty() && data.is_empty() {
+ // single-interface castrated ACM
+ if let Some(iface) = ifaces.first() {
+ self.control_iface = iface.id;
+ self.data_iface = iface.id;
+ self.control_index = iface.id;
+ return Ok(());
+ }
+ return Err(UsbSerialError::ProbeFailed("no CDC interfaces".into()));
+ }
+ if comm.is_empty() {
+ return Err(UsbSerialError::ProbeFailed("no CDC comm interfaces".into()));
+ }
+ let idx = self.port_index.min(comm.len() - 1);
+ self.control_iface = comm[idx];
+ self.data_iface = data.get(idx).copied().unwrap_or(comm[idx]);
+ self.control_index = self.control_iface;
+ Ok(())
+ }
+}
+
+fn resolve_iad_pair(transport: &SharedTransport, port_index: usize) -> Option<(u8, u8)> {
+ let raw = transport.raw_descriptors();
+ let mut iad_ports: Vec<(u8, u8)> = Vec::new();
+ let mut pos = 0usize;
+ while pos + 2 <= raw.len() {
+ let len = raw[pos] as usize;
+ if len < 2 || pos + len > raw.len() {
+ break;
+ }
+ if raw[pos + 1] == 0x0B && len >= 8 && raw[pos + 4] == 2 && raw[pos + 5] == 2 {
+ let first = raw[pos + 2];
+ let count = raw[pos + 3];
+ if count >= 2 {
+ iad_ports.push((first, first + 1));
+ }
+ }
+ pos += len;
+ }
+ iad_ports.get(port_index).copied()
+}
+
+impl Driver for CdcAcmDriver {
+ fn open(&mut self, transport: &SharedTransport) -> Result<()> {
+ self.transport = Some(transport.clone());
+ self.resolve_interfaces(transport)?;
+ transport.claim_interface(self.control_iface)?;
+ if self.data_iface != self.control_iface {
+ transport.claim_interface(self.data_iface)?;
+ }
+ self.endpoints = Some(EndpointPair::open(transport, self.data_iface)?);
+ Ok(())
+ }
+
+ fn close(&mut self) -> Result<()> {
+ if let Some(mut r) = self.reader.take() {
+ r.stop();
+ }
+ if let Some(t) = &self.transport {
+ let _ = t.release_interface(self.data_iface);
+ if self.control_iface != self.data_iface {
+ let _ = t.release_interface(self.control_iface);
+ }
+ }
+ self.endpoints = None;
+ Ok(())
+ }
+
+ fn write(&mut self, data: &[u8]) -> Result {
+ let transport = self.transport.as_ref().unwrap();
+ self.endpoints.as_mut().unwrap().write(transport, data)
+ }
+
+ fn read(&mut self, buf: &mut [u8]) -> Result {
+ if let Some(reader) = &mut self.reader {
+ return reader.try_read(buf);
+ }
+ Ok(0)
+ }
+
+ fn set_line_config(&mut self, cfg: LineConfig) -> Result<()> {
+ self.acm_control(SET_LINE_CODING, 0, line_coding_bytes(&cfg).to_vec())
+ }
+
+ fn set_flow_control(&mut self, flow: FlowControl) -> Result<()> {
+ if flow == FlowControl::None {
+ Ok(())
+ } else {
+ Err(UsbSerialError::Unsupported("flow control".into()))
+ }
+ }
+
+ fn set_dtr(&mut self, value: bool) -> Result<()> {
+ self.dtr = value;
+ let v = (self.rts as u16) << 1 | (self.dtr as u16);
+ self.acm_control(SET_CONTROL_LINE_STATE, v, vec![])
+ }
+
+ fn set_rts(&mut self, value: bool) -> Result<()> {
+ self.rts = value;
+ let v = (self.rts as u16) << 1 | (self.dtr as u16);
+ self.acm_control(SET_CONTROL_LINE_STATE, v, vec![])
+ }
+
+ fn set_break(&mut self, enabled: bool) -> Result<()> {
+ self.acm_control(SEND_BREAK, if enabled { 0xffff } else { 0 }, vec![])
+ }
+
+ fn purge(&mut self, _kind: PurgeKind) -> Result<()> {
+ Ok(())
+ }
+
+ fn modem_status(&mut self) -> Result {
+ Ok(ModemStatus::default())
+ }
+
+ fn bulk_in_mps(&self) -> u16 {
+ self.endpoints.as_ref().map(|e| e.mps).unwrap_or(64)
+ }
+
+ fn take_bulk_in(&mut self) -> Option> {
+ let transport = self.transport.as_ref()?;
+ self.endpoints.as_mut()?.take_in(transport)
+ }
+}
diff --git a/crates/android-usb-serial/src/drivers/ch34x.rs b/crates/android-usb-serial/src/drivers/ch34x.rs
new file mode 100644
index 00000000..d1c46a39
--- /dev/null
+++ b/crates/android-usb-serial/src/drivers/ch34x.rs
@@ -0,0 +1,281 @@
+//! WCH CH34x driver.
+
+use super::{Driver, EndpointPair, ModemStatus, WRITE_TIMEOUT_MS};
+use crate::config::{DataBits, FlowControl, LineConfig, Parity, PurgeKind, StopBits};
+use crate::error::{Result, UsbSerialError};
+use crate::reader::SerialReader;
+use crate::transport::{ControlRequest, SharedTransport};
+
+const LCR_ENABLE_RX: u16 = 0x80;
+const LCR_ENABLE_TX: u16 = 0x40;
+const LCR_MARK_SPACE: u16 = 0x20;
+const LCR_PAR_EVEN: u16 = 0x10;
+const LCR_ENABLE_PAR: u16 = 0x08;
+const LCR_STOP_BITS_2: u16 = 0x04;
+const LCR_CS8: u16 = 0x03;
+const LCR_CS7: u16 = 0x02;
+const LCR_CS6: u16 = 0x01;
+const LCR_CS5: u16 = 0x00;
+
+const GCL_CTS: u8 = 0x01;
+const GCL_DSR: u8 = 0x02;
+const GCL_RI: u8 = 0x04;
+const GCL_CD: u8 = 0x08;
+const SCL_DTR: u16 = 0x20;
+const SCL_RTS: u16 = 0x40;
+
+const DEFAULT_BAUD_RATE: u32 = 9600;
+
+pub struct Ch34xDriver {
+ #[allow(dead_code)]
+ port_index: usize,
+ iface: u8,
+ dtr: bool,
+ rts: bool,
+ endpoints: Option,
+ transport: Option,
+ reader: Option,
+}
+
+impl Ch34xDriver {
+ pub fn new(port_index: usize) -> Self {
+ Self {
+ port_index,
+ iface: 0,
+ dtr: false,
+ rts: false,
+ endpoints: None,
+ transport: None,
+ reader: None,
+ }
+ }
+
+ fn vendor_out(&self, request: u8, value: u16, index: u16) -> Result<()> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_out(&ControlRequest {
+ request_type: 0x40,
+ request,
+ value,
+ index,
+ data: vec![],
+ timeout_ms: WRITE_TIMEOUT_MS,
+ })
+ .map(|_| ())
+ }
+
+ fn vendor_in(&self, request: u8, value: u16, index: u16, len: usize) -> Result> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_in(&ControlRequest::vendor_in(request, value, index, len))
+ }
+
+ fn check_state(&self, request: u8, value: u16, expected: &[Option]) -> Result<()> {
+ let buf = self.vendor_in(request, value, 0, expected.len())?;
+ if buf.len() != expected.len() {
+ return Err(UsbSerialError::Io(format!(
+ "checkState: expected {} bytes, got {}",
+ expected.len(),
+ buf.len()
+ )));
+ }
+ for (i, exp) in expected.iter().enumerate() {
+ if let Some(want) = exp {
+ if buf[i] != *want {
+ return Err(UsbSerialError::Io(format!(
+ "checkState byte[{i}]: expected 0x{want:02x}, got 0x{:02x}",
+ buf[i]
+ )));
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn get_status(&self) -> Result {
+ let buf = self.vendor_in(0x95, 0x0706, 0, 2)?;
+ Ok(buf[0])
+ }
+}
+
+impl Driver for Ch34xDriver {
+ fn open(&mut self, transport: &SharedTransport) -> Result<()> {
+ self.transport = Some(transport.clone());
+ for iface in transport.interfaces() {
+ transport.claim_interface(iface.id)?;
+ }
+ self.iface = transport.interfaces().last().map(|i| i.id).unwrap_or(0);
+ self.endpoints = Some(EndpointPair::open(transport, self.iface)?);
+ self.initialize()?;
+ self.set_baud(DEFAULT_BAUD_RATE)?;
+ Ok(())
+ }
+
+ fn close(&mut self) -> Result<()> {
+ if let Some(mut r) = self.reader.take() {
+ r.stop();
+ }
+ if let Some(t) = &self.transport {
+ for iface in t.interfaces() {
+ let _ = t.release_interface(iface.id);
+ }
+ }
+ self.endpoints = None;
+ Ok(())
+ }
+
+ fn write(&mut self, data: &[u8]) -> Result {
+ let t = self.transport.as_ref().unwrap();
+ self.endpoints.as_mut().unwrap().write(t, data)
+ }
+
+ fn read(&mut self, buf: &mut [u8]) -> Result {
+ if let Some(reader) = &mut self.reader {
+ return reader.try_read(buf);
+ }
+ Ok(0)
+ }
+
+ fn set_line_config(&mut self, cfg: LineConfig) -> Result<()> {
+ if cfg.baud_rate == 0 {
+ return Err(UsbSerialError::Unsupported("invalid baud rate".into()));
+ }
+ self.set_baud(cfg.baud_rate)?;
+
+ let mut lcr = LCR_ENABLE_RX | LCR_ENABLE_TX;
+ lcr |= match cfg.data_bits {
+ DataBits::Five => LCR_CS5,
+ DataBits::Six => LCR_CS6,
+ DataBits::Seven => LCR_CS7,
+ DataBits::Eight => LCR_CS8,
+ };
+ if cfg.stop_bits == StopBits::Two {
+ lcr |= LCR_STOP_BITS_2;
+ } else if cfg.stop_bits == StopBits::OnePointFive {
+ return Err(UsbSerialError::Unsupported("stop bits 1.5".into()));
+ }
+ lcr |= match cfg.parity {
+ Parity::None => 0,
+ Parity::Odd => LCR_ENABLE_PAR,
+ Parity::Even => LCR_ENABLE_PAR | LCR_PAR_EVEN,
+ Parity::Mark => LCR_ENABLE_PAR | LCR_MARK_SPACE,
+ Parity::Space => LCR_ENABLE_PAR | LCR_MARK_SPACE | LCR_PAR_EVEN,
+ };
+ self.vendor_out(0x9A, 0x2518, lcr)
+ }
+
+ fn set_flow_control(&mut self, flow: FlowControl) -> Result<()> {
+ if flow == FlowControl::None {
+ Ok(())
+ } else {
+ Err(UsbSerialError::Unsupported("flow control".into()))
+ }
+ }
+
+ fn set_dtr(&mut self, value: bool) -> Result<()> {
+ self.dtr = value;
+ self.set_control_lines()
+ }
+
+ fn set_rts(&mut self, value: bool) -> Result<()> {
+ self.rts = value;
+ self.set_control_lines()
+ }
+
+ fn set_break(&mut self, enabled: bool) -> Result<()> {
+ let mut req = self.vendor_in(0x95, 0x1805, 0, 2)?;
+ if enabled {
+ req[0] &= !1;
+ req[1] &= !0x40;
+ } else {
+ req[0] |= 1;
+ req[1] |= 0x40;
+ }
+ let val = ((req[1] as u16) << 8) | (req[0] as u16);
+ self.vendor_out(0x9A, 0x1805, val)
+ }
+
+ fn purge(&mut self, _kind: PurgeKind) -> Result<()> {
+ Ok(())
+ }
+
+ fn modem_status(&mut self) -> Result {
+ let status = self.get_status()?;
+ Ok(ModemStatus {
+ cts: (status & GCL_CTS) == 0,
+ dsr: (status & GCL_DSR) == 0,
+ ri: (status & GCL_RI) == 0,
+ cd: (status & GCL_CD) == 0,
+ })
+ }
+
+ fn bulk_in_mps(&self) -> u16 {
+ self.endpoints.as_ref().map(|e| e.mps).unwrap_or(64)
+ }
+
+ fn take_bulk_in(&mut self) -> Option> {
+ let transport = self.transport.as_ref()?;
+ self.endpoints.as_mut()?.take_in(transport)
+ }
+}
+
+impl Ch34xDriver {
+ fn initialize(&self) -> Result<()> {
+ self.check_state(0x5F, 0, &[None, Some(0x00)])?;
+ self.vendor_out(0xA1, 0, 0)?;
+ self.set_baud(DEFAULT_BAUD_RATE)?;
+ self.check_state(0x95, 0x2518, &[None, Some(0x00)])?;
+ self.vendor_out(0x9A, 0x2518, LCR_ENABLE_RX | LCR_ENABLE_TX | LCR_CS8)?;
+ self.check_state(0x95, 0x0706, &[None, None])?;
+ self.vendor_out(0xA1, 0x501F, 0xD90A)?;
+ self.set_baud(DEFAULT_BAUD_RATE)?;
+ self.set_control_lines()?;
+ self.check_state(0x95, 0x0706, &[None, None])?;
+ Ok(())
+ }
+
+ fn set_baud(&self, baud: u32) -> Result<()> {
+ let (val1, val2) = if baud == 921_600 {
+ let divisor = 7u16 | 0x0080;
+ let factor = 0xF300u16;
+ let val1 = (factor & 0xFF00) | divisor;
+ let val2 = factor & 0xFF;
+ (val1, val2)
+ } else {
+ const BAUDBASE_FACTOR: u64 = 1_532_620_800;
+ const BAUDBASE_DIVMAX: u32 = 3;
+
+ let mut factor = BAUDBASE_FACTOR / baud as u64;
+ let mut divisor = BAUDBASE_DIVMAX;
+ while factor > 0xfff0 && divisor > 0 {
+ factor >>= 3;
+ divisor -= 1;
+ }
+ if factor > 0xfff0 {
+ return Err(UsbSerialError::Unsupported(format!(
+ "unsupported baud rate: {baud}"
+ )));
+ }
+ factor = 0x10000 - factor;
+ let divisor = divisor | 0x0080;
+ let val1 = ((factor & 0xff00) as u16) | divisor as u16;
+ let val2 = (factor & 0xff) as u16;
+ (val1, val2)
+ };
+ self.vendor_out(0x9A, 0x1312, val1)?;
+ self.vendor_out(0x9A, 0x0F2C, val2)
+ }
+
+ fn set_control_lines(&self) -> Result<()> {
+ let mut value = 0u16;
+ if self.dtr {
+ value |= SCL_DTR;
+ }
+ if self.rts {
+ value |= SCL_RTS;
+ }
+ self.vendor_out(0xA4, !value, 0)
+ }
+}
diff --git a/crates/android-usb-serial/src/drivers/chrome_ccd.rs b/crates/android-usb-serial/src/drivers/chrome_ccd.rs
new file mode 100644
index 00000000..31c070c4
--- /dev/null
+++ b/crates/android-usb-serial/src/drivers/chrome_ccd.rs
@@ -0,0 +1,100 @@
+//! Google Chrome OS CCD (3 ports — interface N = port N, no CDC init).
+
+use super::{Driver, EndpointPair, ModemStatus};
+use crate::config::{FlowControl, LineConfig, PurgeKind};
+use crate::error::{Result, UsbSerialError};
+use crate::reader::SerialReader;
+use crate::transport::SharedTransport;
+
+pub struct ChromeCcdDriver {
+ port_index: usize,
+ iface: u8,
+ endpoints: Option,
+ transport: Option,
+ reader: Option,
+}
+
+impl ChromeCcdDriver {
+ pub fn new(port_index: usize) -> Self {
+ Self {
+ port_index,
+ iface: port_index as u8,
+ endpoints: None,
+ transport: None,
+ reader: None,
+ }
+ }
+}
+
+impl Driver for ChromeCcdDriver {
+ fn open(&mut self, transport: &SharedTransport) -> Result<()> {
+ self.transport = Some(transport.clone());
+ self.iface = self.port_index as u8;
+ transport.claim_interface(self.iface)?;
+ self.endpoints = Some(EndpointPair::open(transport, self.iface)?);
+ Ok(())
+ }
+
+ fn close(&mut self) -> Result<()> {
+ if let Some(mut r) = self.reader.take() {
+ r.stop();
+ }
+ if let Some(t) = &self.transport {
+ let _ = t.release_interface(self.iface);
+ }
+ self.endpoints = None;
+ Ok(())
+ }
+
+ fn write(&mut self, data: &[u8]) -> Result {
+ let t = self.transport.as_ref().unwrap();
+ self.endpoints.as_mut().unwrap().write(t, data)
+ }
+
+ fn read(&mut self, buf: &mut [u8]) -> Result {
+ if let Some(reader) = &mut self.reader {
+ return reader.try_read(buf);
+ }
+ Ok(0)
+ }
+
+ fn set_line_config(&mut self, _cfg: LineConfig) -> Result<()> {
+ Err(UsbSerialError::Unsupported("line config".into()))
+ }
+
+ fn set_flow_control(&mut self, flow: FlowControl) -> Result<()> {
+ if flow != FlowControl::None {
+ return Err(UsbSerialError::Unsupported("flow control".into()));
+ }
+ Ok(())
+ }
+
+ fn set_dtr(&mut self, _value: bool) -> Result<()> {
+ Err(UsbSerialError::Unsupported("dtr".into()))
+ }
+
+ fn set_rts(&mut self, _value: bool) -> Result<()> {
+ Err(UsbSerialError::Unsupported("rts".into()))
+ }
+
+ fn set_break(&mut self, _enabled: bool) -> Result<()> {
+ Err(UsbSerialError::Unsupported("break".into()))
+ }
+
+ fn purge(&mut self, _kind: PurgeKind) -> Result<()> {
+ Err(UsbSerialError::Unsupported("purge".into()))
+ }
+
+ fn modem_status(&mut self) -> Result {
+ Ok(ModemStatus::default())
+ }
+
+ fn bulk_in_mps(&self) -> u16 {
+ self.endpoints.as_ref().map(|e| e.mps).unwrap_or(64)
+ }
+
+ fn take_bulk_in(&mut self) -> Option> {
+ let transport = self.transport.as_ref()?;
+ self.endpoints.as_mut()?.take_in(transport)
+ }
+}
diff --git a/crates/android-usb-serial/src/drivers/cp21xx.rs b/crates/android-usb-serial/src/drivers/cp21xx.rs
new file mode 100644
index 00000000..e93d0e7b
--- /dev/null
+++ b/crates/android-usb-serial/src/drivers/cp21xx.rs
@@ -0,0 +1,321 @@
+//! Silicon Labs CP21xx driver.
+
+use super::{Driver, EndpointPair, ModemStatus, WRITE_TIMEOUT_MS};
+use crate::config::{
+ DataBits, FlowControl, LineConfig, Parity, PurgeKind, StopBits, CHAR_XOFF, CHAR_XON,
+};
+use crate::error::{Result, UsbSerialError};
+use crate::reader::SerialReader;
+use crate::transport::{ControlRequest, SharedTransport};
+
+const REQTYPE_HOST_TO_DEVICE: u8 = 0x41;
+const REQTYPE_DEVICE_TO_HOST: u8 = 0xC1;
+
+const SILABSER_IFC_ENABLE_REQUEST_CODE: u8 = 0x00;
+const SILABSER_SET_LINE_CTL_REQUEST_CODE: u8 = 0x03;
+const SILABSER_SET_BREAK_REQUEST_CODE: u8 = 0x05;
+const SILABSER_SET_MHS_REQUEST_CODE: u8 = 0x07;
+const SILABSER_GET_MDMSTS_REQUEST_CODE: u8 = 0x08;
+const SILABSER_SET_XON_REQUEST_CODE: u8 = 0x09;
+const SILABSER_SET_XOFF_REQUEST_CODE: u8 = 0x0A;
+const SILABSER_FLUSH_REQUEST_CODE: u8 = 0x12;
+const SILABSER_SET_FLOW_REQUEST_CODE: u8 = 0x13;
+const SILABSER_SET_CHARS_REQUEST_CODE: u8 = 0x19;
+const SILABSER_SET_BAUDRATE_REQUEST_CODE: u8 = 0x1E;
+
+const UART_ENABLE: u16 = 0x0001;
+const UART_DISABLE: u16 = 0x0000;
+
+const DTR_ENABLE: u16 = 0x0101;
+const DTR_DISABLE: u16 = 0x0100;
+const RTS_ENABLE: u16 = 0x0202;
+const RTS_DISABLE: u16 = 0x0200;
+
+const STATUS_CTS: u8 = 0x10;
+const STATUS_DSR: u8 = 0x20;
+const STATUS_RI: u8 = 0x40;
+const STATUS_CD: u8 = 0x80;
+
+pub struct Cp21xxDriver {
+ port_index: usize,
+ iface: u8,
+ dtr: bool,
+ rts: bool,
+ is_restricted_port: bool,
+ endpoints: Option,
+ transport: Option,
+ reader: Option,
+}
+
+impl Cp21xxDriver {
+ pub fn new(port_index: usize) -> Self {
+ Self {
+ port_index,
+ iface: port_index as u8,
+ dtr: false,
+ rts: false,
+ is_restricted_port: false,
+ endpoints: None,
+ transport: None,
+ reader: None,
+ }
+ }
+
+ fn cfg_out(&self, request: u8, value: u16) -> Result<()> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_out(&ControlRequest {
+ request_type: REQTYPE_HOST_TO_DEVICE,
+ request,
+ value,
+ index: self.port_index as u16,
+ data: vec![],
+ timeout_ms: WRITE_TIMEOUT_MS,
+ })?;
+ Ok(())
+ }
+
+ fn cfg_out_data(&self, request: u8, value: u16, data: Vec) -> Result<()> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_out(&ControlRequest {
+ request_type: REQTYPE_HOST_TO_DEVICE,
+ request,
+ value,
+ index: self.port_index as u16,
+ data,
+ timeout_ms: WRITE_TIMEOUT_MS,
+ })?;
+ Ok(())
+ }
+
+ fn cfg_in(&self, request: u8, value: u16, length: usize) -> Result> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_in(&ControlRequest {
+ request_type: REQTYPE_DEVICE_TO_HOST,
+ request,
+ value,
+ index: self.port_index as u16,
+ data: vec![0; length],
+ timeout_ms: WRITE_TIMEOUT_MS,
+ })
+ }
+
+ fn set_xon(&self, value: bool) -> Result<()> {
+ self.cfg_out(
+ if value {
+ SILABSER_SET_XON_REQUEST_CODE
+ } else {
+ SILABSER_SET_XOFF_REQUEST_CODE
+ },
+ 0,
+ )
+ }
+}
+
+impl Driver for Cp21xxDriver {
+ fn open(&mut self, transport: &SharedTransport) -> Result<()> {
+ self.transport = Some(transport.clone());
+ let iface_count = transport.interfaces().len();
+ if self.port_index >= iface_count {
+ return Err(UsbSerialError::Io(format!(
+ "unknown port number {}",
+ self.port_index
+ )));
+ }
+ self.is_restricted_port = iface_count == 2 && self.port_index == 1;
+ self.iface = self.port_index as u8;
+ transport.claim_interface(self.iface)?;
+ self.endpoints = Some(EndpointPair::open(transport, self.iface)?);
+ self.cfg_out(SILABSER_IFC_ENABLE_REQUEST_CODE, UART_ENABLE)?;
+ self.cfg_out(
+ SILABSER_SET_MHS_REQUEST_CODE,
+ (if self.dtr { DTR_ENABLE } else { DTR_DISABLE })
+ | (if self.rts { RTS_ENABLE } else { RTS_DISABLE }),
+ )?;
+ self.set_flow_control(FlowControl::None)?;
+ Ok(())
+ }
+
+ fn close(&mut self) -> Result<()> {
+ if let Some(mut r) = self.reader.take() {
+ r.stop();
+ }
+ let _ = self.cfg_out(SILABSER_IFC_ENABLE_REQUEST_CODE, UART_DISABLE);
+ if let Some(t) = &self.transport {
+ let _ = t.release_interface(self.iface);
+ }
+ self.endpoints = None;
+ Ok(())
+ }
+
+ fn write(&mut self, data: &[u8]) -> Result {
+ let t = self.transport.as_ref().unwrap();
+ self.endpoints.as_mut().unwrap().write(t, data)
+ }
+
+ fn read(&mut self, buf: &mut [u8]) -> Result {
+ if let Some(reader) = &mut self.reader {
+ return reader.try_read(buf);
+ }
+ Ok(0)
+ }
+
+ fn set_line_config(&mut self, cfg: LineConfig) -> Result<()> {
+ if cfg.baud_rate == 0 {
+ return Err(UsbSerialError::Unsupported("invalid baud rate".into()));
+ }
+ self.cfg_out_data(
+ SILABSER_SET_BAUDRATE_REQUEST_CODE,
+ 0,
+ vec![
+ (cfg.baud_rate & 0xff) as u8,
+ ((cfg.baud_rate >> 8) & 0xff) as u8,
+ ((cfg.baud_rate >> 16) & 0xff) as u8,
+ ((cfg.baud_rate >> 24) & 0xff) as u8,
+ ],
+ )?;
+
+ let mut bits = 0u16;
+ match cfg.data_bits {
+ DataBits::Five => {
+ if self.is_restricted_port {
+ return Err(UsbSerialError::Unsupported("data bits 5".into()));
+ }
+ bits |= 0x0500;
+ }
+ DataBits::Six => {
+ if self.is_restricted_port {
+ return Err(UsbSerialError::Unsupported("data bits 6".into()));
+ }
+ bits |= 0x0600;
+ }
+ DataBits::Seven => {
+ if self.is_restricted_port {
+ return Err(UsbSerialError::Unsupported("data bits 7".into()));
+ }
+ bits |= 0x0700;
+ }
+ DataBits::Eight => bits |= 0x0800,
+ };
+ match cfg.parity {
+ Parity::None => {}
+ Parity::Odd => bits |= 0x0010,
+ Parity::Even => bits |= 0x0020,
+ Parity::Mark => {
+ if self.is_restricted_port {
+ return Err(UsbSerialError::Unsupported("parity mark".into()));
+ }
+ bits |= 0x0030;
+ }
+ Parity::Space => {
+ if self.is_restricted_port {
+ return Err(UsbSerialError::Unsupported("parity space".into()));
+ }
+ bits |= 0x0040;
+ }
+ }
+ match cfg.stop_bits {
+ StopBits::One => {}
+ StopBits::OnePointFive => {
+ return Err(UsbSerialError::Unsupported("stop bits 1.5".into()));
+ }
+ StopBits::Two => {
+ if self.is_restricted_port {
+ return Err(UsbSerialError::Unsupported("stop bits 2".into()));
+ }
+ bits |= 2;
+ }
+ }
+ self.cfg_out(SILABSER_SET_LINE_CTL_REQUEST_CODE, bits)
+ }
+
+ fn set_flow_control(&mut self, flow: FlowControl) -> Result<()> {
+ if flow == FlowControl::XonXoffInline {
+ return Err(UsbSerialError::Unsupported("xon/xoff inline".into()));
+ }
+
+ let mut data = vec![0u8; 16];
+ if flow == FlowControl::RtsCts {
+ data[4] |= 0b1000_0000;
+ data[0] |= 0b0000_1000;
+ } else if self.rts {
+ data[4] |= 0b0100_0000;
+ }
+ if flow == FlowControl::DtrDsr {
+ data[0] |= 0b0000_0010;
+ data[0] |= 0b0001_0000;
+ } else if self.dtr {
+ data[0] |= 0b0000_0001;
+ }
+ if flow == FlowControl::XonXoff {
+ self.cfg_out_data(
+ SILABSER_SET_CHARS_REQUEST_CODE,
+ 0,
+ vec![0, 0, 0, 0, CHAR_XON, CHAR_XOFF],
+ )?;
+ data[4] |= 0b0000_0011;
+ data[7] |= 0b1000_0000;
+ data[8] = 128;
+ data[12] = 128;
+ }
+ self.cfg_out_data(SILABSER_SET_FLOW_REQUEST_CODE, 0, data)?;
+ if flow == FlowControl::XonXoff {
+ self.set_xon(true)?;
+ }
+ Ok(())
+ }
+
+ fn set_dtr(&mut self, value: bool) -> Result<()> {
+ self.dtr = value;
+ self.cfg_out(
+ SILABSER_SET_MHS_REQUEST_CODE,
+ if value { DTR_ENABLE } else { DTR_DISABLE },
+ )
+ }
+
+ fn set_rts(&mut self, value: bool) -> Result<()> {
+ self.rts = value;
+ self.cfg_out(
+ SILABSER_SET_MHS_REQUEST_CODE,
+ if value { RTS_ENABLE } else { RTS_DISABLE },
+ )
+ }
+
+ fn set_break(&mut self, enabled: bool) -> Result<()> {
+ self.cfg_out(SILABSER_SET_BREAK_REQUEST_CODE, if enabled { 1 } else { 0 })
+ }
+
+ fn purge(&mut self, kind: PurgeKind) -> Result<()> {
+ let v = match kind {
+ PurgeKind::Rx => 0x0005,
+ PurgeKind::Tx => 0x000a,
+ PurgeKind::Both => 0x000f,
+ };
+ self.cfg_out(SILABSER_FLUSH_REQUEST_CODE, v)
+ }
+
+ fn modem_status(&mut self) -> Result {
+ let buf = self.cfg_in(SILABSER_GET_MDMSTS_REQUEST_CODE, 0, 1)?;
+ let status = buf[0];
+ Ok(ModemStatus {
+ cts: (status & STATUS_CTS) != 0,
+ dsr: (status & STATUS_DSR) != 0,
+ ri: (status & STATUS_RI) != 0,
+ cd: (status & STATUS_CD) != 0,
+ })
+ }
+
+ fn bulk_in_mps(&self) -> u16 {
+ self.endpoints.as_ref().map(|e| e.mps).unwrap_or(64)
+ }
+
+ fn take_bulk_in(&mut self) -> Option> {
+ let transport = self.transport.as_ref()?;
+ self.endpoints.as_mut()?.take_in(transport)
+ }
+}
diff --git a/crates/android-usb-serial/src/drivers/ftdi.rs b/crates/android-usb-serial/src/drivers/ftdi.rs
new file mode 100644
index 00000000..173bab4f
--- /dev/null
+++ b/crates/android-usb-serial/src/drivers/ftdi.rs
@@ -0,0 +1,286 @@
+//! FTDI driver (vendor control + 2-byte RX header strip).
+
+use super::{Driver, EndpointPair, ModemStatus};
+use crate::config::{
+ DataBits, FlowControl, LineConfig, Parity, PurgeKind, StopBits, CHAR_XOFF, CHAR_XON,
+};
+use crate::error::{Result, UsbSerialError};
+use crate::reader::SerialReader;
+use crate::rx_filter::{FtdiHeaderFilter, XonXoffRxFilter};
+use crate::transport::{ControlRequest, SharedTransport};
+
+pub struct FtdiDriver {
+ port_index: usize,
+ iface: u8,
+ dtr: bool,
+ rts: bool,
+ baud_with_port: bool,
+ flow: FlowControl,
+ break_enabled: bool,
+ data_config: u16,
+ endpoints: Option,
+ transport: Option,
+ reader: Option,
+}
+
+impl FtdiDriver {
+ pub fn new(port_index: usize) -> Self {
+ Self {
+ port_index,
+ iface: port_index as u8,
+ dtr: false,
+ rts: false,
+ baud_with_port: false,
+ flow: FlowControl::None,
+ break_enabled: false,
+ data_config: 0,
+ endpoints: None,
+ transport: None,
+ reader: None,
+ }
+ }
+
+ fn w_index(&self) -> u16 {
+ (self.port_index + 1) as u16
+ }
+
+ fn vendor_out(&self, request: u8, value: u16, index: u16) -> Result<()> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_out(&ControlRequest::vendor_out(request, value, index, vec![]))?;
+ Ok(())
+ }
+
+ fn vendor_in(&self, request: u8, value: u16, index: u16, len: usize) -> Result> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_in(&ControlRequest::vendor_in(request, value, index, len))
+ }
+}
+
+impl Driver for FtdiDriver {
+ fn open(&mut self, transport: &SharedTransport) -> Result<()> {
+ self.transport = Some(transport.clone());
+ self.iface = self.port_index as u8;
+ transport.claim_interface(self.iface)?;
+ self.endpoints = Some(EndpointPair::open(transport, self.iface)?);
+ self.vendor_out(0, 0, self.w_index())?;
+ let modem = if self.dtr { 0x0101 } else { 0x0100 } | if self.rts { 0x0202 } else { 0x0200 };
+ self.vendor_out(1, modem, self.w_index())?;
+ self.set_flow_control(FlowControl::None)?;
+ self.baud_with_port = transport.interfaces().len() > 1;
+ let raw = transport.raw_descriptors();
+ if raw.len() >= 14 {
+ let device_type = raw[13];
+ self.baud_with_port =
+ self.baud_with_port || device_type == 7 || device_type == 8 || device_type == 9;
+ }
+ Ok(())
+ }
+
+ fn close(&mut self) -> Result<()> {
+ if let Some(mut r) = self.reader.take() {
+ r.stop();
+ }
+ if let Some(t) = &self.transport {
+ let _ = t.release_interface(self.iface);
+ }
+ self.endpoints = None;
+ Ok(())
+ }
+
+ fn write(&mut self, data: &[u8]) -> Result {
+ let t = self.transport.as_ref().unwrap();
+ self.endpoints.as_mut().unwrap().write(t, data)
+ }
+
+ fn read(&mut self, buf: &mut [u8]) -> Result {
+ if let Some(reader) = &mut self.reader {
+ return reader.try_read(buf);
+ }
+ Ok(0)
+ }
+
+ fn set_line_config(&mut self, cfg: LineConfig) -> Result<()> {
+ self.set_baud(cfg.baud_rate)?;
+ match cfg.data_bits {
+ DataBits::Seven | DataBits::Eight => {}
+ _ => return Err(UsbSerialError::Unsupported("data bits".into())),
+ }
+ if cfg.stop_bits == StopBits::OnePointFive {
+ return Err(UsbSerialError::Unsupported("stop bits".into()));
+ }
+ self.data_config = ftdi_line_config_value(cfg, self.break_enabled);
+ self.vendor_out(4, self.data_config, self.w_index())
+ }
+
+ fn set_flow_control(&mut self, flow: FlowControl) -> Result<()> {
+ self.flow = flow;
+ let mut value = 0u16;
+ let mut index = self.w_index();
+ match flow {
+ FlowControl::None => {}
+ FlowControl::RtsCts => index |= 0x100,
+ FlowControl::DtrDsr => index |= 0x200,
+ FlowControl::XonXoffInline => {
+ value = u16::from(CHAR_XON) | (u16::from(CHAR_XOFF) << 8);
+ index |= 0x400;
+ }
+ _ => return Err(UsbSerialError::Unsupported("flow control".into())),
+ }
+ self.vendor_out(2, value, index)
+ }
+
+ fn set_dtr(&mut self, value: bool) -> Result<()> {
+ self.dtr = value;
+ let v = if value { 0x0101 } else { 0x0100 };
+ self.vendor_out(1, v, self.w_index())
+ }
+
+ fn set_rts(&mut self, value: bool) -> Result<()> {
+ self.rts = value;
+ let v = if value { 0x0202 } else { 0x0200 };
+ self.vendor_out(1, v, self.w_index())
+ }
+
+ fn set_break(&mut self, enabled: bool) -> Result<()> {
+ self.break_enabled = enabled;
+ if enabled {
+ self.data_config |= 0x4000;
+ } else {
+ self.data_config &= !0x4000;
+ }
+ self.vendor_out(4, self.data_config, self.w_index())
+ }
+
+ fn purge(&mut self, kind: PurgeKind) -> Result<()> {
+ match kind {
+ PurgeKind::Rx => self.vendor_out(0, 1, self.w_index()),
+ PurgeKind::Tx => self.vendor_out(0, 2, self.w_index()),
+ PurgeKind::Both => {
+ self.vendor_out(0, 1, self.w_index())?;
+ self.vendor_out(0, 2, self.w_index())
+ }
+ }
+ }
+
+ fn modem_status(&mut self) -> Result {
+ let data = self.vendor_in(5, 0, self.w_index(), 2)?;
+ let status = data.first().copied().unwrap_or(0);
+ Ok(ModemStatus {
+ cts: status & 0x10 != 0,
+ dsr: status & 0x20 != 0,
+ ri: status & 0x40 != 0,
+ cd: status & 0x80 != 0,
+ })
+ }
+
+ fn bulk_in_mps(&self) -> u16 {
+ self.endpoints.as_ref().map(|e| e.mps).unwrap_or(64)
+ }
+
+ fn take_bulk_in(&mut self) -> Option> {
+ let transport = self.transport.as_ref()?;
+ self.endpoints.as_mut()?.take_in(transport)
+ }
+
+ fn rx_filters(&self) -> Vec> {
+ let mut filters: Vec> =
+ vec![Box::new(FtdiHeaderFilter::new(self.bulk_in_mps()))];
+ if self.flow == FlowControl::XonXoffInline {
+ filters.push(Box::new(XonXoffRxFilter::new(true)));
+ }
+ filters
+ }
+}
+
+impl FtdiDriver {
+ fn set_baud(&self, baud: u32) -> Result<()> {
+ if baud > 3_500_000 {
+ return Err(UsbSerialError::Unsupported("baud too high".into()));
+ }
+ let (value, index) = ftdi_baud_encoding(baud, self.baud_with_port, self.port_index)?;
+ self.vendor_out(3, value, index)
+ }
+}
+
+fn ftdi_line_config_value(cfg: LineConfig, break_enabled: bool) -> u16 {
+ let mut config = match cfg.data_bits {
+ DataBits::Seven => 7,
+ DataBits::Eight => 8,
+ _ => 0,
+ };
+ config |= match cfg.parity {
+ Parity::None => 0,
+ Parity::Odd => 0x100,
+ Parity::Even => 0x200,
+ Parity::Mark => 0x300,
+ Parity::Space => 0x400,
+ };
+ if cfg.stop_bits == StopBits::Two {
+ config |= 0x1000;
+ }
+ if break_enabled {
+ config |= 0x4000;
+ }
+ config
+}
+
+/// FTDI baud encoding (ported from FtdiSerialDriver.java).
+pub fn ftdi_baud_encoding(
+ baud: u32,
+ baud_with_port: bool,
+ port_index: usize,
+) -> Result<(u16, u16)> {
+ let (divisor, subdivisor, _) = if baud >= 2_500_000 {
+ (0u32, 0u32, 3_000_000u32)
+ } else if baud >= 1_750_000 {
+ (1, 0, 2_000_000)
+ } else {
+ let mut d = (24_000_000u32 << 1) / baud;
+ d = (d + 1) >> 1;
+ let sub = d & 0x07;
+ d >>= 3;
+ if d > 0x3fff {
+ return Err(UsbSerialError::Unsupported("baud too low".into()));
+ }
+ let effective = (24_000_000u32 << 1) / ((d << 3) + sub);
+ let effective = (effective + 1) >> 1;
+ let err = (1.0 - (effective as f64 / baud as f64)).abs();
+ if err >= 0.031 {
+ return Err(UsbSerialError::Unsupported(format!(
+ "baud deviation {:.1}%",
+ err * 100.0
+ )));
+ }
+ (d, sub, effective)
+ };
+ let mut value = divisor as u16;
+ let mut index = 0u16;
+ match subdivisor {
+ 0 => {}
+ 4 => value |= 0x4000,
+ 2 => value |= 0x8000,
+ 1 => value |= 0xc000,
+ 3 => index |= 1,
+ 5 => {
+ value |= 0x4000;
+ index |= 1;
+ }
+ 6 => {
+ value |= 0x8000;
+ index |= 1;
+ }
+ 7 => {
+ value |= 0xc000;
+ index |= 1;
+ }
+ _ => {}
+ }
+ if baud_with_port {
+ index = (index << 8) | (port_index as u16 + 1);
+ }
+ Ok((value, index))
+}
diff --git a/crates/android-usb-serial/src/drivers/gsm_modem.rs b/crates/android-usb-serial/src/drivers/gsm_modem.rs
new file mode 100644
index 00000000..e98a8ee7
--- /dev/null
+++ b/crates/android-usb-serial/src/drivers/gsm_modem.rs
@@ -0,0 +1,102 @@
+//! Fibocom GSM modem (interface 0, minimal init — not CDC ACM).
+
+use super::{Driver, EndpointPair, ModemStatus};
+use crate::config::{FlowControl, LineConfig, PurgeKind};
+use crate::error::{Result, UsbSerialError};
+use crate::reader::SerialReader;
+use crate::transport::{ControlRequest, SharedTransport};
+
+pub struct GsmModemDriver {
+ #[allow(dead_code)]
+ port_index: usize,
+ iface: u8,
+ endpoints: Option,
+ transport: Option,
+ reader: Option,
+}
+
+impl GsmModemDriver {
+ pub fn new(port_index: usize) -> Self {
+ Self {
+ port_index,
+ iface: 0,
+ endpoints: None,
+ transport: None,
+ reader: None,
+ }
+ }
+}
+
+impl Driver for GsmModemDriver {
+ fn open(&mut self, transport: &SharedTransport) -> Result<()> {
+ self.transport = Some(transport.clone());
+ self.iface = 0;
+ transport.claim_interface(self.iface)?;
+ self.endpoints = Some(EndpointPair::open(transport, self.iface)?);
+ transport.control_out(&ControlRequest::class_out(0x22, 0x01, 0, vec![]))?;
+ Ok(())
+ }
+
+ fn close(&mut self) -> Result<()> {
+ if let Some(mut r) = self.reader.take() {
+ r.stop();
+ }
+ if let Some(t) = &self.transport {
+ let _ = t.release_interface(self.iface);
+ }
+ self.endpoints = None;
+ Ok(())
+ }
+
+ fn write(&mut self, data: &[u8]) -> Result {
+ let t = self.transport.as_ref().unwrap();
+ self.endpoints.as_mut().unwrap().write(t, data)
+ }
+
+ fn read(&mut self, buf: &mut [u8]) -> Result {
+ if let Some(reader) = &mut self.reader {
+ return reader.try_read(buf);
+ }
+ Ok(0)
+ }
+
+ fn set_line_config(&mut self, _cfg: LineConfig) -> Result<()> {
+ Err(UsbSerialError::Unsupported("line config".into()))
+ }
+
+ fn set_flow_control(&mut self, flow: FlowControl) -> Result<()> {
+ if flow != FlowControl::None {
+ return Err(UsbSerialError::Unsupported("flow control".into()));
+ }
+ Ok(())
+ }
+
+ fn set_dtr(&mut self, _value: bool) -> Result<()> {
+ Err(UsbSerialError::Unsupported("dtr".into()))
+ }
+
+ fn set_rts(&mut self, _value: bool) -> Result<()> {
+ Err(UsbSerialError::Unsupported("rts".into()))
+ }
+
+ fn set_break(&mut self, _enabled: bool) -> Result<()> {
+ Err(UsbSerialError::Unsupported("break".into()))
+ }
+
+ fn purge(&mut self, _kind: PurgeKind) -> Result<()> {
+ Err(UsbSerialError::Unsupported("purge".into()))
+ }
+
+ fn modem_status(&mut self) -> Result {
+ Ok(ModemStatus::default())
+ }
+
+ fn bulk_in_mps(&self) -> u16 {
+ self.endpoints.as_ref().map(|e| e.mps).unwrap_or(64)
+ }
+
+ fn take_bulk_in(&mut self) -> Option> {
+ let transport = self.transport.as_ref()?;
+ self.endpoints.as_mut()?.take_in(transport)
+ }
+}
diff --git a/crates/android-usb-serial/src/drivers/mod.rs b/crates/android-usb-serial/src/drivers/mod.rs
new file mode 100644
index 00000000..17be9707
--- /dev/null
+++ b/crates/android-usb-serial/src/drivers/mod.rs
@@ -0,0 +1,183 @@
+//! USB serial driver implementations.
+//!
+//! Each chip family implements the [`Driver`](crate::drivers::Driver) trait. Prefer
+//! [`crate::open_port`], which calls [`create_driver`](crate::drivers::create_driver)
+//! after [`crate::ProbeTable`] selection.
+
+mod cdc_acm;
+mod ch34x;
+mod chrome_ccd;
+mod cp21xx;
+mod ftdi;
+mod gsm_modem;
+mod prolific;
+
+pub use cdc_acm::CdcAcmDriver;
+pub use ch34x::Ch34xDriver;
+pub use chrome_ccd::ChromeCcdDriver;
+pub use cp21xx::Cp21xxDriver;
+pub use ftdi::ftdi_baud_encoding;
+pub use ftdi::FtdiDriver;
+pub use gsm_modem::GsmModemDriver;
+pub use prolific::ProlificDriver;
+
+use crate::config::{FlowControl, LineConfig, PurgeKind};
+use crate::error::Result;
+use crate::probe::DriverType;
+use crate::reader::SerialReader;
+use crate::rx_filter::RxFilter;
+use crate::transport::{BulkIn, BulkOut, SharedTransport};
+
+/// Default bulk OUT timeout used by drivers.
+pub const WRITE_TIMEOUT_MS: u32 = 5000;
+
+/// Modem status lines reported by chips that support them.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct ModemStatus {
+ pub cts: bool,
+ pub dsr: bool,
+ pub ri: bool,
+ pub cd: bool,
+}
+
+/// Chip-specific USB serial protocol (control transfers + bulk endpoints).
+pub trait Driver: Send {
+ fn open(&mut self, transport: &SharedTransport) -> Result<()>;
+ fn close(&mut self) -> Result<()>;
+ fn write(&mut self, data: &[u8]) -> Result;
+ fn read(&mut self, buf: &mut [u8]) -> Result;
+ fn set_line_config(&mut self, cfg: LineConfig) -> Result<()>;
+ fn set_flow_control(&mut self, flow: FlowControl) -> Result<()>;
+ fn set_dtr(&mut self, value: bool) -> Result<()>;
+ fn set_rts(&mut self, value: bool) -> Result<()>;
+ fn set_break(&mut self, enabled: bool) -> Result<()>;
+ fn purge(&mut self, kind: PurgeKind) -> Result<()>;
+ fn modem_status(&mut self) -> Result;
+ fn bulk_in_mps(&self) -> u16;
+ fn take_bulk_in(&mut self) -> Option>;
+ fn rx_filters(&self) -> Vec> {
+ Vec::new()
+ }
+ fn start_reader(&mut self) -> Result {
+ let bulk = self
+ .take_bulk_in()
+ .ok_or_else(|| crate::error::UsbSerialError::Io("no bulk in".into()))?;
+ Ok(SerialReader::start(
+ bulk,
+ self.bulk_in_mps(),
+ 200,
+ self.rx_filters(),
+ ))
+ }
+}
+
+/// Construct an uninitialized driver for `driver_type` / `port_index`.
+pub fn create_driver(driver_type: DriverType, port_index: usize) -> Box {
+ match driver_type {
+ DriverType::CdcAcm => Box::new(CdcAcmDriver::new(port_index)),
+ DriverType::Ftdi => Box::new(FtdiDriver::new(port_index)),
+ DriverType::Cp21xx => Box::new(Cp21xxDriver::new(port_index)),
+ DriverType::Ch34x => Box::new(Ch34xDriver::new(port_index)),
+ DriverType::Prolific => Box::new(ProlificDriver::new(port_index)),
+ DriverType::GsmModem => Box::new(GsmModemDriver::new(port_index)),
+ DriverType::ChromeCcd => Box::new(ChromeCcdDriver::new(port_index)),
+ }
+}
+
+pub fn line_coding_bytes(cfg: &LineConfig) -> [u8; 7] {
+ let stop = match cfg.stop_bits {
+ crate::config::StopBits::One => 0u8,
+ crate::config::StopBits::OnePointFive => 1,
+ crate::config::StopBits::Two => 2,
+ };
+ let parity = match cfg.parity {
+ crate::config::Parity::None => 0,
+ crate::config::Parity::Odd => 1,
+ crate::config::Parity::Even => 2,
+ crate::config::Parity::Mark => 3,
+ crate::config::Parity::Space => 4,
+ };
+ let data = match cfg.data_bits {
+ crate::config::DataBits::Five => 5,
+ crate::config::DataBits::Six => 6,
+ crate::config::DataBits::Seven => 7,
+ crate::config::DataBits::Eight => 8,
+ };
+ [
+ (cfg.baud_rate & 0xff) as u8,
+ ((cfg.baud_rate >> 8) & 0xff) as u8,
+ ((cfg.baud_rate >> 16) & 0xff) as u8,
+ ((cfg.baud_rate >> 24) & 0xff) as u8,
+ stop,
+ parity,
+ data,
+ ]
+}
+
+struct EndpointPair {
+ bulk_in: Option>,
+ bulk_out: Option>,
+ in_ep: u8,
+ out_ep: u8,
+ mps: u16,
+}
+
+impl EndpointPair {
+ pub(crate) fn from_addresses(in_ep: u8, out_ep: u8, mps: u16) -> Self {
+ Self {
+ bulk_in: None,
+ bulk_out: None,
+ in_ep,
+ out_ep,
+ mps,
+ }
+ }
+
+ fn open(transport: &SharedTransport, iface: u8) -> Result {
+ let eps = transport.endpoints(iface);
+ let in_ep = eps
+ .iter()
+ .find(|e| e.is_bulk_in())
+ .ok_or_else(|| crate::error::UsbSerialError::ProbeFailed("no bulk in".into()))?;
+ let out_ep = eps
+ .iter()
+ .find(|e| e.is_bulk_out())
+ .ok_or_else(|| crate::error::UsbSerialError::ProbeFailed("no bulk out".into()))?;
+ Ok(Self {
+ bulk_in: None,
+ bulk_out: None,
+ in_ep: in_ep.address,
+ out_ep: out_ep.address,
+ mps: in_ep.max_packet_size,
+ })
+ }
+
+ fn ensure_out(&mut self, transport: &SharedTransport) -> Result<()> {
+ if self.bulk_out.is_none() {
+ self.bulk_out = Some(transport.open_bulk_out(self.out_ep, self.mps)?);
+ }
+ Ok(())
+ }
+
+ fn ensure_in(&mut self, transport: &SharedTransport) -> Result<()> {
+ if self.bulk_in.is_none() {
+ self.bulk_in = Some(transport.open_bulk_in(self.in_ep, self.mps)?);
+ }
+ Ok(())
+ }
+
+ fn write(&mut self, transport: &SharedTransport, data: &[u8]) -> Result {
+ // Only claim OUT — IN may already be owned by SerialReader after take_in().
+ // Opening IN again hits nusb "endpoint already in use".
+ self.ensure_out(transport)?;
+ self.bulk_out
+ .as_mut()
+ .unwrap()
+ .write(data, WRITE_TIMEOUT_MS)
+ }
+
+ fn take_in(&mut self, transport: &SharedTransport) -> Option> {
+ self.ensure_in(transport).ok()?;
+ self.bulk_in.take()
+ }
+}
diff --git a/crates/android-usb-serial/src/drivers/prolific.rs b/crates/android-usb-serial/src/drivers/prolific.rs
new file mode 100644
index 00000000..609741c5
--- /dev/null
+++ b/crates/android-usb-serial/src/drivers/prolific.rs
@@ -0,0 +1,695 @@
+//! Prolific PL2303 driver (ported from ProlificSerialDriver.java).
+
+use super::{line_coding_bytes, Driver, ModemStatus, WRITE_TIMEOUT_MS};
+use crate::config::{DataBits, FlowControl, LineConfig, Parity, PurgeKind, StopBits};
+use crate::error::{Result, UsbSerialError};
+use crate::reader::SerialReader;
+use crate::rx_filter::XonXoffRxFilter;
+use crate::transport::{BulkIn, ControlRequest, EndpointInfo, SharedTransport};
+use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
+use std::sync::{Arc, Mutex};
+use std::thread::{self, JoinHandle};
+
+const WRITE_ENDPOINT: u8 = 0x02;
+const READ_ENDPOINT: u8 = 0x83;
+const INTERRUPT_ENDPOINT: u8 = 0x81;
+
+const VENDOR_READ_REQUEST: u8 = 0x01;
+const VENDOR_WRITE_REQUEST: u8 = 0x01;
+const VENDOR_READ_HXN_REQUEST: u8 = 0x81;
+const VENDOR_WRITE_HXN_REQUEST: u8 = 0x80;
+
+const RESET_HXN_REQUEST: u8 = 0x07;
+const FLUSH_RX_REQUEST: u8 = 0x08;
+const FLUSH_TX_REQUEST: u8 = 0x09;
+const SET_LINE_REQUEST: u8 = 0x20;
+const SET_CONTROL_REQUEST: u8 = 0x22;
+const SEND_BREAK_REQUEST: u8 = 0x23;
+const GET_CONTROL_HXN_REQUEST: u8 = 0x80;
+const GET_CONTROL_REQUEST: u8 = 0x87;
+const STATUS_NOTIFICATION: u8 = 0xa1;
+
+const RESET_HXN_RX_PIPE: u16 = 1;
+const RESET_HXN_TX_PIPE: u16 = 2;
+
+const CONTROL_DTR: u16 = 0x01;
+const CONTROL_RTS: u16 = 0x02;
+
+const GET_CONTROL_FLAG_CD: u8 = 0x02;
+const GET_CONTROL_FLAG_DSR: u8 = 0x04;
+const GET_CONTROL_FLAG_RI: u8 = 0x01;
+const GET_CONTROL_FLAG_CTS: u8 = 0x08;
+
+const GET_CONTROL_HXN_FLAG_CD: u8 = 0x40;
+const GET_CONTROL_HXN_FLAG_DSR: u8 = 0x20;
+const GET_CONTROL_HXN_FLAG_RI: u8 = 0x80;
+const GET_CONTROL_HXN_FLAG_CTS: u8 = 0x08;
+
+const STATUS_FLAG_CD: u8 = 0x01;
+const STATUS_FLAG_DSR: u8 = 0x02;
+const STATUS_FLAG_RI: u8 = 0x08;
+const STATUS_FLAG_CTS: u8 = 0x80;
+
+const STATUS_BUFFER_SIZE: usize = 10;
+const STATUS_BYTE_IDX: usize = 8;
+
+const STANDARD_BAUD_RATES: &[u32] = &[
+ 75, 150, 300, 600, 1200, 1800, 2400, 3600, 4800, 7200, 9600, 14400, 19200, 28800, 38400, 57600,
+ 115200, 128000, 134400, 161280, 201600, 230400, 268800, 403200, 460800, 614400, 806400, 921600,
+ 1228800, 2457600, 3000000, 6000000,
+];
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum DeviceType {
+ Type01,
+ TypeT,
+ TypeHx,
+ TypeHxn,
+}
+
+struct ProlificEndpoints {
+ data: super::EndpointPair,
+ interrupt_ep: u8,
+ interrupt_mps: u16,
+}
+
+pub struct ProlificDriver {
+ #[allow(dead_code)]
+ port_index: usize,
+ iface: u8,
+ device_type: DeviceType,
+ control_lines: u16,
+ flow: FlowControl,
+ baud_rate: i32,
+ data_bits: i32,
+ stop_bits: i32,
+ parity: i32,
+ endpoints: Option,
+ transport: Option,
+ reader: Option,
+ status: Arc,
+ status_thread: Option>,
+ stop_status_thread: Arc,
+ status_error: Arc>>,
+ status_started: bool,
+}
+
+impl ProlificDriver {
+ pub fn new(port_index: usize) -> Self {
+ Self {
+ port_index,
+ iface: 0,
+ device_type: DeviceType::TypeHx,
+ control_lines: 0,
+ flow: FlowControl::None,
+ baud_rate: -1,
+ data_bits: -1,
+ stop_bits: -1,
+ parity: -1,
+ endpoints: None,
+ transport: None,
+ reader: None,
+ status: Arc::new(AtomicU8::new(0)),
+ status_thread: None,
+ stop_status_thread: Arc::new(AtomicBool::new(false)),
+ status_error: Arc::new(Mutex::new(None)),
+ status_started: false,
+ }
+ }
+
+ fn vendor_read_request(&self) -> u8 {
+ if self.device_type == DeviceType::TypeHxn {
+ VENDOR_READ_HXN_REQUEST
+ } else {
+ VENDOR_READ_REQUEST
+ }
+ }
+
+ fn vendor_write_request(&self) -> u8 {
+ if self.device_type == DeviceType::TypeHxn {
+ VENDOR_WRITE_HXN_REQUEST
+ } else {
+ VENDOR_WRITE_REQUEST
+ }
+ }
+
+ fn vendor_in(&self, value: u16, index: u16, length: usize) -> Result> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_in(&ControlRequest {
+ request_type: 0xC0,
+ request: self.vendor_read_request(),
+ value,
+ index,
+ data: vec![0; length],
+ timeout_ms: WRITE_TIMEOUT_MS,
+ })
+ }
+
+ fn vendor_out(&self, value: u16, index: u16, data: Vec) -> Result<()> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_out(&ControlRequest {
+ request_type: 0x40,
+ request: self.vendor_write_request(),
+ value,
+ index,
+ data,
+ timeout_ms: WRITE_TIMEOUT_MS,
+ })?;
+ Ok(())
+ }
+
+ fn ctrl_out(&self, request: u8, value: u16, index: u16, data: Vec) -> Result<()> {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_out(&ControlRequest::class_out(request, value, index, data))?;
+ Ok(())
+ }
+
+ fn detect_device_type(&mut self, transport: &SharedTransport) -> Result<()> {
+ let raw = transport.raw_descriptors();
+ let desc = if raw.len() >= 14 {
+ raw
+ } else {
+ transport.raw_device_descriptor().to_vec()
+ };
+ if desc.len() < 14 {
+ return Err(UsbSerialError::Io(
+ "could not get device descriptors".into(),
+ ));
+ }
+ let usb_version = u16::from_le_bytes([desc[2], desc[3]]);
+ let device_version = u16::from_le_bytes([desc[12], desc[13]]);
+ let max_packet_size0 = desc[7];
+ let device_class = transport.device_class();
+
+ self.device_type = if device_class == 0x02 || max_packet_size0 != 64 {
+ DeviceType::Type01
+ } else if usb_version == 0x200 {
+ if (device_version == 0x300 || device_version == 0x500) && self.test_hx_status() {
+ DeviceType::TypeT
+ } else {
+ DeviceType::TypeHxn
+ }
+ } else {
+ DeviceType::TypeHx
+ };
+ Ok(())
+ }
+
+ fn test_hx_status(&self) -> bool {
+ self.transport
+ .as_ref()
+ .unwrap()
+ .control_in(&ControlRequest {
+ request_type: 0xC0,
+ request: VENDOR_READ_REQUEST,
+ value: 0x8080,
+ index: 0,
+ data: vec![0; 1],
+ timeout_ms: WRITE_TIMEOUT_MS,
+ })
+ .is_ok()
+ }
+
+ fn reset_device(&self) -> Result<()> {
+ self.purge_hw_buffers(true, true)
+ }
+
+ fn purge_hw_buffers(&self, purge_write: bool, purge_read: bool) -> Result<()> {
+ if self.device_type == DeviceType::TypeHxn {
+ let mut index = 0u16;
+ if purge_write {
+ index |= RESET_HXN_RX_PIPE;
+ }
+ if purge_read {
+ index |= RESET_HXN_TX_PIPE;
+ }
+ if index != 0 {
+ self.vendor_out(RESET_HXN_REQUEST as u16, index, vec![])?;
+ }
+ } else {
+ if purge_write {
+ self.vendor_out(FLUSH_RX_REQUEST as u16, 0, vec![])?;
+ }
+ if purge_read {
+ self.vendor_out(FLUSH_TX_REQUEST as u16, 0, vec![])?;
+ }
+ }
+ Ok(())
+ }
+
+ fn do_black_magic(&self) -> Result<()> {
+ if self.device_type == DeviceType::TypeHxn {
+ return Ok(());
+ }
+ self.vendor_in(0x8484, 0, 1)?;
+ self.vendor_out(0x0404, 0, vec![])?;
+ self.vendor_in(0x8484, 0, 1)?;
+ self.vendor_in(0x8383, 0, 1)?;
+ self.vendor_in(0x8484, 0, 1)?;
+ self.vendor_out(0x0404, 1, vec![])?;
+ self.vendor_in(0x8484, 0, 1)?;
+ self.vendor_in(0x8383, 0, 1)?;
+ self.vendor_out(0, 1, vec![])?;
+ self.vendor_out(1, 0, vec![])?;
+ let magic = if self.device_type == DeviceType::Type01 {
+ 0x24u16
+ } else {
+ 0x44
+ };
+ self.vendor_out(2, magic, vec![])
+ }
+
+ fn set_control_lines(&mut self, value: u16) -> Result<()> {
+ self.ctrl_out(SET_CONTROL_REQUEST, value, 0, vec![])?;
+ self.control_lines = value;
+ Ok(())
+ }
+
+ fn open_endpoints(transport: &SharedTransport, iface: u8) -> Result {
+ let eps = transport.endpoints(iface);
+ let find = |addr: u8| -> Result {
+ eps.iter()
+ .find(|e| e.address == addr)
+ .copied()
+ .ok_or_else(|| {
+ UsbSerialError::ProbeFailed(format!("missing endpoint 0x{addr:02x}"))
+ })
+ };
+ let in_ep = find(READ_ENDPOINT)?;
+ let out_ep = find(WRITE_ENDPOINT)?;
+ let int_ep = find(INTERRUPT_ENDPOINT)?;
+ Ok(ProlificEndpoints {
+ data: super::EndpointPair::from_addresses(
+ in_ep.address,
+ out_ep.address,
+ in_ep.max_packet_size,
+ ),
+ interrupt_ep: int_ep.address,
+ interrupt_mps: int_ep.max_packet_size,
+ })
+ }
+
+ fn ensure_status_thread(&mut self) -> Result<()> {
+ if self.status_started {
+ return Ok(());
+ }
+ self.status.store(0, Ordering::Relaxed);
+
+ let initial = if self.device_type == DeviceType::TypeHxn {
+ let data = self.vendor_in(GET_CONTROL_HXN_REQUEST as u16, 0, 1)?;
+ decode_vendor_status_hxn(data[0])
+ } else {
+ let data = self.vendor_in(GET_CONTROL_REQUEST as u16, 0, 1)?;
+ decode_vendor_status(data[0])
+ };
+ self.status.store(initial, Ordering::Relaxed);
+
+ let transport = self.transport.as_ref().unwrap().clone();
+ let interrupt_ep = self.endpoints.as_ref().unwrap().interrupt_ep;
+ let interrupt_mps = self.endpoints.as_ref().unwrap().interrupt_mps;
+ let stop = self.stop_status_thread.clone();
+ let status = self.status.clone();
+ let status_error = self.status_error.clone();
+
+ stop.store(false, Ordering::Relaxed);
+ let handle = thread::spawn(move || {
+ let mut interrupt = match transport.open_interrupt_in(interrupt_ep, interrupt_mps) {
+ Ok(ep) => ep,
+ Err(e) => {
+ *status_error.lock().unwrap() = Some(e.to_string());
+ return;
+ }
+ };
+ let mut buffer = vec![0u8; STATUS_BUFFER_SIZE];
+ while !stop.load(Ordering::Relaxed) {
+ match interrupt.read(&mut buffer, 500) {
+ Ok(crate::error::ReadOutcome::Data(data)) => {
+ if data.len() != STATUS_BUFFER_SIZE {
+ *status_error.lock().unwrap() = Some(format!(
+ "invalid status notification, expected {STATUS_BUFFER_SIZE} bytes, got {}",
+ data.len()
+ ));
+ break;
+ }
+ if data[0] != STATUS_NOTIFICATION {
+ *status_error.lock().unwrap() = Some(format!(
+ "invalid status notification, expected 0x{STATUS_NOTIFICATION:02x}, got 0x{:02x}",
+ data[0]
+ ));
+ break;
+ }
+ status.store(data[STATUS_BYTE_IDX], Ordering::Relaxed);
+ }
+ Ok(crate::error::ReadOutcome::TimedOut)
+ | Ok(crate::error::ReadOutcome::Cancelled) => {}
+ Err(e) => {
+ if !stop.load(Ordering::Relaxed) {
+ *status_error.lock().unwrap() = Some(e.to_string());
+ }
+ break;
+ }
+ }
+ }
+ });
+ self.status_thread = Some(handle);
+ self.status_started = true;
+ Ok(())
+ }
+
+ fn get_status(&mut self) -> Result {
+ self.ensure_status_thread()?;
+ if let Some(msg) = self.status_error.lock().unwrap().take() {
+ return Err(UsbSerialError::Io(msg));
+ }
+ Ok(self.status.load(Ordering::Relaxed))
+ }
+
+ fn stop_status_thread(&mut self) {
+ self.stop_status_thread.store(true, Ordering::Relaxed);
+ if let Some(handle) = self.status_thread.take() {
+ let _ = handle.join();
+ }
+ self.stop_status_thread.store(false, Ordering::Relaxed);
+ self.status_started = false;
+ *self.status_error.lock().unwrap() = None;
+ }
+
+ fn filter_baud_rate(&self, baud_rate: u32) -> Result {
+ if baud_rate == 0 {
+ return Err(UsbSerialError::Unsupported(format!(
+ "invalid baud rate: {baud_rate}"
+ )));
+ }
+ if self.device_type == DeviceType::TypeHxn {
+ return Ok(baud_rate);
+ }
+ if STANDARD_BAUD_RATES.contains(&baud_rate) {
+ return Ok(baud_rate);
+ }
+
+ let baseline = 12000000u64 * 32;
+ let mut mantissa = (baseline / baud_rate as u64) as u32;
+ if mantissa == 0 {
+ return Err(UsbSerialError::Unsupported("baud rate too high".into()));
+ }
+
+ let (buf, effective) = if self.device_type == DeviceType::TypeT {
+ let mut exponent = 0u32;
+ while mantissa >= 2048 {
+ if exponent < 15 {
+ mantissa >>= 1;
+ exponent += 1;
+ } else {
+ return Err(UsbSerialError::Unsupported("baud rate too low".into()));
+ }
+ }
+ let buf = mantissa + ((exponent & !1) << 12) + ((exponent & 1) << 16) + (1 << 31);
+ let effective = (baseline / mantissa as u64) >> exponent;
+ (buf, effective)
+ } else {
+ let mut exponent = 0u32;
+ while mantissa >= 512 {
+ if exponent < 7 {
+ mantissa >>= 2;
+ exponent += 1;
+ } else {
+ return Err(UsbSerialError::Unsupported("baud rate too low".into()));
+ }
+ }
+ let buf = mantissa + (exponent << 9) + (1 << 31);
+ let effective = (baseline / mantissa as u64) >> (exponent * 2);
+ (buf, effective)
+ };
+
+ let error = (1.0 - (effective as f64 / baud_rate as f64)).abs();
+ if error >= 0.031 {
+ return Err(UsbSerialError::Unsupported(format!(
+ "baud rate deviation {:.1}% is higher than allowed 3%",
+ error * 100.0
+ )));
+ }
+ Ok(buf)
+ }
+
+ fn line_request_data(&self, cfg: &LineConfig, baud: u32) -> Result> {
+ let stop = match cfg.stop_bits {
+ StopBits::One => 0u8,
+ StopBits::OnePointFive => 1,
+ StopBits::Two => 2,
+ };
+ let parity = match cfg.parity {
+ Parity::None => 0,
+ Parity::Odd => 1,
+ Parity::Even => 2,
+ Parity::Mark => 3,
+ Parity::Space => 4,
+ };
+ let data_bits = match cfg.data_bits {
+ DataBits::Five => 5,
+ DataBits::Six => 6,
+ DataBits::Seven => 7,
+ DataBits::Eight => 8,
+ };
+ let mut line = line_coding_bytes(cfg);
+ line[0] = (baud & 0xff) as u8;
+ line[1] = ((baud >> 8) & 0xff) as u8;
+ line[2] = ((baud >> 16) & 0xff) as u8;
+ line[3] = ((baud >> 24) & 0xff) as u8;
+ line[4] = stop;
+ line[5] = parity;
+ line[6] = data_bits;
+ Ok(line.to_vec())
+ }
+}
+
+fn decode_vendor_status(byte: u8) -> u8 {
+ let mut status = 0u8;
+ if byte & GET_CONTROL_FLAG_CTS == 0 {
+ status |= STATUS_FLAG_CTS;
+ }
+ if byte & GET_CONTROL_FLAG_DSR == 0 {
+ status |= STATUS_FLAG_DSR;
+ }
+ if byte & GET_CONTROL_FLAG_CD == 0 {
+ status |= STATUS_FLAG_CD;
+ }
+ if byte & GET_CONTROL_FLAG_RI == 0 {
+ status |= STATUS_FLAG_RI;
+ }
+ status
+}
+
+fn decode_vendor_status_hxn(byte: u8) -> u8 {
+ let mut status = 0u8;
+ if byte & GET_CONTROL_HXN_FLAG_CTS == 0 {
+ status |= STATUS_FLAG_CTS;
+ }
+ if byte & GET_CONTROL_HXN_FLAG_DSR == 0 {
+ status |= STATUS_FLAG_DSR;
+ }
+ if byte & GET_CONTROL_HXN_FLAG_CD == 0 {
+ status |= STATUS_FLAG_CD;
+ }
+ if byte & GET_CONTROL_HXN_FLAG_RI == 0 {
+ status |= STATUS_FLAG_RI;
+ }
+ status
+}
+
+fn status_flag_to_modem(status: u8) -> ModemStatus {
+ ModemStatus {
+ cts: status & STATUS_FLAG_CTS != 0,
+ dsr: status & STATUS_FLAG_DSR != 0,
+ ri: status & STATUS_FLAG_RI != 0,
+ cd: status & STATUS_FLAG_CD != 0,
+ }
+}
+
+impl Driver for ProlificDriver {
+ fn open(&mut self, transport: &SharedTransport) -> Result<()> {
+ self.transport = Some(transport.clone());
+ self.iface = 0;
+ transport.claim_interface(self.iface)?;
+ self.endpoints = Some(Self::open_endpoints(transport, self.iface)?);
+ self.detect_device_type(transport)?;
+ self.reset_device()?;
+ self.do_black_magic()?;
+ self.set_control_lines(self.control_lines)?;
+ self.set_flow_control(self.flow)?;
+ Ok(())
+ }
+
+ fn close(&mut self) -> Result<()> {
+ if let Some(mut r) = self.reader.take() {
+ r.stop();
+ }
+ self.stop_status_thread();
+ let _ = self.reset_device();
+ if let Some(t) = &self.transport {
+ let _ = t.release_interface(self.iface);
+ }
+ self.endpoints = None;
+ Ok(())
+ }
+
+ fn write(&mut self, data: &[u8]) -> Result {
+ let t = self.transport.as_ref().unwrap();
+ self.endpoints.as_mut().unwrap().data.write(t, data)
+ }
+
+ fn read(&mut self, buf: &mut [u8]) -> Result {
+ if let Some(reader) = &mut self.reader {
+ return reader.try_read(buf);
+ }
+ Ok(0)
+ }
+
+ fn set_line_config(&mut self, cfg: LineConfig) -> Result<()> {
+ let baud = self.filter_baud_rate(cfg.baud_rate)?;
+ let data_bits = match cfg.data_bits {
+ DataBits::Five => 5,
+ DataBits::Six => 6,
+ DataBits::Seven => 7,
+ DataBits::Eight => 8,
+ };
+ let stop_bits = match cfg.stop_bits {
+ StopBits::One => 1,
+ StopBits::OnePointFive => 3,
+ StopBits::Two => 2,
+ };
+ let parity = match cfg.parity {
+ Parity::None => 0,
+ Parity::Odd => 1,
+ Parity::Even => 2,
+ Parity::Mark => 3,
+ Parity::Space => 4,
+ };
+
+ if self.baud_rate == baud as i32
+ && self.data_bits == data_bits
+ && self.stop_bits == stop_bits
+ && self.parity == parity
+ {
+ return Ok(());
+ }
+
+ let line = self.line_request_data(&cfg, baud)?;
+ self.ctrl_out(SET_LINE_REQUEST, 0, 0, line)?;
+ self.reset_device()?;
+
+ self.baud_rate = baud as i32;
+ self.data_bits = data_bits;
+ self.stop_bits = stop_bits;
+ self.parity = parity;
+ Ok(())
+ }
+
+ fn set_flow_control(&mut self, flow: FlowControl) -> Result<()> {
+ match flow {
+ FlowControl::None => {
+ if self.device_type == DeviceType::TypeHxn {
+ self.vendor_out(0x0a, 0xff, vec![])?;
+ } else {
+ self.vendor_out(0, 0, vec![])?;
+ }
+ }
+ FlowControl::RtsCts => {
+ if self.device_type == DeviceType::TypeHxn {
+ self.vendor_out(0x0a, 0xfa, vec![])?;
+ } else {
+ self.vendor_out(0, 0x61, vec![])?;
+ }
+ }
+ FlowControl::XonXoffInline => {
+ if self.device_type == DeviceType::TypeHxn {
+ self.vendor_out(0x0a, 0xee, vec![])?;
+ } else {
+ self.vendor_out(0, 0xc1, vec![])?;
+ }
+ }
+ _ => return Err(UsbSerialError::Unsupported("flow control".into())),
+ }
+ self.flow = flow;
+ Ok(())
+ }
+
+ fn set_dtr(&mut self, value: bool) -> Result<()> {
+ let new_lines = if value {
+ self.control_lines | CONTROL_DTR
+ } else {
+ self.control_lines & !CONTROL_DTR
+ };
+ self.set_control_lines(new_lines)
+ }
+
+ fn set_rts(&mut self, value: bool) -> Result<()> {
+ let new_lines = if value {
+ self.control_lines | CONTROL_RTS
+ } else {
+ self.control_lines & !CONTROL_RTS
+ };
+ self.set_control_lines(new_lines)
+ }
+
+ fn set_break(&mut self, enabled: bool) -> Result<()> {
+ let value = if enabled { 0xffffu16 } else { 0 };
+ self.ctrl_out(SEND_BREAK_REQUEST, value, 0, vec![])
+ }
+
+ fn purge(&mut self, kind: PurgeKind) -> Result<()> {
+ match kind {
+ PurgeKind::Rx => self.purge_hw_buffers(true, false),
+ PurgeKind::Tx => self.purge_hw_buffers(false, true),
+ PurgeKind::Both => self.purge_hw_buffers(true, true),
+ }
+ }
+
+ fn modem_status(&mut self) -> Result {
+ let status = self.get_status()?;
+ Ok(status_flag_to_modem(status))
+ }
+
+ fn bulk_in_mps(&self) -> u16 {
+ self.endpoints.as_ref().map(|e| e.data.mps).unwrap_or(64)
+ }
+
+ fn take_bulk_in(&mut self) -> Option> {
+ let transport = self.transport.as_ref()?;
+ self.endpoints.as_mut()?.data.take_in(transport)
+ }
+
+ fn rx_filters(&self) -> Vec> {
+ if self.flow == FlowControl::XonXoffInline {
+ vec![Box::new(XonXoffRxFilter::new(true))]
+ } else {
+ vec![]
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn filter_standard_baud() {
+ let mut d = ProlificDriver::new(0);
+ d.device_type = DeviceType::TypeHx;
+ assert_eq!(d.filter_baud_rate(115200).unwrap(), 115200);
+ }
+
+ #[test]
+ fn filter_custom_baud_hx() {
+ let mut d = ProlificDriver::new(0);
+ d.device_type = DeviceType::TypeHx;
+ let encoded = d.filter_baud_rate(500_000).unwrap();
+ assert_ne!(encoded, 500_000);
+ assert!(encoded & (1 << 31) != 0);
+ }
+}
diff --git a/crates/android-usb-serial/src/error.rs b/crates/android-usb-serial/src/error.rs
new file mode 100644
index 00000000..06f68ad0
--- /dev/null
+++ b/crates/android-usb-serial/src/error.rs
@@ -0,0 +1,69 @@
+//! USB serial driver errors.
+
+use std::fmt;
+
+/// High-level driver / transport failure.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum UsbSerialError {
+ /// Operation or chip feature not supported.
+ Unsupported(String),
+ /// I/O or USB transfer failure (including stall mapped to a message).
+ Io(String),
+ /// Transfer timed out.
+ TimedOut,
+ /// Device unplugged or USB link lost (bulk IN detach / EPROTO, etc.).
+ Disconnected,
+ /// Transfer cancelled by the host.
+ Cancelled,
+ /// Could not identify / open a suitable driver.
+ ProbeFailed(String),
+}
+
+impl fmt::Display for UsbSerialError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Unsupported(msg) => write!(f, "unsupported: {msg}"),
+ Self::Io(msg) => write!(f, "io: {msg}"),
+ Self::TimedOut => write!(f, "timed out"),
+ Self::Disconnected => write!(f, "disconnected"),
+ Self::Cancelled => write!(f, "cancelled"),
+ Self::ProbeFailed(msg) => write!(f, "probe failed: {msg}"),
+ }
+ }
+}
+
+impl std::error::Error for UsbSerialError {}
+
+/// Result alias for this crate.
+pub type Result = std::result::Result;
+
+/// Low-level USB transfer failure (maps into [`UsbSerialError`]).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TransferError {
+ TimedOut,
+ Stall,
+ Disconnected,
+ Cancelled,
+ Other(String),
+}
+
+impl From for UsbSerialError {
+ fn from(value: TransferError) -> Self {
+ match value {
+ TransferError::TimedOut => Self::TimedOut,
+ TransferError::Disconnected => Self::Disconnected,
+ TransferError::Cancelled => Self::Cancelled,
+ TransferError::Stall => Self::Io("stall".into()),
+ TransferError::Other(msg) => Self::Io(msg),
+ }
+ }
+}
+
+/// Bulk/control transfer outcome for [`crate::transport::BulkIn::read`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ReadOutcome {
+ /// Payload bytes received.
+ Data(Vec),
+ TimedOut,
+ Cancelled,
+}
diff --git a/crates/android-usb-serial/src/fake.rs b/crates/android-usb-serial/src/fake.rs
new file mode 100644
index 00000000..259906e9
--- /dev/null
+++ b/crates/android-usb-serial/src/fake.rs
@@ -0,0 +1,793 @@
+//! In-memory transport for golden parity and on-device harness.
+//!
+//! Enable with Cargo feature `fake-transport`. Script control/bulk responses, then open a port
+//! with [`crate::open_port`] exactly as on hardware.
+
+use crate::error::{ReadOutcome, Result, UsbSerialError};
+use crate::transport::{BulkIn, BulkOut, ControlRequest, EndpointInfo, InterfaceInfo, Transport};
+use std::collections::VecDeque;
+use std::sync::{Arc, Mutex};
+
+/// Captured control transfer (for golden parity assertions).
+#[derive(Debug, Clone)]
+pub struct RecordedControl {
+ pub request_type: u8,
+ pub request: u8,
+ pub value: u16,
+ pub index: u16,
+ pub data: Vec,
+}
+
+/// Captured bulk OUT payload.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RecordedBulkOut {
+ pub endpoint: u8,
+ pub data: Vec,
+}
+
+#[derive(Debug, Default)]
+struct FakeState {
+ device_descriptor: [u8; 18],
+ raw_descriptors: Vec,
+ interfaces: Vec,
+ endpoints: Vec<(u8, EndpointInfo)>,
+ recorded: Vec,
+ recorded_bulk_out: Vec,
+ control_in_responses: Vec>,
+ interrupt_in_queue: VecDeque,
+ rx_queue: VecDeque,
+ tx_log: Vec,
+ bulk_read_error: Option,
+ claimed: Vec,
+ /// Mimic nusb: endpoint address may only be opened once until dropped.
+ open_endpoints: Vec,
+}
+
+/// Thread-safe fake USB device for tests ([`crate::Transport`] implementor).
+#[derive(Debug, Clone)]
+pub struct FakeTransport {
+ inner: Arc>,
+}
+
+impl Default for FakeTransport {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl FakeTransport {
+ /// Same as [`Self::cdc_single_iface`].
+ pub fn new() -> Self {
+ Self::cdc_single_iface()
+ }
+
+ /// Single CDC ACM interface with bulk IN/OUT endpoints (castrated single-iface layout).
+ pub fn cdc_single_iface() -> Self {
+ let t = Self {
+ inner: Arc::new(Mutex::new(FakeState::default())),
+ };
+ {
+ let mut s = t.inner.lock().unwrap();
+ s.device_descriptor = [
+ 18, 1, 0x00, 0x02, 0x02, 0x00, 0x00, 64, 0x34, 0x12, 0x78, 0x56, 0x00, 0x01, 0x01,
+ 0x02, 0x00, 1,
+ ];
+ s.interfaces = vec![InterfaceInfo {
+ id: 0,
+ class: 2,
+ subclass: 2,
+ protocol: 0,
+ }];
+ s.endpoints = vec![
+ (
+ 0,
+ EndpointInfo {
+ address: 0x81,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ),
+ (
+ 0,
+ EndpointInfo {
+ address: 0x02,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ),
+ ];
+ }
+ t
+ }
+
+ /// FT232R-shaped single-interface layout (`0403:6001`).
+ pub fn ftdi_ft232r() -> Self {
+ let t = Self::cdc_single_iface();
+ t.set_vendor_product(0x0403, 0x6001);
+ t.set_interfaces(vec![InterfaceInfo {
+ id: 0,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ }]);
+ t.configure_endpoints(&[(
+ 0,
+ vec![
+ EndpointInfo {
+ address: 0x81,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x02,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ )]);
+ t
+ }
+
+ pub fn ftdi_ft2232() -> Self {
+ let t = Self::ftdi_ft232r();
+ t.set_vendor_product(0x0403, 0x6010);
+ t.set_interfaces(vec![
+ InterfaceInfo {
+ id: 0,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ },
+ InterfaceInfo {
+ id: 1,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ },
+ ]);
+ t.configure_endpoints(&[
+ (
+ 0,
+ vec![
+ EndpointInfo {
+ address: 0x81,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x02,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ ),
+ (
+ 1,
+ vec![
+ EndpointInfo {
+ address: 0x83,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x04,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ ),
+ ]);
+ t.patch_device_descriptor(|d| d[13] = 7);
+ t
+ }
+
+ pub fn cp2102() -> Self {
+ Self::ftdi_ft232r().also(|t| t.set_vendor_product(0x10C4, 0xEA60))
+ }
+
+ pub fn cp2105() -> Self {
+ let t = Self::cp2102();
+ t.set_vendor_product(0x10C4, 0xEA70);
+ t.set_interfaces(vec![
+ InterfaceInfo {
+ id: 0,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ },
+ InterfaceInfo {
+ id: 1,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ },
+ ]);
+ t.configure_endpoints(&[
+ (
+ 0,
+ vec![
+ EndpointInfo {
+ address: 0x81,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x02,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ ),
+ (
+ 1,
+ vec![
+ EndpointInfo {
+ address: 0x83,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x04,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ ),
+ ]);
+ t
+ }
+
+ pub fn ch340_dual_iface() -> Self {
+ let t = Self::cdc_single_iface();
+ t.set_vendor_product(0x1A86, 0x7523);
+ t.set_interfaces(vec![
+ InterfaceInfo {
+ id: 0,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ },
+ InterfaceInfo {
+ id: 1,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ },
+ ]);
+ t.configure_endpoints(&[(
+ 1,
+ vec![
+ EndpointInfo {
+ address: 0x82,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x03,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ )]);
+ t
+ }
+
+ pub fn pl2303_hx() -> Self {
+ let t = Self::cdc_single_iface();
+ t.set_vendor_product(0x067B, 0x2303);
+ t.patch_device_descriptor(|d| d[4] = 0);
+ t.configure_endpoints(&[(
+ 0,
+ vec![
+ EndpointInfo {
+ address: 0x81,
+ attributes: 3,
+ max_packet_size: 64,
+ interval: 1,
+ },
+ EndpointInfo {
+ address: 0x02,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x83,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ )]);
+ t.set_interfaces(vec![InterfaceInfo {
+ id: 0,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ }]);
+ t
+ }
+
+ pub fn pl2303_hxn() -> Self {
+ let t = Self::pl2303_hx();
+ t.patch_device_descriptor(|d| {
+ d[4] = 0x00;
+ d[7] = 64;
+ d[2] = 0x00;
+ d[3] = 0x02;
+ });
+ t
+ }
+
+ pub fn pl2303_type01() -> Self {
+ let t = Self::pl2303_hx();
+ t.patch_device_descriptor(|d| d[4] = 0x02);
+ t
+ }
+
+ pub fn pl2303_ta() -> Self {
+ let t = Self::pl2303_hx();
+ t.patch_device_descriptor(|d| {
+ d[2] = 0x00;
+ d[3] = 0x02;
+ d[12] = 0x00;
+ d[13] = 0x03;
+ });
+ t
+ }
+
+ pub fn cdc_iad() -> Self {
+ let t = Self::cdc_single_iface();
+ t.set_vendor_product(0x2341, 0x0043);
+ t.patch_device_descriptor(|d| {
+ d[4] = 0xEF;
+ d[5] = 0x02;
+ d[6] = 0x01;
+ });
+ let i0 = InterfaceInfo {
+ id: 0,
+ class: 2,
+ subclass: 2,
+ protocol: 0,
+ };
+ let i1 = InterfaceInfo {
+ id: 1,
+ class: 10,
+ subclass: 0,
+ protocol: 0,
+ };
+ t.set_interfaces(vec![i0, i1]);
+ t.configure_endpoints(&[
+ (
+ 0,
+ vec![EndpointInfo {
+ address: 0x81,
+ attributes: 3,
+ max_packet_size: 64,
+ interval: 1,
+ }],
+ ),
+ (
+ 1,
+ vec![
+ EndpointInfo {
+ address: 0x82,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x03,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ ),
+ ]);
+ // minimal IAD descriptor in raw config
+ t.set_raw_descriptors(vec![
+ 9, 4, 0, 0, 1, 2, 2, 0, 0, 7, 5, 0x81, 3, 64, 0, 1, 9, 4, 1, 0, 2, 10, 0, 0, 7, 5,
+ 0x82, 2, 64, 0, 7, 5, 0x03, 2, 64, 0, 8, 11, 0, 2, 2, 2, 0, 0,
+ ]);
+ t
+ }
+
+ pub fn cdc_multi() -> Self {
+ let t = Self::cdc_iad();
+ t.set_interfaces(vec![
+ InterfaceInfo {
+ id: 0,
+ class: 2,
+ subclass: 2,
+ protocol: 0,
+ },
+ InterfaceInfo {
+ id: 1,
+ class: 10,
+ subclass: 0,
+ protocol: 0,
+ },
+ InterfaceInfo {
+ id: 2,
+ class: 2,
+ subclass: 2,
+ protocol: 0,
+ },
+ InterfaceInfo {
+ id: 3,
+ class: 10,
+ subclass: 0,
+ protocol: 0,
+ },
+ ]);
+ t.configure_endpoints(&[
+ (
+ 1,
+ vec![
+ EndpointInfo {
+ address: 0x82,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x03,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ ),
+ (
+ 3,
+ vec![
+ EndpointInfo {
+ address: 0x84,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x05,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ ),
+ ]);
+ t
+ }
+
+ pub fn gsm_modem() -> Self {
+ let t = Self::cdc_single_iface();
+ t.set_vendor_product(0x1782, 0x4D10);
+ t.set_interfaces(vec![InterfaceInfo {
+ id: 0,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ }]);
+ t.configure_endpoints(&[(
+ 0,
+ vec![
+ EndpointInfo {
+ address: 0x81,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x02,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ )]);
+ t
+ }
+
+ pub fn chrome_ccd_3port() -> Self {
+ let t = Self::cdc_single_iface();
+ t.set_vendor_product(0x18D1, 0x5014);
+ let mut ifaces = Vec::new();
+ let mut eps = Vec::new();
+ for n in 0..3u8 {
+ ifaces.push(InterfaceInfo {
+ id: n,
+ class: 255,
+ subclass: 0,
+ protocol: 0,
+ });
+ eps.push((
+ n,
+ vec![
+ EndpointInfo {
+ address: 0x81 + n * 2,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ EndpointInfo {
+ address: 0x02 + n * 2,
+ attributes: 2,
+ max_packet_size: 64,
+ interval: 0,
+ },
+ ],
+ ));
+ }
+ t.set_interfaces(ifaces);
+ t.configure_endpoints(&eps);
+ t
+ }
+
+ fn also(self, f: F) -> Self {
+ f(&self);
+ self
+ }
+
+ pub fn recorded_bulk_out(&self) -> Vec {
+ self.inner.lock().unwrap().recorded_bulk_out.clone()
+ }
+
+ pub fn push_rx(&self, data: &[u8]) {
+ let mut s = self.inner.lock().unwrap();
+ s.rx_queue.extend(data);
+ }
+
+ pub fn push_interrupt_in(&self, data: &[u8]) {
+ let mut s = self.inner.lock().unwrap();
+ s.interrupt_in_queue.extend(data);
+ }
+
+ /// Drain and return bytes written to bulk OUT (test asserts).
+ pub fn take_tx(&self) -> Vec {
+ let mut s = self.inner.lock().unwrap();
+ std::mem::take(&mut s.tx_log)
+ }
+
+ /// Snapshot recorded control transfers.
+ pub fn recorded_controls(&self) -> Vec {
+ self.inner.lock().unwrap().recorded.clone()
+ }
+
+ pub fn clear_recorded(&self) {
+ let mut s = self.inner.lock().unwrap();
+ s.recorded.clear();
+ s.recorded_bulk_out.clear();
+ }
+
+ pub fn claimed_interfaces(&self) -> Vec {
+ self.inner.lock().unwrap().claimed.clone()
+ }
+
+ /// Queue the next `control_in` response payload (FIFO).
+ pub fn script_control_in_response(&self, data: Vec) {
+ self.inner.lock().unwrap().control_in_responses.push(data);
+ }
+
+ pub fn inject_bulk_read_error(&self, msg: impl Into) {
+ self.inner.lock().unwrap().bulk_read_error = Some(msg.into());
+ }
+
+ pub fn set_interfaces(&self, interfaces: Vec) {
+ self.inner.lock().unwrap().interfaces = interfaces;
+ }
+
+ pub fn configure_endpoints(&self, layout: &[(u8, Vec)]) {
+ let mut s = self.inner.lock().unwrap();
+ s.endpoints = layout
+ .iter()
+ .flat_map(|(iface, eps)| eps.iter().map(move |ep| (*iface, *ep)))
+ .collect();
+ }
+
+ pub fn set_raw_descriptors(&self, raw: Vec) {
+ self.inner.lock().unwrap().raw_descriptors = raw;
+ }
+
+ pub fn set_vendor_product(&self, vendor_id: u16, product_id: u16) {
+ let mut s = self.inner.lock().unwrap();
+ s.device_descriptor[8] = (vendor_id & 0xff) as u8;
+ s.device_descriptor[9] = (vendor_id >> 8) as u8;
+ s.device_descriptor[10] = (product_id & 0xff) as u8;
+ s.device_descriptor[11] = (product_id >> 8) as u8;
+ }
+
+ pub fn patch_device_descriptor(&self, mut patch: impl FnMut(&mut [u8; 18])) {
+ let mut s = self.inner.lock().unwrap();
+ patch(&mut s.device_descriptor);
+ }
+}
+
+struct FakeBulkIn {
+ inner: Arc>,
+ interrupt: bool,
+ endpoint: u8,
+}
+
+impl Drop for FakeBulkIn {
+ fn drop(&mut self) {
+ if let Ok(mut s) = self.inner.lock() {
+ s.open_endpoints.retain(|&e| e != self.endpoint);
+ }
+ }
+}
+
+impl BulkIn for FakeBulkIn {
+ fn read(&mut self, buf: &mut [u8], _timeout_ms: u32) -> Result {
+ let mut s = self.inner.lock().unwrap();
+ if let Some(err) = s.bulk_read_error.take() {
+ return Err(UsbSerialError::Io(err));
+ }
+ let queue = if self.interrupt {
+ &mut s.interrupt_in_queue
+ } else {
+ &mut s.rx_queue
+ };
+ if queue.is_empty() {
+ return Ok(ReadOutcome::TimedOut);
+ }
+ let n = buf.len().min(queue.len());
+ for (i, byte) in queue.drain(..n).enumerate() {
+ buf[i] = byte;
+ }
+ Ok(ReadOutcome::Data(buf[..n].to_vec()))
+ }
+
+ fn cancel_all(&mut self) {}
+
+ fn clear_halt(&mut self) -> Result<()> {
+ Ok(())
+ }
+}
+
+struct FakeBulkOut {
+ inner: Arc>,
+ endpoint: u8,
+}
+
+impl Drop for FakeBulkOut {
+ fn drop(&mut self) {
+ if let Ok(mut s) = self.inner.lock() {
+ s.open_endpoints.retain(|&e| e != self.endpoint);
+ }
+ }
+}
+
+impl BulkOut for FakeBulkOut {
+ fn write(&mut self, data: &[u8], _timeout_ms: u32) -> Result {
+ let mut s = self.inner.lock().unwrap();
+ s.tx_log.extend_from_slice(data);
+ s.recorded_bulk_out.push(RecordedBulkOut {
+ endpoint: self.endpoint,
+ data: data.to_vec(),
+ });
+ Ok(data.len())
+ }
+
+ fn clear_halt(&mut self) -> Result<()> {
+ Ok(())
+ }
+}
+
+impl Transport for FakeTransport {
+ fn raw_device_descriptor(&self) -> [u8; 18] {
+ self.inner.lock().unwrap().device_descriptor
+ }
+
+ fn raw_descriptors(&self) -> Vec {
+ self.inner.lock().unwrap().raw_descriptors.clone()
+ }
+
+ fn device_class(&self) -> u8 {
+ self.raw_device_descriptor()[4]
+ }
+
+ fn interfaces(&self) -> Vec {
+ self.inner.lock().unwrap().interfaces.clone()
+ }
+
+ fn endpoints(&self, interface: u8) -> Vec {
+ self.inner
+ .lock()
+ .unwrap()
+ .endpoints
+ .iter()
+ .filter(|(iface, _)| *iface == interface)
+ .map(|(_, ep)| *ep)
+ .collect()
+ }
+
+ fn claim_interface(&self, interface: u8) -> Result<()> {
+ self.inner.lock().unwrap().claimed.push(interface);
+ Ok(())
+ }
+
+ fn release_interface(&self, interface: u8) -> Result<()> {
+ let mut s = self.inner.lock().unwrap();
+ s.claimed.retain(|&i| i != interface);
+ Ok(())
+ }
+
+ fn control_out(&self, req: &ControlRequest) -> Result {
+ let mut s = self.inner.lock().unwrap();
+ s.recorded.push(RecordedControl {
+ request_type: req.request_type,
+ request: req.request,
+ value: req.value,
+ index: req.index,
+ data: req.data.clone(),
+ });
+ Ok(req.data.len())
+ }
+
+ fn control_in(&self, req: &ControlRequest) -> Result> {
+ let mut s = self.inner.lock().unwrap();
+ s.recorded.push(RecordedControl {
+ request_type: req.request_type,
+ request: req.request,
+ value: req.value,
+ index: req.index,
+ data: req.data.clone(),
+ });
+ if let Some(resp) = s.control_in_responses.pop() {
+ return Ok(resp);
+ }
+ Ok(req.data.clone())
+ }
+
+ fn open_bulk_in(&self, endpoint: u8, _max_packet_size: u16) -> Result> {
+ self.mark_endpoint_open(endpoint)?;
+ Ok(Box::new(FakeBulkIn {
+ inner: self.inner.clone(),
+ interrupt: false,
+ endpoint,
+ }))
+ }
+
+ fn open_bulk_out(&self, endpoint: u8, _max_packet_size: u16) -> Result> {
+ self.mark_endpoint_open(endpoint)?;
+ Ok(Box::new(FakeBulkOut {
+ inner: self.inner.clone(),
+ endpoint,
+ }))
+ }
+
+ fn open_interrupt_in(&self, endpoint: u8, _max_packet_size: u16) -> Result> {
+ self.mark_endpoint_open(endpoint)?;
+ Ok(Box::new(FakeBulkIn {
+ inner: self.inner.clone(),
+ interrupt: true,
+ endpoint,
+ }))
+ }
+}
+
+impl FakeTransport {
+ fn mark_endpoint_open(&self, endpoint: u8) -> Result<()> {
+ let mut s = self.inner.lock().unwrap();
+ if s.open_endpoints.contains(&endpoint) {
+ return Err(UsbSerialError::Io("endpoint already in use".into()));
+ }
+ s.open_endpoints.push(endpoint);
+ Ok(())
+ }
+}
diff --git a/crates/android-usb-serial/src/lib.rs b/crates/android-usb-serial/src/lib.rs
new file mode 100644
index 00000000..598f2840
--- /dev/null
+++ b/crates/android-usb-serial/src/lib.rs
@@ -0,0 +1,138 @@
+//! Pure Rust USB serial drivers for Android (and Linux), built on [nusb](https://docs.rs/nusb).
+//!
+//! Protocol logic is ported from
+//! [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) and checked against
+//! golden USB control-transfer fixtures under `tests/fixtures/`.
+//!
+//! ## Overview
+//!
+//! This crate does **not** talk to Android `UsbManager`. The host app (typically Kotlin) owns
+//! USB permission, opens a `UsbDeviceConnection`, and passes its raw file descriptor into Rust.
+//! The crate then:
+//!
+//! 1. [`from_raw_fd`] — `dup`s the fd so Java can keep the connection alive.
+//! 2. [`NusbTransport`] — claims interfaces via nusb (`detach_and_claim`).
+//! 3. [`ProbeTable`] / [`open_port`] — selects a vendor driver and returns a [`SerialPortHandle`].
+//!
+//! ```text
+//! UsbManager (Kotlin) → permission → UsbDeviceConnection → fd (unclaimed)
+//! ↓
+//! from_raw_fd / NusbTransport
+//! ↓
+//! ProbeTable → Ftdi / Cp21xx / Ch34x / … drivers
+//! ↓
+//! SerialPortHandle (write / reader / modem / purge)
+//! ```
+//!
+//! ## Android usage
+//!
+//! - Declare `android.hardware.usb.host` and a `device_filter.xml` in the app.
+//! - Request runtime USB permission before `UsbManager.openDevice()`.
+//! - **Do not** call `UsbDeviceConnection.claimInterface()` in Kotlin — nusb claims after
+//! [`from_raw_fd`]. Pre-claim causes `io interface is busy`.
+//! - Keep the `UsbDeviceConnection` open for the whole session; close it only after Rust
+//! [`SerialPortHandle::close`].
+//! - Prefer [`SerialPortHandle::start_reader`] **after** line config and DTR/RTS (important for
+//! weak OTG / CH340).
+//! - Multi-port chips: pass `port_index` to [`open_port`] (`0`, `1`, …). App-level enumerate
+//! often exposes paths as `deviceName` / `deviceName#N`.
+//!
+//! Full Kotlin/permission walkthrough: crate README (*Using on Android*) in the repository.
+//!
+//! ## Quick start (real USB fd)
+//!
+//! ```ignore
+//! // fd from UsbDeviceConnection.fileDescriptor (dup'd inside from_raw_fd)
+//! use android_usb_serial::{from_raw_fd, open_port, NusbTransport, Transport};
+//! use std::sync::Arc;
+//!
+//! let device = from_raw_fd(fd)?;
+//! let transport = Arc::new(NusbTransport::from_device(device)?) as Arc;
+//! let mut port = open_port(transport, 0)?;
+//! port.write(b"AT\r\n")?;
+//! ```
+//!
+//! ## Quick start (`fake-transport`)
+//!
+//! ```
+//! # #[cfg(feature = "fake-transport")]
+//! # {
+//! use android_usb_serial::{open_port, FakeTransport, Transport};
+//! use std::sync::Arc;
+//!
+//! let fake = FakeTransport::cdc_single_iface();
+//! let transport: Arc = Arc::new(fake.clone());
+//! let mut port = open_port(transport, 0).unwrap();
+//! port.write(b"PING").unwrap();
+//! assert_eq!(fake.take_tx(), b"PING");
+//! # }
+//! ```
+//!
+//! ## Features
+//!
+//! | Feature | Default | Description |
+//! |---------|---------|-------------|
+//! | `serialport-compat` | yes | [`serialport::SerialPort`] adapter ([`serialport_compat`]) |
+//! | `fake-transport` | no | [`FakeTransport`] + `golden_record` binary |
+//!
+//! ## Platform notes
+//!
+//! - **Android / Linux:** real USB via nusb ([`from_raw_fd`], [`NusbTransport`]).
+//! - **Other hosts:** drivers + [`fake`] for tests; supply your own [`Transport`]
+//! for hardware.
+
+#![cfg_attr(docsrs, feature(doc_cfg))]
+
+/// Line / flow / purge configuration types.
+pub mod config;
+/// Device probe and port open helpers.
+pub mod device;
+/// Chip-specific USB serial drivers.
+pub mod drivers;
+/// Error types.
+pub mod error;
+/// High-level serial port handle.
+pub mod port;
+/// VID/PID probe table (ported from usb-serial-for-android).
+pub mod probe;
+/// Continuous bulk-IN reader thread.
+pub mod reader;
+/// RX filter chain (FTDI header strip, XON/XOFF).
+pub mod rx_filter;
+/// USB transport trait and request types.
+pub mod transport;
+/// XON/XOFF inline filter.
+pub mod xonxoff;
+
+#[cfg(feature = "fake-transport")]
+#[cfg_attr(docsrs, doc(cfg(feature = "fake-transport")))]
+/// In-memory [`Transport`](crate::transport::Transport) for golden parity and harnesses.
+pub mod fake;
+
+#[cfg(any(target_os = "android", target_os = "linux"))]
+#[cfg_attr(docsrs, doc(cfg(any(target_os = "android", target_os = "linux"))))]
+/// `nusb`-backed transport (`from_raw_fd` / Android `UsbDeviceConnection`).
+pub mod nusb_transport;
+
+#[cfg(feature = "serialport-compat")]
+#[cfg_attr(docsrs, doc(cfg(feature = "serialport-compat")))]
+/// Adapter implementing [`serialport::SerialPort`].
+pub mod serialport_compat;
+
+pub use config::*;
+pub use device::{describe_device, open_port, DeviceDescriptor, PortDescriptor};
+pub use drivers::ModemStatus;
+pub use error::{ReadOutcome, Result, TransferError, UsbSerialError};
+pub use port::SerialPortHandle;
+pub use probe::{DriverType, ProbeTable};
+pub use transport::{
+ BulkIn, BulkOut, ControlRequest, EndpointInfo, InterfaceInfo, SharedTransport, Transport,
+};
+
+#[cfg(feature = "fake-transport")]
+#[cfg_attr(docsrs, doc(cfg(feature = "fake-transport")))]
+pub use fake::FakeTransport;
+
+#[cfg(any(target_os = "android", target_os = "linux"))]
+#[cfg_attr(docsrs, doc(cfg(any(target_os = "android", target_os = "linux"))))]
+pub use nusb_transport::{from_raw_fd, NusbTransport};
diff --git a/crates/android-usb-serial/src/nusb_transport.rs b/crates/android-usb-serial/src/nusb_transport.rs
new file mode 100644
index 00000000..e54d3718
--- /dev/null
+++ b/crates/android-usb-serial/src/nusb_transport.rs
@@ -0,0 +1,441 @@
+//! nusb-backed transport (Linux / Android).
+//!
+//! Obtain an [`nusb::Device`] with [`from_raw_fd`] (Android `UsbDeviceConnection` fd) or open
+//! via nusb on Linux, then wrap it in [`NusbTransport`].
+
+use crate::error::{ReadOutcome, Result, TransferError, UsbSerialError};
+use crate::transport::{
+ parse_control_recipient, BulkIn, BulkOut, ControlRequest, EndpointInfo, InterfaceInfo,
+ Transport,
+};
+use nusb::io::EndpointRead;
+use nusb::transfer::{
+ Bulk, ControlIn, ControlOut, ControlType, In, Interrupt, Out, Recipient,
+ TransferError as NusbXfer,
+};
+use nusb::{Device, Interface, MaybeFuture};
+use std::collections::HashMap;
+use std::io::{ErrorKind, Read};
+use std::os::fd::{FromRawFd, OwnedFd, RawFd};
+use std::sync::Mutex;
+use std::time::Duration;
+
+/// Fewer in-flight bulk IN URBs on Android OTG reduces EPROTO/detach on weak ports (CH340).
+const IN_FLIGHT_TRANSFERS: usize = 2;
+
+/// Duplicate an Android `UsbDeviceConnection` fd and open via nusb.
+///
+/// Calls `libc::dup` so Java/Kotlin may keep owning the original connection. Do **not**
+/// claim interfaces on the Kotlin side before this — nusb uses `detach_and_claim`.
+pub fn from_raw_fd(fd: RawFd) -> Result {
+ let dup_fd = unsafe { libc::dup(fd) };
+ if dup_fd < 0 {
+ return Err(UsbSerialError::Io(format!(
+ "dup failed: {}",
+ std::io::Error::last_os_error()
+ )));
+ }
+ let owned = unsafe { OwnedFd::from_raw_fd(dup_fd) };
+ Device::from_fd(owned)
+ .wait()
+ .map_err(|e| UsbSerialError::Io(e.to_string()))
+}
+
+/// [`crate::Transport`] over a live [`nusb::Device`] (claim, control, bulk endpoints).
+pub struct NusbTransport {
+ device: Device,
+ claimed: Mutex>,
+ interfaces: Vec,
+ endpoints: HashMap>,
+ device_descriptor: [u8; 18],
+ raw_config_descriptors: Vec,
+}
+
+impl NusbTransport {
+ /// Build a transport from an already-opened nusb device (parses config descriptors).
+ pub fn from_device(device: Device) -> Result {
+ let config = device
+ .active_configuration()
+ .map_err(|e| UsbSerialError::Io(e.to_string()))?;
+ let raw_config_descriptors = config.as_bytes().to_vec();
+ let mut interfaces = Vec::new();
+ let mut endpoints = HashMap::new();
+ for iface_group in config.interfaces() {
+ let info = iface_group.first_alt_setting();
+ let id = iface_group.interface_number();
+ interfaces.push(InterfaceInfo {
+ id,
+ class: info.class(),
+ subclass: info.subclass(),
+ protocol: info.protocol(),
+ });
+ let eps: Vec = info
+ .endpoints()
+ .map(|ep| EndpointInfo {
+ address: ep.address(),
+ attributes: ep.attributes(),
+ max_packet_size: ep.max_packet_size() as u16,
+ interval: ep.interval(),
+ })
+ .collect();
+ endpoints.insert(id, eps);
+ }
+ let mut device_descriptor = [0u8; 18];
+ let desc = device.device_descriptor();
+ let raw = desc.as_bytes();
+ let n = raw.len().min(18);
+ device_descriptor[..n].copy_from_slice(&raw[..n]);
+ Ok(Self {
+ device,
+ claimed: Mutex::new(HashMap::new()),
+ interfaces,
+ endpoints,
+ device_descriptor,
+ raw_config_descriptors,
+ })
+ }
+
+ fn interface(&self, interface: u8) -> Result {
+ let mut claimed = self.claimed.lock().unwrap();
+ if let Some(iface) = claimed.get(&interface) {
+ return Ok(iface.clone());
+ }
+ let iface = self
+ .device
+ .detach_and_claim_interface(interface)
+ .wait()
+ .map_err(|e| UsbSerialError::Io(e.to_string()))?;
+ claimed.insert(interface, iface.clone());
+ Ok(iface)
+ }
+}
+
+struct NusbPipelinedIn {
+ reader: Mutex