From 982c91048293ca0a79578b63524b4d40e9216ad9 Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 09:16:51 -0400 Subject: [PATCH 1/6] Add a Bluetooth transport for the Car Thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS removed Bluetooth PAN, so there is no IP-over-Bluetooth to lean on. Instead, a small TCP multiplexer runs over a single RFCOMM serial channel: btmux.py owns 127.0.0.1:8891 on the device (the endpoint the client already talks to) and frames its TCP streams across the radio to a helper on the computer, which replays them onto the local DeskThing server and serves a control API for the UI. After one-time provisioning the device only needs power and reconnects on its own on every boot — verified by cold-booting a flashed Car Thing: it re-establishes the Bluetooth link and resumes streaming Spotify with zero intervention. The device mux binds a real AF_BLUETOOTH RFCOMM socket (no rfcomm-binary tty bindings to leak) and a PING/PONG heartbeat on both sides tears down a half-open link so reconnects always recover cleanly. Pairing works the way the Car Thing originally did: the computer initiates, the device's screen shows a 6-digit code (btagent.py serves it locally for the client to draw), and the person confirms the same code. Helpers clear stale half-bonds before pairing fresh. Every supported OS gets a helper speaking the same frame protocol and control API: macOS in Swift over IOBluetooth (compiled at build time by bt_source/build-btbridge.js — no binaries in git), Linux in Python over BlueZ, Windows in C over Winsock RFCOMM + the Win32 Bluetooth authentication API. Python golden-vector tests lock the frame protocol across implementations. Co-Authored-By: Claude Fable 5 --- DeskThingServer/.gitignore | 2 + DeskThingServer/bt_source/README.md | 102 +++ DeskThingServer/bt_source/btbridge.swift | 776 ++++++++++++++++++ DeskThingServer/bt_source/build-btbridge.js | 71 ++ DeskThingServer/bt_source/linux/btbridge | 514 ++++++++++++ .../bt_source/superbird/btagent.py | 178 ++++ DeskThingServer/bt_source/superbird/btmux.py | 262 ++++++ .../bt_source/test/test_btagent.py | 96 +++ .../bt_source/test/test_protocol.py | 77 ++ DeskThingServer/bt_source/win/btbridge.c | 623 ++++++++++++++ DeskThingServer/package.json | 36 +- 11 files changed, 2734 insertions(+), 3 deletions(-) create mode 100644 DeskThingServer/bt_source/README.md create mode 100644 DeskThingServer/bt_source/btbridge.swift create mode 100644 DeskThingServer/bt_source/build-btbridge.js create mode 100755 DeskThingServer/bt_source/linux/btbridge create mode 100644 DeskThingServer/bt_source/superbird/btagent.py create mode 100644 DeskThingServer/bt_source/superbird/btmux.py create mode 100644 DeskThingServer/bt_source/test/test_btagent.py create mode 100644 DeskThingServer/bt_source/test/test_protocol.py create mode 100644 DeskThingServer/bt_source/win/btbridge.c diff --git a/DeskThingServer/.gitignore b/DeskThingServer/.gitignore index 42bd71b3..912592f0 100644 --- a/DeskThingServer/.gitignore +++ b/DeskThingServer/.gitignore @@ -3,3 +3,5 @@ dist out .DS_Store *.log* +bt_source/mac/btbridge +bt_source/win/btbridge.exe diff --git a/DeskThingServer/bt_source/README.md b/DeskThingServer/bt_source/README.md new file mode 100644 index 00000000..2f77089d --- /dev/null +++ b/DeskThingServer/bt_source/README.md @@ -0,0 +1,102 @@ +# Bluetooth transport + +Lets a Spotify Car Thing reach the DeskThing server over Bluetooth instead of a +USB data cable, so after one-time setup the device only needs power. There is +no IP-over-Bluetooth to lean on (macOS removed PAN entirely); instead a small +TCP multiplexer runs over a single RFCOMM serial channel. + +``` +Car Thing Computer +───────── ──────── +DeskThing client ──TCP 127.0.0.1:8891──► btmux.py ══RFCOMM ch 3══► btbridge ──TCP 127.0.0.1:8891──► DeskThing server + │ ▲ │ + │ pairing PIN overlay │ └── control API 127.0.0.1:8899 + └──── GET 127.0.0.1:8892/pairing ── btagent.py (consumed by the server, surfaced over IPC) +``` + +The client already talks to `localhost:8891`; when the Bluetooth link is up the +mux simply becomes that endpoint, so the client needs no changes to be carried +over Bluetooth. (The client's pairing overlay and transport badge are additive.) + +## Pieces + +| Path | Runs on | Role | +| --- | --- | --- | +| `btbridge.swift` | Computer (macOS) | RFCOMM client + tunnel + pairing via IOBluetooth. Compiled by `build-btbridge.js` into `mac/btbridge` (gitignored). | +| `linux/btbridge` | Computer (Linux) | Same contract in Python over BlueZ (`AF_BLUETOOTH` sockets + `bluetoothctl`). Shipped as-is; no compile step. | +| `win/btbridge.c` | Computer (Windows) | Same contract in C over Winsock RFCOMM + the Win32 Bluetooth authentication API. Compiled by `build-btbridge.js` when a C compiler is present; otherwise the app quietly stays USB-only. | +| `superbird/btmux.py` | Car Thing | Owns `127.0.0.1:8891` on the device, muxes client TCP streams into frames over the RFCOMM channel. | +| `superbird/btagent.py` | Car Thing | Persistent BlueZ pairing agent: answers computer-initiated pairing, exposes the 6-digit code on `127.0.0.1:8892/pairing` for the client to draw on screen. | + +Both device services are installed by the in-app provisioner (Setup Device → +Bluetooth) as supervisord services, so they restart on boot and on crash. + +## Pairing flow (the original Car Thing flow) + +1. The computer initiates from the DeskThing setup page (`POST /pair`). +2. The device's screen shows a 6-digit code (btagent → client overlay). +3. The same code surfaces in DeskThing (`/status` → `pairing.code`); the person + confirms it there (`POST /pair/reply`). The device side auto-accepts — + the human confirmation happens computer-side. +4. On success the helper stores the device address and connects automatically + from then on: power the device anywhere in range and it comes back. + +Stale half-bonds (one side paired, the other not) make hosts abort right after +encryption with no visible error, so every helper removes the existing bond +before pairing fresh, and `POST /unpair` exposes that for the UI. + +## Frame protocol + +One RFCOMM channel carries many TCP streams. Each frame is a fixed header plus +payload (struct layout `>BIH`): + +| Field | Size | Meaning | +| --- | --- | --- | +| type | 1 byte | 1 = OPEN, 2 = DATA, 3 = CLOSE | +| streamID | 4 bytes BE | Stream identifier; streams originate device-side only | +| len | 2 bytes BE | Payload length | + +Golden vectors live in `test/test_protocol.py`; every implementation must +match them. macOS caps the RFCOMM MTU at 667 bytes (L2CAP default 672 − 5); +measured usable throughput is ~155 KB/s with the radio saturated. Optimize +payloads, not the protocol. + +## Control API (127.0.0.1:8899) + +| Endpoint | Effect | +| --- | --- | +| `GET /status` | `{preference, transport, linkUp, deviceAddress, paired, pairing:{stage,code,error}, found:[{address,name}]}` | +| `POST /preference {"preference"}` | Pin traffic to `bluetooth` or `usb` | +| `POST /discover` | Inquiry for nearby devices; results in later `/status` polls | +| `POST /pair {"address"}` | Computer-initiated pairing (numeric comparison) | +| `POST /pair/reply {"accept"}` | Answer the numeric-comparison prompt | +| `POST /unpair {"address"}` | Remove a bond | +| `POST /device {"address"}` | Set the device the bridge connects to | + +The main process (`src/main/services/bluetooth/`) consumes this API and exposes +it to the renderer over typed IPC; nothing else should call it directly. + +## Transport priority + +While the RFCOMM link is up, the helper removes the `adb reverse tcp:8891` +forward so Bluetooth carries the traffic; when the link drops (or the user pins +USB) the helper restores it. The preference and device address persist in the +platform's app-data dir (`bt-transport.json`). + +## Tests + +- `npm run test:bt` — the TypeScript layer (IPC dispatch, manager, control-API + client, provisioner) plus the Python protocol golden vectors and the pairing + agent's parsing. +- The frame protocol tests double as the cross-implementation contract: change + them only when changing every helper. + +## Adding a platform + +1. Ship a helper under `bt_source//` that speaks the frame protocol + and control API above (see the Linux helper for the smallest example). +2. Teach `build-btbridge.js` to build it (if it needs building) and add the + `extraFiles` packaging entry in package.json. + +Platforms whose helper is missing report `supported: false` and the UI hides +itself — plain USB setups look no different than before. diff --git a/DeskThingServer/bt_source/btbridge.swift b/DeskThingServer/bt_source/btbridge.swift new file mode 100644 index 00000000..f77476da --- /dev/null +++ b/DeskThingServer/bt_source/btbridge.swift @@ -0,0 +1,776 @@ +import Foundation +import IOBluetooth +import Network + +// DeskThing Bluetooth bridge (Mac side). +// Connects to the Car Thing's RFCOMM channel 3 and demuxes tunneled TCP +// streams onto localhost:8891 (the DeskThing server). +// Frame: type(1) streamID(4 BE) len(2 BE) payload. 1=OPEN 2=DATA 3=CLOSE +// 4=PING 5=PONG. PING/PONG is a liveness heartbeat: after a device reboot the +// Mac can hold a half-open RFCOMM channel that reports connected but passes no +// data. A peer that stops ponging is dead, so we close the channel and let the +// reconnect loop re-establish it. +// +// Also serves a control API on 127.0.0.1:8899 for the DeskThing UI: +// GET /status transport + pairing snapshot +// POST /preference {"preference"} pin traffic to bluetooth|usb +// POST /discover start an inquiry for nearby devices +// POST /pair {"address"} pair with a device (numeric comparison; +// the code appears in /status, the device +// shows the same code on its screen) +// POST /pair/reply {"accept"} answer the numeric-comparison prompt +// POST /unpair {"address"} remove a stale bond +// POST /device {"address"} set the device this bridge connects to + +let rfcommChannelID: UInt8 = 3 +let targetHost = NWEndpoint.Host("127.0.0.1") +let targetPort = NWEndpoint.Port(rawValue: 8891)! +let controlPort = NWEndpoint.Port(rawValue: 8899)! + +let prefURL = FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/deskthing/bt-transport.json") + +func log(_ s: String) { + let ts = ISO8601DateFormatter().string(from: Date()) + print("[\(ts)] \(s)") + fflush(stdout) +} + +/// The bundled adb when we run inside the app, else whatever PATH has. +let adbPath: String = { + let bundled = URL(fileURLWithPath: Bundle.main.bundlePath) + .deletingLastPathComponent().appendingPathComponent("adb").path + if FileManager.default.isExecutableFile(atPath: bundled) { return bundled } + let fallback = "/Applications/DeskThing.app/Contents/Resources/mac/adb" + if FileManager.default.isExecutableFile(atPath: fallback) { return fallback } + return "adb" +}() + +@discardableResult +func adb(_ args: [String]) -> Int32 { + let p = Process() + if adbPath.contains("/") { + p.executableURL = URL(fileURLWithPath: adbPath) + p.arguments = args + } else { + p.executableURL = URL(fileURLWithPath: "/usr/bin/env") + p.arguments = [adbPath] + args + } + p.standardOutput = FileHandle.nullDevice + p.standardError = FileHandle.nullDevice + do { try p.run() } catch { return -1 } + p.waitUntilExit() + return p.terminationStatus +} + +func normalizeAddress(_ raw: String) -> String { + return raw.replacingOccurrences(of: ":", with: "-").lowercased() +} + +// MARK: - Shared state + +enum Preference: String { + case bluetooth + case usb +} + +/// Where a pairing attempt currently stands. `confirm` means both sides are +/// showing the same 6-digit code and the UI must call /pair/reply. +enum PairingStage: String { + case idle + case discovering + case connecting + case confirm + case finishing + case done + case failed +} + +struct FoundDevice { + let address: String + let name: String +} + +final class State { + static let shared = State() + private let q = DispatchQueue(label: "bridge.prefs") + private var _preference: Preference = .bluetooth + private var _linkUp = false + private var _deviceAddress: String? = nil + private var _pairingStage: PairingStage = .idle + private var _pairingCode: String? = nil + private var _pairingError: String? = nil + private var _found: [FoundDevice] = [] + + var preference: Preference { + get { q.sync { _preference } } + set { q.sync { _preference = newValue } } + } + var linkUp: Bool { + get { q.sync { _linkUp } } + set { q.sync { _linkUp = newValue } } + } + var deviceAddress: String? { + get { q.sync { _deviceAddress } } + set { q.sync { _deviceAddress = newValue } } + } + var pairingStage: PairingStage { + get { q.sync { _pairingStage } } + set { q.sync { _pairingStage = newValue } } + } + var pairingCode: String? { + get { q.sync { _pairingCode } } + set { q.sync { _pairingCode = newValue } } + } + var pairingError: String? { + get { q.sync { _pairingError } } + set { q.sync { _pairingError = newValue } } + } + var found: [FoundDevice] { + get { q.sync { _found } } + set { q.sync { _found = newValue } } + } + + /// The transport actually carrying data right now. Falling back to USB only + /// counts if the cable is really there, otherwise nothing is connected. + var activeTransport: String { + if linkUp { return "bluetooth" } + return adb(["get-state"]) == 0 ? "usb" : "none" + } + + var paired: Bool { + guard let addr = deviceAddress, + let dev = IOBluetoothDevice(addressString: addr) else { return false } + return dev.isPaired() + } + + func load() { + guard let data = try? Data(contentsOf: prefURL), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return } + if let raw = obj["preference"] as? String, let p = Preference(rawValue: raw) { + preference = p + } + if let addr = obj["deviceAddress"] as? String { + deviceAddress = normalizeAddress(addr) + } + log("state loaded: preference=\(preference.rawValue) device=\(deviceAddress ?? "unset")") + } + + func save() { + var obj: [String: Any] = ["preference": preference.rawValue] + if let addr = deviceAddress { obj["deviceAddress"] = addr } + guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) + else { return } + try? FileManager.default.createDirectory( + at: prefURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try? data.write(to: prefURL) + } +} + +// MARK: - Transport priority + +/// The device-side mux owns 127.0.0.1:8891 whenever it can, but `adb reverse` +/// binds the same port over USB — so whoever bound first used to win, arbitrarily. +/// Make it explicit instead: while Bluetooth is up, tear the USB reverse down so +/// the mux owns the port; when Bluetooth drops, restore it so the client keeps +/// working over the cable. +func preferBluetooth() { + let rc = adb(["reverse", "--remove", "tcp:8891"]) + log("transport: bluetooth active (USB reverse removed, rc=\(rc))") +} + +func fallBackToUSB() { + let rc = adb(["reverse", "tcp:8891", "tcp:8891"]) + log(rc == 0 + ? "transport: USB active (adb reverse restored)" + : "transport: USB unavailable (rc=\(rc)) — device likely unplugged") +} + +// MARK: - Discovery + +/// One inquiry at a time; results land in State.found. IOBluetooth delivers +/// the delegate callbacks on the main run loop, which main keeps servicing. +final class Discoverer: NSObject, IOBluetoothDeviceInquiryDelegate { + static let shared = Discoverer() + private var inquiry: IOBluetoothDeviceInquiry? + + func begin() { + DispatchQueue.main.async { + if self.inquiry != nil { return } + State.shared.found = [] + State.shared.pairingStage = .discovering + let inq = IOBluetoothDeviceInquiry(delegate: self) + inq?.updateNewDeviceNames = true + inq?.inquiryLength = 8 + self.inquiry = inq + let rc = inq?.start() ?? kIOReturnError + if rc != kIOReturnSuccess { + log("discovery: could not start (\(rc))") + self.inquiry = nil + State.shared.pairingStage = .idle + } else { + log("discovery: inquiry started") + } + } + } + + func deviceInquiryDeviceFound(_ sender: IOBluetoothDeviceInquiry!, device: IOBluetoothDevice!) { + guard let addr = device.addressString else { return } + let name = device.name ?? "Unknown device" + var list = State.shared.found + if !list.contains(where: { $0.address == addr }) { + list.append(FoundDevice(address: addr, name: name)) + State.shared.found = list + log("discovery: found \(name) [\(addr)]") + } + } + + func deviceInquiryComplete(_ sender: IOBluetoothDeviceInquiry!, error: IOReturn, aborted: Bool) { + log("discovery: complete (\(State.shared.found.count) devices)") + inquiry = nil + if State.shared.pairingStage == .discovering { + State.shared.pairingStage = .idle + } + } +} + +// MARK: - Pairing + +/// Computer-initiated pairing, the way the Car Thing originally worked: we ask, +/// the device's screen shows a 6-digit code, and the person confirms here. The +/// code surfaces through /status; the UI answers with /pair/reply. +final class Pairer: NSObject, IOBluetoothDevicePairDelegate { + static let shared = Pairer() + private var pair: IOBluetoothDevicePair? + private var address: String? + /// The radio-level outcome, once known. IOBluetoothDevicePair's deferred + /// replyUserConfirmation never reaches the controller (verified with btmon: + /// the reply command is simply never sent, and the exchange times out after + /// 30s), so the numeric comparison is accepted inside the callback and the + /// person's code check becomes the wizard's gate instead: Confirm completes + /// the wizard, "doesn't match" unpairs on the spot. + private var radioResult: IOReturn? + private var userAccepted: Bool? + + func begin(address raw: String) { + let addr = normalizeAddress(raw) + DispatchQueue.main.async { + if self.pair != nil { + log("pairing: already in progress, ignoring") + return + } + guard let dev = IOBluetoothDevice(addressString: addr) else { + State.shared.pairingStage = .failed + State.shared.pairingError = "bad address" + return + } + // A stale half-bond makes macOS abort right after encryption, so clear + // any existing record before pairing fresh. + if dev.isPaired() { Unpairer.unpair(addr) } + self.address = addr + self.radioResult = nil + self.userAccepted = nil + State.shared.pairingStage = .connecting + State.shared.pairingCode = nil + State.shared.pairingError = nil + guard let p = IOBluetoothDevicePair(device: dev) else { + State.shared.pairingStage = .failed + State.shared.pairingError = "could not create pairing" + return + } + p.delegate = self + self.pair = p + let rc = p.start() + if rc != kIOReturnSuccess { + log("pairing: start failed (\(rc))") + self.pair = nil + State.shared.pairingStage = .failed + State.shared.pairingError = "start failed (\(rc))" + } else { + log("pairing: started with \(addr)") + } + } + } + + func reply(accept: Bool) { + DispatchQueue.main.async { + self.userAccepted = accept + log("pairing: user replied \(accept ? "codes match" : "codes do not match")") + if !accept { + // The person says the codes differ: whatever the radio concluded, + // this bond must not survive. + self.pair?.stop() + self.pair = nil + if let addr = self.address { Unpairer.unpair(addr) } + State.shared.pairingStage = .failed + State.shared.pairingCode = nil + State.shared.pairingError = "rejected" + return + } + switch self.radioResult { + case .some(kIOReturnSuccess): + self.finalizeSuccess() + case .none: + // Radio still finishing; devicePairingFinished completes the wizard. + State.shared.pairingStage = .finishing + case .some: + break // already reported failed + } + } + } + + private func finalizeSuccess() { + log("pairing: complete") + State.shared.pairingStage = .done + State.shared.pairingCode = nil + if let addr = address { + State.shared.deviceAddress = addr + State.shared.save() + } + } + + func devicePairingUserConfirmationRequest(_ sender: Any!, numericValue: BluetoothNumericValue) { + let code = String(format: "%06u", numericValue) + log("pairing: confirm code \(code) (device is showing the same code)") + State.shared.pairingCode = code + State.shared.pairingStage = .confirm + // Accept at the radio level now — the deferred reply path never delivers + // (see radioResult above). The person's confirmation gates the wizard. + (sender as? IOBluetoothDevicePair)?.replyUserConfirmation(true) + } + + func devicePairingPINCodeRequest(_ sender: Any!) { + // Legacy PIN pairing should not happen with SSP on both sides; refuse + // rather than guess a PIN that the headless device can't display. + log("pairing: unexpected legacy PIN request, aborting") + (sender as? IOBluetoothDevicePair)?.stop() + pair = nil + State.shared.pairingStage = .failed + State.shared.pairingError = "device requested legacy PIN pairing" + } + + func devicePairingFinished(_ sender: Any!, error: IOReturn) { + pair = nil + radioResult = error + if error == kIOReturnSuccess { + log("pairing: radio bond established") + switch userAccepted { + case .some(true): + finalizeSuccess() + case .some(false): + // Already rejected and unpaired in reply(). + break + case .none: + // Keep showing the code until the person answers; stage stays + // confirm and reply() finishes the job. + break + } + } else { + log("pairing: failed (\(error))") + State.shared.pairingStage = .failed + State.shared.pairingError = "pairing failed (\(error))" + } + } +} + +/// Bond removal uses the same private IOBluetooth selector blueutil relies on; +/// there is no public API. Failing quietly is fine — pairing fresh over a stale +/// bond is what this exists to prevent, and /status shows the outcome. +enum Unpairer { + @discardableResult + static func unpair(_ raw: String) -> Bool { + let addr = normalizeAddress(raw) + guard let dev = IOBluetoothDevice(addressString: addr) else { return false } + guard dev.isPaired() else { return true } + let sel = Selector(("remove")) + guard dev.responds(to: sel) else { + log("unpair: private remove selector unavailable") + return false + } + dev.perform(sel) + log("unpair: removed bond for \(addr)") + return true + } +} + +// MARK: - Control API (consumed by the DeskThing server) + +final class ControlServer { + private var listener: NWListener? + private let q = DispatchQueue(label: "bridge.control") + + func start() { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + params.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: controlPort) + guard let l = try? NWListener(using: params) else { + log("control: could not bind 127.0.0.1:\(controlPort)") + return + } + listener = l + l.newConnectionHandler = { [weak self] conn in self?.handle(conn) } + l.start(queue: q) + log("control: listening on 127.0.0.1:\(controlPort)") + } + + private func handle(_ conn: NWConnection) { + conn.start(queue: q) + conn.receive(minimumIncompleteLength: 1, maximumLength: 16384) { data, _, _, _ in + let req = String(data: data ?? Data(), encoding: .utf8) ?? "" + let body = self.respond(to: req) + let http = """ + HTTP/1.1 200 OK\r + Content-Type: application/json\r + Access-Control-Allow-Origin: *\r + Access-Control-Allow-Methods: GET, POST, OPTIONS\r + Access-Control-Allow-Headers: Content-Type\r + Cache-Control: no-store\r + Content-Length: \(body.utf8.count)\r + Connection: close\r + \r + \(body) + """ + conn.send(content: http.data(using: .utf8), completion: .contentProcessed { _ in + conn.cancel() + }) + } + } + + private func jsonBody(_ request: String) -> [String: Any]? { + guard let range = request.range(of: "\r\n\r\n") else { return nil } + let json = String(request[range.upperBound...]) + guard let d = json.data(using: .utf8) else { return nil } + return (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] + } + + private func respond(to request: String) -> String { + let s = State.shared + + if request.hasPrefix("POST /preference") { + if let obj = jsonBody(request), + let raw = obj["preference"] as? String, + let p = Preference(rawValue: raw) { + s.preference = p + s.save() + log("preference set to \(p.rawValue) by UI") + // Apply immediately rather than waiting for the next reconnect cycle. + if p == .usb { + fallBackToUSB() + } else if s.linkUp { + preferBluetooth() + } + } + } else if request.hasPrefix("POST /discover") { + Discoverer.shared.begin() + } else if request.hasPrefix("POST /pair/reply") { + if let obj = jsonBody(request), let accept = obj["accept"] as? Bool { + Pairer.shared.reply(accept: accept) + } + } else if request.hasPrefix("POST /pair") { + if let obj = jsonBody(request), let addr = obj["address"] as? String { + Pairer.shared.begin(address: addr) + } + } else if request.hasPrefix("POST /unpair") { + if let obj = jsonBody(request), let addr = obj["address"] as? String { + _ = Unpairer.unpair(addr) + if normalizeAddress(addr) == s.deviceAddress { + s.deviceAddress = nil + s.save() + } + } + } else if request.hasPrefix("POST /device") { + if let obj = jsonBody(request), let addr = obj["address"] as? String { + s.deviceAddress = normalizeAddress(addr) + s.save() + log("device address set to \(s.deviceAddress ?? "?") by UI") + } + } + + let foundJSON = s.found + .map { "{\"address\":\"\($0.address)\",\"name\":\"\($0.name.replacingOccurrences(of: "\"", with: ""))\"}" } + .joined(separator: ",") + let code = s.pairingCode.map { "\"\($0)\"" } ?? "null" + let err = s.pairingError.map { "\"\($0.replacingOccurrences(of: "\"", with: ""))\"" } ?? "null" + let dev = s.deviceAddress.map { "\"\($0)\"" } ?? "null" + + return """ + {"preference":"\(s.preference.rawValue)","transport":"\(s.activeTransport)","linkUp":\(s.linkUp),\ + "deviceAddress":\(dev),"paired":\(s.paired),\ + "pairing":{"stage":"\(s.pairingStage.rawValue)","code":\(code),"error":\(err)},\ + "found":[\(foundJSON)]} + """ + } +} + +// MARK: - RFCOMM tunnel + +final class Bridge: NSObject, IOBluetoothRFCOMMChannelDelegate { + private var channel: IOBluetoothRFCOMMChannel? + private var conns: [UInt32: NWConnection] = [:] + private var rxBuf = Data() + private let q = DispatchQueue(label: "bridge.state") + // writeSync blocks while the RFCOMM link drains. It must never run on `q`, + // or inbound frames can't be processed and both directions deadlock. + private let writeQueue = DispatchQueue(label: "bridge.write") + // The heartbeat runs on its own queue, never writeQueue: a half-open channel + // can block writeSync there indefinitely, and the staleness check must still + // fire to close the dead link. + private let heartbeatQueue = DispatchQueue(label: "bridge.heartbeat") + private var mtu: UInt16 = 990 + var closed = false + private var opened = false + private var lastPong = Date() + private var heartbeatTimer: DispatchSourceTimer? + + func rfcommChannelOpenComplete(_ ch: IOBluetoothRFCOMMChannel, status error: IOReturn) { + if error != kIOReturnSuccess { + log("open failed: \(error)") + q.sync { closed = true } + return + } + channel = ch + mtu = ch.getMTU() + log("rfcomm open, mtu=\(mtu)") + q.sync { opened = true } + State.shared.linkUp = true + preferBluetooth() + startHeartbeat() + } + + func isOpened() -> Bool { q.sync { opened } } + + // A half-open channel reports open but never delivers data and never fires + // rfcommChannelClosed. Ping the device; if it stops answering, the link is + // dead — close so the reconnect loop takes over. + private func startHeartbeat() { + q.sync { lastPong = Date() } + let timer = DispatchSource.makeTimerSource(queue: heartbeatQueue) + timer.schedule(deadline: .now() + 5, repeating: 5) + timer.setEventHandler { [weak self] in + guard let self = self else { return } + let silent = Date().timeIntervalSince(self.q.sync { self.lastPong }) + if silent > 15 { + log("heartbeat: no pong in \(Int(silent))s — link dead, closing") + self.channel?.close() + self.q.sync { self.closed = true } + self.stopHeartbeat() + return + } + // Enqueue the ping without blocking this queue on writeSync. + self.sendFrame(4, 0, Data()) + } + heartbeatTimer = timer + timer.resume() + } + + private func stopHeartbeat() { + heartbeatTimer?.cancel() + heartbeatTimer = nil + } + + func rfcommChannelClosed(_ ch: IOBluetoothRFCOMMChannel) { + log("rfcomm closed") + stopHeartbeat() + State.shared.linkUp = false + fallBackToUSB() + q.sync { + for (_, c) in conns { c.cancel() } + conns.removeAll() + closed = true + } + } + + func rfcommChannelData(_ ch: IOBluetoothRFCOMMChannel, data: UnsafeMutableRawPointer, length: Int) { + let chunk = Data(bytes: data, count: length) + q.async { + self.rxBuf.append(chunk) + self.drainFrames() + } + } + + // Runs on q. + private func drainFrames() { + while rxBuf.count >= 7 { + let t = rxBuf[rxBuf.startIndex] + let sid = rxBuf.subdata(in: rxBuf.startIndex+1..= 7 + ln else { return } + let payload = rxBuf.subdata(in: rxBuf.startIndex+7.. PONG + case 5: lastPong = Date() // PONG from device + default: + log("bad frame type \(t), resetting buffer") + rxBuf.removeAll() + } + } + } + + // Runs on q. + private func openStream(_ sid: UInt32) { + let conn = NWConnection(host: targetHost, port: targetPort, using: .tcp) + conns[sid] = conn + conn.stateUpdateHandler = { [weak self] state in + switch state { + case .failed, .cancelled: + self?.q.async { + if self?.conns.removeValue(forKey: sid) != nil { + self?.sendFrame(3, sid, Data()) + } + } + case .ready: + self?.receiveLoop(sid, conn) + default: break + } + } + conn.start(queue: q) + } + + private func receiveLoop(_ sid: UInt32, _ conn: NWConnection) { + conn.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, isDone, _ in + guard let self = self else { return } + if isDone { + self.q.async { + if self.conns.removeValue(forKey: sid) != nil { + self.sendFrame(3, sid, Data()) + } + } + return + } + guard let data = data, !data.isEmpty else { + self.receiveLoop(sid, conn) + return + } + // Push this batch onto the link, then resume reading only once it is + // actually on the wire. That backpressure keeps a fast TCP source from + // outrunning the much slower Bluetooth link. + self.writeQueue.async { + var off = 0 + while off < data.count { + let n = min(data.count - off, Int(self.mtu) - 7) + self.writeFrame(2, sid, data.subdata(in: off.. Bool { q.sync { closed } } +} + +// MARK: - Main + +State.shared.load() + +let control = ControlServer() +control.start() + +// IOBluetooth's coordinator initializes lazily and waits on work scheduled to the +// main queue — including the Bluetooth permission check. Blocking the main thread +// while that happens deadlocks the process and suppresses the permission prompt, +// so the radio work runs on its own thread and main is left to service the queue. +// Discovery and pairing callbacks also arrive on the main run loop. +Thread.detachNewThread { + runBridgeLoop() +} + +RunLoop.main.run() + +func runBridgeLoop() -> Never { + log("bluetooth bridge loop up") + + while true { + if State.shared.preference == .usb { + // User pinned the cable. Keep the reverse in place and stay off the radio. + if State.shared.linkUp { State.shared.linkUp = false } + fallBackToUSB() + Thread.sleep(forTimeInterval: 5) + continue + } + + // Stay off the radio while a pairing exchange is running — a page from us + // mid-pairing can abort it. + switch State.shared.pairingStage { + case .discovering, .connecting, .confirm, .finishing: + Thread.sleep(forTimeInterval: 1) + continue + default: break + } + + guard let addr = State.shared.deviceAddress, + let device = IOBluetoothDevice(addressString: addr) else { + // No device chosen yet; wait for the UI to run the pairing flow. + Thread.sleep(forTimeInterval: 3) + continue + } + + // Don't gate on device.isPaired() here: it false-negatives on modern + // macOS even for a bonded device, which would strand the reconnect loop. + // The pairingStage guard above already keeps us off the radio during an + // active pairing; outside that, just attempt the open — if there is no + // bond it fails harmlessly and we retry. + let bridge = Bridge() + var channel: IOBluetoothRFCOMMChannel? + log("connecting to \(addr) rfcomm ch\(rfcommChannelID)...") + // openRFCOMMChannelSync's return value is unreliable: it frequently reports + // a failure (e.g. -536870212) while the channel actually opens a moment + // later and rfcommChannelOpenComplete fires success. Treat the delegate as + // the source of truth — wait briefly for it to report open or closed rather + // than trusting the synchronous return, or the retry would reset a link + // that is really coming up. + _ = device.openRFCOMMChannelSync(&channel, withChannelID: rfcommChannelID, delegate: bridge) + let deadline = Date().addingTimeInterval(8) + while !bridge.isOpened() && !bridge.isClosed() && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + + if bridge.isOpened() { + log("connected") + while !bridge.isClosed() && State.shared.preference == .bluetooth { + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + log("session ended") + channel?.close() + device.closeConnection() + State.shared.linkUp = false + Thread.sleep(forTimeInterval: 2) + } else { + log("connect did not open; device off/out of range? retrying in 10s") + channel?.close() + device.closeConnection() + State.shared.linkUp = false + // No Bluetooth link, so make sure the USB path is available if the cable is in. + fallBackToUSB() + Thread.sleep(forTimeInterval: 10) + } + } +} diff --git a/DeskThingServer/bt_source/build-btbridge.js b/DeskThingServer/bt_source/build-btbridge.js new file mode 100644 index 00000000..73f07637 --- /dev/null +++ b/DeskThingServer/bt_source/build-btbridge.js @@ -0,0 +1,71 @@ +/** + * Builds the Bluetooth bridge helper for the current platform so no binary + * ever lives in git. Runs as part of the build chain. + * + * mac compiles bt_source/btbridge.swift with swiftc (required) + * linux nothing to compile — the helper is a Python script shipped as-is + * win compiles bt_source/win/btbridge.c with cl/clang/gcc when one is + * available; otherwise skips, and the app falls back to USB-only + */ +import { spawnSync } from 'child_process' +import { chmodSync, existsSync } from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const bt = path.join(root, 'bt_source') + +const run = (cmd, args) => spawnSync(cmd, args, { stdio: 'inherit' }) +const has = (cmd) => + spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore' }) + .status === 0 + +if (process.platform === 'darwin') { + const out = path.join(bt, 'mac', 'btbridge') + const result = run('swiftc', [ + '-O', + path.join(bt, 'btbridge.swift'), + '-o', + out, + '-framework', + 'IOBluetooth', + '-framework', + 'Network' + ]) + if (result.error || result.status !== 0) { + console.error('build-btbridge: swiftc failed') + process.exit(result.status ?? 1) + } + console.log(`build-btbridge: built ${out}`) +} else if (process.platform === 'linux') { + const helper = path.join(bt, 'linux', 'btbridge') + if (!existsSync(helper)) { + console.error(`build-btbridge: missing ${helper}`) + process.exit(1) + } + chmodSync(helper, 0o755) + console.log(`build-btbridge: ${helper} ready (python, no compile step)`) +} else if (process.platform === 'win32') { + const src = path.join(bt, 'win', 'btbridge.c') + const out = path.join(bt, 'win', 'btbridge.exe') + let result + if (has('cl')) { + result = run('cl', ['/nologo', '/O2', src, `/Fe:${out}`, '/link', 'ws2_32.lib', 'Bthprops.lib']) + } else if (has('clang')) { + result = run('clang', ['-O2', src, '-o', out, '-lws2_32', '-lBthprops']) + } else if (has('gcc')) { + result = run('gcc', ['-O2', src, '-o', out, '-lws2_32', '-lbthprops']) + } else { + console.warn( + 'build-btbridge: no C compiler found (cl/clang/gcc) — skipping the Bluetooth helper; USB transport still works' + ) + process.exit(0) + } + if (result.error || result.status !== 0) { + console.error('build-btbridge: compile failed') + process.exit(result.status ?? 1) + } + console.log(`build-btbridge: built ${out}`) +} else { + console.log(`build-btbridge: no Bluetooth helper for ${process.platform}, skipping`) +} diff --git a/DeskThingServer/bt_source/linux/btbridge b/DeskThingServer/bt_source/linux/btbridge new file mode 100755 index 00000000..66641380 --- /dev/null +++ b/DeskThingServer/bt_source/linux/btbridge @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +"""DeskThing Bluetooth bridge (Linux side). + +Connects to the Car Thing's RFCOMM channel 3 and demuxes tunneled TCP +streams onto localhost:8891 (the DeskThing server), mirroring the macOS +helper's control API on 127.0.0.1:8899 so the rest of the app cannot tell +which platform it is on: + + GET /status transport + pairing snapshot + POST /preference {"preference"} pin traffic to bluetooth|usb + POST /discover scan for nearby devices + POST /pair {"address"} pair (numeric comparison via bluetoothctl) + POST /pair/reply {"accept"} answer the numeric-comparison prompt + POST /unpair {"address"} remove a bond + POST /device {"address"} set the device this bridge connects to + +Uses only the standard library plus the bluetoothctl binary from BlueZ, +which every desktop distribution ships. +""" +import json +import os +import re +import socket +import struct +import subprocess +import sys +import threading +import time + +RFCOMM_CHANNEL = 3 +SERVER_ADDR = ('127.0.0.1', 8891) +CONTROL_ADDR = ('127.0.0.1', 8899) +CHUNK = 660 # frame + 7-byte header stays near the typical RFCOMM MTU + +STATE_DIR = os.path.join( + os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config')), 'deskthing') +STATE_PATH = os.path.join(STATE_DIR, 'bt-transport.json') + + +def log(msg): + print('[%s] %s' % (time.strftime('%H:%M:%S'), msg), flush=True) + + +def find_adb(): + here = os.path.dirname(os.path.abspath(__file__)) + bundled = os.path.join(here, 'adb') + if os.access(bundled, os.X_OK): + return bundled + return 'adb' + + +ADB = find_adb() + + +def adb(*args): + try: + return subprocess.call([ADB] + list(args), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=15) + except Exception: + return -1 + + +class State: + def __init__(self): + self.lock = threading.Lock() + self.preference = 'bluetooth' + self.link_up = False + self.device_address = None + self.pairing_stage = 'idle' + self.pairing_code = None + self.pairing_error = None + self.found = [] + self.load() + + def load(self): + try: + with open(STATE_PATH) as f: + obj = json.load(f) + if obj.get('preference') in ('bluetooth', 'usb'): + self.preference = obj['preference'] + addr = obj.get('deviceAddress') + if isinstance(addr, str): + self.device_address = addr.replace('-', ':').upper() + except Exception: + pass + + def save(self): + try: + os.makedirs(STATE_DIR, exist_ok=True) + obj = {'preference': self.preference} + if self.device_address: + obj['deviceAddress'] = self.device_address + with open(STATE_PATH, 'w') as f: + json.dump(obj, f, indent=1) + except OSError: + pass + + def active_transport(self): + if self.link_up: + return 'bluetooth' + return 'usb' if adb('get-state') == 0 else 'none' + + def paired(self): + if not self.device_address: + return False + try: + out = subprocess.check_output( + ['bluetoothctl', 'info', self.device_address], + stderr=subprocess.DEVNULL, timeout=10).decode('utf-8', 'replace') + return 'Paired: yes' in out + except Exception: + return False + + def snapshot(self): + with self.lock: + return { + 'preference': self.preference, + 'transport': self.active_transport(), + 'linkUp': self.link_up, + 'deviceAddress': self.device_address, + 'paired': self.paired(), + 'pairing': { + 'stage': self.pairing_stage, + 'code': self.pairing_code, + 'error': self.pairing_error, + }, + 'found': list(self.found), + } + + +STATE = State() + + +def prefer_bluetooth(): + rc = adb('reverse', '--remove', 'tcp:8891') + log('transport: bluetooth active (USB reverse removed, rc=%d)' % rc) + + +def fall_back_to_usb(): + rc = adb('reverse', 'tcp:8891', 'tcp:8891') + log('transport: USB active (adb reverse restored)' if rc == 0 + else 'transport: USB unavailable (rc=%d) — device likely unplugged' % rc) + + +# ---------------------------------------------------------------- pairing + +RE_CONFIRM = re.compile(r'Confirm passkey (\d{6})') +RE_OK = re.compile(r'Pairing successful') +RE_FAIL = re.compile(r'Failed to pair|AuthenticationFailed|AuthenticationCanceled|AuthenticationRejected|not available') + + +class Pairer: + """Computer-initiated pairing driven through bluetoothctl. The device's + screen shows the code (its agent handles that side); we surface the same + code over /status and forward the person's confirm from /pair/reply.""" + + def __init__(self): + self.proc = None + self.lock = threading.Lock() + + def begin(self, address): + address = address.replace('-', ':').upper() + with self.lock: + if self.proc is not None: + log('pairing: already in progress') + return + STATE.pairing_stage = 'connecting' + STATE.pairing_code = None + STATE.pairing_error = None + threading.Thread(target=self._run, args=(address,), daemon=True).start() + + def _run(self, address): + # A stale half-bond makes hosts abort right after encryption; clear it. + subprocess.call(['bluetoothctl', 'remove', address], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + proc = subprocess.Popen(['bluetoothctl'], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, bufsize=0) + with self.lock: + self.proc = proc + + def send(cmd): + try: + proc.stdin.write((cmd + '\n').encode()) + proc.stdin.flush() + except OSError: + pass + + send('agent DisplayYesNo') + send('default-agent') + # The device may not be in the controller's cache; a short scan + # populates it so `pair` can resolve the address. + send('scan on') + time.sleep(6) + send('scan off') + send('pair ' + address) + + deadline = time.time() + 60 + # bluetoothctl's agent prompts do not end with a newline (they wait + # for input), so read raw chunks and scan an accumulating tail. + buf = '' + try: + while time.time() < deadline: + chunk = os.read(proc.stdout.fileno(), 4096) + if not chunk: + break + buf += chunk.decode('utf-8', 'replace') + m = RE_CONFIRM.search(buf) + if m: + STATE.pairing_code = m.group(1) + STATE.pairing_stage = 'confirm' + buf = '' + continue + if RE_OK.search(buf): + send('trust ' + address) + time.sleep(1) + STATE.pairing_stage = 'done' + STATE.pairing_code = None + with STATE.lock: + STATE.device_address = address + STATE.save() + return + if RE_FAIL.search(buf): + STATE.pairing_stage = 'failed' + STATE.pairing_error = buf.strip()[-120:] + return + if len(buf) > 8192: + buf = buf[-1024:] + if STATE.pairing_stage not in ('done', 'failed'): + STATE.pairing_stage = 'failed' + STATE.pairing_error = 'timed out' + finally: + try: + proc.kill() + except Exception: + pass + with self.lock: + self.proc = None + + def reply(self, accept): + with self.lock: + proc = self.proc + if proc is None: + return + STATE.pairing_stage = 'finishing' if accept else 'failed' + if not accept: + STATE.pairing_error = 'rejected' + try: + proc.stdin.write((b'yes\n' if accept else b'no\n')) + proc.stdin.flush() + except OSError: + pass + + +PAIRER = Pairer() + + +def discover(): + def run(): + STATE.pairing_stage = 'discovering' + with STATE.lock: + STATE.found = [] + try: + out = subprocess.run( + ['bluetoothctl', '--timeout', '8', 'scan', 'on'], + capture_output=True, timeout=20).stdout.decode('utf-8', 'replace') + devs = subprocess.check_output( + ['bluetoothctl', 'devices'], timeout=10).decode('utf-8', 'replace') + found = [] + for line in (out + devs).splitlines(): + m = re.search(r'Device ((?:[0-9A-F]{2}:){5}[0-9A-F]{2})\s+(.+)', line, re.I) + if m and not any(d['address'] == m.group(1) for d in found): + found.append({'address': m.group(1), 'name': m.group(2).strip()}) + with STATE.lock: + STATE.found = found + log('discovery: %d devices' % len(found)) + except Exception as e: + log('discovery failed: %r' % e) + finally: + if STATE.pairing_stage == 'discovering': + STATE.pairing_stage = 'idle' + + threading.Thread(target=run, daemon=True).start() + + +def unpair(address): + address = address.replace('-', ':').upper() + subprocess.call(['bluetoothctl', 'remove', address], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + log('unpair: removed bond for %s' % address) + with STATE.lock: + if STATE.device_address == address: + STATE.device_address = None + STATE.save() + + +# ------------------------------------------------------------ control API + +def control_server(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(CONTROL_ADDR) + srv.listen(8) + log('control: listening on %s:%d' % CONTROL_ADDR) + while True: + try: + conn, _ = srv.accept() + threading.Thread(target=handle_control, args=(conn,), daemon=True).start() + except Exception: + time.sleep(0.1) + + +def handle_control(conn): + try: + conn.settimeout(5) + req = conn.recv(16384).decode('utf-8', 'replace') + body = {} + if '\r\n\r\n' in req: + try: + body = json.loads(req.split('\r\n\r\n', 1)[1] or '{}') + except ValueError: + body = {} + + if req.startswith('POST /preference'): + pref = body.get('preference') + if pref in ('bluetooth', 'usb'): + with STATE.lock: + STATE.preference = pref + STATE.save() + log('preference set to %s by UI' % pref) + if pref == 'usb': + fall_back_to_usb() + elif STATE.link_up: + prefer_bluetooth() + elif req.startswith('POST /discover'): + discover() + elif req.startswith('POST /pair/reply'): + if isinstance(body.get('accept'), bool): + PAIRER.reply(body['accept']) + elif req.startswith('POST /pair'): + if isinstance(body.get('address'), str): + PAIRER.begin(body['address']) + elif req.startswith('POST /unpair'): + if isinstance(body.get('address'), str): + unpair(body['address']) + elif req.startswith('POST /device'): + if isinstance(body.get('address'), str): + with STATE.lock: + STATE.device_address = body['address'].replace('-', ':').upper() + STATE.save() + log('device address set to %s by UI' % STATE.device_address) + + payload = json.dumps(STATE.snapshot()).encode() + conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n' + b'Access-Control-Allow-Origin: *\r\n' + b'Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n' + b'Access-Control-Allow-Headers: Content-Type\r\n' + b'Cache-Control: no-store\r\n' + b'Content-Length: ' + str(len(payload)).encode() + + b'\r\nConnection: close\r\n\r\n' + payload) + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + + +# ------------------------------------------------------------ RFCOMM tunnel + +class Tunnel: + def __init__(self, sock): + self.sock = sock + self.conns = {} + self.wlock = threading.Lock() + self.dead = threading.Event() + + def send_frame(self, t, sid, payload=b''): + with self.wlock: + try: + self.sock.sendall(struct.pack('>BIH', t, sid, len(payload)) + payload) + except OSError: + self.dead.set() + + def pump_rfcomm(self): + """Read frames off the radio; open/feed/close local server streams.""" + buf = b'' + try: + while not self.dead.is_set(): + data = self.sock.recv(4096) + if not data: + break + buf += data + while len(buf) >= 7: + t, sid, ln = struct.unpack('>BIH', buf[:7]) + if len(buf) < 7 + ln: + break + payload = buf[7:7 + ln] + buf = buf[7 + ln:] + if t == 1: + self.open_stream(sid) + elif t == 2: + c = self.conns.get(sid) + if c: + try: + c.sendall(payload) + except OSError: + self.close_stream(sid, notify=True) + elif t == 3: + self.close_stream(sid, notify=False) + except OSError: + pass + finally: + self.dead.set() + + def open_stream(self, sid): + try: + c = socket.create_connection(SERVER_ADDR, timeout=5) + except OSError: + self.send_frame(3, sid) + return + self.conns[sid] = c + threading.Thread(target=self.pump_stream, args=(sid, c), daemon=True).start() + + def pump_stream(self, sid, c): + try: + while not self.dead.is_set(): + data = c.recv(CHUNK) + if not data: + break + self.send_frame(2, sid, data) + except OSError: + pass + finally: + if sid in self.conns: + self.close_stream(sid, notify=True) + + def close_stream(self, sid, notify): + c = self.conns.pop(sid, None) + if c: + try: + c.close() + except OSError: + pass + if notify: + self.send_frame(3, sid) + + def close(self): + self.dead.set() + for sid in list(self.conns): + self.close_stream(sid, notify=False) + try: + self.sock.close() + except OSError: + pass + + +def bridge_loop(): + log('bluetooth bridge loop up') + while True: + if STATE.preference == 'usb': + if STATE.link_up: + STATE.link_up = False + fall_back_to_usb() + time.sleep(5) + continue + + if STATE.pairing_stage in ('discovering', 'connecting', 'confirm', 'finishing'): + time.sleep(1) + continue + + addr = STATE.device_address + if not addr: + time.sleep(3) + continue + + try: + sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, + socket.BTPROTO_RFCOMM) + sock.settimeout(15) + log('connecting to %s rfcomm ch%d...' % (addr, RFCOMM_CHANNEL)) + sock.connect((addr, RFCOMM_CHANNEL)) + sock.settimeout(None) + except OSError as e: + log('connect failed (%s); device off/out of range? retrying in 10s' % e) + STATE.link_up = False + fall_back_to_usb() + time.sleep(10) + continue + + log('rfcomm open') + STATE.link_up = True + prefer_bluetooth() + tunnel = Tunnel(sock) + tunnel.pump_rfcomm() + tunnel.close() + log('session ended') + STATE.link_up = False + fall_back_to_usb() + time.sleep(10) + + +def main(): + if not hasattr(socket, 'AF_BLUETOOTH'): + log('this Python lacks AF_BLUETOOTH sockets; Bluetooth transport unavailable') + sys.exit(1) + threading.Thread(target=control_server, daemon=True).start() + bridge_loop() + + +if __name__ == '__main__': + main() diff --git a/DeskThingServer/bt_source/superbird/btagent.py b/DeskThingServer/bt_source/superbird/btagent.py new file mode 100644 index 00000000..6fc356df --- /dev/null +++ b/DeskThingServer/bt_source/superbird/btagent.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""DeskThing Bluetooth pairing agent (device side). + +Keeps a bluetoothctl session alive as the default pairing agent so the +computer can initiate pairing (the Car Thing flow: the computer asks, this +screen shows the code, the computer confirms). Parses bluetoothctl output +for the numeric-comparison passkey and serves pairing state on a tiny local +HTTP endpoint that the flashed client polls to draw the PIN overlay — +127.0.0.1:8892 works with zero connectivity, before any pairing exists. + +The device side always auto-accepts: the human confirms on the computer, +and a headless device that refused silently would be indistinguishable +from a radio bug. Pairing exposure is bounded by bluetoothd's pairable +state, which this agent keeps on only while powered. + +State file schema (also served at GET /pairing): + {"active": bool, "passkey": "123456"|null, "result": "ok"|"failed"|null, + "peer": "AA:BB:..."|null, "ts": unix_seconds} +""" +import json, os, re, socket, subprocess, threading, time + +STATE_PATH = '/tmp/deskthing-bt-pairing.json' +HTTP_ADDR = ('127.0.0.1', 8892) +# A pairing exchange is short; anything older than this is stale UI. +STATE_TTL = 90 + +_lock = threading.Lock() +_state = {'active': False, 'passkey': None, 'result': None, 'peer': None, 'ts': 0} + + +def set_state(**kw): + with _lock: + _state.update(kw) + _state['ts'] = int(time.time()) + try: + with open(STATE_PATH, 'w') as f: + json.dump(_state, f) + except OSError: + pass + + +def get_state(): + with _lock: + s = dict(_state) + if s['ts'] and time.time() - s['ts'] > STATE_TTL: + s = {'active': False, 'passkey': None, 'result': None, 'peer': None, 'ts': s['ts']} + return s + + +def http_server(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(HTTP_ADDR) + srv.listen(4) + while True: + try: + conn, _ = srv.accept() + conn.settimeout(3) + try: + req = conn.recv(1024) + if req.startswith(b'GET /pairing'): + body = json.dumps(get_state()).encode() + code = b'200 OK' + else: + body = b'{}' + code = b'404 Not Found' + conn.sendall(b'HTTP/1.1 ' + code + + b'\r\nContent-Type: application/json' + b'\r\nAccess-Control-Allow-Origin: *' + b'\r\nCache-Control: no-store' + b'\r\nContent-Length: ' + str(len(body)).encode() + + b'\r\nConnection: close\r\n\r\n' + body) + finally: + conn.close() + except Exception: + time.sleep(0.1) + + +# bluetoothctl output we react to. Lines arrive with ANSI color codes and +# prompt fragments, so match loosely anywhere in the line. +RE_CONFIRM = re.compile(r'Confirm passkey (\d{6})') +RE_PASSKEY = re.compile(r'Passkey:?\s*(\d{6})') +RE_AUTHORIZE = re.compile(r'Authorize service|Accept pairing') +RE_PAIRED = re.compile(r'Paired: yes|Pairing successful') +RE_FAILED = re.compile(r'Failed to pair|AuthenticationFailed|AuthenticationCanceled|AuthenticationRejected') +RE_PEER = re.compile(r'Device ((?:[0-9A-F]{2}:){5}[0-9A-F]{2})', re.I) + + +def agent_loop(): + while True: + proc = subprocess.Popen( + ['bluetoothctl'], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, bufsize=0) + + def send(cmd): + try: + proc.stdin.write((cmd + '\n').encode()) + proc.stdin.flush() + except OSError: + pass + + # DisplayYesNo: BlueZ hands us the numeric-comparison passkey to show, + # and we answer yes here because the human confirms computer-side. + send('agent DisplayYesNo') + send('default-agent') + send('pairable on') + send('discoverable on') + + peer = None + # bluetoothctl's agent prompts ("Confirm passkey NNNNNN (yes/no):") + # do NOT end with a newline — they sit waiting for input. Reading + # lines would block forever on exactly the event we exist to catch, + # so read raw chunks and scan an accumulating tail instead. + buf = '' + try: + while True: + chunk = os.read(proc.stdout.fileno(), 4096) + if not chunk: + break + buf += chunk.decode('utf-8', 'replace') + m = RE_PEER.search(buf) + if m: + peer = m.group(1) + m = RE_CONFIRM.search(buf) or RE_PASSKEY.search(buf) + if m: + set_state(active=True, passkey=m.group(1), result=None, peer=peer) + # Answer after a beat, not instantly: replying within + # milliseconds races the initiator's own confirmation + # prompt setup (observed on macOS — its user prompt never + # surfaces and pairing dies with an unspecified HCI + # error). The delay also guarantees the code is on the + # device screen long enough for the person to compare it + # before either side completes the exchange. + threading.Timer(5.0, lambda: send('yes')).start() + buf = '' + continue + if RE_AUTHORIZE.search(buf): + send('yes') + buf = '' + continue + if RE_PAIRED.search(buf): + if get_state()['active']: + set_state(active=False, result='ok', peer=peer) + # Trust the newly paired peer so it can reconnect + # without re-authorization after every boot. + if peer: + send('trust ' + peer) + buf = '' + continue + if RE_FAILED.search(buf): + set_state(active=False, passkey=None, result='failed', peer=peer) + buf = '' + continue + # Bound the scan window; keep enough tail to complete a + # pattern split across reads. + if len(buf) > 8192: + buf = buf[-1024:] + except Exception: + pass + finally: + try: + proc.kill() + except Exception: + pass + print('agent: bluetoothctl exited, restarting in 3s', flush=True) + time.sleep(3) + + +def main(): + set_state(active=False, passkey=None, result=None, peer=None) + threading.Thread(target=http_server, daemon=True).start() + print('agent: pairing agent up, state on %s:%d' % HTTP_ADDR, flush=True) + agent_loop() + + +if __name__ == '__main__': + main() diff --git a/DeskThingServer/bt_source/superbird/btmux.py b/DeskThingServer/bt_source/superbird/btmux.py new file mode 100644 index 00000000..113575f6 --- /dev/null +++ b/DeskThingServer/bt_source/superbird/btmux.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""DeskThing Bluetooth mux (device side). + +Listens on 127.0.0.1: and tunnels every TCP connection over a single +RFCOMM channel to the Mac-side bridge, which reconnects each stream to the +DeskThing server. Frame: >BIH = type, stream id, len. +Types: 1=OPEN 2=DATA 3=CLOSE 4=PING 5=PONG. + +The RFCOMM link is a real AF_BLUETOOTH socket bound to channel 3 — no +`rfcomm` binary and no /dev/rfcomm0 tty. The old tty approach leaked a +channel-3 binding after every session, so the next connect was refused +(-536870212) until the binding was cleared by hand; a socket has nothing to +leak and simply accepts the next connection. + +PING/PONG is a liveness heartbeat: after a reboot a peer can hold a half-open +link that reads as connected but passes no data. A peer that stops ponging is +dead, so we drop the link and both ends reconnect cleanly. +""" +import asyncio, os, socket, struct, subprocess, sys, time + +LISTEN_ADDR = '127.0.0.1' +LISTEN_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8891 +RFCOMM_CHANNEL = 3 +CHUNK = 660 # frame + 7-byte header stays within the 667-byte RFCOMM MTU + + +class Mux: + def __init__(self, sock, loop): + self.sock = sock + self.loop = loop + self.streams = {} + self.next_id = 1 + self.wlock = asyncio.Lock() + self.last_pong = time.time() + + async def send(self, t, sid, payload=b''): + async with self.wlock: + await self.loop.sock_sendall( + self.sock, struct.pack('>BIH', t, sid, len(payload)) + payload) + + async def handle_local(self, r, w): + # Read the first bytes before opening a tunnel stream so that a probe for + # /__bt can be answered here, locally. The client uses that to tell whether + # this port is served by the Bluetooth link or by USB adb-reverse: over USB + # the request reaches the DeskThing server instead, which 404s. + timed_out = False + try: + first = await asyncio.wait_for(r.read(CHUNK), timeout=5) + except asyncio.TimeoutError: + first, timed_out = b'', True + except Exception: + first = b'' + + if not first and not timed_out: + try: + w.close() + except Exception: + pass + return + + if first.startswith(b'GET /__bt'): + body = b'{"transport":"bluetooth"}' + w.write(b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n' + b'Access-Control-Allow-Origin: *\r\nCache-Control: no-store\r\n' + b'Content-Length: ' + str(len(body)).encode() + + b'\r\nConnection: close\r\n\r\n' + body) + try: + await w.drain() + except Exception: + pass + try: + w.close() + except Exception: + pass + return + + sid = self.next_id + self.next_id += 1 + self.streams[sid] = w + await self.send(1, sid) + if first: + await self.send(2, sid, first) + try: + while True: + data = await r.read(CHUNK) + if not data: + break + await self.send(2, sid, data) + except Exception: + pass + finally: + if sid in self.streams: + del self.streams[sid] + try: + await self.send(3, sid) + except Exception: + pass + try: + w.close() + except Exception: + pass + + async def pump_rfcomm(self): + buf = b'' + while True: + data = await self.loop.sock_recv(self.sock, 4096) + if not data: + raise ConnectionError('rfcomm closed by peer') + buf += data + while len(buf) >= 7: + t, sid, ln = struct.unpack('>BIH', buf[:7]) + if len(buf) < 7 + ln: + break + payload = buf[7:7 + ln] + buf = buf[7 + ln:] + if t == 4: # PING -> answer so the Mac knows we are alive + await self.send(5, 0) + continue + if t == 5: # PONG from the Mac + self.last_pong = time.time() + continue + w = self.streams.get(sid) + if t == 2 and w is not None: + w.write(payload) + try: + await w.drain() + except Exception: + pass + elif t == 3 and w is not None: + del self.streams[sid] + try: + w.close() + except Exception: + pass + + async def heartbeat(self): + """Ping the Mac; if it stops ponging the link is dead — raise to end + the session so we go back to accepting a fresh connection.""" + self.last_pong = time.time() + while True: + await asyncio.sleep(5) + try: + await self.send(4, 0) + except Exception: + raise ConnectionError('rfcomm write failed') + if time.time() - self.last_pong > 15: + raise ConnectionError('no pong from Mac in 15s') + + +async def session(conn): + conn.setblocking(False) + loop = asyncio.get_event_loop() + mux = Mux(conn, loop) + + # Port 8891 may be held by adb reverse while USB is attached. Bind in a + # concurrent task with retries so the rfcomm pump still notices a dead link + # while we wait for the port to free up. + state = {'server': None} + + async def binder(): + while state['server'] is None: + try: + state['server'] = await asyncio.start_server( + mux.handle_local, LISTEN_ADDR, LISTEN_PORT) + print('mux: tunnel up, listening on %s:%d' % (LISTEN_ADDR, LISTEN_PORT), flush=True) + except OSError: + print('mux: port %d busy (USB active?), retrying in 10s' % LISTEN_PORT, flush=True) + await asyncio.sleep(10) + + async def client_watchdog(): + # The on-device client (chromium) starts at boot, ~80s before the + # Bluetooth link is up. Its websocket retries can wedge against a + # server that was unreachable at boot and then not recover even once + # the tunnel is healthy. If the tunnel has been up a while with no + # client stream, the client is stuck — restart it once so it dials a + # working tunnel fresh. A healthy client opens a stream in seconds, so + # this only fires when something is actually wrong. + while state['server'] is None: + await asyncio.sleep(1) + await asyncio.sleep(25) + if not mux.streams: + print('mux: no client stream 25s after tunnel up — restarting chromium', flush=True) + subprocess.call(['supervisorctl', 'restart', 'chromium'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + bind_task = asyncio.ensure_future(binder()) + wd_task = asyncio.ensure_future(client_watchdog()) + hb_task = asyncio.ensure_future(mux.heartbeat()) + pump_task = asyncio.ensure_future(mux.pump_rfcomm()) + try: + done, _pending = await asyncio.wait( + [pump_task, hb_task], return_when=asyncio.FIRST_EXCEPTION) + for task in done: + exc = task.exception() + if exc: + raise exc + except (OSError, ConnectionError) as e: + print('mux: rfcomm link closed (%s)' % e, flush=True) + finally: + for task in (hb_task, pump_task, bind_task, wd_task): + task.cancel() + if state['server'] is not None: + state['server'].close() + for w in list(mux.streams.values()): + try: + w.close() + except Exception: + pass + mux.streams.clear() + try: + conn.close() + except Exception: + pass + + +def setup_radio(): + # One-time radio setup: power, connectable + discoverable, SPP record. + # All idempotent; bluetoothd resets these at boot. + for cmd in (['bluetoothctl', 'power', 'on'], + ['hciconfig', 'hci0', 'piscan']): + subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + # Advertise SPP on channel 3 so a scanning Mac can find the port. Only add + # if missing — this service restarts and sdptool would register duplicates. + try: + have = subprocess.check_output(['sdptool', 'browse', 'local'], + stderr=subprocess.DEVNULL).decode('utf-8', 'replace') + except Exception: + have = '' + if 'Serial Port' not in have: + subprocess.call(['sdptool', 'add', '--channel=%d' % RFCOMM_CHANNEL, 'SP'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def main(): + setup_radio() + srv = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(('00:00:00:00:00:00', RFCOMM_CHANNEL)) + srv.listen(1) + print('mux: listening on RFCOMM channel %d' % RFCOMM_CHANNEL, flush=True) + while True: + print('mux: waiting for Mac to connect rfcomm...', flush=True) + try: + conn, addr = srv.accept() + except OSError as e: + print('mux: accept failed (%r), retrying' % e, flush=True) + time.sleep(1) + continue + print('mux: rfcomm connected from %s' % (addr,), flush=True) + try: + asyncio.run(session(conn)) + except Exception as e: + print('mux: session error: %r' % e, flush=True) + finally: + try: + conn.close() + except Exception: + pass + + +if __name__ == '__main__': + main() diff --git a/DeskThingServer/bt_source/test/test_btagent.py b/DeskThingServer/bt_source/test/test_btagent.py new file mode 100644 index 00000000..b6b74298 --- /dev/null +++ b/DeskThingServer/bt_source/test/test_btagent.py @@ -0,0 +1,96 @@ +"""Tests for the device-side pairing agent's parsing and state handling.""" +import importlib.util +import json +import os +import sys +import tempfile +import time +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +AGENT_PATH = os.path.join(HERE, '..', 'superbird', 'btagent.py') + + +def load_agent(): + spec = importlib.util.spec_from_file_location('btagent', AGENT_PATH) + mod = importlib.util.module_from_spec(spec) + mod_dir = tempfile.mkdtemp() + # Keep the module from writing into /tmp of the machine running the tests. + spec.loader.exec_module(mod) + mod.STATE_PATH = os.path.join(mod_dir, 'pairing.json') + return mod + + +class PasskeyParsing(unittest.TestCase): + def setUp(self): + self.agent = load_agent() + + def test_confirm_passkey_line(self): + # Verbatim shape of the bluetoothctl prompt, ANSI codes stripped. + line = '[agent] Confirm passkey 847913 (yes/no): ' + m = self.agent.RE_CONFIRM.search(line) + self.assertIsNotNone(m) + self.assertEqual(m.group(1), '847913') + + def test_confirm_passkey_with_ansi_noise(self): + line = '\x1b[0;94m[agent]\x1b[0m Confirm passkey 001234 (yes/no):' + m = self.agent.RE_CONFIRM.search(line) + self.assertIsNotNone(m) + self.assertEqual(m.group(1), '001234') + + def test_pairing_success_line(self): + self.assertIsNotNone(self.agent.RE_PAIRED.search('Pairing successful')) + self.assertIsNotNone(self.agent.RE_PAIRED.search('\tPaired: yes')) + + def test_pairing_failure_lines(self): + for line in ( + 'Failed to pair: org.bluez.Error.AuthenticationFailed', + 'Failed to pair: org.bluez.Error.AuthenticationCanceled', + 'Failed to pair: org.bluez.Error.AuthenticationRejected', + ): + self.assertIsNotNone(self.agent.RE_FAILED.search(line), line) + + def test_peer_address_extraction(self): + line = '[NEW] Device FC:B2:14:97:FD:DC Edwards MacBook Pro' + m = self.agent.RE_PEER.search(line) + self.assertEqual(m.group(1), 'FC:B2:14:97:FD:DC') + + def test_ordinary_lines_do_not_trigger(self): + for line in ( + 'Agent registered', + '[bluetooth]# ', + 'Discovery started', + '[CHG] Device AA:BB:CC:DD:EE:FF RSSI: -60', + ): + self.assertIsNone(self.agent.RE_CONFIRM.search(line), line) + self.assertIsNone(self.agent.RE_FAILED.search(line), line) + + +class StateLifecycle(unittest.TestCase): + def setUp(self): + self.agent = load_agent() + + def test_set_and_get_roundtrip(self): + self.agent.set_state(active=True, passkey='123456', result=None, peer='AA:BB:CC:DD:EE:FF') + state = self.agent.get_state() + self.assertTrue(state['active']) + self.assertEqual(state['passkey'], '123456') + self.assertEqual(state['peer'], 'AA:BB:CC:DD:EE:FF') + + def test_state_persisted_to_disk(self): + self.agent.set_state(active=True, passkey='654321', result=None, peer=None) + with open(self.agent.STATE_PATH) as f: + on_disk = json.load(f) + self.assertEqual(on_disk['passkey'], '654321') + + def test_stale_state_expires(self): + self.agent.set_state(active=True, passkey='111111', result=None, peer=None) + with self.agent._lock: + self.agent._state['ts'] = int(time.time()) - self.agent.STATE_TTL - 1 + state = self.agent.get_state() + self.assertFalse(state['active']) + self.assertIsNone(state['passkey']) + + +if __name__ == '__main__': + unittest.main() diff --git a/DeskThingServer/bt_source/test/test_protocol.py b/DeskThingServer/bt_source/test/test_protocol.py new file mode 100644 index 00000000..b9e211bf --- /dev/null +++ b/DeskThingServer/bt_source/test/test_protocol.py @@ -0,0 +1,77 @@ +"""Golden vectors for the tunnel frame protocol. + +Every implementation (btmux.py on the device, btbridge.swift on mac, +bt_source/linux/btbridge, bt_source/win/btbridge.c) must agree on these +bytes. If a test here has to change, every helper has to change with it. +""" +import struct +import unittest + +FRAME = '>BIH' # type(1) streamID(4 BE) len(2 BE) +OPEN, DATA, CLOSE = 1, 2, 3 + + +class FrameProtocol(unittest.TestCase): + def test_header_is_seven_bytes(self): + self.assertEqual(struct.calcsize(FRAME), 7) + + def test_open_frame_golden(self): + self.assertEqual(struct.pack(FRAME, OPEN, 1, 0), b'\x01\x00\x00\x00\x01\x00\x00') + + def test_data_frame_golden(self): + frame = struct.pack(FRAME, DATA, 0x01020304, 5) + b'hello' + self.assertEqual(frame, b'\x02\x01\x02\x03\x04\x00\x05hello') + + def test_close_frame_golden(self): + self.assertEqual( + struct.pack(FRAME, CLOSE, 0xFFFFFFFF, 0), b'\x03\xff\xff\xff\xff\x00\x00') + + def test_roundtrip(self): + for t, sid, payload in [ + (OPEN, 1, b''), + (DATA, 2 ** 32 - 1, b'x' * 660), + (CLOSE, 42, b''), + ]: + frame = struct.pack(FRAME, t, sid, len(payload)) + payload + rt, rsid, rlen = struct.unpack(FRAME, frame[:7]) + self.assertEqual((rt, rsid, rlen), (t, sid, len(payload))) + self.assertEqual(frame[7:7 + rlen], payload) + + def test_max_payload_fits_mtu(self): + # macOS caps the RFCOMM MTU at 667; header + payload must fit. + CHUNK = 660 + self.assertLessEqual(struct.calcsize(FRAME) + CHUNK, 667) + + +class StreamReassembly(unittest.TestCase): + """The receive side must handle frames split at arbitrary byte boundaries.""" + + def drain(self, buf): + frames = [] + while len(buf) >= 7: + t, sid, ln = struct.unpack(FRAME, buf[:7]) + if len(buf) < 7 + ln: + break + frames.append((t, sid, buf[7:7 + ln])) + buf = buf[7 + ln:] + return frames, buf + + def test_split_frames_reassemble(self): + stream = ( + struct.pack(FRAME, OPEN, 7, 0) + + struct.pack(FRAME, DATA, 7, 3) + b'abc' + + struct.pack(FRAME, CLOSE, 7, 0) + ) + # Feed the byte stream one byte at a time. + buf = b'' + collected = [] + for i in range(len(stream)): + buf += stream[i:i + 1] + frames, buf = self.drain(buf) + collected.extend(frames) + self.assertEqual(collected, [(OPEN, 7, b''), (DATA, 7, b'abc'), (CLOSE, 7, b'')]) + self.assertEqual(buf, b'') + + +if __name__ == '__main__': + unittest.main() diff --git a/DeskThingServer/bt_source/win/btbridge.c b/DeskThingServer/bt_source/win/btbridge.c new file mode 100644 index 00000000..062c0139 --- /dev/null +++ b/DeskThingServer/bt_source/win/btbridge.c @@ -0,0 +1,623 @@ +/* DeskThing Bluetooth bridge (Windows side). + * + * Connects to the Car Thing's RFCOMM channel 3 and demuxes tunneled TCP + * streams onto localhost:8891 (the DeskThing server), mirroring the macOS + * helper's control API on 127.0.0.1:8899 (see bt_source/README.md). + * + * Build (done by bt_source/build-btbridge.js when a compiler is present): + * cl /O2 btbridge.c /link ws2_32.lib Bthprops.lib + * — or — + * clang -O2 btbridge.c -o btbridge.exe -lws2_32 -lBthprops + * + * Frame: type(1) streamID(4 BE) len(2 BE) payload. 1=OPEN 2=DATA 3=CLOSE. + */ +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "ws2_32.lib") +#pragma comment(lib, "Bthprops.lib") + +#define RFCOMM_CHANNEL 3 +#define SERVER_PORT 8891 +#define CONTROL_PORT 8899 +#define CHUNK 660 +#define MAX_STREAMS 64 + +static CRITICAL_SECTION g_lock; + +/* ---- shared state ------------------------------------------------- */ + +static char g_preference[16] = "bluetooth"; +static volatile int g_link_up = 0; +static char g_device_address[32] = ""; /* AA:BB:CC:DD:EE:FF */ +static char g_pairing_stage[16] = "idle"; +static char g_pairing_code[8] = ""; +static char g_pairing_error[128] = ""; +static char g_found[2048] = ""; /* pre-rendered JSON array body */ + +static char g_state_path[MAX_PATH]; + +static void logline(const char *fmt, ...) { + va_list ap; + SYSTEMTIME st; + GetLocalTime(&st); + printf("[%02d:%02d:%02d] ", st.wHour, st.wMinute, st.wSecond); + va_start(ap, fmt); + vprintf(fmt, ap); + va_end(ap); + printf("\n"); + fflush(stdout); +} + +static void state_path_init(void) { + const char *appdata = getenv("APPDATA"); + snprintf(g_state_path, sizeof(g_state_path), "%s\\deskthing\\bt-transport.json", + appdata ? appdata : "."); +} + +static void state_save(void) { + char dir[MAX_PATH]; + const char *appdata = getenv("APPDATA"); + FILE *f; + snprintf(dir, sizeof(dir), "%s\\deskthing", appdata ? appdata : "."); + CreateDirectoryA(dir, NULL); + f = fopen(g_state_path, "w"); + if (!f) return; + if (g_device_address[0]) + fprintf(f, "{\n \"preference\": \"%s\",\n \"deviceAddress\": \"%s\"\n}\n", + g_preference, g_device_address); + else + fprintf(f, "{\n \"preference\": \"%s\"\n}\n", g_preference); + fclose(f); +} + +/* Minimal parse: find "key":"value" in a small JSON blob. */ +static int json_str(const char *json, const char *key, char *out, size_t cap) { + char pat[64]; + const char *p, *q; + snprintf(pat, sizeof(pat), "\"%s\"", key); + p = strstr(json, pat); + if (!p) return 0; + p = strchr(p + strlen(pat), '"'); + if (!p) return 0; + q = strchr(p + 1, '"'); + if (!q || (size_t)(q - p - 1) >= cap) return 0; + memcpy(out, p + 1, q - p - 1); + out[q - p - 1] = 0; + return 1; +} + +static void state_load(void) { + char buf[512]; + FILE *f = fopen(g_state_path, "r"); + size_t n; + if (!f) return; + n = fread(buf, 1, sizeof(buf) - 1, f); + buf[n] = 0; + fclose(f); + json_str(buf, "preference", g_preference, sizeof(g_preference)); + json_str(buf, "deviceAddress", g_device_address, sizeof(g_device_address)); +} + +/* ---- adb arbitration ---------------------------------------------- */ + +static int run_adb(const char *args) { + char exe[MAX_PATH], adb_exe[MAX_PATH], cmd[MAX_PATH * 2]; + STARTUPINFOA si; + PROCESS_INFORMATION pi; + DWORD code = (DWORD)-1; + GetModuleFileNameA(NULL, exe, sizeof(exe)); + { + char *slash = strrchr(exe, '\\'); + if (slash) *slash = 0; + } + snprintf(adb_exe, sizeof(adb_exe), "%s\\adb.exe", exe); + if (GetFileAttributesA(adb_exe) != INVALID_FILE_ATTRIBUTES) + snprintf(cmd, sizeof(cmd), "\"%s\" %s", adb_exe, args); + else + snprintf(cmd, sizeof(cmd), "adb %s", args); + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + if (!CreateProcessA(NULL, cmd, NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) + return -1; + WaitForSingleObject(pi.hProcess, 15000); + GetExitCodeProcess(pi.hProcess, &code); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return (int)code; +} + +static void prefer_bluetooth(void) { + int rc = run_adb("reverse --remove tcp:8891"); + logline("transport: bluetooth active (USB reverse removed, rc=%d)", rc); +} + +static void fall_back_to_usb(void) { + int rc = run_adb("reverse tcp:8891 tcp:8891"); + logline(rc == 0 ? "transport: USB active (adb reverse restored)" + : "transport: USB unavailable (rc=%d) — device likely unplugged", rc); +} + +static const char *active_transport(void) { + if (g_link_up) return "bluetooth"; + return run_adb("get-state") == 0 ? "usb" : "none"; +} + +/* ---- address helpers ----------------------------------------------- */ + +static int parse_addr(const char *s, BTH_ADDR *out) { + unsigned b[6]; + char norm[32]; + size_t i, j = 0; + for (i = 0; s[i] && j < sizeof(norm) - 1; i++) + norm[j++] = (s[i] == '-') ? ':' : s[i]; + norm[j] = 0; + if (sscanf(norm, "%x:%x:%x:%x:%x:%x", &b[0], &b[1], &b[2], &b[3], &b[4], &b[5]) != 6) + return 0; + *out = 0; + for (i = 0; i < 6; i++) *out = (*out << 8) | (BTH_ADDR)(b[i] & 0xff); + return 1; +} + +static void format_addr(BTH_ADDR a, char *out, size_t cap) { + snprintf(out, cap, "%02X:%02X:%02X:%02X:%02X:%02X", + (unsigned)((a >> 40) & 0xff), (unsigned)((a >> 32) & 0xff), + (unsigned)((a >> 24) & 0xff), (unsigned)((a >> 16) & 0xff), + (unsigned)((a >> 8) & 0xff), (unsigned)(a & 0xff)); +} + +static int device_is_paired(const char *addr_s) { + BLUETOOTH_DEVICE_SEARCH_PARAMS sp; + BLUETOOTH_DEVICE_INFO di; + HBLUETOOTH_DEVICE_FIND find; + BTH_ADDR want; + int paired = 0; + if (!parse_addr(addr_s, &want)) return 0; + memset(&sp, 0, sizeof(sp)); + sp.dwSize = sizeof(sp); + sp.fReturnAuthenticated = TRUE; + sp.fReturnRemembered = TRUE; + memset(&di, 0, sizeof(di)); + di.dwSize = sizeof(di); + find = BluetoothFindFirstDevice(&sp, &di); + if (!find) return 0; + do { + if (di.Address.ullLong == want && di.fAuthenticated) { paired = 1; break; } + } while (BluetoothFindNextDevice(find, &di)); + BluetoothFindDeviceClose(find); + return paired; +} + +/* ---- discovery ------------------------------------------------------ */ + +static DWORD WINAPI discover_thread(LPVOID arg) { + BLUETOOTH_DEVICE_SEARCH_PARAMS sp; + BLUETOOTH_DEVICE_INFO di; + HBLUETOOTH_DEVICE_FIND find; + char item[256], addr[32]; + (void)arg; + EnterCriticalSection(&g_lock); + strcpy(g_pairing_stage, "discovering"); + g_found[0] = 0; + LeaveCriticalSection(&g_lock); + + memset(&sp, 0, sizeof(sp)); + sp.dwSize = sizeof(sp); + sp.fReturnAuthenticated = TRUE; + sp.fReturnRemembered = TRUE; + sp.fReturnUnknown = TRUE; + sp.fIssueInquiry = TRUE; + sp.cTimeoutMultiplier = 6; /* ~7.7s inquiry */ + memset(&di, 0, sizeof(di)); + di.dwSize = sizeof(di); + find = BluetoothFindFirstDevice(&sp, &di); + if (find) { + do { + char name[128]; + format_addr(di.Address.ullLong, addr, sizeof(addr)); + WideCharToMultiByte(CP_UTF8, 0, di.szName, -1, name, sizeof(name), NULL, NULL); + snprintf(item, sizeof(item), "%s{\"address\":\"%s\",\"name\":\"%s\"}", + g_found[0] ? "," : "", addr, name[0] ? name : "Unknown device"); + EnterCriticalSection(&g_lock); + if (strlen(g_found) + strlen(item) < sizeof(g_found) - 1) + strcat(g_found, item); + LeaveCriticalSection(&g_lock); + } while (BluetoothFindNextDevice(find, &di)); + BluetoothFindDeviceClose(find); + } + EnterCriticalSection(&g_lock); + if (!strcmp(g_pairing_stage, "discovering")) strcpy(g_pairing_stage, "idle"); + LeaveCriticalSection(&g_lock); + logline("discovery: complete"); + return 0; +} + +/* ---- pairing --------------------------------------------------------- */ + +static volatile HANDLE g_pair_reply_event = NULL; +static volatile int g_pair_accept = 0; +static BLUETOOTH_AUTHENTICATION_CALLBACK_PARAMS g_auth_params; + +static BOOL CALLBACK auth_callback(LPVOID param, + PBLUETOOTH_AUTHENTICATION_CALLBACK_PARAMS p) { + BLUETOOTH_AUTHENTICATE_RESPONSE resp; + (void)param; + if (p->authenticationMethod == BLUETOOTH_AUTHENTICATION_METHOD_NUMERIC_COMPARISON) { + EnterCriticalSection(&g_lock); + snprintf(g_pairing_code, sizeof(g_pairing_code), "%06lu", + (unsigned long)p->Numeric_Value); + strcpy(g_pairing_stage, "confirm"); + g_auth_params = *p; + LeaveCriticalSection(&g_lock); + logline("pairing: confirm code %06lu (device is showing the same code)", + (unsigned long)p->Numeric_Value); + /* Wait for /pair/reply. */ + WaitForSingleObject(g_pair_reply_event, 60000); + memset(&resp, 0, sizeof(resp)); + resp.authMethod = BLUETOOTH_AUTHENTICATION_METHOD_NUMERIC_COMPARISON; + resp.bthAddressRemote = p->deviceInfo.Address; + resp.negativeResponse = g_pair_accept ? FALSE : TRUE; + BluetoothSendAuthenticationResponseEx(NULL, &resp); + return TRUE; + } + return FALSE; +} + +static DWORD WINAPI pair_thread(LPVOID arg) { + char *addr_s = (char *)arg; + BLUETOOTH_DEVICE_INFO di; + HBLUETOOTH_AUTHENTICATION_REGISTRATION reg = NULL; + BTH_ADDR addr; + DWORD rc; + + if (!parse_addr(addr_s, &addr)) { + EnterCriticalSection(&g_lock); + strcpy(g_pairing_stage, "failed"); + strcpy(g_pairing_error, "bad address"); + LeaveCriticalSection(&g_lock); + free(addr_s); + return 0; + } + + memset(&di, 0, sizeof(di)); + di.dwSize = sizeof(di); + di.Address.ullLong = addr; + + /* A stale half-bond makes hosts abort right after encryption; clear it. */ + BluetoothRemoveDevice(&di.Address); + + EnterCriticalSection(&g_lock); + strcpy(g_pairing_stage, "connecting"); + g_pairing_code[0] = 0; + g_pairing_error[0] = 0; + LeaveCriticalSection(&g_lock); + + BluetoothRegisterForAuthenticationEx(&di, ®, auth_callback, NULL); + rc = BluetoothAuthenticateDeviceEx(NULL, NULL, &di, NULL, + MITMProtectionNotRequired); + if (reg) BluetoothUnregisterAuthentication(reg); + + EnterCriticalSection(&g_lock); + if (rc == ERROR_SUCCESS) { + strcpy(g_pairing_stage, "done"); + g_pairing_code[0] = 0; + strncpy(g_device_address, addr_s, sizeof(g_device_address) - 1); + state_save(); + logline("pairing: finished OK"); + } else { + strcpy(g_pairing_stage, "failed"); + snprintf(g_pairing_error, sizeof(g_pairing_error), "pairing failed (%lu)", rc); + logline("pairing: failed (%lu)", rc); + } + LeaveCriticalSection(&g_lock); + free(addr_s); + return 0; +} + +/* ---- RFCOMM tunnel ---------------------------------------------------- */ + +typedef struct { + SOCKET rf; + SOCKET conns[MAX_STREAMS]; + unsigned ids[MAX_STREAMS]; + CRITICAL_SECTION wlock; + volatile int dead; +} Tunnel; + +static Tunnel *g_tun = NULL; + +static void tunnel_send(Tunnel *t, unsigned char type, unsigned sid, + const char *payload, unsigned len) { + char hdr[7]; + hdr[0] = (char)type; + hdr[1] = (char)(sid >> 24); hdr[2] = (char)(sid >> 16); + hdr[3] = (char)(sid >> 8); hdr[4] = (char)sid; + hdr[5] = (char)(len >> 8); hdr[6] = (char)len; + EnterCriticalSection(&t->wlock); + if (send(t->rf, hdr, 7, 0) != 7 || + (len && send(t->rf, payload, (int)len, 0) != (int)len)) + t->dead = 1; + LeaveCriticalSection(&t->wlock); +} + +static int slot_for(Tunnel *t, unsigned sid, int alloc) { + int i, free_i = -1; + for (i = 0; i < MAX_STREAMS; i++) { + if (t->conns[i] != INVALID_SOCKET && t->ids[i] == sid) return i; + if (alloc && t->conns[i] == INVALID_SOCKET && free_i < 0) free_i = i; + } + return alloc ? free_i : -1; +} + +typedef struct { Tunnel *t; int slot; } PumpArg; + +static DWORD WINAPI stream_pump(LPVOID argp) { + PumpArg *arg = (PumpArg *)argp; + Tunnel *t = arg->t; + int slot = arg->slot; + SOCKET c = t->conns[slot]; + unsigned sid = t->ids[slot]; + char buf[CHUNK]; + int n; + free(arg); + while (!t->dead && (n = recv(c, buf, sizeof(buf), 0)) > 0) + tunnel_send(t, 2, sid, buf, (unsigned)n); + if (t->conns[slot] == c) { + closesocket(c); + t->conns[slot] = INVALID_SOCKET; + tunnel_send(t, 3, sid, NULL, 0); + } + return 0; +} + +static void tunnel_open_stream(Tunnel *t, unsigned sid) { + struct sockaddr_in sa; + SOCKET c; + int slot = slot_for(t, sid, 1); + if (slot < 0) { tunnel_send(t, 3, sid, NULL, 0); return; } + c = socket(AF_INET, SOCK_STREAM, 0); + memset(&sa, 0, sizeof(sa)); + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = inet_addr("127.0.0.1"); + sa.sin_port = htons(SERVER_PORT); + if (connect(c, (struct sockaddr *)&sa, sizeof(sa)) != 0) { + closesocket(c); + tunnel_send(t, 3, sid, NULL, 0); + return; + } + t->conns[slot] = c; + t->ids[slot] = sid; + { + PumpArg *arg = (PumpArg *)malloc(sizeof(PumpArg)); + arg->t = t; arg->slot = slot; + CloseHandle(CreateThread(NULL, 0, stream_pump, arg, 0, NULL)); + } +} + +static void tunnel_run(Tunnel *t) { + char buf[8192], frame[CHUNK + 16]; + unsigned have = 0; + int n, i; + (void)frame; + while (!t->dead) { + n = recv(t->rf, buf + have, (int)(sizeof(buf) - have), 0); + if (n <= 0) break; + have += (unsigned)n; + for (;;) { + unsigned char type; unsigned sid, len; + if (have < 7) break; + type = (unsigned char)buf[0]; + sid = ((unsigned char)buf[1] << 24) | ((unsigned char)buf[2] << 16) | + ((unsigned char)buf[3] << 8) | (unsigned char)buf[4]; + len = ((unsigned char)buf[5] << 8) | (unsigned char)buf[6]; + if (have < 7 + len) break; + if (type == 1) { + tunnel_open_stream(t, sid); + } else if (type == 2) { + int slot = slot_for(t, sid, 0); + if (slot >= 0) send(t->conns[slot], buf + 7, (int)len, 0); + } else if (type == 3) { + int slot = slot_for(t, sid, 0); + if (slot >= 0) { closesocket(t->conns[slot]); t->conns[slot] = INVALID_SOCKET; } + } + memmove(buf, buf + 7 + len, have - 7 - len); + have -= 7 + len; + } + } + t->dead = 1; + for (i = 0; i < MAX_STREAMS; i++) + if (t->conns[i] != INVALID_SOCKET) { closesocket(t->conns[i]); t->conns[i] = INVALID_SOCKET; } +} + +/* ---- control API ------------------------------------------------------- */ + +static void control_respond(SOCKET c, const char *req) { + char body[4096], out[8192], val[64]; + const char *json = strstr(req, "\r\n\r\n"); + json = json ? json + 4 : ""; + + if (!strncmp(req, "POST /preference", 16)) { + if (json_str(json, "preference", val, sizeof(val)) && + (!strcmp(val, "bluetooth") || !strcmp(val, "usb"))) { + EnterCriticalSection(&g_lock); + strcpy(g_preference, val); + state_save(); + LeaveCriticalSection(&g_lock); + logline("preference set to %s by UI", val); + if (!strcmp(val, "usb")) fall_back_to_usb(); + else if (g_link_up) prefer_bluetooth(); + } + } else if (!strncmp(req, "POST /discover", 14)) { + CloseHandle(CreateThread(NULL, 0, discover_thread, NULL, 0, NULL)); + } else if (!strncmp(req, "POST /pair/reply", 16)) { + g_pair_accept = strstr(json, "true") != NULL; + EnterCriticalSection(&g_lock); + strcpy(g_pairing_stage, g_pair_accept ? "finishing" : "failed"); + if (!g_pair_accept) strcpy(g_pairing_error, "rejected"); + LeaveCriticalSection(&g_lock); + if (g_pair_reply_event) SetEvent(g_pair_reply_event); + } else if (!strncmp(req, "POST /pair", 10)) { + if (json_str(json, "address", val, sizeof(val))) + CloseHandle(CreateThread(NULL, 0, pair_thread, _strdup(val), 0, NULL)); + } else if (!strncmp(req, "POST /unpair", 12)) { + if (json_str(json, "address", val, sizeof(val))) { + BTH_ADDR a; + if (parse_addr(val, &a)) { + BLUETOOTH_ADDRESS ba; + ba.ullLong = a; + BluetoothRemoveDevice(&ba); + logline("unpair: removed bond for %s", val); + EnterCriticalSection(&g_lock); + if (!_stricmp(val, g_device_address)) { g_device_address[0] = 0; state_save(); } + LeaveCriticalSection(&g_lock); + } + } + } else if (!strncmp(req, "POST /device", 12)) { + if (json_str(json, "address", val, sizeof(val))) { + EnterCriticalSection(&g_lock); + strncpy(g_device_address, val, sizeof(g_device_address) - 1); + state_save(); + LeaveCriticalSection(&g_lock); + logline("device address set to %s by UI", val); + } + } + + EnterCriticalSection(&g_lock); + snprintf(body, sizeof(body), + "{\"preference\":\"%s\",\"transport\":\"%s\",\"linkUp\":%s," + "\"deviceAddress\":%s%s%s,\"paired\":%s," + "\"pairing\":{\"stage\":\"%s\",\"code\":%s%s%s,\"error\":%s%s%s}," + "\"found\":[%s]}", + g_preference, active_transport(), g_link_up ? "true" : "false", + g_device_address[0] ? "\"" : "", g_device_address[0] ? g_device_address : "null", + g_device_address[0] ? "\"" : "", + g_device_address[0] && device_is_paired(g_device_address) ? "true" : "false", + g_pairing_stage, + g_pairing_code[0] ? "\"" : "", g_pairing_code[0] ? g_pairing_code : "null", + g_pairing_code[0] ? "\"" : "", + g_pairing_error[0] ? "\"" : "", g_pairing_error[0] ? g_pairing_error : "null", + g_pairing_error[0] ? "\"" : "", + g_found); + LeaveCriticalSection(&g_lock); + + snprintf(out, sizeof(out), + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + "Access-Control-Allow-Origin: *\r\n" + "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" + "Access-Control-Allow-Headers: Content-Type\r\n" + "Cache-Control: no-store\r\nContent-Length: %u\r\n" + "Connection: close\r\n\r\n%s", + (unsigned)strlen(body), body); + send(c, out, (int)strlen(out), 0); +} + +static DWORD WINAPI control_thread(LPVOID arg) { + SOCKET srv = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in sa; + int one = 1; + (void)arg; + setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, (const char *)&one, sizeof(one)); + memset(&sa, 0, sizeof(sa)); + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = inet_addr("127.0.0.1"); + sa.sin_port = htons(CONTROL_PORT); + if (bind(srv, (struct sockaddr *)&sa, sizeof(sa)) != 0 || listen(srv, 8) != 0) { + logline("control: could not bind 127.0.0.1:%d", CONTROL_PORT); + return 1; + } + logline("control: listening on 127.0.0.1:%d", CONTROL_PORT); + for (;;) { + SOCKET c = accept(srv, NULL, NULL); + char req[16384]; + int n; + if (c == INVALID_SOCKET) continue; + n = recv(c, req, sizeof(req) - 1, 0); + if (n > 0) { + req[n] = 0; + control_respond(c, req); + } + closesocket(c); + } +} + +/* ---- main loop ----------------------------------------------------------- */ + +int main(void) { + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); + InitializeCriticalSection(&g_lock); + g_pair_reply_event = CreateEventA(NULL, FALSE, FALSE, NULL); + state_path_init(); + state_load(); + logline("state loaded: preference=%s device=%s", g_preference, + g_device_address[0] ? g_device_address : "unset"); + + CloseHandle(CreateThread(NULL, 0, control_thread, NULL, 0, NULL)); + logline("bluetooth bridge loop up"); + + for (;;) { + SOCKADDR_BTH sab; + SOCKET rf; + BTH_ADDR addr; + int i; + + if (!strcmp(g_preference, "usb")) { + if (g_link_up) g_link_up = 0; + fall_back_to_usb(); + Sleep(5000); + continue; + } + if (!strcmp(g_pairing_stage, "discovering") || !strcmp(g_pairing_stage, "connecting") || + !strcmp(g_pairing_stage, "confirm") || !strcmp(g_pairing_stage, "finishing")) { + Sleep(1000); + continue; + } + if (!g_device_address[0] || !parse_addr(g_device_address, &addr)) { + Sleep(3000); + continue; + } + + rf = socket(AF_BTH, SOCK_STREAM, BTHPROTO_RFCOMM); + memset(&sab, 0, sizeof(sab)); + sab.addressFamily = AF_BTH; + sab.btAddr = addr; + sab.port = RFCOMM_CHANNEL; + logline("connecting to %s rfcomm ch%d...", g_device_address, RFCOMM_CHANNEL); + if (connect(rf, (struct sockaddr *)&sab, sizeof(sab)) != 0) { + logline("connect failed (%d); device off/out of range? retrying in 10s", + WSAGetLastError()); + closesocket(rf); + g_link_up = 0; + fall_back_to_usb(); + Sleep(10000); + continue; + } + + logline("rfcomm open"); + g_link_up = 1; + prefer_bluetooth(); + { + Tunnel t; + memset(&t, 0, sizeof(t)); + t.rf = rf; + for (i = 0; i < MAX_STREAMS; i++) t.conns[i] = INVALID_SOCKET; + InitializeCriticalSection(&t.wlock); + g_tun = &t; + tunnel_run(&t); + g_tun = NULL; + DeleteCriticalSection(&t.wlock); + } + closesocket(rf); + logline("session ended"); + g_link_up = 0; + fall_back_to_usb(); + Sleep(10000); + } +} diff --git a/DeskThingServer/package.json b/DeskThingServer/package.json index a18b7de8..5f869039 100644 --- a/DeskThingServer/package.json +++ b/DeskThingServer/package.json @@ -16,10 +16,12 @@ "start": "electron-vite preview", "dev": "electron-vite dev", "debug": "electron-vite dev --inspect=8893", - "build": "node electron-builder.env.js && npm run typecheck && electron-vite build && electron-builder", + "build": "node electron-builder.env.js && npm run typecheck && npm run build:btbridge && electron-vite build && electron-builder", + "build:btbridge": "node bt_source/build-btbridge.js", "postinstall": "electron-builder install-app-deps && electron-rebuild && npm rebuild sharp", "build:unpack": "npm run build && electron-builder --dir", - "test": "vitest" + "test": "vitest", + "test:bt": "vitest run test/main/services/bluetooth && python3 -m unittest discover -s bt_source/test -v" }, "repository": { "type": "git", @@ -132,6 +134,17 @@ { "from": "adb_source/win", "to": "resources/win" + }, + { + "from": "bt_source/win", + "to": "resources/win", + "filter": [ + "btbridge.exe" + ] + }, + { + "from": "bt_source/superbird", + "to": "resources/superbird" } ] }, @@ -152,7 +165,8 @@ "entitlementsInherit": "build/entitlements.mac.plist", "extendInfo": { "NSAppleEventsUsageDescription": "This app requires access to send Apple Events to communicate with other applications.", - "NSMicrophoneUsageDescription": "This app requires microphone access to record audio." + "NSMicrophoneUsageDescription": "This app requires microphone access to record audio.", + "NSBluetoothAlwaysUsageDescription": "DeskThing connects to your Car Thing over Bluetooth so it can run without a data cable." }, "notarize": false, "category": "public.app-category.developer-tools", @@ -160,6 +174,14 @@ { "from": "adb_source/mac", "to": "resources/mac" + }, + { + "from": "bt_source/mac", + "to": "resources/mac" + }, + { + "from": "bt_source/superbird", + "to": "resources/superbird" } ] }, @@ -178,6 +200,14 @@ { "from": "adb_source/linux", "to": "resources/linux" + }, + { + "from": "bt_source/linux", + "to": "resources/linux" + }, + { + "from": "bt_source/superbird", + "to": "resources/superbird" } ] }, From a4ca10b197b997a94badf0963b01cab51f6a3f49 Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 09:17:06 -0400 Subject: [PATCH 2/6] Run the Bluetooth bridge from the server and expose it over IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main process owns the helper's lifecycle through a small manager facade: bridgeProcess starts and stops the packaged helper with the app, bridgeClient wraps its local control API, and the provisioner turns a USB-connected Car Thing into a Bluetooth-ready one — installing the mux and the pairing agent as supervisord services and reporting the device's radio address back so the pairing wizard knows who to talk to. Platforms without a helper get a stub manager that reports unsupported. The renderer reaches all of it through a typed BLUETOOTH IPC domain. Vitest suites cover the IPC dispatch, the manager's degradation when the helper is missing or unreachable, the control-API client, and the provisioner step machine. Co-Authored-By: Claude Fable 5 --- .../src/main/lifecycle/appLifecycle.ts | 6 + .../main/services/bluetooth/bridgeClient.ts | 75 ++++++++ .../main/services/bluetooth/bridgeProcess.ts | 104 +++++++++++ .../src/main/services/bluetooth/index.ts | 138 ++++++++++++++ .../main/services/bluetooth/provisioner.ts | 161 +++++++++++++++++ .../src/main/services/ipc/bluetoothIpc.ts | 27 +++ .../src/main/services/ipc/initializer.ts | 18 ++ .../src/preload/api/ipcBluetooth.ts | 67 +++++++ DeskThingServer/src/preload/index.d.ts | 1 + DeskThingServer/src/preload/index.ts | 2 + DeskThingServer/src/shared/types/ipc/index.ts | 1 + .../src/shared/types/ipc/ipcBluetooth.ts | 124 +++++++++++++ .../src/shared/types/ipc/ipcTypes.ts | 1 + .../services/bluetooth/bluetoothIpc.test.ts | 170 ++++++++++++++++++ .../services/bluetooth/bridgeClient.test.ts | 75 ++++++++ .../main/services/bluetooth/manager.test.ts | 120 +++++++++++++ .../services/bluetooth/provisioner.test.ts | 112 ++++++++++++ 17 files changed, 1202 insertions(+) create mode 100644 DeskThingServer/src/main/services/bluetooth/bridgeClient.ts create mode 100644 DeskThingServer/src/main/services/bluetooth/bridgeProcess.ts create mode 100644 DeskThingServer/src/main/services/bluetooth/index.ts create mode 100644 DeskThingServer/src/main/services/bluetooth/provisioner.ts create mode 100644 DeskThingServer/src/main/services/ipc/bluetoothIpc.ts create mode 100644 DeskThingServer/src/preload/api/ipcBluetooth.ts create mode 100644 DeskThingServer/src/shared/types/ipc/ipcBluetooth.ts create mode 100644 DeskThingServer/test/main/services/bluetooth/bluetoothIpc.test.ts create mode 100644 DeskThingServer/test/main/services/bluetooth/bridgeClient.test.ts create mode 100644 DeskThingServer/test/main/services/bluetooth/manager.test.ts create mode 100644 DeskThingServer/test/main/services/bluetooth/provisioner.test.ts diff --git a/DeskThingServer/src/main/lifecycle/appLifecycle.ts b/DeskThingServer/src/main/lifecycle/appLifecycle.ts index 93e3f434..9d93e44e 100644 --- a/DeskThingServer/src/main/lifecycle/appLifecycle.ts +++ b/DeskThingServer/src/main/lifecycle/appLifecycle.ts @@ -12,6 +12,7 @@ import { nextTick } from 'node:process' import { updateLoadingStatus } from '@server/windows/loadingWindow' import { join } from 'node:path' import { checkFlag } from './lifecycleCheck' +import { bluetoothManager } from '../services/bluetooth' /** * Initialize the application lifecycle @@ -60,6 +61,10 @@ export async function initializeAppLifecycle(): Promise { } }) + // Bring up the Bluetooth transport alongside the server so a Car Thing can + // connect without a data cable. No-ops on platforms without a helper. + bluetoothManager.start() + setTimeout(async () => { try { const { afterStartTasks } = await import('@server/services/initialization/AfterStartupTasks') @@ -72,6 +77,7 @@ export async function initializeAppLifecycle(): Promise { app.on('before-quit', async () => { console.log('Quitting app') + bluetoothManager.stop() const { storeProvider } = await import('../stores/storeProvider') const statsCollector = await storeProvider.getStore('statsCollector') await statsCollector.collectSessionCloseStats() diff --git a/DeskThingServer/src/main/services/bluetooth/bridgeClient.ts b/DeskThingServer/src/main/services/bluetooth/bridgeClient.ts new file mode 100644 index 00000000..8a6f8919 --- /dev/null +++ b/DeskThingServer/src/main/services/bluetooth/bridgeClient.ts @@ -0,0 +1,75 @@ +import { + BluetoothFoundDevice, + BluetoothPairingState, + BluetoothPreference, + BluetoothTransport +} from '@shared/types' + +/** + * Client for the bridge helper's local control API. + * + * The helper owns the RFCOMM link, pairing, and the USB-reverse arbitration, + * and exposes its state on a loopback port. This module is the only place + * that knows that protocol; everything else in the app goes through the + * manager. + */ + +const CONTROL_URL = 'http://127.0.0.1:8899' +const TIMEOUT_MS = 3000 + +export interface BridgeControlState { + preference: BluetoothPreference + transport: BluetoothTransport + linkUp: boolean + deviceAddress: string | null + paired: boolean + pairing: BluetoothPairingState + found: BluetoothFoundDevice[] +} + +const request = async (path: string, init?: RequestInit): Promise => { + try { + const res = await fetch(`${CONTROL_URL}${path}`, { + ...init, + signal: AbortSignal.timeout(TIMEOUT_MS) + }) + if (!res.ok) return null + return (await res.json()) as BridgeControlState + } catch { + return null + } +} + +const post = (path: string, body?: object): Promise => + request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}) + }) + +/** Returns the helper's live state, or null when the helper isn't reachable. */ +export const fetchBridgeState = (): Promise => request('/status') + +/** Sets the transport preference; returns the resulting state or null. */ +export const pushBridgePreference = ( + preference: BluetoothPreference +): Promise => post('/preference', { preference }) + +/** Starts an inquiry for nearby devices; results appear in later /status polls. */ +export const startBridgeDiscovery = (): Promise => post('/discover') + +/** Starts computer-initiated pairing with a device. */ +export const startBridgePairing = (address: string): Promise => + post('/pair', { address }) + +/** Answers the numeric-comparison prompt of an in-flight pairing. */ +export const replyBridgePairing = (accept: boolean): Promise => + post('/pair/reply', { accept }) + +/** Removes the bond for a device (also clears it as the connect target). */ +export const removeBridgePairing = (address: string): Promise => + post('/unpair', { address }) + +/** Tells the helper which device to keep connecting to. */ +export const setBridgeDevice = (address: string): Promise => + post('/device', { address }) diff --git a/DeskThingServer/src/main/services/bluetooth/bridgeProcess.ts b/DeskThingServer/src/main/services/bluetooth/bridgeProcess.ts new file mode 100644 index 00000000..850d1f1b --- /dev/null +++ b/DeskThingServer/src/main/services/bluetooth/bridgeProcess.ts @@ -0,0 +1,104 @@ +import path from 'path' +import { existsSync } from 'node:fs' +import { ChildProcess, spawn } from 'child_process' +import getPlatform from '@server/utils/get-platform' +import Logger from '@server/utils/logger' +import { LOGGING_LEVELS } from '@deskthing/types' + +/** + * Process supervision for the platform's Bluetooth bridge helper. + * + * This module's single job is keeping the helper process alive: spawn it, + * restart it if it dies, stop it on shutdown, and surface its output through + * the app logger. Talking to the helper is bridgeClient's job; deciding + * whether this platform has a helper at all is the manager's job. + */ + +const isDevelopment = process.env.NODE_ENV === 'development' + +const resourceRoot = isDevelopment + ? path.join(__dirname, '..', '..', '..', '..', 'bt_source') + : process.resourcesPath + +export const bridgeBinaryPath = path.join( + resourceRoot, + getPlatform(), + process.platform === 'win32' ? 'btbridge.exe' : 'btbridge' +) + +/** Where the device-side mux ships, for provisioning a Car Thing over adb. */ +export const deviceMuxScriptPath = path.join(resourceRoot, 'superbird', 'btmux.py') + +/** Device-side pairing agent, installed next to the mux during provisioning. */ +export const deviceAgentScriptPath = path.join(resourceRoot, 'superbird', 'btagent.py') + +const RESTART_DELAY_MS = 5000 + +let child: ChildProcess | undefined +let stopping = false +let restartTimer: NodeJS.Timeout | undefined + +export const bridgeBinaryExists = (): boolean => existsSync(bridgeBinaryPath) + +export const isBridgeRunning = (): boolean => child !== undefined + +const launch = (): void => { + if (stopping) return + + if (!bridgeBinaryExists()) { + Logger.log( + LOGGING_LEVELS.WARN, + `[btBridge] helper not found at ${bridgeBinaryPath} — Bluetooth transport unavailable` + ) + return + } + + child = spawn(bridgeBinaryPath, [], { stdio: ['ignore', 'pipe', 'pipe'] }) + + child.stdout?.on('data', (chunk: Buffer) => { + chunk + .toString() + .split('\n') + .filter(Boolean) + .forEach((line) => Logger.log(LOGGING_LEVELS.DEBUG, `[btBridge] ${line}`)) + }) + + child.stderr?.on('data', (chunk: Buffer) => { + Logger.log(LOGGING_LEVELS.WARN, `[btBridge] ${chunk.toString().trim()}`) + }) + + child.on('error', (error) => { + Logger.log(LOGGING_LEVELS.ERROR, `[btBridge] failed to start: ${error.message}`) + }) + + child.on('exit', (code, signal) => { + child = undefined + if (stopping) return + Logger.log( + LOGGING_LEVELS.WARN, + `[btBridge] exited (code=${code} signal=${signal}); restarting in ${RESTART_DELAY_MS}ms` + ) + restartTimer = setTimeout(launch, RESTART_DELAY_MS) + }) + + Logger.log(LOGGING_LEVELS.LOG, `[btBridge] started (pid ${child.pid})`) +} + +export const startBridgeProcess = (): void => { + if (child) return + stopping = false + launch() +} + +export const stopBridgeProcess = (): void => { + stopping = true + if (restartTimer) { + clearTimeout(restartTimer) + restartTimer = undefined + } + if (child) { + Logger.log(LOGGING_LEVELS.LOG, '[btBridge] stopping') + child.kill() + child = undefined + } +} diff --git a/DeskThingServer/src/main/services/bluetooth/index.ts b/DeskThingServer/src/main/services/bluetooth/index.ts new file mode 100644 index 00000000..c93d0fe7 --- /dev/null +++ b/DeskThingServer/src/main/services/bluetooth/index.ts @@ -0,0 +1,138 @@ +import { + BluetoothBridgeStatus, + BluetoothPreference, + BluetoothProvisionResult +} from '@shared/types' +import { + bridgeBinaryExists, + isBridgeRunning, + startBridgeProcess, + stopBridgeProcess +} from './bridgeProcess' +import { + fetchBridgeState, + pushBridgePreference, + removeBridgePairing, + replyBridgePairing, + setBridgeDevice, + startBridgeDiscovery, + startBridgePairing +} from './bridgeClient' +import { provisionDevice } from './provisioner' + +/** + * Platform-neutral entry point for the Bluetooth transport. + * + * Everything outside this directory — lifecycle, IPC, UI — talks to this + * interface only. Supporting a new OS means shipping a helper that speaks + * the same control API under bt_source//; no call sites change. + */ +export interface BluetoothTransportManager { + start(): void + stop(): void + getStatus(): Promise + setPreference(preference: BluetoothPreference): Promise + provision(adbId: string): Promise + discover(): Promise + pair(address: string): Promise + pairReply(accept: boolean): Promise + unpair(address: string): Promise +} + +const IDLE_PAIRING = { stage: 'idle' as const, code: null, error: null } + +const UNSUPPORTED_STATUS: BluetoothBridgeStatus = { + supported: false, + running: false, + linkUp: false, + transport: 'none', + preference: 'bluetooth', + deviceAddress: null, + paired: false, + pairing: IDLE_PAIRING, + found: [] +} + +/** A helper shipped in Resources/, supervised by us. */ +const helperBridgeManager: BluetoothTransportManager = { + start: startBridgeProcess, + stop: stopBridgeProcess, + + getStatus: async (): Promise => { + const running = isBridgeRunning() + const state = running ? await fetchBridgeState() : null + return { + supported: true, + running, + linkUp: state?.linkUp ?? false, + transport: state?.transport ?? 'none', + preference: state?.preference ?? 'bluetooth', + deviceAddress: state?.deviceAddress ?? null, + paired: state?.paired ?? false, + pairing: state?.pairing ?? IDLE_PAIRING, + found: state?.found ?? [] + } + }, + + setPreference: async (preference): Promise => { + await pushBridgePreference(preference) + return helperBridgeManager.getStatus() + }, + + provision: async (adbId): Promise => { + const result = await provisionDevice(adbId) + // Hand the freshly provisioned device to the helper so the pairing wizard + // and the reconnect loop know who to talk to. + if (result.success && result.deviceAddress) { + await setBridgeDevice(result.deviceAddress) + } + return result + }, + + discover: async (): Promise => { + await startBridgeDiscovery() + return helperBridgeManager.getStatus() + }, + + pair: async (address): Promise => { + await startBridgePairing(address) + return helperBridgeManager.getStatus() + }, + + pairReply: async (accept): Promise => { + await replyBridgePairing(accept) + return helperBridgeManager.getStatus() + }, + + unpair: async (address): Promise => { + await removeBridgePairing(address) + return helperBridgeManager.getStatus() + } +} + +/** Platforms without a helper: report unsupported, never fail. */ +const unsupportedManager: BluetoothTransportManager = { + start: (): void => {}, + stop: (): void => {}, + getStatus: async (): Promise => UNSUPPORTED_STATUS, + setPreference: async (): Promise => UNSUPPORTED_STATUS, + provision: async (): Promise => ({ + success: false, + steps: [ + { + id: 'unsupported', + label: 'Bluetooth transport', + ok: false, + detail: 'No Bluetooth bridge is available for this operating system yet' + } + ] + }), + discover: async (): Promise => UNSUPPORTED_STATUS, + pair: async (): Promise => UNSUPPORTED_STATUS, + pairReply: async (): Promise => UNSUPPORTED_STATUS, + unpair: async (): Promise => UNSUPPORTED_STATUS +} + +export const bluetoothManager: BluetoothTransportManager = bridgeBinaryExists() + ? helperBridgeManager + : unsupportedManager diff --git a/DeskThingServer/src/main/services/bluetooth/provisioner.ts b/DeskThingServer/src/main/services/bluetooth/provisioner.ts new file mode 100644 index 00000000..c48d5798 --- /dev/null +++ b/DeskThingServer/src/main/services/bluetooth/provisioner.ts @@ -0,0 +1,161 @@ +import { handleAdbCommands } from '@server/handlers/adbHandler' +import Logger from '@server/utils/logger' +import { LOGGING_LEVELS } from '@deskthing/types' +import { BluetoothProvisionResult, BluetoothProvisionStep } from '@shared/types' +import { deviceAgentScriptPath, deviceMuxScriptPath } from './bridgeProcess' + +/** + * One-time device provisioning for the Bluetooth transport. + * + * Runs over adb while the Car Thing is on USB, and leaves the device able to + * reach the server over Bluetooth on every subsequent boot: the mux and the + * pairing agent are installed under supervisord, and bluetoothd gains the + * compat flag it needs to advertise a serial port. + * + * Pairing itself is not done here — it is computer-initiated from the setup + * wizard (the device screen shows the code, the person confirms here), which + * is the same flow the Car Thing shipped with. Provisioning ends by reporting + * the device's radio address so the wizard knows who to pair with. + * + * Every step is recorded so the UI can show exactly what happened; a failed + * step stops the sequence. + */ + +/** RFCOMM channel the device listens on; must match the helper and the mux. */ +const RFCOMM_CHANNEL = 3 + +const MUX_REMOTE_PATH = '/etc/deskthing-bt/btmux.py' +const AGENT_REMOTE_PATH = '/etc/deskthing-bt/btagent.py' + +const supervisorConf = (name: string, command: string): string => + `[program:${name}]\\n` + + `command=${command}\\n` + + 'autostart=true\\n' + + 'autorestart=true\\n' + + 'startretries=999\\n' + + 'stopasgroup=true\\n' + + 'killasgroup=true\\n' + + 'redirect_stderr=true\\n' + + `stdout_logfile=/var/log/${name}.log\\n` + + 'stdout_logfile_maxbytes=512KB\\n' + + 'stdout_logfile_backups=1\\n' + +type StepRunner = () => Promise + +const runSteps = async ( + steps: Array<{ id: string; label: string; run: StepRunner }> +): Promise => { + const results: BluetoothProvisionStep[] = [] + for (const step of steps) { + try { + const detail = await step.run() + results.push({ id: step.id, label: step.label, ok: true, detail }) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + Logger.log(LOGGING_LEVELS.ERROR, `[btProvision] ${step.id} failed: ${detail}`) + results.push({ id: step.id, label: step.label, ok: false, detail }) + return { success: false, steps: results } + } + } + return { success: true, steps: results } +} + +export const provisionDevice = async (adbId: string): Promise => { + const adb = (args: string): Promise => handleAdbCommands(`-s ${adbId} ${args}`) + + let deviceAddress: string | null = null + + const steps: Array<{ id: string; label: string; run: StepRunner }> = [ + { + id: 'remount', + label: 'Make device filesystem writable', + run: () => adb('shell mount -o remount,rw /') + }, + { + id: 'push-mux', + label: 'Install Bluetooth services on device', + run: async () => { + await adb('shell mkdir -p /etc/deskthing-bt') + await adb(`push "${deviceMuxScriptPath}" ${MUX_REMOTE_PATH}`) + return adb(`push "${deviceAgentScriptPath}" ${AGENT_REMOTE_PATH}`) + } + }, + { + id: 'supervisor', + label: 'Register services to start at boot', + run: async () => { + await adb('shell mkdir -p /etc/supervisor.d') + await adb( + `shell "printf '${supervisorConf('btmux', `/usr/bin/python3 ${MUX_REMOTE_PATH} 8891`)}' > /etc/supervisor.d/btmux.conf"` + ) + return adb( + `shell "printf '${supervisorConf('btagent', `/usr/bin/python3 ${AGENT_REMOTE_PATH}`)}' > /etc/supervisor.d/btagent.conf"` + ) + } + }, + { + id: 'bluetoothd-compat', + label: 'Enable serial port support in device Bluetooth', + run: async () => { + // The stock image runs bluetoothd without --compat, which the SDP + // registration in the mux needs. Idempotent: only patch once. + const script = await adb('shell cat /etc/start_bluetoothd.sh') + if (!script.includes('--compat')) { + await adb( + `shell "sed -i 's|bluetoothd -n -d|bluetoothd -n -d --compat|' /etc/start_bluetoothd.sh"` + ) + // Restarting via supervisor orphans the old daemon; kill it explicitly. + await adb( + `shell "for p in \\$(pidof bluetoothd); do kill \\$p; done; supervisorctl restart bluetoothd"` + ) + return 'patched and restarted' + } + return 'already enabled' + } + }, + { + id: 'start-services', + label: 'Start the Bluetooth services', + run: async () => { + await adb('shell supervisorctl reread') + await adb('shell supervisorctl update') + await adb('shell "supervisorctl restart btmux || supervisorctl start btmux"') + await adb('shell "supervisorctl restart btagent || supervisorctl start btagent"') + const status = await adb('shell "supervisorctl status btmux btagent"') + const running = (status.match(/RUNNING/g) || []).length + if (running < 2) throw new Error(`services not running: ${status.trim()}`) + return status.trim() + } + }, + { + id: 'verify-radio', + label: 'Verify device radio is connectable', + run: async () => { + const flags = await adb('shell hciconfig hci0') + if (!flags.includes('PSCAN')) throw new Error('page scan not enabled') + const records = await adb('shell sdptool browse local') + if (!records.includes('Serial Port')) throw new Error('serial port not advertised') + return `serial port on channel ${RFCOMM_CHANNEL}, radio connectable` + } + }, + { + id: 'read-address', + label: 'Read device Bluetooth address', + run: async () => { + const out = await adb('shell hciconfig hci0') + const match = out.match(/BD Address:\s*((?:[0-9A-F]{2}:){5}[0-9A-F]{2})/i) + if (!match) throw new Error('could not read radio address') + deviceAddress = match[1].toUpperCase() + return deviceAddress + } + } + ] + + const result = await runSteps(steps) + if (result.success && deviceAddress) result.deviceAddress = deviceAddress + Logger.log( + result.success ? LOGGING_LEVELS.LOG : LOGGING_LEVELS.WARN, + `[btProvision] ${adbId}: ${result.success ? 'complete' : 'failed'} (${result.steps.length} steps)` + ) + return result +} diff --git a/DeskThingServer/src/main/services/ipc/bluetoothIpc.ts b/DeskThingServer/src/main/services/ipc/bluetoothIpc.ts new file mode 100644 index 00000000..ae560029 --- /dev/null +++ b/DeskThingServer/src/main/services/ipc/bluetoothIpc.ts @@ -0,0 +1,27 @@ +import { + BluetoothIPCData, + IPC_BLUETOOTH_TYPES, + BluetoothHandlerReturnMap +} from '@shared/types/ipc/ipcBluetooth' +import { bluetoothManager } from '@server/services/bluetooth' + +export const bluetoothHandler = async ( + data: BluetoothIPCData +): Promise => { + switch (data.type) { + case IPC_BLUETOOTH_TYPES.GET_STATUS: + return await bluetoothManager.getStatus() + case IPC_BLUETOOTH_TYPES.SET_PREFERENCE: + return await bluetoothManager.setPreference(data.payload) + case IPC_BLUETOOTH_TYPES.PROVISION_DEVICE: + return await bluetoothManager.provision(data.payload.adbId) + case IPC_BLUETOOTH_TYPES.DISCOVER: + return await bluetoothManager.discover() + case IPC_BLUETOOTH_TYPES.PAIR: + return await bluetoothManager.pair(data.payload.address) + case IPC_BLUETOOTH_TYPES.PAIR_REPLY: + return await bluetoothManager.pairReply(data.payload.accept) + case IPC_BLUETOOTH_TYPES.UNPAIR: + return await bluetoothManager.unpair(data.payload.address) + } +} diff --git a/DeskThingServer/src/main/services/ipc/initializer.ts b/DeskThingServer/src/main/services/ipc/initializer.ts index 004d57df..6e30d774 100644 --- a/DeskThingServer/src/main/services/ipc/initializer.ts +++ b/DeskThingServer/src/main/services/ipc/initializer.ts @@ -10,6 +10,7 @@ import { IPC_CLIENT_TYPES, IPC_UTILITY_TYPES, DeviceIPCData, + BluetoothIPCData, FeedbackIPCData, ReleaseIPCData, TaskIPCData, @@ -157,6 +158,23 @@ export const initializeIpcHandlers = async (ipcMain: Electron.IpcMain): Promise< } ) + // Handle bluetooth-related IPC messages + ipcMain.handle(IPC_HANDLERS.BLUETOOTH, async (_event, data: BluetoothIPCData) => { + const { bluetoothHandler } = await import('./bluetoothIpc') + + try { + return await bluetoothHandler(data) + } catch (error) { + logger.error(`Error in IPC handler with event ${data.type}: ${error}`, { + domain: 'server', + source: 'ipcHandlers', + function: 'BLUETOOTH', + error: error instanceof Error ? error : new Error(String(error)) + }) + return undefined + } + }) + // Handle feedback-related IPC messages ipcMain.handle(IPC_HANDLERS.FEEDBACK, async (_event, data: FeedbackIPCData) => { const { feedbackHandler } = await import('./feedbackIpc') diff --git a/DeskThingServer/src/preload/api/ipcBluetooth.ts b/DeskThingServer/src/preload/api/ipcBluetooth.ts new file mode 100644 index 00000000..a86f235e --- /dev/null +++ b/DeskThingServer/src/preload/api/ipcBluetooth.ts @@ -0,0 +1,67 @@ +import { + IPC_HANDLERS, + IPC_BLUETOOTH_TYPES, + BluetoothIPCData, + BluetoothHandlerReturnMap, + BluetoothBridgeStatus, + BluetoothPreference, + BluetoothProvisionResult +} from '@shared/types' +import { ipcRenderer } from 'electron' + +export const bluetooth = { + getStatus: async (): Promise => + await sendCommand({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.GET_STATUS, + request: 'get' + }), + setPreference: async (preference: BluetoothPreference): Promise => + await sendCommand({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.SET_PREFERENCE, + request: 'set', + payload: preference + }), + provision: async (adbId: string): Promise => + await sendCommand({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.PROVISION_DEVICE, + request: 'set', + payload: { adbId } + }), + discover: async (): Promise => + await sendCommand({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.DISCOVER, + request: 'set' + }), + pair: async (address: string): Promise => + await sendCommand({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.PAIR, + request: 'set', + payload: { address } + }), + pairReply: async (accept: boolean): Promise => + await sendCommand({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.PAIR_REPLY, + request: 'set', + payload: { accept } + }), + unpair: async (address: string): Promise => + await sendCommand({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.UNPAIR, + request: 'set', + payload: { address } + }) +} + +const sendCommand = ( + payload: Extract +): Promise => { + const requestPayload = { ...payload, kind: IPC_HANDLERS.BLUETOOTH } + return ipcRenderer.invoke(IPC_HANDLERS.BLUETOOTH, requestPayload) +} diff --git a/DeskThingServer/src/preload/index.d.ts b/DeskThingServer/src/preload/index.d.ts index 6a4ccc20..caf2dc48 100644 --- a/DeskThingServer/src/preload/index.d.ts +++ b/DeskThingServer/src/preload/index.d.ts @@ -8,6 +8,7 @@ declare global { } electron: ElectronAPI & { app: typeof import('./api/ipcApps').app + bluetooth: typeof import('./api/ipcBluetooth').bluetooth client: typeof import('./api/ipcClient').client device: typeof import('./api/ipcDevice').device feedback: typeof import('./api/ipcFeedback').feedback diff --git a/DeskThingServer/src/preload/index.ts b/DeskThingServer/src/preload/index.ts index 54f68e85..b6fdd187 100644 --- a/DeskThingServer/src/preload/index.ts +++ b/DeskThingServer/src/preload/index.ts @@ -3,6 +3,7 @@ import { electronAPI } from '@electron-toolkit/preload' import { platform as currentPlatform } from 'os' import { ProgressEvent } from '@shared/types' import { app } from './api/ipcApps' +import { bluetooth } from './api/ipcBluetooth' import { client } from './api/ipcClient' import { utility } from './api/ipcUtility' import { task } from './api/ipcTask' @@ -15,6 +16,7 @@ import { device } from './api/ipcDevice' // Custom APIs for renderer const api = { app, + bluetooth, client, device, feedback, diff --git a/DeskThingServer/src/shared/types/ipc/index.ts b/DeskThingServer/src/shared/types/ipc/index.ts index cb3225dd..49d9cb8f 100644 --- a/DeskThingServer/src/shared/types/ipc/index.ts +++ b/DeskThingServer/src/shared/types/ipc/index.ts @@ -1,4 +1,5 @@ export * from './ipcApps' +export * from './ipcBluetooth' export * from './ipcClient' export * from './ipcDevice' export * from './ipcFeedback' diff --git a/DeskThingServer/src/shared/types/ipc/ipcBluetooth.ts b/DeskThingServer/src/shared/types/ipc/ipcBluetooth.ts new file mode 100644 index 00000000..eb272460 --- /dev/null +++ b/DeskThingServer/src/shared/types/ipc/ipcBluetooth.ts @@ -0,0 +1,124 @@ +import { IPC_HANDLERS } from './ipcTypes' + +/** Which link is actually carrying client traffic right now. */ +export type BluetoothTransport = 'bluetooth' | 'usb' | 'none' + +/** Which link the user wants to carry traffic when both are available. */ +export type BluetoothPreference = 'bluetooth' | 'usb' + +/** + * Where a pairing exchange currently stands. `confirm` means the device is + * showing a 6-digit code on its screen and the same code is in `pairing.code`, + * waiting for the person to confirm or reject it here. + */ +export type BluetoothPairingStage = + | 'idle' + | 'discovering' + | 'connecting' + | 'confirm' + | 'finishing' + | 'done' + | 'failed' + +export interface BluetoothPairingState { + stage: BluetoothPairingStage + code: string | null + error: string | null +} + +/** A device seen during discovery. */ +export interface BluetoothFoundDevice { + address: string + name: string +} + +export interface BluetoothBridgeStatus { + /** Whether this platform ships a Bluetooth bridge helper at all. */ + supported: boolean + /** Whether the helper process is currently running. */ + running: boolean + /** Whether an RFCOMM session to a device is established. */ + linkUp: boolean + transport: BluetoothTransport + preference: BluetoothPreference + /** The device this bridge connects to, once known. */ + deviceAddress: string | null + /** Whether that device is currently bonded with this computer. */ + paired: boolean + pairing: BluetoothPairingState + found: BluetoothFoundDevice[] +} + +export interface BluetoothProvisionStep { + id: string + label: string + ok: boolean + detail?: string +} + +export interface BluetoothProvisionResult { + success: boolean + steps: BluetoothProvisionStep[] + /** The device's radio address, reported when provisioning succeeds. */ + deviceAddress?: string +} + +export enum IPC_BLUETOOTH_TYPES { + GET_STATUS = 'get-status', + SET_PREFERENCE = 'set-preference', + PROVISION_DEVICE = 'provision-device', + DISCOVER = 'discover', + PAIR = 'pair', + PAIR_REPLY = 'pair-reply', + UNPAIR = 'unpair' +} + +export type BluetoothIPCData = { + kind: IPC_HANDLERS.BLUETOOTH +} & ( + | { + type: IPC_BLUETOOTH_TYPES.GET_STATUS + request: 'get' + } + | { + type: IPC_BLUETOOTH_TYPES.SET_PREFERENCE + request: 'set' + payload: BluetoothPreference + } + | { + type: IPC_BLUETOOTH_TYPES.PROVISION_DEVICE + request: 'set' + payload: { adbId: string } + } + | { + type: IPC_BLUETOOTH_TYPES.DISCOVER + request: 'set' + } + | { + type: IPC_BLUETOOTH_TYPES.PAIR + request: 'set' + payload: { address: string } + } + | { + type: IPC_BLUETOOTH_TYPES.PAIR_REPLY + request: 'set' + payload: { accept: boolean } + } + | { + type: IPC_BLUETOOTH_TYPES.UNPAIR + request: 'set' + payload: { address: string } + } +) + +export type BluetoothHandlerReturnMap = { + [IPC_BLUETOOTH_TYPES.GET_STATUS]: BluetoothBridgeStatus + [IPC_BLUETOOTH_TYPES.SET_PREFERENCE]: BluetoothBridgeStatus + [IPC_BLUETOOTH_TYPES.PROVISION_DEVICE]: BluetoothProvisionResult + [IPC_BLUETOOTH_TYPES.DISCOVER]: BluetoothBridgeStatus + [IPC_BLUETOOTH_TYPES.PAIR]: BluetoothBridgeStatus + [IPC_BLUETOOTH_TYPES.PAIR_REPLY]: BluetoothBridgeStatus + [IPC_BLUETOOTH_TYPES.UNPAIR]: BluetoothBridgeStatus +} + +export type BluetoothHandlerReturnType = BluetoothHandlerReturnMap[K] diff --git a/DeskThingServer/src/shared/types/ipc/ipcTypes.ts b/DeskThingServer/src/shared/types/ipc/ipcTypes.ts index 282a93f5..1db65b4a 100644 --- a/DeskThingServer/src/shared/types/ipc/ipcTypes.ts +++ b/DeskThingServer/src/shared/types/ipc/ipcTypes.ts @@ -4,6 +4,7 @@ import { UtilityIPCData } from './ipcUtility' export enum IPC_HANDLERS { APPS = 'apps', + BLUETOOTH = 'bluetooth', CLIENT = 'client', DEVICE = 'device', FEEDBACK = 'feedback', diff --git a/DeskThingServer/test/main/services/bluetooth/bluetoothIpc.test.ts b/DeskThingServer/test/main/services/bluetooth/bluetoothIpc.test.ts new file mode 100644 index 00000000..dce54f6d --- /dev/null +++ b/DeskThingServer/test/main/services/bluetooth/bluetoothIpc.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + BluetoothBridgeStatus, + BluetoothIPCData, + IPC_BLUETOOTH_TYPES, + IPC_HANDLERS +} from '../../../../src/shared/types' + +const managerMock = vi.hoisted(() => ({ + start: vi.fn(), + stop: vi.fn(), + getStatus: vi.fn(), + setPreference: vi.fn(), + provision: vi.fn(), + discover: vi.fn(), + pair: vi.fn(), + pairReply: vi.fn(), + unpair: vi.fn() +})) + +vi.mock('@server/services/bluetooth', () => ({ bluetoothManager: managerMock })) + +import { bluetoothHandler } from '../../../../src/main/services/ipc/bluetoothIpc' + +const STATUS: BluetoothBridgeStatus = { + supported: true, + running: true, + linkUp: true, + transport: 'bluetooth', + preference: 'bluetooth', + deviceAddress: 'AA:BB:CC:DD:EE:FF', + paired: true, + pairing: { stage: 'idle', code: null, error: null }, + found: [] +} + +describe('bluetoothHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('routes GET_STATUS to the manager', async () => { + managerMock.getStatus.mockResolvedValue(STATUS) + const result = await bluetoothHandler({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.GET_STATUS, + request: 'get' + }) + expect(managerMock.getStatus).toHaveBeenCalledOnce() + expect(result).toBe(STATUS) + }) + + it('routes SET_PREFERENCE with the chosen preference', async () => { + managerMock.setPreference.mockResolvedValue(STATUS) + await bluetoothHandler({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.SET_PREFERENCE, + request: 'set', + payload: 'usb' + }) + expect(managerMock.setPreference).toHaveBeenCalledWith('usb') + }) + + it('routes PROVISION_DEVICE with the adb id', async () => { + managerMock.provision.mockResolvedValue({ success: true, steps: [] }) + await bluetoothHandler({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.PROVISION_DEVICE, + request: 'set', + payload: { adbId: 'serial123' } + }) + expect(managerMock.provision).toHaveBeenCalledWith('serial123') + }) + + it('routes DISCOVER to the manager', async () => { + managerMock.discover.mockResolvedValue(STATUS) + await bluetoothHandler({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.DISCOVER, + request: 'set' + }) + expect(managerMock.discover).toHaveBeenCalledOnce() + }) + + it('routes PAIR with the target address', async () => { + managerMock.pair.mockResolvedValue(STATUS) + await bluetoothHandler({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.PAIR, + request: 'set', + payload: { address: 'AA:BB:CC:DD:EE:FF' } + }) + expect(managerMock.pair).toHaveBeenCalledWith('AA:BB:CC:DD:EE:FF') + }) + + it('routes PAIR_REPLY with the accept flag', async () => { + managerMock.pairReply.mockResolvedValue(STATUS) + await bluetoothHandler({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.PAIR_REPLY, + request: 'set', + payload: { accept: false } + }) + expect(managerMock.pairReply).toHaveBeenCalledWith(false) + }) + + it('routes UNPAIR with the target address', async () => { + managerMock.unpair.mockResolvedValue(STATUS) + await bluetoothHandler({ + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.UNPAIR, + request: 'set', + payload: { address: 'AA:BB:CC:DD:EE:FF' } + }) + expect(managerMock.unpair).toHaveBeenCalledWith('AA:BB:CC:DD:EE:FF') + }) + + it('covers every declared IPC type', async () => { + // If a new IPC type is added without a handler branch, the switch returns + // undefined — catch that here rather than in production. + const calls: Record = { + [IPC_BLUETOOTH_TYPES.GET_STATUS]: { + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.GET_STATUS, + request: 'get' + }, + [IPC_BLUETOOTH_TYPES.SET_PREFERENCE]: { + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.SET_PREFERENCE, + request: 'set', + payload: 'bluetooth' + }, + [IPC_BLUETOOTH_TYPES.PROVISION_DEVICE]: { + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.PROVISION_DEVICE, + request: 'set', + payload: { adbId: 'x' } + }, + [IPC_BLUETOOTH_TYPES.DISCOVER]: { + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.DISCOVER, + request: 'set' + }, + [IPC_BLUETOOTH_TYPES.PAIR]: { + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.PAIR, + request: 'set', + payload: { address: 'x' } + }, + [IPC_BLUETOOTH_TYPES.PAIR_REPLY]: { + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.PAIR_REPLY, + request: 'set', + payload: { accept: true } + }, + [IPC_BLUETOOTH_TYPES.UNPAIR]: { + kind: IPC_HANDLERS.BLUETOOTH, + type: IPC_BLUETOOTH_TYPES.UNPAIR, + request: 'set', + payload: { address: 'x' } + } + } + for (const method of Object.values(managerMock)) { + if ('mockResolvedValue' in method) method.mockResolvedValue(STATUS) + } + for (const data of Object.values(calls)) { + await expect(bluetoothHandler(data)).resolves.toBeDefined() + } + }) +}) diff --git a/DeskThingServer/test/main/services/bluetooth/bridgeClient.test.ts b/DeskThingServer/test/main/services/bluetooth/bridgeClient.test.ts new file mode 100644 index 00000000..e47e48ed --- /dev/null +++ b/DeskThingServer/test/main/services/bluetooth/bridgeClient.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + fetchBridgeState, + pushBridgePreference, + removeBridgePairing, + replyBridgePairing, + setBridgeDevice, + startBridgeDiscovery, + startBridgePairing +} from '../../../../src/main/services/bluetooth/bridgeClient' + +const STATE = { + preference: 'bluetooth', + transport: 'bluetooth', + linkUp: true, + deviceAddress: 'aa-bb-cc-dd-ee-ff', + paired: true, + pairing: { stage: 'idle', code: null, error: null }, + found: [] +} + +const okResponse = (): Response => + ({ ok: true, json: async () => STATE }) as unknown as Response + +describe('bridgeClient', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('fetches /status and returns the parsed state', async () => { + fetchMock.mockResolvedValue(okResponse()) + const state = await fetchBridgeState() + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:8899/status', + expect.objectContaining({ signal: expect.anything() }) + ) + expect(state).toEqual(STATE) + }) + + it.each([ + ['preference', () => pushBridgePreference('usb'), '/preference', { preference: 'usb' }], + ['discover', () => startBridgeDiscovery(), '/discover', {}], + ['pair', () => startBridgePairing('AA:BB'), '/pair', { address: 'AA:BB' }], + ['pair reply', () => replyBridgePairing(true), '/pair/reply', { accept: true }], + ['unpair', () => removeBridgePairing('AA:BB'), '/unpair', { address: 'AA:BB' }], + ['device', () => setBridgeDevice('AA:BB'), '/device', { address: 'AA:BB' }] + ])('POSTs %s to the right endpoint with the right body', async (_name, call, path, body) => { + fetchMock.mockResolvedValue(okResponse()) + await call() + expect(fetchMock).toHaveBeenCalledWith( + `http://127.0.0.1:8899${path}`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(body) + }) + ) + }) + + it('returns null when the helper is unreachable', async () => { + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')) + expect(await fetchBridgeState()).toBeNull() + }) + + it('returns null on a non-OK response', async () => { + fetchMock.mockResolvedValue({ ok: false } as Response) + expect(await fetchBridgeState()).toBeNull() + }) +}) diff --git a/DeskThingServer/test/main/services/bluetooth/manager.test.ts b/DeskThingServer/test/main/services/bluetooth/manager.test.ts new file mode 100644 index 00000000..21fde3e4 --- /dev/null +++ b/DeskThingServer/test/main/services/bluetooth/manager.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@server/utils/logger', () => ({ + default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() } +})) + +const processMock = vi.hoisted(() => ({ + bridgeBinaryExists: vi.fn(() => true), + isBridgeRunning: vi.fn(() => true), + startBridgeProcess: vi.fn(), + stopBridgeProcess: vi.fn(), + bridgeBinaryPath: '/tmp/btbridge', + deviceMuxScriptPath: '/tmp/btmux.py', + deviceAgentScriptPath: '/tmp/btagent.py' +})) +vi.mock('../../../../src/main/services/bluetooth/bridgeProcess', () => processMock) + +const clientMock = vi.hoisted(() => ({ + fetchBridgeState: vi.fn(), + pushBridgePreference: vi.fn(), + startBridgeDiscovery: vi.fn(), + startBridgePairing: vi.fn(), + replyBridgePairing: vi.fn(), + removeBridgePairing: vi.fn(), + setBridgeDevice: vi.fn() +})) +vi.mock('../../../../src/main/services/bluetooth/bridgeClient', () => clientMock) + +const provisionMock = vi.hoisted(() => vi.fn()) +vi.mock('../../../../src/main/services/bluetooth/provisioner', () => ({ + provisionDevice: provisionMock +})) + +import { bluetoothManager } from '../../../../src/main/services/bluetooth' + +const HELPER_STATE = { + preference: 'bluetooth' as const, + transport: 'bluetooth' as const, + linkUp: true, + deviceAddress: 'aa-bb-cc-dd-ee-ff', + paired: true, + pairing: { stage: 'idle' as const, code: null, error: null }, + found: [{ address: 'aa-bb-cc-dd-ee-ff', name: 'Car Thing' }] +} + +describe('bluetoothManager (helper present)', () => { + beforeEach(() => { + vi.clearAllMocks() + processMock.isBridgeRunning.mockReturnValue(true) + clientMock.fetchBridgeState.mockResolvedValue(HELPER_STATE) + }) + + it('reports supported with the helper state merged in', async () => { + const status = await bluetoothManager.getStatus() + expect(status).toMatchObject({ + supported: true, + running: true, + linkUp: true, + transport: 'bluetooth', + deviceAddress: 'aa-bb-cc-dd-ee-ff', + paired: true + }) + expect(status.found).toHaveLength(1) + }) + + it('degrades to safe defaults when the helper is not running', async () => { + processMock.isBridgeRunning.mockReturnValue(false) + const status = await bluetoothManager.getStatus() + expect(status).toMatchObject({ + supported: true, + running: false, + linkUp: false, + transport: 'none', + deviceAddress: null, + paired: false + }) + expect(clientMock.fetchBridgeState).not.toHaveBeenCalled() + }) + + it('degrades to safe defaults when the control API is unreachable', async () => { + clientMock.fetchBridgeState.mockResolvedValue(null) + const status = await bluetoothManager.getStatus() + expect(status.linkUp).toBe(false) + expect(status.pairing.stage).toBe('idle') + }) + + it('pushes the preference then re-reads status', async () => { + await bluetoothManager.setPreference('usb') + expect(clientMock.pushBridgePreference).toHaveBeenCalledWith('usb') + expect(clientMock.fetchBridgeState).toHaveBeenCalled() + }) + + it('hands the provisioned device address to the helper', async () => { + provisionMock.mockResolvedValue({ + success: true, + steps: [], + deviceAddress: '30:E3:D6:05:78:45' + }) + const result = await bluetoothManager.provision('serial') + expect(result.success).toBe(true) + expect(clientMock.setBridgeDevice).toHaveBeenCalledWith('30:E3:D6:05:78:45') + }) + + it('does not touch the helper when provisioning fails', async () => { + provisionMock.mockResolvedValue({ success: false, steps: [] }) + await bluetoothManager.provision('serial') + expect(clientMock.setBridgeDevice).not.toHaveBeenCalled() + }) + + it('forwards pairing calls to the helper', async () => { + await bluetoothManager.pair('AA:BB') + expect(clientMock.startBridgePairing).toHaveBeenCalledWith('AA:BB') + await bluetoothManager.pairReply(true) + expect(clientMock.replyBridgePairing).toHaveBeenCalledWith(true) + await bluetoothManager.unpair('AA:BB') + expect(clientMock.removeBridgePairing).toHaveBeenCalledWith('AA:BB') + await bluetoothManager.discover() + expect(clientMock.startBridgeDiscovery).toHaveBeenCalled() + }) +}) diff --git a/DeskThingServer/test/main/services/bluetooth/provisioner.test.ts b/DeskThingServer/test/main/services/bluetooth/provisioner.test.ts new file mode 100644 index 00000000..c9997401 --- /dev/null +++ b/DeskThingServer/test/main/services/bluetooth/provisioner.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@server/utils/logger', () => ({ + default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() } +})) + +const adbMock = vi.hoisted(() => vi.fn()) +vi.mock('@server/handlers/adbHandler', () => ({ + handleAdbCommands: adbMock +})) + +vi.mock('../../../../src/main/services/bluetooth/bridgeProcess', () => ({ + deviceMuxScriptPath: '/resources/superbird/btmux.py', + deviceAgentScriptPath: '/resources/superbird/btagent.py' +})) + +import { provisionDevice } from '../../../../src/main/services/bluetooth/provisioner' + +/** Route adb calls to canned replies keyed by a substring of the command. */ +const cannedAdb = (overrides: Record = {}): void => { + adbMock.mockImplementation(async (cmd: string) => { + for (const [key, value] of Object.entries(overrides)) { + if (cmd.includes(key)) { + if (value instanceof Error) throw value + return value + } + } + if (cmd.includes('supervisorctl status')) return 'btmux RUNNING\nbtagent RUNNING' + if (cmd.includes('hciconfig')) { + return 'hci0: UP RUNNING PSCAN ISCAN\n\tBD Address: 30:E3:D6:05:78:45' + } + if (cmd.includes('sdptool browse')) return 'Service Name: Serial Port' + if (cmd.includes('cat /etc/start_bluetoothd.sh')) return 'bluetoothd -n -d --compat' + return '' + }) +} + +describe('provisionDevice', () => { + beforeEach(() => { + adbMock.mockReset() + }) + + it('runs the full sequence and reports the device radio address', async () => { + cannedAdb() + const result = await provisionDevice('serial123') + expect(result.success).toBe(true) + expect(result.deviceAddress).toBe('30:E3:D6:05:78:45') + const ids = result.steps.map((s) => s.id) + expect(ids).toEqual([ + 'remount', + 'push-mux', + 'supervisor', + 'bluetoothd-compat', + 'start-services', + 'verify-radio', + 'read-address' + ]) + expect(result.steps.every((s) => s.ok)).toBe(true) + }) + + it('targets every adb call at the requested device', async () => { + cannedAdb() + await provisionDevice('serial123') + for (const call of adbMock.mock.calls) { + expect(call[0]).toMatch(/^-s serial123 /) + } + }) + + it('installs both the mux and the pairing agent', async () => { + cannedAdb() + await provisionDevice('serial123') + const pushes = adbMock.mock.calls.map((c) => c[0]).filter((c: string) => c.includes('push')) + expect(pushes.some((c: string) => c.includes('btmux.py'))).toBe(true) + expect(pushes.some((c: string) => c.includes('btagent.py'))).toBe(true) + const confs = adbMock.mock.calls.map((c) => c[0]).filter((c: string) => c.includes('supervisor.d')) + expect(confs.some((c: string) => c.includes('btmux.conf'))).toBe(true) + expect(confs.some((c: string) => c.includes('btagent.conf'))).toBe(true) + }) + + it('stops at the first failing step and reports it', async () => { + cannedAdb({ 'mount -o remount': new Error('device is read-only') }) + const result = await provisionDevice('serial123') + expect(result.success).toBe(false) + expect(result.steps).toHaveLength(1) + expect(result.steps[0]).toMatchObject({ id: 'remount', ok: false }) + expect(result.deviceAddress).toBeUndefined() + }) + + it('fails verify-radio when page scan is off', async () => { + cannedAdb({ hciconfig: 'hci0: UP RUNNING ISCAN\n\tBD Address: 30:E3:D6:05:78:45' }) + const result = await provisionDevice('serial123') + expect(result.success).toBe(false) + expect(result.steps.at(-1)).toMatchObject({ id: 'verify-radio', ok: false }) + }) + + it('patches bluetoothd only when compat is missing', async () => { + cannedAdb({ 'cat /etc/start_bluetoothd.sh': 'bluetoothd -n -d' }) + const result = await provisionDevice('serial123') + expect(result.success).toBe(true) + const step = result.steps.find((s) => s.id === 'bluetoothd-compat') + expect(step?.detail).toBe('patched and restarted') + expect(adbMock.mock.calls.some((c) => (c[0] as string).includes('sed -i'))).toBe(true) + }) + + it('fails read-address when the radio address is unreadable', async () => { + cannedAdb({ hciconfig: 'hci0: UP RUNNING PSCAN ISCAN' }) + const result = await provisionDevice('serial123') + // verify-radio passes (PSCAN present) but read-address cannot parse. + expect(result.success).toBe(false) + expect(result.steps.at(-1)).toMatchObject({ id: 'read-address', ok: false }) + }) +}) From 2fe38b9fe34e88c0d5c953b7cf9884045147018a Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 09:17:06 -0400 Subject: [PATCH 3/6] Surface the Bluetooth transport in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bluetooth setup page becomes a pairing wizard: scan, pick the Car Thing, watch the same 6-digit code appear on the device's screen and in this dialog, confirm, done — plus one-time USB provisioning with a live step checklist, and unpair. Device details gain a Connection Type card with a Bluetooth/USB preference toggle, and the top bar shows an always-visible Bluetooth chip while the link is live. Everything hides itself on platforms without a bridge or when the helper isn't running. Co-Authored-By: Claude Fable 5 --- .../src/components/BluetoothStatusChip.tsx | 62 ++++ .../src/renderer/src/nav/TopBar.tsx | 2 + .../modals/DeviceDetails/DeviceDetails.tsx | 3 + .../DeviceDetails/TransportSelector.tsx | 116 +++++++ .../src/overlays/setup/BluetoothPage.tsx | 288 +++++++++++++++++- 5 files changed, 462 insertions(+), 9 deletions(-) create mode 100644 DeskThingServer/src/renderer/src/components/BluetoothStatusChip.tsx create mode 100644 DeskThingServer/src/renderer/src/overlays/modals/DeviceDetails/TransportSelector.tsx diff --git a/DeskThingServer/src/renderer/src/components/BluetoothStatusChip.tsx b/DeskThingServer/src/renderer/src/components/BluetoothStatusChip.tsx new file mode 100644 index 00000000..b16118fe --- /dev/null +++ b/DeskThingServer/src/renderer/src/components/BluetoothStatusChip.tsx @@ -0,0 +1,62 @@ +import { FC, useEffect, useState } from 'react' +import { IconBluetooth } from '@renderer/assets/icons' +import { BluetoothBridgeStatus } from '@shared/types' + +const POLL_MS = 5000 + +/** + * Always-visible transport indicator for the top bar. When a Car Thing is + * carrying data over Bluetooth this is the unmistakable signal; when the + * platform has no bridge (or nothing is paired yet) it renders nothing so + * non-Bluetooth setups look unchanged. + */ +export const BluetoothStatusChip: FC = () => { + const [status, setStatus] = useState(null) + + useEffect(() => { + let cancelled = false + const refresh = async (): Promise => { + try { + const s = await window.electron.bluetooth.getStatus() + if (!cancelled) setStatus(s) + } catch { + if (!cancelled) setStatus(null) + } + } + refresh() + const id = setInterval(refresh, POLL_MS) + return () => { + cancelled = true + clearInterval(id) + } + }, []) + + if (!status?.supported || !status.running) return null + // Nothing paired yet: stay out of the way until Bluetooth matters. + if (!status.deviceAddress && !status.linkUp) return null + + const live = status.linkUp && status.transport === 'bluetooth' + + return ( +
+ + + + {live ? 'Bluetooth' : 'BT idle'} + +
+ ) +} + +export default BluetoothStatusChip diff --git a/DeskThingServer/src/renderer/src/nav/TopBar.tsx b/DeskThingServer/src/renderer/src/nav/TopBar.tsx index 964c1b4a..7eda9e03 100644 --- a/DeskThingServer/src/renderer/src/nav/TopBar.tsx +++ b/DeskThingServer/src/renderer/src/nav/TopBar.tsx @@ -2,6 +2,7 @@ import React from 'react' import Nav from './Nav' import { IconLogo, IconWifi } from '@renderer/assets/icons' import { useClientStore } from '@renderer/stores' +import BluetoothStatusChip from '@renderer/components/BluetoothStatusChip' const TopBar: React.FC = () => { const connections = useClientStore((state) => state.connections) @@ -9,6 +10,7 @@ const TopBar: React.FC = () => { return (
+ {connections == 0 ? ( ) : ( diff --git a/DeskThingServer/src/renderer/src/overlays/modals/DeviceDetails/DeviceDetails.tsx b/DeskThingServer/src/renderer/src/overlays/modals/DeviceDetails/DeviceDetails.tsx index 0c047a8d..9489190c 100644 --- a/DeskThingServer/src/renderer/src/overlays/modals/DeviceDetails/DeviceDetails.tsx +++ b/DeskThingServer/src/renderer/src/overlays/modals/DeviceDetails/DeviceDetails.tsx @@ -1,5 +1,6 @@ import { Client, ConnectionState, ClientPlatformIDs } from '@deskthing/types' import { FC, useEffect, useState } from 'react' +import { TransportSelector } from './TransportSelector' type DeviceDetailsProps = { client: Client @@ -104,6 +105,8 @@ export const DeviceDetails: FC = ({ client }) => {
)} + +
diff --git a/DeskThingServer/src/renderer/src/overlays/modals/DeviceDetails/TransportSelector.tsx b/DeskThingServer/src/renderer/src/overlays/modals/DeviceDetails/TransportSelector.tsx new file mode 100644 index 00000000..d727a790 --- /dev/null +++ b/DeskThingServer/src/renderer/src/overlays/modals/DeviceDetails/TransportSelector.tsx @@ -0,0 +1,116 @@ +import { FC, useCallback, useEffect, useState } from 'react' +import { BluetoothBridgeStatus, BluetoothPreference, BluetoothTransport } from '@shared/types' + +/** + * Shows which transport is actually carrying data to the Car Thing, and lets the + * user pin a preference. + * + * Status comes from the main process over IPC; the bluetooth service there owns + * the bridge helper, the RFCOMM link, and the USB `adb reverse` arbitration. If + * the platform has no bridge or the helper isn't running, this section hides + * itself so a plain USB setup looks no different than before. + */ + +const POLL_MS = 4000 + +const TRANSPORT_LABEL: Record = { + bluetooth: 'Bluetooth', + usb: 'USB Cable', + none: 'Not Connected' +} + +const TRANSPORT_COLOR: Record = { + bluetooth: 'text-sky-400', + usb: 'text-amber-400', + none: 'text-zinc-500' +} + +export const TransportSelector: FC = () => { + const [status, setStatus] = useState(null) + const [busy, setBusy] = useState(false) + + const refresh = useCallback(async (): Promise => { + try { + setStatus(await window.electron.bluetooth.getStatus()) + } catch { + setStatus(null) + } + }, []) + + useEffect(() => { + refresh() + const id = setInterval(refresh, POLL_MS) + return () => clearInterval(id) + }, [refresh]) + + const choose = async (preference: BluetoothPreference): Promise => { + setBusy(true) + try { + setStatus(await window.electron.bluetooth.setPreference(preference)) + } catch { + setStatus(null) + } finally { + setBusy(false) + } + } + + if (!status || !status.supported || !status.running) return null + + const active = status.transport + + return ( +
+

+ 📶 + Connection Type +

+ +
+
+ Carrying Data + + + {TRANSPORT_LABEL[active]} + +
+ +
+
+ Prefer + {status.preference === 'bluetooth' && ( + recommended + )} +
+
+ {(['bluetooth', 'usb'] as BluetoothPreference[]).map((option) => { + const selected = status.preference === option + return ( + + ) + })} +
+

+ {status.preference === 'bluetooth' + ? 'Bluetooth is used whenever it is available, falling back to USB automatically.' + : 'Pinned to the USB cable. Bluetooth stays disconnected until you switch back.'} +

+
+
+
+ ) +} diff --git a/DeskThingServer/src/renderer/src/overlays/setup/BluetoothPage.tsx b/DeskThingServer/src/renderer/src/overlays/setup/BluetoothPage.tsx index 1abf1340..9a1a8804 100644 --- a/DeskThingServer/src/renderer/src/overlays/setup/BluetoothPage.tsx +++ b/DeskThingServer/src/renderer/src/overlays/setup/BluetoothPage.tsx @@ -1,22 +1,292 @@ import SponsorButton from '@renderer/components/SponsorButton' -import React from 'react' +import Button from '@renderer/components/Button' +import { IconBluetooth, IconRefresh } from '@renderer/assets/icons' +import { useClientStore } from '@renderer/stores' +import { TransportSelector } from '@renderer/overlays/modals/DeviceDetails/TransportSelector' +import { ClientConnectionMethod } from '@deskthing/types' +import { BluetoothBridgeStatus, BluetoothProvisionResult } from '@shared/types' +import React, { useEffect, useState } from 'react' +const POLL_MS = 2000 + +/** + * Bluetooth setup, in the shape the Car Thing originally shipped with: + * this computer asks to connect, the device's screen shows a 6-digit code, + * and the person confirms the same code here. Once paired, the device + * reconnects by itself every time it powers up. + */ const BluetoothPage: React.FC = () => { + const [status, setStatus] = useState(null) + const clients = useClientStore((store) => store.clients) + const refreshDevices = useClientStore((store) => store.requestADBDevices) + const [refreshing, setRefreshing] = useState(false) + const [provisioning, setProvisioning] = useState(null) + const [results, setResults] = useState>({}) + const [busy, setBusy] = useState(false) + + const adbDevices = clients.filter( + (client) => client.manifest?.context.method === ClientConnectionMethod.ADB + ) + + useEffect(() => { + let cancelled = false + const refresh = async (): Promise => { + try { + const s = await window.electron.bluetooth.getStatus() + if (!cancelled) setStatus(s) + } catch { + if (!cancelled) setStatus(null) + } + } + refresh() + const id = setInterval(refresh, POLL_MS) + return () => { + cancelled = true + clearInterval(id) + } + }, []) + + const call = async (fn: () => Promise): Promise => { + setBusy(true) + try { + setStatus(await fn()) + } finally { + setBusy(false) + } + } + + const handleRefreshDevices = async (): Promise => { + setRefreshing(true) + await refreshDevices() + setTimeout(() => setRefreshing(false), 1000) + } + + const handleProvision = async (adbId: string): Promise => { + setProvisioning(adbId) + try { + const result = await window.electron.bluetooth.provision(adbId) + setResults((prev) => ({ ...prev, [adbId]: result })) + } finally { + setProvisioning(null) + } + } + + const pairing = status?.pairing + const carThings = status?.found.filter( + (d) => /car\s*thing|superbird/i.test(d.name) || d.address === status?.deviceAddress + ) + const others = status?.found.filter((d) => carThings && !carThings.includes(d)) + return (

Bluetooth Settings

-
-
-

🛠️ Coming Soon!

-

- Bluetooth functionality will be available in a future update -

-
+
+ {status && !status.supported ? ( +
+

+ The Bluetooth transport is not available on this computer. +

+

+ Your Car Thing will continue to work normally over its USB cable. +

+
+ ) : ( + <> +
+ +
+ + {/* Pairing */} +
+

+ + Pair with a Car Thing +

+ + {status?.paired && status.deviceAddress && pairing?.stage !== 'confirm' ? ( +
+

+ Paired with {status.deviceAddress}.{' '} + {status.linkUp + ? 'Connected — the device only needs power.' + : 'It will connect by itself whenever it has power and is in range.'} +

+ +
+ ) : pairing?.stage === 'confirm' && pairing.code ? ( +
+

+ Your Car Thing is showing this code on its screen. Make sure it matches: +

+

+ {pairing.code} +

+
+ + +
+
+ ) : pairing?.stage === 'connecting' || pairing?.stage === 'finishing' ? ( +

Pairing… watch the device screen for a code.

+ ) : ( +
+

+ {pairing?.stage === 'failed' && pairing.error && ( + + Pairing failed: {pairing.error}. Try again. + + )} + Scan for the device, then pair — a matching 6-digit code appears on the Car + Thing's screen and here. The device must be powered on and set up once over + USB (below). +

+

+ On macOS, if the system shows its own Bluetooth pairing request, confirm it there + and check the code matches the device screen — that completes pairing too. +

+
+ + {status?.deviceAddress && ( + + )} +
+ {(carThings?.length || 0) + (others?.length || 0) > 0 && ( +
    + {[...(carThings ?? []), ...(others ?? [])].map((d) => ( +
  • + + {d.name} {d.address} + + +
  • + ))} +
+ )} +
+ )} +
+ + {/* One-time USB provisioning */} +
+
+

First-time Setup (USB)

+ +
+

+ Done once per device, over the USB cable: installs the Bluetooth service so the Car + Thing can reach this computer wirelessly from then on. +

+

+ After this finishes, pair above — then the cable is only ever needed for power. +

+ + {adbDevices.length === 0 ? ( +

+ No ADB devices connected. Plug the Car Thing in over USB and hit Find Devices. +

+ ) : ( +
+ {adbDevices.map((device) => { + const adbId = device.meta.adb?.adbId + if (!adbId) return null + const result = results[adbId] + return ( +
+
+

{adbId}

+ +
+ {result && ( +
    + {result.steps.map((step) => ( +
  • + + {step.ok ? '✓' : '✗'} + + + {step.label} + {step.detail && ( + — {step.detail} + )} + +
  • + ))} +
  • + {result.success + ? 'Device is ready — now pair it above.' + : 'Setup did not finish. Fix the failing step and try again.'} +
  • +
+ )} +
+ ) + })} +
+ )} +
+ + )} +

Support the development of deskthing

Your support helps keep this project alive

-
{' '} +
) From 1e2c549bbaf748eb790dc931e36e85397516d192 Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 09:17:06 -0400 Subject: [PATCH 4/6] Enable websocket compression for client connections Song-state updates dominate client traffic and are highly repetitive; perMessageDeflate shrinks them substantially, which matters on the ~155 KB/s Bluetooth link and costs nothing on faster transports. Co-Authored-By: Claude Fable 5 --- .../main/stores/platforms/websocket/wsWebsocket.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/DeskThingServer/src/main/stores/platforms/websocket/wsWebsocket.ts b/DeskThingServer/src/main/stores/platforms/websocket/wsWebsocket.ts index f0effb57..f264bd4c 100644 --- a/DeskThingServer/src/main/stores/platforms/websocket/wsWebsocket.ts +++ b/DeskThingServer/src/main/stores/platforms/websocket/wsWebsocket.ts @@ -92,7 +92,18 @@ export class WSPlatform { this.setupExpressListeners() this.httpServer = this.expressServer.getServer() as HttpServer - this.server = new WebSocketServer({ server: this.httpServer }) + // Song-state updates are small and highly repetitive, so deflate with context + // takeover shrinks them by roughly an order of magnitude. That matters when a + // client is reached over a Bluetooth tunnel, where every frame costs a full + // radio packet regardless of how little it carries. + this.server = new WebSocketServer({ + server: this.httpServer, + perMessageDeflate: { + threshold: 256, + zlibDeflateOptions: { level: 3 }, + concurrencyLimit: 4 + } + }) this.server.on('connection', this.handleConnection.bind(this)) From afb76ddb2fdffcc469320fa1aa82557c128644de Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 11:42:02 -0400 Subject: [PATCH 5/6] Document the Bluetooth transport for users Setup and everyday-use instructions: one-time USB provisioning, pairing with the code shown on the device screen, going wireless, the transport indicators, and troubleshooting. Complements the developer-oriented bt_source/README.md. Co-Authored-By: Claude Fable 5 --- docs/bluetooth.md | 127 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/bluetooth.md diff --git a/docs/bluetooth.md b/docs/bluetooth.md new file mode 100644 index 00000000..a545ae6d --- /dev/null +++ b/docs/bluetooth.md @@ -0,0 +1,127 @@ +# Connecting a Car Thing over Bluetooth + +DeskThing can talk to a Spotify Car Thing over Bluetooth instead of a USB data +cable. After a one-time setup the device needs **only power** — plug it into any +USB charger, and it reconnects to your computer on its own every time it powers +up. + +> **Platform support.** The Bluetooth transport ships for macOS, Linux, and +> Windows. macOS is the most heavily tested today; on any platform without a +> bundled helper the Bluetooth options simply don't appear and USB keeps working +> exactly as before. + +--- + +## What you need + +- A Car Thing already flashed with DeskThing and working over USB. +- The USB cable — **for first-time setup only**. After that it's just power. +- Your computer's Bluetooth turned on. + +--- + +## First-time setup + +This is done once per device, over USB. + +### 1. Install the Bluetooth service on the device + +1. Connect the Car Thing to your computer with the USB cable. +2. Open DeskThing → **Setup Device** → **Bluetooth**. +3. Under **First-time Setup (USB)**, click **Find Devices**, then click + **Set up Bluetooth** next to your Car Thing. + +You'll see a checklist run — installing the service, enabling the radio, and +reading the device's Bluetooth address. When it finishes it says *"Device is +ready — now pair it above."* + +### 2. Pair the device + +Pairing works just like it did on the original Car Thing: your computer asks to +connect, the Car Thing's screen shows a 6-digit code, and you confirm that the +codes match. + +1. In the **Pair with a Car Thing** section, click **Scan for devices**. +2. When your Car Thing appears, click **Pair** next to it. +3. A **6-digit code appears on the Car Thing's screen** and in DeskThing. + Check that they match, then click **The codes match — Pair**. + +> **On macOS**, your computer may show its own system "Bluetooth Pairing +> Request" instead. That's expected — confirm it there, and check the code +> matches the one on the Car Thing's screen. (macOS's built-in pairing dialog is +> the reliable way to confirm on a Mac.) + +The first time the helper runs, macOS also asks for **Bluetooth permission for +DeskThing** — click **Allow**. Until you do, the helper waits and USB keeps +working. + +That's it. Once paired, the device remembers your computer. + +### 3. Go wireless + +Unplug the USB cable and plug the Car Thing into any USB power adapter +(a **USB-A** charger — a USB-C-to-C cable delivers no power to this device). +Within a minute it connects over Bluetooth and your music appears, no cable. + +--- + +## Everyday use + +- **Just add power.** A paired Car Thing reconnects by itself whenever it powers + up and is in range. Nothing to click. +- **You'll know it's on Bluetooth.** DeskThing's top bar shows a blue + **Bluetooth** chip while the wireless link is carrying data, and the Car + Thing's screen shows a small **BT** badge in the corner (a **USB** badge when + it's on the cable). +- **Prefer one link.** Open **Clients → your device → Connection Type** to see + which link is active and to pin **Bluetooth** or **USB**. Bluetooth is used + automatically whenever it's available and falls back to USB if you plug the + cable back in. + +--- + +## Good to know + +- **Speed.** The Bluetooth link runs at about 155 KB/s. That's plenty for + playback control and album art (DeskThing already sends compressed updates and + smaller artwork over it), but large transfers are slower than USB. If you're + doing something bandwidth-heavy, pin USB. +- **Range.** Standard Bluetooth range — same room / desk works best; thick walls + will drop it, and it reconnects when back in range. +- **The cable still works.** USB is always available as a fallback and for + first-time setup. Bluetooth never removes that. + +--- + +## Troubleshooting + +**The Car Thing won't connect over Bluetooth.** +Make sure it's actually powered — this device has no battery, so "not +connecting" is usually "not powered." Use a **USB-A** charger; a C-to-C cable +provides no power. Give it up to a minute after powering on to boot and +reconnect. + +**macOS never asked for Bluetooth permission / the helper seems stuck.** +Quit and reopen DeskThing so the permission prompt reappears, and click +**Allow**. This can happen after an app update, because the permission is tied +to the exact app. + +**Pairing failed, or the codes didn't match.** +In the Bluetooth setup page, click **Unpair this device**, then pair again from +scratch. If your Mac shows a stale "Car Thing" entry, you can also remove it in +**System Settings → Bluetooth** and re-pair. + +**It connected but the screen is stuck / blank after a reboot.** +Give it a moment — the device reconnects and the screen refreshes on its own +within a couple of minutes of powering up. If it stays stuck, power-cycle the +Car Thing once more. + +**I want to go back to USB only.** +Open **Clients → your device → Connection Type** and pin **USB**, or just keep +the cable plugged in. To remove the pairing entirely, use **Unpair this device** +on the Bluetooth setup page. + +--- + +*Developer/architecture notes (frame protocol, control API, adding a platform) +live in [`DeskThingServer/bt_source/README.md`](../DeskThingServer/bt_source/README.md).* From a8bc70b5bb2ac2c1c653871b14d4167c73916ddf Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 14:32:49 -0400 Subject: [PATCH 6/6] Answer PING on the Linux and Windows helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heartbeat added during reboot-survival work only ever landed on the device mux and the macOS helper. btmux.py pings every 5s and drops the link after 15s without a PONG, and neither linux/btbridge nor win/btbridge.c handled frame type 4 at all — so on those platforms the link could never survive 15 seconds. It would come up, go silent, get torn down, reconnect, and repeat forever. Both now answer PING with PONG, track inbound PONGs, and run their own outbound heartbeat so a half-open link is torn down locally instead of lingering — matching the macOS helper and the device. Tests: PING/PONG golden vectors join the cross-implementation contract, plus a functional suite driving the Linux helper's real frame pump against a fake socket. Co-Authored-By: Claude Fable 5 --- DeskThingServer/bt_source/linux/btbridge | 30 ++++ .../bt_source/test/test_linux_helper.py | 142 ++++++++++++++++++ .../bt_source/test/test_protocol.py | 20 ++- DeskThingServer/bt_source/win/btbridge.c | 28 ++++ 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 DeskThingServer/bt_source/test/test_linux_helper.py diff --git a/DeskThingServer/bt_source/linux/btbridge b/DeskThingServer/bt_source/linux/btbridge index 66641380..495e1d5d 100755 --- a/DeskThingServer/bt_source/linux/btbridge +++ b/DeskThingServer/bt_source/linux/btbridge @@ -32,6 +32,12 @@ SERVER_ADDR = ('127.0.0.1', 8891) CONTROL_ADDR = ('127.0.0.1', 8899) CHUNK = 660 # frame + 7-byte header stays near the typical RFCOMM MTU +# Heartbeat timing. Must stay compatible with the device mux, which pings on +# the same interval and drops the link after the same deadline; the deadline +# spans several intervals so one dropped frame doesn't tear down a good link. +PING_INTERVAL_S = 5 +PONG_DEADLINE_S = 15 + STATE_DIR = os.path.join( os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config')), 'deskthing') STATE_PATH = os.path.join(STATE_DIR, 'bt-transport.json') @@ -376,6 +382,7 @@ class Tunnel: self.conns = {} self.wlock = threading.Lock() self.dead = threading.Event() + self.last_pong = time.time() def send_frame(self, t, sid, payload=b''): with self.wlock: @@ -410,11 +417,33 @@ class Tunnel: self.close_stream(sid, notify=True) elif t == 3: self.close_stream(sid, notify=False) + elif t == 4: # PING -> answer so the peer knows we live + self.send_frame(5, 0) + elif t == 5: # PONG + self.last_pong = time.time() except OSError: pass finally: self.dead.set() + def heartbeat(self): + """Mirror of the device mux's heartbeat. The device pings every 5s and + drops the link after 15s of silence, so answering PING is mandatory — + without it the link cannot survive 15 seconds. We also ping outward so + a half-open link (reports connected, passes no data) gets torn down + here rather than lingering.""" + self.last_pong = time.time() + while not self.dead.wait(PING_INTERVAL_S): + self.send_frame(4, 0) + if time.time() - self.last_pong > PONG_DEADLINE_S: + log('heartbeat: no pong in %ss — link dead, closing' % PONG_DEADLINE_S) + self.dead.set() + try: + self.sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + return + def open_stream(self, sid): try: c = socket.create_connection(SERVER_ADDR, timeout=5) @@ -494,6 +523,7 @@ def bridge_loop(): STATE.link_up = True prefer_bluetooth() tunnel = Tunnel(sock) + threading.Thread(target=tunnel.heartbeat, daemon=True).start() tunnel.pump_rfcomm() tunnel.close() log('session ended') diff --git a/DeskThingServer/bt_source/test/test_linux_helper.py b/DeskThingServer/bt_source/test/test_linux_helper.py new file mode 100644 index 00000000..11ea1f1e --- /dev/null +++ b/DeskThingServer/bt_source/test/test_linux_helper.py @@ -0,0 +1,142 @@ +"""Functional tests for the Linux helper's frame handling. + +The helper is a plain Python script, so its Tunnel class can be driven +directly against a fake socket. This exists because the heartbeat was once +implemented only on the device and macOS sides — the Linux and Windows +helpers silently ignored PING, so the device tore the link down every 15 +seconds, forever. These tests make that regression impossible to reship. +""" +import importlib.util +import os +import struct +import threading +import time +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +HELPER = os.path.join(HERE, '..', 'linux', 'btbridge') + +FRAME = '>BIH' +OPEN, DATA, CLOSE, PING, PONG = 1, 2, 3, 4, 5 + + +def load_helper(): + """Import the extension-less helper script as a module.""" + spec = importlib.util.spec_from_loader( + 'btbridge_linux', + importlib.machinery.SourceFileLoader('btbridge_linux', HELPER)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class FakeSocket: + """Minimal socket stand-in: scripted inbound bytes, captured outbound.""" + + def __init__(self, inbound=b''): + self.inbound = inbound + self.sent = b'' + self.closed = False + self._lock = threading.Lock() + + def recv(self, n): + if not self.inbound: + time.sleep(0.05) + return b'' # EOF ends the pump + chunk, self.inbound = self.inbound[:n], self.inbound[n:] + return chunk + + def sendall(self, data): + with self._lock: + self.sent += data + + def shutdown(self, how): + self.closed = True + + def close(self): + self.closed = True + + +def frames_in(blob): + """Parse a byte stream into (type, sid, payload) tuples.""" + out = [] + while len(blob) >= 7: + t, sid, ln = struct.unpack(FRAME, blob[:7]) + if len(blob) < 7 + ln: + break + out.append((t, sid, blob[7:7 + ln])) + blob = blob[7 + ln:] + return out + + +class LinuxHelperHeartbeat(unittest.TestCase): + def setUp(self): + self.mod = load_helper() + + def test_ping_is_answered_with_pong(self): + """The regression that broke Linux/Windows: PING must produce a PONG.""" + sock = FakeSocket(struct.pack(FRAME, PING, 0, 0)) + tunnel = self.mod.Tunnel(sock) + tunnel.pump_rfcomm() + replies = frames_in(sock.sent) + self.assertIn(PONG, [t for t, _, _ in replies], + "helper did not answer PING with PONG — the device will " + "drop the link after 15s") + + def test_pong_refreshes_liveness(self): + sock = FakeSocket(struct.pack(FRAME, PONG, 0, 0)) + tunnel = self.mod.Tunnel(sock) + tunnel.last_pong = 0 + tunnel.pump_rfcomm() + self.assertGreater(tunnel.last_pong, 0, + "receiving a PONG must refresh the liveness clock") + + def test_multiple_pings_each_get_a_pong(self): + sock = FakeSocket(struct.pack(FRAME, PING, 0, 0) * 3) + tunnel = self.mod.Tunnel(sock) + tunnel.pump_rfcomm() + pongs = [t for t, _, _ in frames_in(sock.sent) if t == PONG] + self.assertEqual(len(pongs), 3) + + def test_heartbeat_pings_on_its_interval(self): + self.mod.PING_INTERVAL_S = 0.05 + self.mod.PONG_DEADLINE_S = 10 # long enough not to trip during this test + sock = FakeSocket() + tunnel = self.mod.Tunnel(sock) + t = threading.Thread(target=tunnel.heartbeat, daemon=True) + t.start() + time.sleep(0.3) + tunnel.dead.set() + t.join(timeout=2) + pings = [ty for ty, _, _ in frames_in(sock.sent) if ty == PING] + self.assertGreaterEqual(len(pings), 2, + "heartbeat is not sending PING on its interval") + + def test_heartbeat_closes_a_silent_link(self): + """A peer that stops ponging must get torn down, not linger half-open.""" + self.mod.PING_INTERVAL_S = 0.05 + self.mod.PONG_DEADLINE_S = 0.2 + sock = FakeSocket() # never pongs + tunnel = self.mod.Tunnel(sock) + t = threading.Thread(target=tunnel.heartbeat, daemon=True) + t.start() + t.join(timeout=5) + self.assertTrue(tunnel.dead.is_set(), + "heartbeat did not mark a silent link dead") + self.assertTrue(sock.closed, + "heartbeat did not shut the dead socket down") + + def test_unknown_frame_type_does_not_desync(self): + """An unknown type must not consume the frames that follow it.""" + blob = (struct.pack(FRAME, 99, 0, 0) + + struct.pack(FRAME, PING, 0, 0)) + sock = FakeSocket(blob) + tunnel = self.mod.Tunnel(sock) + tunnel.pump_rfcomm() + pongs = [t for t, _, _ in frames_in(sock.sent) if t == PONG] + self.assertEqual(len(pongs), 1, + "a PING following an unknown frame was lost") + + +if __name__ == '__main__': + unittest.main() diff --git a/DeskThingServer/bt_source/test/test_protocol.py b/DeskThingServer/bt_source/test/test_protocol.py index b9e211bf..f6585d83 100644 --- a/DeskThingServer/bt_source/test/test_protocol.py +++ b/DeskThingServer/bt_source/test/test_protocol.py @@ -8,7 +8,13 @@ import unittest FRAME = '>BIH' # type(1) streamID(4 BE) len(2 BE) -OPEN, DATA, CLOSE = 1, 2, 3 +OPEN, DATA, CLOSE, PING, PONG = 1, 2, 3, 4, 5 + +# The device drops the link after this long without a PONG, and pings this +# often. Every computer-side helper MUST answer PING or the link cannot +# survive — this is not optional behavior. +PING_INTERVAL_S = 5 +PONG_DEADLINE_S = 15 class FrameProtocol(unittest.TestCase): @@ -26,6 +32,18 @@ def test_close_frame_golden(self): self.assertEqual( struct.pack(FRAME, CLOSE, 0xFFFFFFFF, 0), b'\x03\xff\xff\xff\xff\x00\x00') + def test_ping_frame_golden(self): + # Heartbeat frames carry no stream and no payload. + self.assertEqual(struct.pack(FRAME, PING, 0, 0), b'\x04\x00\x00\x00\x00\x00\x00') + + def test_pong_frame_golden(self): + self.assertEqual(struct.pack(FRAME, PONG, 0, 0), b'\x05\x00\x00\x00\x00\x00\x00') + + def test_heartbeat_deadline_allows_missed_pings(self): + # The deadline must span more than one ping so a single dropped frame + # doesn't tear down a healthy link. + self.assertGreaterEqual(PONG_DEADLINE_S, 3 * PING_INTERVAL_S) + def test_roundtrip(self): for t, sid, payload in [ (OPEN, 1, b''), diff --git a/DeskThingServer/bt_source/win/btbridge.c b/DeskThingServer/bt_source/win/btbridge.c index 062c0139..c7646789 100644 --- a/DeskThingServer/bt_source/win/btbridge.c +++ b/DeskThingServer/bt_source/win/btbridge.c @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -329,6 +330,7 @@ typedef struct { unsigned ids[MAX_STREAMS]; CRITICAL_SECTION wlock; volatile int dead; + volatile time_t last_pong; } Tunnel; static Tunnel *g_tun = NULL; @@ -401,6 +403,26 @@ static void tunnel_open_stream(Tunnel *t, unsigned sid) { } } +/* The device pings every 5s and drops the link after 15s of silence, so + * answering PING is mandatory — without it the link cannot survive 15 + * seconds. We also ping outward so a half-open link (reports connected, + * passes no data) gets torn down here rather than lingering. */ +static DWORD WINAPI heartbeat_thread(LPVOID arg) { + Tunnel *t = (Tunnel *)arg; + while (!t->dead) { + Sleep(5000); + if (t->dead) break; + tunnel_send(t, 4, 0, NULL, 0); + if (difftime(time(NULL), t->last_pong) > 15) { + logline("heartbeat: no pong in 15s - link dead, closing"); + t->dead = 1; + shutdown(t->rf, SD_BOTH); + break; + } + } + return 0; +} + static void tunnel_run(Tunnel *t) { char buf[8192], frame[CHUNK + 16]; unsigned have = 0; @@ -426,6 +448,10 @@ static void tunnel_run(Tunnel *t) { } else if (type == 3) { int slot = slot_for(t, sid, 0); if (slot >= 0) { closesocket(t->conns[slot]); t->conns[slot] = INVALID_SOCKET; } + } else if (type == 4) { + tunnel_send(t, 5, 0, NULL, 0); /* PING -> PONG */ + } else if (type == 5) { + t->last_pong = time(NULL); /* PONG */ } memmove(buf, buf + 7 + len, have - 7 - len); have -= 7 + len; @@ -609,7 +635,9 @@ int main(void) { t.rf = rf; for (i = 0; i < MAX_STREAMS; i++) t.conns[i] = INVALID_SOCKET; InitializeCriticalSection(&t.wlock); + t.last_pong = time(NULL); g_tun = &t; + CloseHandle(CreateThread(NULL, 0, heartbeat_thread, &t, 0, NULL)); tunnel_run(&t); g_tun = NULL; DeleteCriticalSection(&t.wlock);