From 6d05d63506e73ab63072634c65747a90fd044e21 Mon Sep 17 00:00:00 2001 From: dsward2 Date: Mon, 26 Oct 2026 18:33:42 -0500 Subject: [PATCH] USB hub power: read the USB tree at launch and when a status is requested A status asked for before anything had read the tree named the chosen port by its USB ID ("1a40:0101") and listed no RTL-SDRs, so AntennaHead's Now Playing section and its power cycle confirmation were incomplete until the USB Tree window was opened. Read the tree when ControlBooth starts, and for each `usb hub status` unless it was read within the last 10 seconds, so AntennaHead's polling doesn't launch ioreg and uhubctl every time. Co-Authored-By: Claude Sonnet 5.5 --- ControlBooth/ControlBoothApp.swift | 1 + ControlBooth/Services/ScriptingCommands.swift | 4 +++- .../Services/USBHubPowerController.swift | 15 +++++++++++++++ ControlBoothTests/USBTreeTests.swift | 16 ++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/ControlBooth/ControlBoothApp.swift b/ControlBooth/ControlBoothApp.swift index 7a047ef..cac5380 100644 --- a/ControlBooth/ControlBoothApp.swift +++ b/ControlBooth/ControlBoothApp.swift @@ -42,6 +42,7 @@ struct ControlBoothApp: App { // receiver competing for port 5000. guard !AppDelegate.isHostingUnitTests else { return } store.seedDsdNeoScannerPipelineIfNeeded() + usbHubPower.refresh() scheduler.reschedule(events: eventStore.events, pipelineStore: store, runner: runner) airPlayReceiverService.setSettingsStore(airPlaySettingsStore) airPlayReceiverService.applySettings(airPlaySettingsStore.settings) diff --git a/ControlBooth/Services/ScriptingCommands.swift b/ControlBooth/Services/ScriptingCommands.swift index 74782d9..de38521 100644 --- a/ControlBooth/Services/ScriptingCommands.swift +++ b/ControlBooth/Services/ScriptingCommands.swift @@ -329,7 +329,9 @@ private func usbHubPower(reportingTo command: NSScriptCommand) -> USBHubPowerCon @MainActor private func usbHubPowerJSON(_ controller: USBHubPowerController, refreshing: Bool) -> String { - if refreshing { controller.refresh() } + // A status without the tree still names the chosen port and the RTL-SDRs + // on it, which come from the tree. + if refreshing { controller.refresh() } else { controller.refreshIfStale() } let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] let data = (try? encoder.encode(controller.status(includingTree: refreshing))) ?? Data("{}".utf8) diff --git a/ControlBooth/Services/USBHubPowerController.swift b/ControlBooth/Services/USBHubPowerController.swift index 74b2a1b..66e8600 100644 --- a/ControlBooth/Services/USBHubPowerController.swift +++ b/ControlBooth/Services/USBHubPowerController.swift @@ -73,8 +73,23 @@ final class USBHubPowerController { var canCycle: Bool { settings.isConfigured && cycler.isInstalled && !isCycling } + /// When the tree was last read; nil before the first read. + @ObservationIgnored private var lastRefresh: Date? + + /// How long a read of the tree is good for when a status is asked for. + static let statusMaxAge: TimeInterval = 10 + + /// Reads the tree unless it was read within `maxAge` seconds, so a status + /// poll names the chosen port and its RTL-SDRs without launching ioreg and + /// uhubctl on every request. + func refreshIfStale(maxAge: TimeInterval = USBHubPowerController.statusMaxAge) { + if let lastRefresh, Date().timeIntervalSince(lastRefresh) < maxAge { return } + refresh() + } + /// Reads the tree and uhubctl's hub list again. func refresh() { + lastRefresh = Date() let cycler = self.cycler do { let hubs = cycler.isInstalled ? try cycler.listHubs() : [] diff --git a/ControlBoothTests/USBTreeTests.swift b/ControlBoothTests/USBTreeTests.swift index 99ac311..51f465b 100644 --- a/ControlBoothTests/USBTreeTests.swift +++ b/ControlBoothTests/USBTreeTests.swift @@ -82,4 +82,20 @@ struct USBTreeTests { // 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" }) } + + /// A status asked for before anything read the tree used to name the port by + /// its USB ID and list no RTL-SDRs. + @MainActor @Test func statusReadsTheTreeWhenItHasNone() { + let controller = USBHubPowerController( + settings: USBHubPowerSettings(enabled: true, hubLocation: "2-2.1", hubID: "045b:0209", + port: 1, portDeviceID: "1a40:0101"), + cycler: USBHubPowerCycler(executable: nil)) + #expect(controller.status(includingTree: false).affectedRTLSDRs.isEmpty) + controller.refreshIfStale() + // The tree was read (whatever this machine has); a second ask within the + // age limit doesn't read it again. + let first = controller.rows + controller.refreshIfStale() + #expect(controller.rows == first) + } }