diff --git a/ControlBooth/ControlBooth.sdef b/ControlBooth/ControlBooth.sdef
index bcbb2f8..6f8ec89 100644
--- a/ControlBooth/ControlBooth.sdef
+++ b/ControlBooth/ControlBooth.sdef
@@ -63,6 +63,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ControlBooth/ControlBoothApp.swift b/ControlBooth/ControlBoothApp.swift
index 8ae8e0d..7a047ef 100644
--- a/ControlBooth/ControlBoothApp.swift
+++ b/ControlBooth/ControlBoothApp.swift
@@ -12,6 +12,7 @@ struct ControlBoothApp: App {
@State private var airPlaySettingsStore = AirPlaySettingsStore()
@State private var airPlayReceiverService = AirPlayReceiverService()
@State private var radioStation = RadioStation()
+ @State private var usbHubPower = USBHubPowerController()
init() {
LogStore.shared.configure(appName: "ControlBooth")
@@ -27,12 +28,14 @@ struct ControlBoothApp: App {
.environment(airPlaySettingsStore)
.environment(airPlayReceiverService)
.environment(radioStation)
+ .environment(usbHubPower)
.background(CloseButtonHider())
.onAppear {
appDelegate.runner = runner
appDelegate.store = store
appDelegate.airPlayReceiverService = airPlayReceiverService
appDelegate.radioStation = radioStation
+ appDelegate.usbHubPower = usbHubPower
radioStation.configure(runner: runner, airPlay: airPlayReceiverService)
// A copy hosting unit tests must leave the real app's world
// alone: no database seeding, scheduled starts, or AirPlay
@@ -71,6 +74,8 @@ struct ControlBoothApp: App {
}
CommandMenu("Commands") {
StopPipelineMenuButton(store: store, runner: runner)
+ Divider()
+ USBHubPowerMenuButtons(controller: usbHubPower)
}
}
@@ -79,6 +84,12 @@ struct ControlBoothApp: App {
}
.windowResizability(.contentSize)
+ Window("USB Tree", id: "usb-tree") {
+ USBTreeView()
+ .environment(usbHubPower)
+ }
+ .defaultSize(width: 640, height: 480)
+
Window("Logs", id: "logs") {
LogViewerView()
}
@@ -126,6 +137,23 @@ private struct StopPipelineMenuButton: View {
}
}
+/// Commands > USB Tree… and Power Cycle USB Hub…: the second opens the USB
+/// Tree window, which asks to confirm before anything loses power.
+private struct USBHubPowerMenuButtons: View {
+ let controller: USBHubPowerController
+ @Environment(\.openWindow) private var openWindow
+
+ var body: some View {
+ Button("USB Tree…") { openWindow(id: "usb-tree") }
+ .keyboardShortcut("u", modifiers: [.command, .shift])
+ Button("Power Cycle USB Hub…") {
+ controller.confirmationRequested = true
+ openWindow(id: "usb-tree")
+ }
+ .disabled(!controller.canCycle)
+ }
+}
+
private struct RevealRecordingsFolderButton: View {
var body: some View {
Button("Show Recordings Folder in Finder") {
@@ -153,6 +181,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
var store: PipelineStore?
var airPlayReceiverService: AirPlayReceiverService?
var radioStation: RadioStation?
+ var usbHubPower: USBHubPowerController?
override init() {
super.init()
diff --git a/ControlBooth/Services/DsdNeo/DsdNeoScanner.swift b/ControlBooth/Services/DsdNeo/DsdNeoScanner.swift
index 23a1052..d0cd7df 100644
--- a/ControlBooth/Services/DsdNeo/DsdNeoScanner.swift
+++ b/ControlBooth/Services/DsdNeo/DsdNeoScanner.swift
@@ -22,6 +22,13 @@ final class DsdNeoScanner {
case failed(String)
}
+ /// What a hardware recovery (a USB hub power cycle) achieved.
+ enum HardwareRecoveryResult: Sendable, Equatable {
+ /// The RTL-SDR is back, at this USB index.
+ case recovered(index: UInt32)
+ case failed(String)
+ }
+
private(set) var state: State = .idle
/// The pipeline this scanner is feeding, while active.
private(set) var pipelineID: Int64?
@@ -71,6 +78,12 @@ final class DsdNeoScanner {
/// wedged RTL-SDR or a dead antenna, so after a few it gives up.
@ObservationIgnored private var restartsWithoutSignal = 0
static let maxRestartsWithoutSignal = 3
+ /// Power cycles the USB hub the RTL-SDR is on (given its serial) once the
+ /// restarts above have failed, when the user has set that up. It reports
+ /// the dongle's new USB index, which can differ from before.
+ @ObservationIgnored var hardwareRecovery: (@Sendable (String) async -> HardwareRecoveryResult)?
+ /// One power cycle per run: a second wouldn't fix what the first didn't.
+ @ObservationIgnored private var hardwareRecoveryTried = false
@ObservationIgnored private var pendingGrant: (talkgroup: Int, encrypted: Bool)?
@ObservationIgnored private var listNames: [Int: String] = [:]
@ObservationIgnored private var sitesDirty = false
@@ -128,6 +141,7 @@ final class DsdNeoScanner {
lastRestartReason = nil
budget = DsdNeoRestartBudget()
restartsWithoutSignal = 0
+ hardwareRecoveryTried = false
nowPlayingTalkgroup = nil
nowPlayingText = nil
terminalBacklog.removeAll()
@@ -191,6 +205,7 @@ final class DsdNeoScanner {
self.run = run
budget = DsdNeoRestartBudget()
restartsWithoutSignal = 0
+ hardwareRecoveryTried = false
restartCount = 0
lastRestartReason = nil
nowPlayingTalkgroup = nil
@@ -448,9 +463,15 @@ final class DsdNeoScanner {
private func restart(because reason: String) {
guard run != nil else { return }
guard restartsWithoutSignal < Self.maxRestartsWithoutSignal else {
+ if let hardwareRecovery, !hardwareRecoveryTried, let serial = run?.settings.rtlSerial, !serial.isEmpty {
+ hardwareRecoveryTried = true
+ powerCycleHub(serial: serial, recovery: hardwareRecovery, reason: reason)
+ return
+ }
let device = run.map { $0.settings.rtlSerial.isEmpty ? "" : " (\($0.settings.rtlSerial))" } ?? ""
let type = run?.settings.effectiveSystemType ?? .p25Trunked
fail("dsd-neo has had no \(type.protocolName) signal through \(Self.maxRestartsWithoutSignal) restarts. "
+ + (hardwareRecoveryTried ? "Power cycling its USB hub didn't help. " : "")
+ "The RTL-SDR\(device) may have stopped delivering samples: unplug it, plug it back in, "
+ "and start the scanner again. If that doesn't help, check the antenna and the "
+ "\(type.channelTitle.lowercased()) frequency.")
@@ -485,6 +506,46 @@ final class DsdNeoScanner {
}
}
+ /// Stops dsd-neo, power cycles the RTL-SDR's USB hub, and launches dsd-neo
+ /// again on the dongle's new index. Failing here stops the pipeline.
+ private func powerCycleHub(serial: String,
+ recovery: @escaping @Sendable (String) async -> HardwareRecoveryResult,
+ reason: String) {
+ LogStore.shared.log(.warning, source: "DsdNeoScanner",
+ "\(reason); power cycling the USB hub of RTL-SDR \(serial)")
+ lastRestartReason = reason
+ nowPlayingTalkgroup = nil
+ nowPlayingText = nil
+ state = .restarting("power cycling the USB hub")
+ generation += 1
+ let current = generation
+ process?.terminateAndWait()
+ process = nil
+ readEventLog()
+ closeStderrLog()
+ Task { [weak self] in
+ let result = await recovery(serial)
+ guard let self, self.generation == current, self.run != nil,
+ case .restarting = self.state else { return }
+ switch result {
+ case .recovered(let index):
+ LogStore.shared.log(.info, source: "DsdNeoScanner",
+ "RTL-SDR \(serial) is back at USB index \(index); restarting dsd-neo")
+ self.run?.rtlIndex = index
+ self.restartsWithoutSignal = 0
+ self.budget = DsdNeoRestartBudget()
+ do {
+ try self.launch()
+ } catch {
+ self.fail("could not restart dsd-neo: \(error)")
+ }
+ case .failed(let message):
+ self.fail("Power cycling the USB hub failed: \(message) Unplug the RTL-SDR (\(serial)), "
+ + "plug it back in, and start the scanner again.")
+ }
+ }
+ }
+
private func fail(_ message: String) {
LogStore.shared.log(.error, source: "DsdNeoScanner", message)
stop()
diff --git a/ControlBooth/Services/PipelineRunner.swift b/ControlBooth/Services/PipelineRunner.swift
index 973a56d..732c5c5 100644
--- a/ControlBooth/Services/PipelineRunner.swift
+++ b/ControlBooth/Services/PipelineRunner.swift
@@ -452,6 +452,10 @@ final class PipelineRunner {
saved.systemID = id
saved.save()
}
+ // A wedged RTL-SDR only recovers when it loses power; with an
+ // upstream hub chosen for that, the scanner may cycle it before giving up.
+ let hubPower = USBHubPowerSettings.load()
+ dsdNeoScanner.hardwareRecovery = USBHubRecovery.recovery(for: hubPower)
dsdNeoScanner.onFatal = { [weak self] _ in
guard let self, let manager = self.managers[id] else { return }
manager.terminateAndWait()
diff --git a/ControlBooth/Services/ScriptingCommands.swift b/ControlBooth/Services/ScriptingCommands.swift
index b3af969..74782d9 100644
--- a/ControlBooth/Services/ScriptingCommands.swift
+++ b/ControlBooth/Services/ScriptingCommands.swift
@@ -313,3 +313,66 @@ nonisolated final class StopRadioCommand: NSScriptCommand {
}
}
}
+
+
+// MARK: USB hub power
+
+@MainActor
+private func usbHubPower(reportingTo command: NSScriptCommand) -> USBHubPowerController? {
+ guard let controller = AppDelegate.shared?.usbHubPower else {
+ command.scriptErrorNumber = NSInternalScriptError
+ command.scriptErrorString = "ControlBooth is still launching; try again."
+ return nil
+ }
+ return controller
+}
+
+@MainActor
+private func usbHubPowerJSON(_ controller: USBHubPowerController, refreshing: Bool) -> String {
+ if refreshing { controller.refresh() }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = (try? encoder.encode(controller.status(includingTree: refreshing))) ?? Data("{}".utf8)
+ return String(decoding: data, as: UTF8.self)
+}
+
+/// 'usb hub status': the chosen power-cycle port and the last outcome, as JSON.
+@objc(UsbHubStatusCommand)
+nonisolated final class UsbHubStatusCommand: NSScriptCommand {
+ override func performDefaultImplementation() -> Any? {
+ MainActor.assumeIsolated {
+ guard let controller = usbHubPower(reportingTo: self) else { return nil }
+ return usbHubPowerJSON(controller, refreshing: false)
+ }
+ }
+}
+
+/// 'usb tree': the same, with the USB tree read fresh.
+@objc(UsbTreeCommand)
+nonisolated final class UsbTreeCommand: NSScriptCommand {
+ override func performDefaultImplementation() -> Any? {
+ MainActor.assumeIsolated {
+ guard let controller = usbHubPower(reportingTo: self) else { return nil }
+ return usbHubPowerJSON(controller, refreshing: true)
+ }
+ }
+}
+
+/// 'power cycle usb hub': starts cycling the chosen port and returns at once
+/// (the cycle takes several seconds); the outcome is in 'usb hub status'.
+@objc(UsbHubCycleCommand)
+nonisolated final class UsbHubCycleCommand: NSScriptCommand {
+ override func performDefaultImplementation() -> Any? {
+ MainActor.assumeIsolated {
+ guard let controller = usbHubPower(reportingTo: self) else { return nil }
+ controller.refresh()
+ do {
+ try controller.startCycle()
+ } catch {
+ scriptErrorNumber = NSInternalScriptError
+ scriptErrorString = "\(error)"
+ }
+ return nil
+ }
+ }
+}
diff --git a/ControlBooth/Services/USBHubPower.swift b/ControlBooth/Services/USBHubPower.swift
new file mode 100644
index 0000000..44cfae3
--- /dev/null
+++ b/ControlBooth/Services/USBHubPower.swift
@@ -0,0 +1,290 @@
+import Foundation
+import SDRDeviceAccess
+
+/// Which upstream USB hub port to power cycle when an RTL-SDR wedges.
+///
+/// A wedged RTL-SDR ("PLL not locked", no samples) only recovers when it is
+/// unplugged. When the dongles sit on a hub that can't switch its own ports
+/// (`ganged` power), the hub one level up often can: cutting its port cuts the
+/// downstream hub and every dongle on it, like replugging that hub.
+nonisolated struct USBHubPowerSettings: Codable, Equatable {
+ var enabled = false
+ /// uhubctl's hub location, e.g. "2-2.1".
+ var hubLocation = ""
+ /// The hub's USB ID ("045b:0209"). Checked before every cycle so a
+ /// changed USB topology can't make this switch off some other hub.
+ var hubID = ""
+ var port = 0
+ /// The ID of the device on that port ("1a40:0101", the downstream hub),
+ /// checked the same way. Empty skips the check.
+ var portDeviceID = ""
+ /// Seconds the port stays off.
+ var offSeconds = 3
+
+ var isConfigured: Bool { !hubLocation.isEmpty && !hubID.isEmpty && port > 0 }
+
+ static let defaultsKey = "usbHubPower.settings"
+
+ static func load(from defaults: UserDefaults = .standard) -> USBHubPowerSettings {
+ guard let json = defaults.string(forKey: defaultsKey),
+ let data = json.data(using: .utf8),
+ let settings = try? JSONDecoder().decode(USBHubPowerSettings.self, from: data) else {
+ return USBHubPowerSettings()
+ }
+ return settings
+ }
+
+ func save(to defaults: UserDefaults = .standard) {
+ guard let data = try? JSONEncoder().encode(self),
+ let json = String(data: data, encoding: .utf8) else { return }
+ defaults.set(json, forKey: Self.defaultsKey)
+ }
+}
+
+/// A hub as `uhubctl` lists it.
+nonisolated struct USBHub: Equatable, Identifiable {
+ struct Port: Equatable {
+ var number: Int
+ /// The USB ID of what's plugged in, nil when the port is empty or off.
+ var deviceID: String?
+ var deviceName: String?
+ }
+
+ var location: String
+ var id: String { location }
+ /// "045b:0209".
+ var usbID: String
+ var portCount: Int
+ /// uhubctl's `ppps`: each port's power can be switched on its own. A
+ /// `ganged` hub switches all its ports together.
+ var switchesPerPort: Bool
+ var ports: [Port]
+
+ func port(_ number: Int) -> Port? { ports.first { $0.number == number } }
+}
+
+/// Reads `uhubctl`'s listing, e.g.
+///
+/// Current status for hub 2-2.1 [045b:0209, USB 2.10, 4 ports, ppps]
+/// Port 1: 0503 power highspeed enable connect [1a40:0101 USB 2.0 Hub [MTT], USB 2.00, 4 ports, ganged]
+/// Port 3: 0100 power
+nonisolated enum UhubctlParser {
+ static func parse(_ output: String) -> [USBHub] {
+ var hubs: [USBHub] = []
+ for rawLine in output.split(whereSeparator: \.isNewline) {
+ let line = String(rawLine)
+ if let hub = parseHub(line) {
+ hubs.append(hub)
+ } else if let port = parsePort(line), !hubs.isEmpty {
+ hubs[hubs.count - 1].ports.append(port)
+ }
+ }
+ return hubs
+ }
+
+ private static func parseHub(_ line: String) -> USBHub? {
+ guard line.hasPrefix("Current status for hub ") else { return nil }
+ let rest = line.dropFirst("Current status for hub ".count)
+ guard let space = rest.firstIndex(of: " "),
+ let open = rest.firstIndex(of: "["), let close = rest.lastIndex(of: "]") else { return nil }
+ let location = String(rest[..= 9 else { return nil }
+ let usbID = String(first.prefix(9))
+ guard usbID.dropFirst(4).first == ":" else { return nil }
+ let ports = fields.compactMap { field -> Int? in
+ guard field.hasSuffix(" ports") else { return nil }
+ return Int(field.dropLast(" ports".count))
+ }.first ?? 0
+ return USBHub(location: location, usbID: usbID, portCount: ports,
+ switchesPerPort: fields.contains("ppps"), ports: [])
+ }
+
+ private static func parsePort(_ line: String) -> USBHub.Port? {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard trimmed.hasPrefix("Port "), let colon = trimmed.firstIndex(of: ":"),
+ let number = Int(trimmed[trimmed.index(trimmed.startIndex, offsetBy: 5).. [USBHub] {
+ UhubctlParser.parse(try run([]))
+ }
+
+ /// Checks `settings` still names the hub and device it was set up for.
+ /// Returns the command-line arguments that cycle the port.
+ func validatedCycleArguments(for settings: USBHubPowerSettings, hubs: [USBHub]) throws -> [String] {
+ guard settings.isConfigured else { throw USBHubPowerError.notConfigured }
+ guard let hub = hubs.first(where: { $0.location == settings.hubLocation }) else {
+ throw USBHubPowerError.hubNotFound(location: settings.hubLocation)
+ }
+ guard hub.usbID == settings.hubID else {
+ throw USBHubPowerError.hubChanged(location: hub.location, found: hub.usbID, expected: settings.hubID)
+ }
+ guard hub.switchesPerPort else { throw USBHubPowerError.portNotSwitchable(location: hub.location) }
+ if !settings.portDeviceID.isEmpty {
+ let found = hub.port(settings.port)?.deviceID
+ guard found == settings.portDeviceID else {
+ throw USBHubPowerError.portDeviceChanged(port: settings.port, found: found,
+ expected: settings.portDeviceID)
+ }
+ }
+ return ["-l", hub.location, "-p", String(settings.port),
+ "-a", "cycle", "-d", String(max(1, settings.offSeconds))]
+ }
+
+ /// Cuts power to the chosen port and restores it (uhubctl also cycles a
+ /// USB 3 companion port when the hub has one). Blocks for the off time
+ /// plus a moment. Returns uhubctl's report.
+ @discardableResult
+ func cycle(_ settings: USBHubPowerSettings) throws -> String {
+ let arguments = try validatedCycleArguments(for: settings, hubs: try listHubs())
+ return try run(arguments)
+ }
+
+ private func run(_ arguments: [String]) throws -> String {
+ guard let executable else { throw USBHubPowerError.uhubctlMissing }
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = pipe
+ do {
+ try process.run()
+ } catch {
+ throw USBHubPowerError.failed("could not run uhubctl: \(error)")
+ }
+ // Read before waiting, so a full pipe can't block uhubctl.
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ let output = String(decoding: data, as: UTF8.self)
+ guard process.terminationStatus == 0 else {
+ let detail = output.trimmingCharacters(in: .whitespacesAndNewlines)
+ throw USBHubPowerError.failed("uhubctl failed (status \(process.terminationStatus))"
+ + (detail.isEmpty ? "" : ": \(detail)"))
+ }
+ return output
+ }
+}
+
+/// Brings a wedged RTL-SDR back by power cycling the hub port in
+/// `USBHubPowerSettings`, then waiting for the dongle to reappear.
+nonisolated enum USBHubRecovery {
+ /// How long to wait for the dongle to re-enumerate after the power returns.
+ static let reappearTimeout: TimeInterval = 40
+
+ /// The recovery to give the scanner, or nil when power cycling is off or
+ /// no hub port has been chosen.
+ static func recovery(for settings: USBHubPowerSettings)
+ -> (@Sendable (String) async -> DsdNeoScanner.HardwareRecoveryResult)? {
+ guard settings.enabled, settings.isConfigured else { return nil }
+ return { serial in await powerCycleAndWait(forSerial: serial, settings: settings) }
+ }
+
+ static func powerCycleAndWait(forSerial serial: String,
+ settings: USBHubPowerSettings = .load(),
+ cycler: USBHubPowerCycler = USBHubPowerCycler()) async
+ -> DsdNeoScanner.HardwareRecoveryResult {
+ await Task.detached(priority: .userInitiated) {
+ do {
+ try cycler.cycle(settings)
+ } catch {
+ return .failed("\(error)")
+ }
+ // Hubs and dongles take a few seconds to enumerate; the dongle's
+ // USB index can differ from before, so it's found again by serial.
+ let deadline = Date().addingTimeInterval(reappearTimeout)
+ Thread.sleep(forTimeInterval: 3)
+ while Date() < deadline {
+ let report = RTLSDRPreflight.check(device: serial, backend: LibRTLSDRBackend())
+ if report.isAvailable, let index = report.index { return .recovered(index: index) }
+ Thread.sleep(forTimeInterval: 1)
+ }
+ return .failed("The RTL-SDR (\(serial)) did not come back within \(Int(reappearTimeout)) seconds "
+ + "of the hub's power returning.")
+ }.value
+ }
+}
+
+/// What a hub must be able to do for ControlBooth to power cycle it, as shown
+/// in the dsd-neo Scanner's USB Hub Power section and the USB Tree window.
+nonisolated enum USBHubRequirements {
+ static let summary = "Needs a hub that switches each port's power on its own (uhubctl lists it as ppps)."
+
+ static let details = """
+ • The hub whose port is cycled must support per-port power switching (PPPS). uhubctl's listing marks such a hub "ppps"; a hub marked "ganged" can only switch all its ports together, and ControlBooth won't use it.
+ • The RTL-SDRs' own hub can be any hub, even a ganged one. What counts is the hub above it, whose port feeds it: that port is switched off and on, which drops the hub below and everything on it.
+ • Hubs that claim PPPS don't always cut the power: some only disconnect the data lines, so a wedged RTL-SDR stays wedged. Try "Power Cycle Now…" and watch the dongles disappear and come back (their LEDs go dark if power was cut). uhubctl's project page lists hubs known to work.
+ • A hub with its own power supply is far more likely to work than a bus-powered one. A Mac's built-in ports and most unpowered travel hubs don't switch power.
+ • USB 3 hubs have a USB 2 and a USB 3 side; uhubctl cycles both.
+ • uhubctl must be installed (MacPorts: sudo port install uhubctl; Homebrew: brew install uhubctl). It worked here without sudo; if cycling fails for lack of permission, uhubctl needs to run as root.
+ • Everything below the chosen port loses power for a few seconds, including every RTL-SDR on it.
+ """
+}
diff --git a/ControlBooth/Services/USBHubPowerController.swift b/ControlBooth/Services/USBHubPowerController.swift
new file mode 100644
index 0000000..74b2a1b
--- /dev/null
+++ b/ControlBooth/Services/USBHubPowerController.swift
@@ -0,0 +1,140 @@
+import Foundation
+import SharedLogging
+import Observation
+
+/// What `usb hub status` tells AntennaHead (JSON).
+nonisolated struct USBHubPowerStatus: Codable, Equatable {
+ var uhubctlInstalled: Bool
+ /// Automatic recovery is on in the dsd-neo Scanner's settings.
+ var enabled: Bool
+ /// A hub port is chosen for power cycling.
+ var configured: Bool
+ /// "Hub 2-2.1, port 1: USB 2.0 Hub [MTT]", empty when not configured.
+ var target: String
+ /// RTL-SDR serials that lose power with that port.
+ var affectedRTLSDRs: [String]
+ var cycling: Bool
+ /// The outcome of the last power cycle, empty before any.
+ var message: String
+ var messageIsError: Bool
+ /// Included when asked for.
+ var tree: [USBTreeRow]?
+}
+
+/// The USB tree, the chosen hub port and power cycling it: shared by the USB
+/// Tree window, the Commands menu, the dsd-neo Scanner's settings and the
+/// AppleEvents AntennaHead's web page uses.
+@MainActor
+@Observable
+final class USBHubPowerController {
+ var settings: USBHubPowerSettings {
+ didSet { if settings != oldValue { settings.save() } }
+ }
+ private(set) var rows: [USBTreeRow] = []
+ private(set) var hubs: [USBHub] = []
+ private(set) var isCycling = false
+ private(set) var message: String?
+ private(set) var messageIsError = false
+ private(set) var listingError: String?
+ /// Set by Commands > Power Cycle USB Hub…; the USB Tree window asks the
+ /// user to confirm and clears it.
+ var confirmationRequested = false
+
+ @ObservationIgnored let cycler: USBHubPowerCycler
+
+ init(settings: USBHubPowerSettings = .load(), cycler: USBHubPowerCycler = USBHubPowerCycler()) {
+ self.settings = settings
+ self.cycler = cycler
+ }
+
+ var uhubctlInstalled: Bool { cycler.isInstalled }
+
+ /// The tree row for the chosen port, when it's in the tree.
+ var selectedRow: USBTreeRow? {
+ guard settings.isConfigured else { return nil }
+ return rows.first { $0.parentPath == settings.hubLocation && $0.port == settings.port }
+ }
+
+ /// RTL-SDRs that lose power with the chosen port.
+ var affectedRTLSDRs: [USBTreeRow] {
+ selectedRow.map { USBTree.rtlsdrs(affectedBy: $0, in: rows) } ?? []
+ }
+
+ /// Everything that loses power with the chosen port.
+ var affectedDevices: [USBTreeRow] {
+ selectedRow.map { USBTree.devices(affectedBy: $0, in: rows) } ?? []
+ }
+
+ var targetDescription: String {
+ guard settings.isConfigured else { return "" }
+ let name = selectedRow?.name ?? settings.portDeviceID
+ return "Hub \(settings.hubLocation), port \(settings.port): \(name)"
+ }
+
+ var canCycle: Bool { settings.isConfigured && cycler.isInstalled && !isCycling }
+
+ /// Reads the tree and uhubctl's hub list again.
+ func refresh() {
+ let cycler = self.cycler
+ do {
+ let hubs = cycler.isInstalled ? try cycler.listHubs() : []
+ let rows = try USBTree.readRows(hubs: hubs)
+ self.hubs = hubs
+ self.rows = rows
+ listingError = nil
+ } catch {
+ listingError = "\(error)"
+ }
+ }
+
+ /// Chooses `row`, a device on a switchable hub, as what to power cycle.
+ func select(_ row: USBTreeRow) {
+ guard row.selectable, let parent = row.parentPath, let parentID = row.parentUSBID, let port = row.port else {
+ return
+ }
+ settings.hubLocation = parent
+ settings.hubID = parentID
+ settings.port = port
+ settings.portDeviceID = row.usbID
+ }
+
+ /// Starts a power cycle of the chosen port in the background; throws when
+ /// it can't be started. The outcome lands in `message`. `completion` runs
+ /// on the main actor once it finishes.
+ func startCycle(completion: (@MainActor (Bool) -> Void)? = nil) throws {
+ guard settings.isConfigured else { throw USBHubPowerError.notConfigured }
+ guard cycler.isInstalled else { throw USBHubPowerError.uhubctlMissing }
+ guard !isCycling else { throw USBHubPowerError.failed("A power cycle is already running.") }
+ isCycling = true
+ message = nil
+ let settings = self.settings, cycler = self.cycler
+ let target = targetDescription
+ LogStore.shared.log(.warning, source: "USBHubPower", "power cycling \(target)")
+ Task {
+ let result = await Task.detached { Result { try cycler.cycle(settings) } }.value
+ isCycling = false
+ switch result {
+ case .success:
+ message = "Power cycled \(target)."
+ messageIsError = false
+ case .failure(let error):
+ message = "\(error)"
+ messageIsError = true
+ LogStore.shared.log(.error, source: "USBHubPower", "power cycle failed: \(error)")
+ }
+ // The devices come back over the next few seconds.
+ try? await Task.sleep(for: .seconds(5))
+ refresh()
+ completion?(!messageIsError)
+ }
+ }
+
+ func status(includingTree: Bool) -> USBHubPowerStatus {
+ USBHubPowerStatus(
+ uhubctlInstalled: uhubctlInstalled, enabled: settings.enabled, configured: settings.isConfigured,
+ target: targetDescription,
+ affectedRTLSDRs: affectedRTLSDRs.map { $0.serial.isEmpty ? $0.name : $0.serial },
+ cycling: isCycling, message: message ?? "", messageIsError: messageIsError,
+ tree: includingTree ? rows : nil)
+ }
+}
diff --git a/ControlBooth/Services/USBTree.swift b/ControlBooth/Services/USBTree.swift
new file mode 100644
index 0000000..36f4c9f
--- /dev/null
+++ b/ControlBooth/Services/USBTree.swift
@@ -0,0 +1,118 @@
+import Foundation
+
+/// One USB device in the tree `ioreg` reports, in the order it is drawn.
+nonisolated struct USBTreeRow: Identifiable, Equatable, Codable {
+ var id: String { path }
+ /// The device's place in uhubctl's notation: bus, then the port numbers
+ /// from the root hub down, e.g. "2-2.1.1" is port 1 of hub "2-2.1".
+ var path: String
+ /// How many hubs deep this device is.
+ var depth: Int
+ var name: String
+ /// "vendor:product" in lower-case hex, e.g. "0bda:2838".
+ var usbID: String
+ /// The USB serial number; for an RTL-SDR the EEPROM serial.
+ var serial: String
+ var isHub: Bool
+ var isRTLSDR: Bool
+ /// The hub this device is plugged into and its port there; nil for a root hub.
+ var parentPath: String?
+ var parentUSBID: String?
+ var port: Int?
+ /// True when the hub it is plugged into can switch this port's power on
+ /// its own (uhubctl's `ppps`), so this is something that can be power cycled.
+ var selectable: Bool
+}
+
+/// Builds the USB tree from the IORegistry (`ioreg -a -p IOUSB -l`), marking
+/// which ports `uhubctl` can switch.
+nonisolated enum USBTree {
+ /// Reads the live tree. `hubs` is uhubctl's listing, empty when it isn't installed.
+ static func readRows(hubs: [USBHub]) throws -> [USBTreeRow] {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/sbin/ioreg")
+ process.arguments = ["-a", "-p", "IOUSB", "-l"]
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = FileHandle.nullDevice
+ do {
+ try process.run()
+ } catch {
+ throw USBHubPowerError.failed("could not run ioreg: \(error)")
+ }
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ throw USBHubPowerError.failed("ioreg failed (status \(process.terminationStatus))")
+ }
+ return rows(fromIORegPlist: data, hubs: hubs)
+ }
+
+ /// `data` is the property list `ioreg -a` writes.
+ static func rows(fromIORegPlist data: Data, hubs: [USBHub]) -> [USBTreeRow] {
+ guard let root = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else {
+ return []
+ }
+ var rows: [USBTreeRow] = []
+ walk(root, depth: 0, parent: nil, hubs: hubs, into: &rows)
+ return rows
+ }
+
+ private struct Parent {
+ var path: String
+ var usbID: String
+ var switchesPorts: Bool
+ }
+
+ private static func walk(_ node: [String: Any], depth: Int, parent: Parent?, hubs: [USBHub],
+ into rows: inout [USBTreeRow]) {
+ let children = node["IORegistryEntryChildren"] as? [[String: Any]] ?? []
+ // Host controllers and other registry entries aren't devices (they
+ // have no vendor ID); their children are.
+ guard let vendor = node["idVendor"] as? Int, let product = node["idProduct"] as? Int,
+ let location = node["locationID"] as? Int else {
+ for child in children { walk(child, depth: depth, parent: parent, hubs: hubs, into: &rows) }
+ return
+ }
+ let usbID = String(format: "%04x:%04x", vendor, product)
+ let path = uhubctlPath(forLocationID: UInt32(truncatingIfNeeded: location))
+ let isHub = (node["bDeviceClass"] as? Int) == 9
+ let name = (node["USB Product Name"] as? String) ?? (node["kUSBProductString"] as? String)
+ ?? (isHub ? "USB Hub" : "USB Device")
+ let isRTL = vendor == 0x0bda && [0x2832, 0x2838].contains(product) || name.hasPrefix("RTL28")
+ rows.append(USBTreeRow(
+ path: path, depth: depth, name: name, usbID: usbID,
+ serial: (node["USB Serial Number"] as? String) ?? "",
+ isHub: isHub, isRTLSDR: isRTL,
+ parentPath: parent?.path, parentUSBID: parent?.usbID,
+ port: parent == nil ? nil : Int(path.split(separator: ".").last ?? ""),
+ selectable: parent?.switchesPorts ?? false))
+ let hub = hubs.first { $0.location == path && $0.usbID == usbID }
+ let me = Parent(path: path, usbID: usbID, switchesPorts: hub?.switchesPerPort ?? false)
+ for child in children { walk(child, depth: depth + 1, parent: me, hubs: hubs, into: &rows) }
+ }
+
+ /// macOS packs a device's route into its location ID: the bus in the top
+ /// byte, then one nibble per hub port ("0x02211442" is bus 2, ports
+ /// 2.1.1.4.4.2). uhubctl writes that "2-2.1.1.4.4.2".
+ static func uhubctlPath(forLocationID location: UInt32) -> String {
+ let bus = location >> 24
+ var ports: [String] = []
+ for shift in stride(from: 20, through: 0, by: -4) {
+ let nibble = (location >> UInt32(shift)) & 0xF
+ if nibble == 0 { break }
+ ports.append(String(nibble))
+ }
+ return ports.isEmpty ? "\(bus)-0" : "\(bus)-\(ports.joined(separator: "."))"
+ }
+
+ /// The RTL-SDRs at or below `row`: what loses power with it.
+ static func rtlsdrs(affectedBy row: USBTreeRow, in rows: [USBTreeRow]) -> [USBTreeRow] {
+ rows.filter { $0.isRTLSDR && ($0.path == row.path || $0.path.hasPrefix(row.path + ".")) }
+ }
+
+ /// Everything at or below `row`.
+ static func devices(affectedBy row: USBTreeRow, in rows: [USBTreeRow]) -> [USBTreeRow] {
+ rows.filter { $0.path == row.path || $0.path.hasPrefix(row.path + ".") }
+ }
+}
diff --git a/ControlBooth/Views/DsdNeoScannerView.swift b/ControlBooth/Views/DsdNeoScannerView.swift
index 126bcc7..3c7821d 100644
--- a/ControlBooth/Views/DsdNeoScannerView.swift
+++ b/ControlBooth/Views/DsdNeoScannerView.swift
@@ -300,6 +300,7 @@ struct DsdNeoScannerView: View {
.font(.caption).foregroundStyle(.secondary)
}
}
+ USBHubPowerSection()
Section {
TextField("Extra dsd-neo Arguments", text: $extraArgumentsText, prompt: Text("e.g. -W"))
TextField("Audio Port", value: $draft.audioPort, format: .number.grouping(.never))
diff --git a/ControlBooth/Views/USBHubPowerSection.swift b/ControlBooth/Views/USBHubPowerSection.swift
new file mode 100644
index 0000000..b5039d5
--- /dev/null
+++ b/ControlBooth/Views/USBHubPowerSection.swift
@@ -0,0 +1,47 @@
+import SwiftUI
+
+/// Form section for automatic power cycling of an upstream USB hub when an
+/// RTL-SDR wedges. The port is chosen in the USB Tree window; the choice
+/// belongs to the hardware, not to a scanner configuration, so it saves as it
+/// changes.
+struct USBHubPowerSection: View {
+ @Environment(USBHubPowerController.self) private var controller
+ @Environment(\.openWindow) private var openWindow
+
+ var body: some View {
+ @Bindable var controller = controller
+ Section {
+ Toggle("Power cycle a USB hub if the RTL-SDR wedges", isOn: $controller.settings.enabled)
+ if controller.settings.enabled {
+ LabeledContent("Hub Port") {
+ Text(controller.settings.isConfigured ? controller.targetDescription : "None chosen")
+ .foregroundStyle(controller.settings.isConfigured ? .primary : .secondary)
+ }
+ HStack {
+ Spacer()
+ Button("USB Tree…") { openWindow(id: "usb-tree") }
+ .controlSize(.small)
+ }
+ DisclosureGroup("Hub requirements") {
+ Text(USBHubRequirements.details)
+ .font(.caption).foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ if !controller.uhubctlInstalled {
+ Label(String(describing: USBHubPowerError.uhubctlMissing), systemImage: "exclamationmark.triangle.fill")
+ .font(.caption).foregroundStyle(.orange)
+ }
+ }
+ } header: {
+ Text("USB Hub Power")
+ } footer: {
+ Text("A wedged RTL-SDR only recovers when it loses power. If your RTL-SDRs are on a hub that can't "
+ + "switch its own ports, choose (in the USB Tree) the port of the hub above it that the "
+ + "RTL-SDR hub is plugged into. When the scanner gets no signal after restarting, that port "
+ + "is switched off and on, which drops everything on the hub below for a few seconds. "
+ + "Applies the next time the scanner starts.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+}
diff --git a/ControlBooth/Views/USBTreeView.swift b/ControlBooth/Views/USBTreeView.swift
new file mode 100644
index 0000000..f2066b7
--- /dev/null
+++ b/ControlBooth/Views/USBTreeView.swift
@@ -0,0 +1,126 @@
+import SwiftUI
+
+/// The "USB Tree" window: every USB device with its hubs indented beneath
+/// each other, the RTL-SDRs among them. Ports of hubs that can switch their
+/// power are selectable (the choice is what gets power cycled when an
+/// RTL-SDR wedges); everything else is dimmed.
+struct USBTreeView: View {
+ @Environment(USBHubPowerController.self) private var controller
+ @State private var confirming = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if controller.rows.isEmpty {
+ ContentUnavailableView("No USB Devices", systemImage: "cable.connector",
+ description: Text(controller.listingError ?? "Click Refresh to read the USB tree."))
+ .frame(maxHeight: .infinity)
+ } else {
+ List {
+ ForEach(controller.rows) { row in
+ rowView(row)
+ }
+ }
+ }
+ Divider()
+ footer.padding(12)
+ }
+ .frame(minWidth: 560, minHeight: 420)
+ .onAppear {
+ controller.refresh()
+ if controller.confirmationRequested { present() }
+ }
+ .onChange(of: controller.confirmationRequested) { _, requested in
+ if requested { present() }
+ }
+ .confirmationDialog("Power cycle \(controller.targetDescription)?", isPresented: $confirming,
+ titleVisibility: .visible) {
+ Button("Power Cycle", role: .destructive) { cycle() }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text(confirmationText)
+ }
+ }
+
+ private func present() {
+ controller.confirmationRequested = false
+ if controller.canCycle { confirming = true }
+ }
+
+ private var confirmationText: String {
+ let devices = controller.affectedDevices
+ let radios = controller.affectedRTLSDRs.map { $0.serial.isEmpty ? $0.name : $0.serial }
+ var text = "\(devices.count) device(s) lose power for \(controller.settings.offSeconds) seconds"
+ if !radios.isEmpty { text += ", including RTL-SDR \(radios.joined(separator: ", "))" }
+ return text + ". Anything using them stops receiving."
+ }
+
+ private func cycle() {
+ do {
+ try controller.startCycle()
+ } catch {
+ let alert = NSAlert()
+ alert.messageText = "Couldn't power cycle"
+ alert.informativeText = "\(error)"
+ alert.runModal()
+ }
+ }
+
+ @ViewBuilder
+ private func rowView(_ row: USBTreeRow) -> some View {
+ let isChosen = controller.settings.isConfigured
+ && row.parentPath == controller.settings.hubLocation && row.port == controller.settings.port
+ HStack(spacing: 6) {
+ Image(systemName: isChosen ? "largecircle.fill.circle" : (row.selectable ? "circle" : "minus.circle"))
+ .foregroundStyle(isChosen ? Color.accentColor : .secondary)
+ .frame(width: 18)
+ Image(systemName: row.isRTLSDR ? "antenna.radiowaves.left.and.right"
+ : (row.isHub ? "square.grid.2x2" : "cable.connector"))
+ .frame(width: 18)
+ Text(row.name).fontWeight(row.isHub ? .semibold : .regular)
+ if !row.serial.isEmpty {
+ Text(row.serial).font(.caption.monospaced()).foregroundStyle(.secondary)
+ }
+ Spacer()
+ if let port = row.port, let parent = row.parentPath {
+ Text("\(parent) port \(port)").font(.caption).foregroundStyle(.secondary)
+ }
+ Text(row.usbID).font(.caption.monospaced()).foregroundStyle(.tertiary)
+ }
+ .padding(.leading, CGFloat(row.depth) * 18)
+ .opacity(row.selectable ? 1 : 0.45)
+ .contentShape(Rectangle())
+ .onTapGesture { controller.select(row) }
+ .help(row.selectable
+ ? "Choose this port to power cycle"
+ : "Its hub can't switch this port's power. Choose the hub above it that can.")
+ }
+
+ private var footer: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if controller.uhubctlInstalled, !controller.rows.isEmpty, !controller.rows.contains(where: \.selectable) {
+ Label("No hub here can switch its ports' power individually. " + USBHubRequirements.summary,
+ systemImage: "info.circle")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ if !controller.uhubctlInstalled {
+ Label(String(describing: USBHubPowerError.uhubctlMissing), systemImage: "exclamationmark.triangle.fill")
+ .font(.caption).foregroundStyle(.orange)
+ }
+ HStack {
+ if controller.settings.isConfigured {
+ Text("Power cycle: \(controller.targetDescription)").font(.callout)
+ } else {
+ Text("Select a port of a hub that can switch power.").font(.callout).foregroundStyle(.secondary)
+ }
+ Spacer()
+ if controller.isCycling { ProgressView().controlSize(.small) }
+ Button("Refresh") { controller.refresh() }
+ Button("Power Cycle Now…") { confirming = true }
+ .disabled(!controller.canCycle)
+ }
+ if let message = controller.message {
+ Text(message).font(.caption).foregroundStyle(controller.messageIsError ? .red : .secondary)
+ }
+ }
+ }
+}
diff --git a/ControlBoothTests/USBHubPowerTests.swift b/ControlBoothTests/USBHubPowerTests.swift
new file mode 100644
index 0000000..470a303
--- /dev/null
+++ b/ControlBoothTests/USBHubPowerTests.swift
@@ -0,0 +1,77 @@
+import Testing
+import Foundation
+@testable import ControlBooth
+
+/// The USB hub power cycle: reading uhubctl's listing and refusing to cycle a
+/// hub or port that isn't the one the user chose. The listing is verbatim
+/// from uhubctl on macOS.
+struct USBHubPowerTests {
+ static let listing = """
+ Current status for hub 2-2.1 [045b:0209, USB 2.10, 4 ports, ppps]
+ Port 1: 0503 power highspeed enable connect [1a40:0101 USB 2.0 Hub [MTT], USB 2.00, 4 ports, ganged]
+ Port 2: 0103 power enable connect [077d:07af Griffin Technology, Inc iMic USB audio system]
+ Port 3: 0100 power
+ Current status for hub 2-5 [1a40:0101 USB 2.0 Hub [MTT], USB 2.00, 4 ports, ganged]
+ Port 1: 0503 power highspeed enable connect [0bda:2838 RTL2838UHIDIR]
+ """
+
+ @Test func parsesHubsAndPorts() throws {
+ let hubs = UhubctlParser.parse(Self.listing)
+ #expect(hubs.count == 2)
+ let hub = try #require(hubs.first)
+ #expect(hub.location == "2-2.1")
+ #expect(hub.usbID == "045b:0209")
+ #expect(hub.portCount == 4)
+ #expect(hub.switchesPerPort)
+ #expect(hub.ports.count == 3)
+ #expect(hub.port(1)?.deviceID == "1a40:0101")
+ #expect(hub.port(1)?.deviceName == "USB 2.0 Hub [MTT]")
+ #expect(hub.port(2)?.deviceName == "Griffin Technology, Inc iMic USB audio system")
+ #expect(hub.port(3)?.deviceID == nil)
+ #expect(hubs[1].switchesPerPort == false)
+ }
+
+ @Test func cycleArgumentsForTheChosenPort() throws {
+ let settings = USBHubPowerSettings(enabled: true, hubLocation: "2-2.1", hubID: "045b:0209",
+ port: 1, portDeviceID: "1a40:0101", offSeconds: 5)
+ let arguments = try USBHubPowerCycler(executable: nil)
+ .validatedCycleArguments(for: settings, hubs: UhubctlParser.parse(Self.listing))
+ #expect(arguments == ["-l", "2-2.1", "-p", "1", "-a", "cycle", "-d", "5"])
+ }
+
+ @Test func refusesWhenTheTopologyChanged() {
+ let hubs = UhubctlParser.parse(Self.listing)
+ let cycler = USBHubPowerCycler(executable: nil)
+ var settings = USBHubPowerSettings(enabled: true, hubLocation: "2-2.1", hubID: "045b:0209",
+ port: 1, portDeviceID: "1a40:0101")
+
+ settings.hubLocation = "9-9"
+ #expect(throws: USBHubPowerError.self) { try cycler.validatedCycleArguments(for: settings, hubs: hubs) }
+
+ settings.hubLocation = "2-2.1"
+ settings.hubID = "dead:beef" // another hub moved into that slot
+ #expect(throws: USBHubPowerError.self) { try cycler.validatedCycleArguments(for: settings, hubs: hubs) }
+
+ settings.hubID = "045b:0209"
+ settings.port = 2 // a different device is on the port
+ #expect(throws: USBHubPowerError.self) { try cycler.validatedCycleArguments(for: settings, hubs: hubs) }
+
+ settings.port = 1
+ #expect(throws: USBHubPowerError.self) { // a ganged hub can't switch one port
+ try cycler.validatedCycleArguments(
+ for: USBHubPowerSettings(enabled: true, hubLocation: "2-5", hubID: "1a40:0101", port: 1),
+ hubs: hubs)
+ }
+ }
+
+ @Test func settingsRoundTrip() throws {
+ let defaults = try #require(UserDefaults(suiteName: "USBHubPowerTests-\(UUID().uuidString)"))
+ #expect(USBHubPowerSettings.load(from: defaults) == USBHubPowerSettings())
+ let settings = USBHubPowerSettings(enabled: true, hubLocation: "2-2.1", hubID: "045b:0209",
+ port: 1, portDeviceID: "1a40:0101", offSeconds: 4)
+ settings.save(to: defaults)
+ #expect(USBHubPowerSettings.load(from: defaults) == settings)
+ #expect(settings.isConfigured)
+ #expect(!USBHubPowerSettings().isConfigured)
+ }
+}
diff --git a/ControlBoothTests/USBTreeTests.swift b/ControlBoothTests/USBTreeTests.swift
new file mode 100644
index 0000000..99ac311
--- /dev/null
+++ b/ControlBoothTests/USBTreeTests.swift
@@ -0,0 +1,85 @@
+import Testing
+import Foundation
+@testable import ControlBooth
+
+/// The USB tree built from the IORegistry (`ioreg -a`): locations in
+/// uhubctl's notation, hubs' depth, which ports can be power cycled, and
+/// which RTL-SDRs lose power with a port.
+struct USBTreeTests {
+ private func device(_ name: String, vendor: Int, product: Int, location: Int, deviceClass: Int = 0,
+ serial: String? = nil, children: [[String: Any]] = []) -> [String: Any] {
+ var node: [String: Any] = ["IORegistryEntryName": name, "USB Product Name": name, "idVendor": vendor,
+ "idProduct": product, "locationID": location, "bDeviceClass": deviceClass,
+ "IORegistryEntryChildren": children]
+ if let serial { node["USB Serial Number"] = serial }
+ return node
+ }
+
+ /// A trimmed copy of this Mac's tree: a ppps hub (2-2.1) with a mouse and
+ /// a ganged hub holding a cascaded hub with two RTL-SDRs.
+ private var plist: Data {
+ let rtl1 = device("RTL2838UHIDIR", vendor: 0x0bda, product: 0x2838, location: 0x2211442, serial: "00000180")
+ let rtl2 = device("RTL2838UHIDIR", vendor: 0x0bda, product: 0x2838, location: 0x2211443, serial: "00000360")
+ let cascade = device("USB2.1 Hub", vendor: 0x05e3, product: 0x0610, location: 0x2211440,
+ deviceClass: 9, children: [rtl1, rtl2])
+ let mtt2 = device("USB 2.0 Hub [MTT]", vendor: 0x1a40, product: 0x0101, location: 0x2211400,
+ deviceClass: 9, children: [cascade])
+ let mtt = device("USB 2.0 Hub [MTT]", vendor: 0x1a40, product: 0x0101, location: 0x2211000,
+ deviceClass: 9, children: [mtt2])
+ let mouse = device("USB Optical Mouse", vendor: 0x093a, product: 0x2510, location: 0x2213000)
+ let hub = device("Hub", vendor: 0x045b, product: 0x0209, location: 0x2210000,
+ deviceClass: 9, children: [mouse, mtt])
+ let rootHub = device("Hub", vendor: 0x045b, product: 0x0209, location: 0x2200000,
+ deviceClass: 9, children: [hub])
+ let controller: [String: Any] = ["IORegistryEntryName": "AppleEmbeddedUSBXHCIFL1100",
+ "locationID": 0x2000000, "IORegistryEntryChildren": [rootHub]]
+ let root: [String: Any] = ["IORegistryEntryName": "Root", "IORegistryEntryChildren": [controller]]
+ return try! PropertyListSerialization.data(fromPropertyList: root, format: .xml, options: 0)
+ }
+
+ private var hubs: [USBHub] {
+ UhubctlParser.parse("""
+ Current status for hub 2-2.1 [045b:0209, USB 2.10, 4 ports, ppps]
+ Port 1: 0503 power highspeed enable connect [1a40:0101 USB 2.0 Hub [MTT], USB 2.00, 4 ports, ganged]
+ """)
+ }
+
+ @Test func locationIDsBecomeUhubctlPaths() {
+ #expect(USBTree.uhubctlPath(forLocationID: 0x02211442) == "2-2.1.1.4.4.2")
+ #expect(USBTree.uhubctlPath(forLocationID: 0x02210000) == "2-2.1")
+ #expect(USBTree.uhubctlPath(forLocationID: 0x02200000) == "2-2")
+ }
+
+ @Test func treeKeepsHierarchyAndDepth() {
+ let rows = USBTree.rows(fromIORegPlist: plist, hubs: hubs)
+ #expect(rows.map(\.path) == ["2-2", "2-2.1", "2-2.1.3", "2-2.1.1", "2-2.1.1.4", "2-2.1.1.4.4",
+ "2-2.1.1.4.4.2", "2-2.1.1.4.4.3"])
+ #expect(rows.map(\.depth) == [0, 1, 2, 2, 3, 4, 5, 5])
+ #expect(rows[0].parentPath == nil)
+ }
+
+ @Test func onlyPortsOfAPerPortSwitchableHubAreSelectable() throws {
+ let rows = USBTree.rows(fromIORegPlist: plist, hubs: hubs)
+ let selectable = rows.filter(\.selectable).map(\.path)
+ // Children of 2-2.1 (ppps): the mouse and the first MTT hub.
+ #expect(selectable == ["2-2.1.3", "2-2.1.1"])
+ let mtt = try #require(rows.first { $0.path == "2-2.1.1" })
+ #expect(mtt.parentPath == "2-2.1" && mtt.port == 1 && mtt.parentUSBID == "045b:0209")
+ #expect(mtt.usbID == "1a40:0101")
+ // Without uhubctl nothing can be switched.
+ #expect(USBTree.rows(fromIORegPlist: plist, hubs: []).allSatisfy { !$0.selectable })
+ }
+
+ @Test func rtlsdrsBelowAPortLosePowerWithIt() throws {
+ let rows = USBTree.rows(fromIORegPlist: plist, hubs: hubs)
+ let mtt = try #require(rows.first { $0.path == "2-2.1.1" })
+ let radios = USBTree.rtlsdrs(affectedBy: mtt, in: rows)
+ #expect(radios.map(\.serial) == ["00000180", "00000360"])
+ #expect(radios.allSatisfy { $0.isRTLSDR })
+ let mouse = try #require(rows.first { $0.path == "2-2.1.3" })
+ #expect(USBTree.rtlsdrs(affectedBy: mouse, in: rows).isEmpty)
+ #expect(USBTree.devices(affectedBy: mtt, in: rows).count == 5)
+ // A path that merely starts with the same digits isn't below it.
+ #expect(!USBTree.devices(affectedBy: mtt, in: rows).contains { $0.path == "2-2.1.3" })
+ }
+}
diff --git a/SETUP.md b/SETUP.md
index 051409b..8b11411 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -101,6 +101,33 @@ shairport-sync are vendored per-app rather than shared through SPM — none of
these native, OS-library-dependent tools can cleanly go through a package
target.
+## USB hub power cycling (wedged RTL-SDRs)
+
+A wedged RTL-SDR ("PLL not locked", no samples) only recovers when it loses power. ControlBooth can switch a
+USB hub port off and on with [uhubctl](https://github.com/mvp/uhubctl): choose the port in **Commands > USB Tree…**
+(⇧⌘U), turn on "Power cycle a USB hub if the RTL-SDR wedges" in the dsd-neo Scanner's settings for automatic
+recovery, or use **Commands > Power Cycle USB Hub…** and AntennaHead's Now Playing page by hand.
+
+Requirements:
+
+- **uhubctl** installed (`sudo port install uhubctl` or `brew install uhubctl`). ControlBooth looks in
+ `/opt/local`, `/opt/homebrew` and `/usr/local`. It is not bundled. It worked without `sudo` on macOS; if cycling
+ fails for lack of permission, uhubctl has to run as root.
+- A hub that supports **per-port power switching (PPPS)**. `uhubctl` marks such a hub `ppps` in its listing
+ (`Current status for hub 2-2.1 [045b:0209, USB 2.10, 4 ports, ppps]`); `ganged` means all ports switch together
+ and ControlBooth won't offer it.
+- The RTL-SDRs themselves can be on any hub, including a ganged one. The switchable hub is the one *above* it:
+ choose the port that the RTL-SDRs' hub is plugged into. Everything below that port loses power.
+- The hub must really cut power. Some hubs advertise PPPS but only drop the data lines, which doesn't un-wedge a
+ dongle. Test with "Power Cycle Now…" and watch the dongles go away and return (their LEDs go dark if power was cut).
+ uhubctl's project page lists hubs known to work.
+- Self-powered hubs are much more likely to work than bus-powered ones. A Mac's built-in ports and most unpowered
+ travel hubs can't switch power.
+- A USB 3 hub has a USB 2 and a USB 3 side; uhubctl cycles both.
+
+ControlBooth checks the hub's location and USB ID, and the device on the port, before every cycle, and refuses
+(asking you to choose again) if the USB layout has changed.
+
## Audio contract
Every path into AntennaHead's LiveAudioServer is 48 kHz / 2-channel S16LE.