Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions Helpers/hci_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -105,6 +116,14 @@ def accept(self, kind, data):
if len(att) < 3 or att[0] not in (0x1b, 0x1d) or struct.unpack_from("<H", att, 1)[0] != self.attribute:
return None
payload = list(att[3:])
if self.report_format == "consumer16":
if len(payload) != 2:
return None
usage = payload[0] | payload[1] << 8
if usage == 0:
return [0]
button = self.CONSUMER_BUTTONS.get(usage)
return [button] if button is not None else None
return payload if 1 <= len(payload) <= 2 and all(value <= 17 for value in payload) else None


Expand Down Expand Up @@ -157,8 +176,8 @@ def peer_uid(connection):


def session(connection, config):
decoder = RemoteReports(config["address"], config["attribute"], config.get("report_format", "indexed"))
capture = Capture(config["packetlogger"])
decoder = RemoteReports(config["address"], config["attribute"])
selector = selectors.DefaultSelector()
selector.register(connection, selectors.EVENT_READ, "client")
selector.register(capture.fd, selectors.EVENT_READ, "capture")
Expand Down
8 changes: 6 additions & 2 deletions Installer/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ def address(value):
return value.upper()


def documents(uid, gid, home, remote, attribute=0x46):
def documents(uid, gid, home, remote, attribute=0x46, report_format="indexed"):
if uid < 501 or gid < 0 or not str(home).startswith("/"):
raise ValueError("Select a regular macOS user account with an absolute home directory")
if not 1 <= attribute <= 0xFFFF:
raise ValueError("ATT handle must be between 1 and 65535")
if report_format not in ("indexed", "consumer16"):
raise ValueError("Unsupported remote report format")
config = dict(uid=uid, gid=gid, address=address(remote), attribute=attribute,
report_format=report_format,
socket=f"/var/run/{LABEL}.hci.sock", packetlogger=str(SERVICE / "PacketLogger.app/Contents/Resources/packetlogger"))
daemon = dict(Label=LABEL + ".hci", ProgramArguments=[PYTHON, "-I", "-S", "-B", "-u", str(SERVICE / "hci_helper.py")],
RunAtLoad=True, KeepAlive=True, ThrottleInterval=5, ProcessType="Background",
Expand All @@ -42,10 +45,11 @@ def main():
parser.add_argument("--user", required=True)
parser.add_argument("--address", required=True)
parser.add_argument("--attribute", type=lambda value: int(value, 0), default=0x46)
parser.add_argument("--report-format", choices=("indexed", "consumer16"), default="indexed")
parser.add_argument("--output", type=pathlib.Path, required=True)
args = parser.parse_args()
account = pwd.getpwnam(args.user)
config, daemon, agent = documents(account.pw_uid, account.pw_gid, account.pw_dir, args.address, args.attribute)
config, daemon, agent = documents(account.pw_uid, account.pw_gid, account.pw_dir, args.address, args.attribute, args.report_format)
args.output.mkdir(parents=True, exist_ok=True)
(args.output / "hci-config.json").write_text(json.dumps(config, indent=2) + "\n")
(args.output / "daemon.plist").write_bytes(plistlib.dumps(daemon))
Expand Down
3 changes: 2 additions & 1 deletion Installer/install-system.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ source "$RB_ROOT/Installer/common.sh"
rb_user="${2:?target user required}"
rb_address="${3:?remote address required}"
rb_attribute="${4:-0x46}"
rb_format="${5:-indexed}"
[[ "$rb_user" =~ '^[a-zA-Z_][a-zA-Z0-9_.-]*$' ]] || rb_fail 'Invalid account name.'
[[ "$(id -u "$rb_user")" -ge 501 ]] || rb_fail 'Select a normal user account.'
rb_verify_dependencies
Expand Down Expand Up @@ -35,7 +36,7 @@ for item in [p, *p.parents]:
raise SystemExit("Python runtime path must be root-owned and not writable by other users: " + str(item))
'
"$RB_PYTHON" -I -S "$RB_ROOT/Installer/configure.py" --user "$rb_user" --address "$rb_address" \
--attribute "$rb_attribute" --output "$RB_ROOT/generated"
--attribute "$rb_attribute" --report-format "$rb_format" --output "$RB_ROOT/generated"
if [[ ! -d /Library/Audio/Plug-Ins/HAL/BlackHole2ch.driver ]]; then
/usr/sbin/installer -pkg "$RB_ROOT/Dependencies/BlackHole2ch-0.7.1.pkg" -target /
fi
Expand Down
6 changes: 4 additions & 2 deletions Installer/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ rb_verify_dependencies
rb_tool="${1:-}"
rb_address="${2:-}"
rb_attribute="${3:-0x46}"
rb_format="${4:-indexed}"
[[ "$rb_format" == indexed || "$rb_format" == consumer16 ]] || rb_fail 'Unsupported remote report format.'
if [[ -z "$rb_tool" && -d "$RB_ROOT/PrivateDependencies/PacketLogger.app" ]]; then
rb_tool="$RB_ROOT/PrivateDependencies/PacketLogger.app"
print '使用自用包中的原版 PacketLogger / Using the original PacketLogger from this personal package.'
Expand Down Expand Up @@ -47,7 +49,7 @@ print -r -- " RemoteBuddy → $HOME/Applications (login startup)"
print ' BlackHole 2ch + Python 3.13 (install if missing/outdated)'
print ' Python 3.13 framework: root-owned, read-only to other users for the system helper'
print ' A system HCI helper restricted to your user and remote'
print -r -- " Remote: $rb_address; ATT handle: $rb_attribute"
print -r -- " Remote: $rb_address; ATT handle: $rb_attribute; report format: $rb_format"
print '蓝牙兼容描述文件和蓝牙/辅助功能权限需要在系统设置中手动确认。'
print 'The Bluetooth profile and Bluetooth/Accessibility permissions require confirmation in System Settings.'
read -r 'rb_answer?继续安装?/ Continue? [y/N] '
Expand All @@ -58,7 +60,7 @@ for rb_dir in Installer Helpers Dependencies; do /usr/bin/ditto "$RB_ROOT/$rb_di
/usr/bin/ditto "$rb_app" "$rb_stage/RemoteBuddy.app"
/usr/bin/ditto "$rb_tool" "$rb_stage/PacketLogger.app"
# Keep the stage on failure for diagnosis; remove after a successful install.
sudo /bin/zsh "$rb_stage/Installer/install-system.sh" "$rb_stage" "$rb_user" "$rb_address" "$rb_attribute"
sudo /bin/zsh "$rb_stage/Installer/install-system.sh" "$rb_stage" "$rb_user" "$rb_address" "$rb_attribute" "$rb_format"
/bin/zsh "$RB_ROOT/Installer/install-user.sh" "$rb_stage/RemoteBuddy.app" "$rb_stage/generated/agent.plist"
open "$RB_ROOT/Resources/RemoteBuddy-Bluetooth.mobileconfig"
print '在系统设置中安装 RemoteBuddy Bluetooth Compatibility,然后重启 Mac。'
Expand Down
4 changes: 3 additions & 1 deletion Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,7 @@ else
codesign --force --sign - --requirements '=designated => 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"
42 changes: 34 additions & 8 deletions Sources/RemoteBuddy/Bluetooth/BLEController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -103,6 +107,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg
subscribed.removeAll()
requestedCapabilities = false
session = ATVVSession()
remoteDeviceInformation = RemoteDeviceInformation()
peripheral.discoverServices([service, deviceInformation])
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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 }
Expand All @@ -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?) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -319,17 +340,19 @@ 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())
}
holdWorkItem = work
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:
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
16 changes: 9 additions & 7 deletions Sources/RemoteBuddy/Input/RemoteButtonController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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("按键:等待遥控器…"))
}

