diff --git a/Helpers/hci_helper.py b/Helpers/hci_helper.py index 4716d4d..0854793 100644 --- a/Helpers/hci_helper.py +++ b/Helpers/hci_helper.py @@ -49,9 +49,20 @@ def feed(self, data): class RemoteReports: - def __init__(self, address, attribute): + # Consumer usages observed on the Jieli hid_mouse / 0.0.1 remote. + # Normalize them to the original remote's mapping IDs before IPC. + CONSUMER_BUTTONS = { + 0x0042: 3, 0x0043: 4, 0x0044: 5, 0x0045: 6, 0x0041: 7, + 0x0224: 11, 0x0223: 10, 0x00e9: 12, 0x00ea: 13, 0x00e2: 8, + 0x0077: 14, 0x0078: 15, 0x019e: 1, 0x0189: 17, + } + + def __init__(self, address, attribute, report_format="indexed"): + if report_format not in ("indexed", "consumer16"): + raise ValueError("Unsupported remote report format") self.address = bytes.fromhex(address.replace(":", ""))[::-1] self.attribute = attribute + self.report_format = report_format self.connections = set() self.fragments = {} @@ -105,6 +116,14 @@ def accept(self, kind, data): if len(att) < 3 or att[0] not in (0x1b, 0x1d) or struct.unpack_from(" identifier "local.codex.RemoteMic"' "$app" fi codesign --verify --deep --strict "$app" -lipo "$app/Contents/MacOS/RemoteBuddy" -verify_arch arm64 x86_64 +for arch in arm64 x86_64; do + lipo "$app/Contents/MacOS/RemoteBuddy" -verify_arch "$arch" +done print -r -- "Built $app" diff --git a/Sources/RemoteBuddy/Bluetooth/BLEController.swift b/Sources/RemoteBuddy/Bluetooth/BLEController.swift index e1e4570..baaf89d 100644 --- a/Sources/RemoteBuddy/Bluetooth/BLEController.swift +++ b/Sources/RemoteBuddy/Bluetooth/BLEController.swift @@ -9,6 +9,10 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg var shortcutsSuspended = false private let diagnostics = Logger(subsystem: "local.codex.RemoteMic", category: "device-info") private let deviceInformation = CBUUID(string: "180A") + private var remoteDeviceInformation = RemoteDeviceInformation() + private var usesAudioDurationForHold: Bool { + remoteDeviceInformation.compatibilityProfile.usesAudioDurationForHold + } private let audio: AudioOutput private var central: CBCentralManager! @@ -103,6 +107,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg subscribed.removeAll() requestedCapabilities = false session = ATVVSession() + remoteDeviceInformation = RemoteDeviceInformation() peripheral.discoverServices([service, deviceInformation]) } @@ -138,7 +143,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { guard error == nil, let characteristics = service.characteristics else { return } if service.uuid == deviceInformation { - for item in characteristics where ["2A26", "2A27", "2A28", "2A24"].contains(item.uuid.uuidString) { + for item in characteristics where ["2A29", "2A26", "2A27", "2A28", "2A24"].contains(item.uuid.uuidString) { peripheral.readValue(for: item) } return @@ -186,6 +191,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg } guard let data = characteristic.value else { return } if characteristic.service?.uuid == deviceInformation { + updateDeviceInformation(uuid: characteristic.uuid.uuidString, value: data) diagnostics.notice("Live device info uuid=\(characteristic.uuid.uuidString, privacy: .public) value=\(String(data: data, encoding: .utf8) ?? data.description, privacy: .public)") return } @@ -196,6 +202,15 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg } } + func updateDeviceInformation(uuid: String, value: Data) { + let previous = remoteDeviceInformation.compatibilityProfile + remoteDeviceInformation.update(uuid: uuid, value: value) + let profile = remoteDeviceInformation.compatibilityProfile + if previous != profile { + diagnostics.notice("Remote compatibility profile=\(profile.rawValue, privacy: .public) audioTimedHold=\(profile.usesAudioDurationForHold)") + } + } + func receiveAudio(_ data: Data, at now: TimeInterval = ProcessInfo.processInfo.systemUptime) { receivedAudioPackets += 1 guard streaming, !shortcutsSuspended, let samples = session.decodeAudio(data), let codec = session.codec else { return } @@ -210,6 +225,12 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg peak = max(peak, abs(Int(sample))) }) audio.feed(samples, sampleRate: codec.sampleRate) + // This firmware delays AUDIO_STOP by about a second after its last + // audio packet, making a tap look like a 1.3 s hold. Count decoded + // audio instead of that release delay; silent samples count as well. + if usesAudioDurationForHold, decodedAudioDuration >= Self.holdThreshold { + perform(voiceGesture.holdThresholdReached()) + } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { @@ -233,7 +254,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg audioDiagnostics.notice("Voice sync codec=\(codec.rawValue) sourceRate=\(codec.sampleRate) sequence=\(sequence)") session.applySync(codec: codec, sequence: sequence, predictor: predictor, stepIndex: stepIndex) case .audioStart(let reason, let codec, let streamID): - if streaming, reason == 0x03, session.streamID == streamID { return } + if streaming, reason == 0x03, voiceGesture.isPressed, session.streamID == streamID { return } if reason == 0x03 { onVoiceButtonActivity?(true) if !shortcutsSuspended { @@ -266,7 +287,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg maxPacketGap = 0 decodedAudioDuration = 0 gainClippedSamples = 0 - audioDiagnostics.notice("Voice start decoder=ATVV-high-first codec=\(codec.rawValue) sourceRate=\(codec.sampleRate) suspended=\(self.shortcutsSuspended) \(self.audio.diagnosticSummary, privacy: .public)") + audioDiagnostics.notice("Voice start reason=\(reason) streamID=\(streamID) audioTimedHold=\(self.usesAudioDurationForHold) decoder=ATVV-high-first codec=\(codec.rawValue) sourceRate=\(codec.sampleRate) suspended=\(self.shortcutsSuspended) \(self.audio.diagnosticSummary, privacy: .public)") watchdog.awaitingStream(at: streamStartedAt) nextKeepAlive = streamStartedAt + 4 onStreaming?(true) @@ -283,7 +304,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg onVoiceButtonActivity?(false) holdWorkItem?.cancel() holdWorkItem = nil - perform(voiceGesture.pressUp()) + perform(voiceGesture.pressUp(), remoteAlreadyStopped: true) if !voiceGesture.toggleActive { watchdog.invalidate() } onStreaming?(voiceGesture.toggleActive || pendingShortcutStop != nil) } else if reason == 0x04 { @@ -319,9 +340,11 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg private func scheduleHoldThreshold() { holdWorkItem?.cancel() + holdWorkItem = nil + guard !usesAudioDurationForHold else { return } let generation = watchdog.generation let work = DispatchWorkItem { [weak self] in - guard let self, self.watchdog.generation == generation else { return } + guard let self, self.watchdog.generation == generation, !self.usesAudioDurationForHold else { return } self.holdWorkItem = nil self.perform(self.voiceGesture.holdThresholdReached()) } @@ -329,7 +352,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg DispatchQueue.main.asyncAfter(deadline: .now() + Self.holdThreshold, execute: work) } - private func perform(_ actions: [VoiceGestureAction]) { + private func perform(_ actions: [VoiceGestureAction], remoteAlreadyStopped: Bool = false) { for action in actions { switch action { case .fnDown: @@ -376,7 +399,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg case .closeMicrophone: cancelPendingWork() if streaming { watchdog.requestedStop(at: ProcessInfo.processInfo.systemUptime) } - if let command = session.closeCommand() { write(command) } + if !remoteAlreadyStopped, let command = session.closeCommand() { write(command) } audio.endCapture() } } @@ -433,7 +456,10 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg cancelPendingWork() _ = voiceGesture.reset() } else { - resetVoice() + // The remote has already stopped. Sending MIC_CLOSE here makes + // firmware that acknowledges every close send another AUDIO_STOP, + // recursively generating close/stop traffic even while idle. + resetVoice(closeMicrophone: false) } } diff --git a/Sources/RemoteBuddy/Input/RemoteButtonController.swift b/Sources/RemoteBuddy/Input/RemoteButtonController.swift index 6957acf..1f300c5 100644 --- a/Sources/RemoteBuddy/Input/RemoteButtonController.swift +++ b/Sources/RemoteBuddy/Input/RemoteButtonController.swift @@ -53,6 +53,15 @@ final class RemoteButtonController { func start() { stop() + // The HCI bridge also needs repeat/release timers and error reporting, + // even when macOS refuses access to the native HID device. + actionSender.onError = { [weak self] in self?.onStatus?($0) } + let timer = Timer(timeInterval: 0.02, repeats: true) { [weak self] _ in + guard let self else { return } + self.apply(self.state.tick(now: ProcessInfo.processInfo.systemUptime)) + } + self.timer = timer + RunLoop.main.add(timer, forMode: .common) let manager = IOHIDManagerCreate(kCFAllocatorDefault, 0) IOHIDManagerSetDeviceMatching(manager, [kIOHIDVendorIDKey: Self.vendorID, kIOHIDProductIDKey: Self.productID] as CFDictionary) @@ -70,13 +79,6 @@ final class RemoteButtonController { return } self.manager = manager - actionSender.onError = { [weak self] in self?.onStatus?($0) } - let timer = Timer(timeInterval: 0.02, repeats: true) { [weak self] _ in - guard let self else { return } - self.apply(self.state.tick(now: ProcessInfo.processInfo.systemUptime)) - } - self.timer = timer - RunLoop.main.add(timer, forMode: .common) onStatus?(L10n.tr("按键:等待遥控器…")) } diff --git a/Sources/RemoteBuddy/Support/RemoteCompatibility.swift b/Sources/RemoteBuddy/Support/RemoteCompatibility.swift new file mode 100644 index 0000000..dde155e --- /dev/null +++ b/Sources/RemoteBuddy/Support/RemoteCompatibility.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Only opt in to firmware workarounds for an identified, tested device. +/// The name advertised over Bluetooth and the shared VID/PID are insufficient. +enum RemoteCompatibilityProfile: String { + case standard + case jieliHIDMouse001 + + var usesAudioDurationForHold: Bool { self == .jieliHIDMouse001 } +} + +struct RemoteDeviceInformation { + private(set) var manufacturer: String? + private(set) var model: String? + private(set) var firmware: String? + + var compatibilityProfile: RemoteCompatibilityProfile { + if manufacturer == "zhuhai_jieli", model == "hid_mouse", firmware == "0.0.1" { + return .jieliHIDMouse001 + } + return .standard + } + + mutating func update(uuid: String, value: Data) { + let text = String(data: value, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines.union(.controlCharacters)).lowercased() + switch uuid.uppercased() { + case "2A29": manufacturer = text + case "2A24": model = text + case "2A26": firmware = text + default: break + } + } +} diff --git a/Tests/Python/test_hci_helper.py b/Tests/Python/test_hci_helper.py index 2403918..224b101 100644 --- a/Tests/Python/test_hci_helper.py +++ b/Tests/Python/test_hci_helper.py @@ -55,6 +55,35 @@ def test_acl_fragments_reassemble_without_partial_key_output(self): self.assertIsNone(self.decoder.accept(3, first)) self.assertEqual(self.decoder.accept(3, second), [7, 5]) + def test_consumer_remote_preserves_existing_button_mapping_ids(self): + decoder = helper.RemoteReports("AA:BB:CC:DD:EE:FF", 0x2b, "consumer16") + decoder.accept(0xfd, configuration()) + # Up, down, left, right, select, back, home, volume +/-, mute, + # YouTube, Netflix, power, input. Each press is followed by release. + usages = [0x42, 0x43, 0x44, 0x45, 0x41, 0x224, 0x223, + 0xe9, 0xea, 0xe2, 0x77, 0x78, 0x19e, 0x189] + buttons = [3, 4, 5, 6, 7, 11, 10, 12, 13, 8, 14, 15, 1, 17] + for usage, button in zip(usages, buttons): + report = notification(struct.pack("", "<"): diff --git a/Tests/Python/test_installer.py b/Tests/Python/test_installer.py index 4507c99..da3e331 100644 --- a/Tests/Python/test_installer.py +++ b/Tests/Python/test_installer.py @@ -38,6 +38,13 @@ def test_rejects_root_invalid_address_and_out_of_range_handles(self): with self.assertRaises(ValueError): configure.documents(uid, 20, home, address, attribute) + def test_consumer_remote_configuration_and_unknown_format(self): + config, _, _ = configure.documents(501, 20, "/Users/demo", "AA:BB:CC:DD:EE:FF", 0x2b, "consumer16") + self.assertEqual(config["attribute"], 0x2b) + self.assertEqual(config["report_format"], "consumer16") + with self.assertRaises(ValueError): + configure.documents(501, 20, "/Users/demo", "AA:BB:CC:DD:EE:FF", 0x2b, "unknown") + def test_dependency_verification_fails_even_when_later_files_match(self): with tempfile.TemporaryDirectory() as temporary: base = pathlib.Path(temporary) diff --git a/Tests/RemoteBuddyTests/RemoteCompatibilityTests.swift b/Tests/RemoteBuddyTests/RemoteCompatibilityTests.swift new file mode 100644 index 0000000..2b706ca --- /dev/null +++ b/Tests/RemoteBuddyTests/RemoteCompatibilityTests.swift @@ -0,0 +1,68 @@ +import Foundation +import XCTest +@testable import RemoteBuddy + +final class RemoteCompatibilityTests: XCTestCase { + private func information(manufacturer: String?, model: String?, firmware: String?) -> RemoteDeviceInformation { + var result = RemoteDeviceInformation() + for (uuid, value) in [("2A29", manufacturer), ("2A24", model), ("2A26", firmware)] { + if let value { result.update(uuid: uuid, value: Data(value.utf8)) } + } + return result + } + + func testKnownFirmwareOptsIntoDelayedReleaseWorkaround() { + let device = information(manufacturer: "zhuhai_jieli", model: "hid_mouse", firmware: "0.0.1") + XCTAssertEqual(device.compatibilityProfile, .jieliHIDMouse001) + XCTAssertTrue(device.compatibilityProfile.usesAudioDurationForHold) + } + + func testDifferentOrMissingIdentityKeepsStandardTiming() { + let devices = [ + information(manufacturer: "another_vendor", model: "hid_mouse", firmware: "0.0.1"), + information(manufacturer: "zhuhai_jieli", model: "another_model", firmware: "0.0.1"), + information(manufacturer: "zhuhai_jieli", model: "hid_mouse", firmware: "0.0.2"), + information(manufacturer: "zhuhai_jieli", model: "hid_mouse", firmware: "0.0.10"), + information(manufacturer: "zhuhai_jieli", model: "hid_mouse_plus", firmware: "0.0.1"), + information(manufacturer: nil, model: "hid_mouse", firmware: "0.0.1"), + information(manufacturer: "zhuhai_jieli", model: nil, firmware: "0.0.1"), + information(manufacturer: "zhuhai_jieli", model: "hid_mouse", firmware: nil), + information(manufacturer: "", model: "hid_mouse", firmware: "0.0.1"), + information(manufacturer: "Google", model: "ABBEY", firmware: "22.2"), + RemoteDeviceInformation(), + ] + for device in devices { + XCTAssertEqual(device.compatibilityProfile, .standard) + XCTAssertFalse(device.compatibilityProfile.usesAudioDurationForHold) + } + } + + func testIdentityNormalizesCaseAndStringPadding() { + let device = information(manufacturer: " ZHUHAI_JIELI\0", model: "HID_MOUSE\n", firmware: "0.0.1\0") + XCTAssertEqual(device.compatibilityProfile, .jieliHIDMouse001) + } + + func testAllThreeFieldsMustArriveInAnyOrder() { + let fields = [("2A29", "zhuhai_jieli"), ("2A24", "hid_mouse"), ("2A26", "0.0.1")] + for order in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] { + var device = RemoteDeviceInformation() + for (index, field) in order.enumerated() { + device.update(uuid: fields[field].0, value: Data(fields[field].1.utf8)) + XCTAssertEqual(device.compatibilityProfile, index == 2 ? .jieliHIDMouse001 : .standard) + } + } + } + + func testUnrelatedFieldsDoNotSubstituteForFirmwareAndInvalidIdentityDisablesWorkaround() { + var device = information(manufacturer: "zhuhai_jieli", model: "hid_mouse", firmware: nil) + device.update(uuid: "2A28", value: Data("0.0.1".utf8)) + XCTAssertEqual(device.compatibilityProfile, .standard) + device.update(uuid: "2a26", value: Data("0.0.1".utf8)) + XCTAssertEqual(device.compatibilityProfile, .jieliHIDMouse001) + device.update(uuid: "2A29", value: Data([0xff])) + XCTAssertEqual(device.compatibilityProfile, .standard) + device = RemoteDeviceInformation() + device.update(uuid: "2A26", value: Data("0.0.1".utf8)) + XCTAssertEqual(device.compatibilityProfile, .standard) + } +} diff --git a/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift b/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift index ddb9742..f0fbdcf 100644 --- a/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift +++ b/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift @@ -17,6 +17,12 @@ final class VoiceRecoveryTests: XCTestCase { return result } + private func identifyDelayedReleaseRemote(_ ble: BLEController) { + for (uuid, value) in [("2A29", "zhuhai_jieli"), ("2A24", "hid_mouse"), ("2A26", "0.0.1")] { + ble.updateDeviceInformation(uuid: uuid, value: Data(value.utf8)) + } + } + func testPrefixIsNotConsumedBeforeInputMethodStartup() { let ring = SampleRing() ring.beginCapture() @@ -193,6 +199,124 @@ final class VoiceRecoveryTests: XCTestCase { ble.stop() } + func testRemoteStopAcknowledgementDoesNotCreateCloseLoop() { + let audio = AudioOutput() + let keyboard = Keyboard() + var commands: [Data] = [] + let ble = controller(audio, keyboard) { commands.append($0) } + // This firmware acknowledges MIC_CLOSE even if it was already idle. + for _ in 0..<3 { ble.handle(.audioStop(reason: 0)) } + XCTAssertTrue(commands.isEmpty) + ble.handle(.startSearch) + ble.handle(.audioStart(reason: 0, codec: .adpcm16k, streamID: 1)) + commands.removeAll() + ble.handle(.audioStop(reason: 8)) + XCTAssertEqual(keyboard.taps, 2) + XCTAssertTrue(commands.isEmpty) + ble.handle(.audioStop(reason: 0)) + XCTAssertEqual(keyboard.taps, 2) + XCTAssertTrue(commands.isEmpty) + ble.stop() + } + + func testRemoteHoldReleaseDoesNotCloseAnAlreadyStoppedStream() { + let audio = AudioOutput() + let keyboard = Keyboard() + var commands: [Data] = [] + let ble = controller(audio, keyboard) { commands.append($0) } + for streamID: UInt8 in [1, 2] { + ble.handle(.audioStart(reason: 3, codec: .adpcm16k, streamID: streamID)) + let threshold = expectation(description: "hold threshold") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { threshold.fulfill() } + wait(for: [threshold], timeout: 1.5) + ble.receiveAudio(Data([0x71])) + ble.handle(.audioStop(reason: 2)) + ble.checkVoiceHealth(now: ProcessInfo.processInfo.systemUptime + 2.1) + } + XCTAssertEqual(keyboard.holds, [true, false, true, false]) + XCTAssertFalse(commands.contains { $0.first == 0x0d }) + ble.stop() + } + + func testDelayedReleaseTapsStartAndStopToggleWithReusedStreamID() { + for packetCount in [0, 9] { + let audio = AudioOutput() + let keyboard = Keyboard() + var commands: [Data] = [] + let ble = controller(audio, keyboard) { commands.append($0) } + identifyDelayedReleaseRemote(ble) + ble.handle(.audioStart(reason: 3, codec: .adpcm16k, streamID: 0)) + for _ in 0..