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..495e1d5d --- /dev/null +++ b/DeskThingServer/bt_source/linux/btbridge @@ -0,0 +1,544 @@ +#!/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 + +# 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') + + +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() + self.last_pong = time.time() + + 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) + 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) + 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) + threading.Thread(target=tunnel.heartbeat, daemon=True).start() + 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_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 new file mode 100644 index 00000000..f6585d83 --- /dev/null +++ b/DeskThingServer/bt_source/test/test_protocol.py @@ -0,0 +1,95 @@ +"""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, 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): + 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_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''), + (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..c7646789 --- /dev/null +++ b/DeskThingServer/bt_source/win/btbridge.c @@ -0,0 +1,651 @@ +/* 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 +#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; + volatile time_t last_pong; +} 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)); + } +} + +/* 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; + 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; } + } 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; + } + } + 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); + 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); + } + 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" } ] }, 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/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)) 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/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

-
{' '} +
) 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 }) + }) +}) 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).*