Expand Down
34 changes: 34 additions & 0 deletions Sources/RemoteBuddy/Support/RemoteCompatibility.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
29 changes: 29 additions & 0 deletions Tests/Python/test_hci_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<H", usage), attribute=0x2b)
self.assertEqual(decoder.accept(3, report), [button])
self.assertEqual(decoder.accept(3, notification([0, 0], attribute=0x2b)), [0])

def test_consumer_reports_still_require_matching_identity_attribute_and_format(self):
decoder = helper.RemoteReports("AA:BB:CC:DD:EE:FF", 0x2b, "consumer16")
self.assertIsNone(decoder.accept(3, notification([0x42, 0], attribute=0x2b)))
decoder.accept(0xfd, configuration())
for packet in (notification([0x42, 0]),
notification([0x42, 0], connection=0x4d, attribute=0x2b),
notification([0x42], attribute=0x2b),
notification([0x42, 0, 0], attribute=0x2b),
notification([0xff, 0xff], attribute=0x2b)):
self.assertIsNone(decoder.accept(3, packet))
# The original format must not mistake a Consumer usage for two keys.
self.decoder.accept(0xfd, configuration())
self.assertIsNone(self.decoder.accept(3, notification([0x42, 0])))
with self.assertRaises(ValueError):
helper.RemoteReports("AA:BB:CC:DD:EE:FF", 0x2b, "unknown")

def test_streaming_frames_across_arbitrary_reads(self):
expected = [(0xfd, configuration()), (3, notification([3])), (3, notification([0]))]
for endian in (">", "<"):
Expand Down
Loading
Loading