import CoreBluetooth // bletype — send a {"cmd":"type"} JSON command to the AGI dongle's custom GATT // service from macOS, so the dongle types over its BLE HID keyboard while the // bench monitors capture what arrives. Usage: ./bletype "aA!" [key_delay_ms] import Foundation let SVC = CBUUID(string: "12345678-1234-5678-1234-56789abcdef0") let CMD = CBUUID(string: "12345678-1234-5678-1234-56789abcdef1") let STATUS = CBUUID(string: "12345678-1234-5678-1234-56789abcdef2") let text = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "aA!" let delayMs = CommandLine.arguments.count > 2 ? Int(CommandLine.arguments[2]) ?? 100 : 100 final class Runner: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { var central: CBCentralManager! var peripheral: CBPeripheral? var cmdChar: CBCharacteristic? func centralManagerDidUpdateState(_ c: CBCentralManager) { guard c.state == .poweredOn else { if c.state == .unauthorized { fail("Bluetooth permission denied (System Settings > Privacy & Security > Bluetooth)") } return } let connected = c.retrieveConnectedPeripherals(withServices: [SVC]) if let p = connected.first { log("found connected peripheral: \(p.name ?? "?")") adopt(p) } else { log("not in connected list; scanning for advertised service...") c.scanForPeripherals(withServices: [SVC], options: nil) } } func centralManager(_ c: CBCentralManager, didDiscover p: CBPeripheral, advertisementData _: [String: Any], rssi: NSNumber) { log("discovered \(p.name ?? "?") rssi \(rssi)") c.stopScan() adopt(p) } func adopt(_ p: CBPeripheral) { peripheral = p p.delegate = self central.connect(p, options: nil) } func centralManager(_: CBCentralManager, didConnect p: CBPeripheral) { log("connected; discovering services") p.discoverServices([SVC]) } func peripheral(_ p: CBPeripheral, didDiscoverServices error: Error?) { guard let s = p.services?.first(where: { $0.uuid == SVC }) else { return fail("custom service not found: \(error?.localizedDescription ?? "absent")") } p.discoverCharacteristics([CMD, STATUS], for: s) } func peripheral(_ p: CBPeripheral, didDiscoverCharacteristicsFor s: CBService, error _: Error?) { guard let chars = s.characteristics else { return fail("no characteristics") } cmdChar = chars.first(where: { $0.uuid == CMD }) if let st = chars.first(where: { $0.uuid == STATUS }) { p.setNotifyValue(true, for: st) } guard let cc = cmdChar else { return fail("command characteristic not found") } let json = "{\"cmd\":\"type\",\"value\":\(jsonString(text)),\"key_delay_ms\":\(delayMs)}" log("writing: \(json)") p.writeValue(json.data(using: .utf8)!, for: cc, type: .withResponse) } func peripheral(_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?) { if let e = error { return fail("write failed: \(e.localizedDescription)") } log("write acked by radio; awaiting macro completion notify...") } func peripheral(_: CBPeripheral, didUpdateValueFor c: CBCharacteristic, error _: Error?) { guard c.uuid == STATUS, let d = c.value, let s = String(data: d, encoding: .utf8) else { return } print("DONE \(s)") exit(0) } func jsonString(_ s: String) -> String { let d = try! JSONSerialization.data(withJSONObject: [s]) let a = String(data: d, encoding: .utf8)! return String(a.dropFirst().dropLast()) } func log(_ m: String) { FileHandle.standardError.write(("[bletype] " + m + "\n").data(using: .utf8)!) } func fail(_ m: String) { log("FAIL: " + m); exit(1) } } let r = Runner() r.central = CBCentralManager(delegate: r, queue: nil) // Timeout: completion notify normally arrives well under this for short strings. DispatchQueue.main.asyncAfter(deadline: .now() + 15) { r.log("timeout waiting for completion notify"); exit(2) } RunLoop.main.run()