Thanks for being a part of WWDC25!

How did we do? We’d love to know your thoughts on this year’s conference. Take the survey here

CoreHID: Enumerate all devices *once* (e.g. in a command-line tool)

I am aware the USB / HID devices can come and go, if you have a long running application that's what you want to monitor.

But for a "one-shot" command-line tool for example, I would like to enumerate the devices present on a system at a certain point in time, interact with a subset of them (and this interaction can fail since the device may have been disconnected in-between enumerating and me creating the HIDDeviceClient), and then exit the application.

It seems that HIDDeviceManager only allows monitoring an Async[Throwing]Stream which provides the initial devices matching the query but then continues to deliver updates, and I have no idea when this initial list is done.

I could sleep for a while and then cancel the stream and see what was received up to then, but that seems like the wrong way to go about this, if I just want to know "which devices are connected", so I can maybe list them in a "usage" or help screen.

Am I missing something?

Answered by DTS Engineer in 838461022

You’re bumping up into a fundamental limitation:

  • In the dynamic world our devices operate in there’s no point where things are “done”.

  • Command-line UIs don’t have a good way to representing this reality to the user.

I commonly see this in networking, but the same sorts of issues crop up when dealing with accessories [1].

So, something has to give here:

  • You could rework your UI to allow for updates (A).

  • You could add your own timeout (B).

  • You could file an enhancement request requesting that the Core HID framework suport this directly (C).

Of course, if you do the C then you have to do either A or B in the interim.

With regards B, it’s probably best to avoid an absolute timeout but instead add a timeout waiting for the list to stabilise. That is, use a short timer and reset it every time a new device arrives. That allows for a shorter timeout while still working well if the list is really long.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] As a Network Person™, I’ve always thought of USB as a badly designed network interface which insists on inventing new solutions to problems that Ethernet solved decades ago (-:

You’re bumping up into a fundamental limitation:

  • In the dynamic world our devices operate in there’s no point where things are “done”.

  • Command-line UIs don’t have a good way to representing this reality to the user.

I commonly see this in networking, but the same sorts of issues crop up when dealing with accessories [1].

So, something has to give here:

  • You could rework your UI to allow for updates (A).

  • You could add your own timeout (B).

  • You could file an enhancement request requesting that the Core HID framework suport this directly (C).

Of course, if you do the C then you have to do either A or B in the interim.

With regards B, it’s probably best to avoid an absolute timeout but instead add a timeout waiting for the list to stabilise. That is, use a short timer and reset it every time a new device arrives. That allows for a shorter timeout while still working well if the list is really long.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] As a Network Person™, I’ve always thought of USB as a badly designed network interface which insists on inventing new solutions to problems that Ethernet solved decades ago (-:

I've cobbled the following together, which seems quite roundabout, but at least pretends to work.

I'm not sure whether the watchdog timer via a a new Task that sleeps is the brightest idea, but I wasn't sure how the [NS]Timer-based stuff would work with async / await.

var manager = HIDDeviceManager()
let searchCriteria = HIDDeviceManager.DeviceMatchingCriteria(... omitted...)

let deviceEnumerationTask = Task.detached {

    let managerStream = await manager.monitorNotifications(matchingCriteria: [searchCriteria])

    var watchDogTask = Task {
        NSLog("watchdog start")
        try await Task.sleep(nanoseconds: 10_000_000)
        NSLog("watchdog kill")
        deviceEnumerationTask.cancel()
    }

    monitorDevice: for try await notification in managerStream {
        NSLog("watchdog cancel")
        watchDogTask.cancel()

        // handle the actual notification here

        // rearm the watchdog        
        watchDogTask = Task {
            NSLog("watchdog start")
            try await Task.sleep(nanoseconds: 10_000_000)
            NSLog("watchdog kill")
            deviceEnumerationTask.cancel()
        }
    } // for monitor stream
} // Task.detached deviceEnumeration

// wait for the deviceEnumeration to be done or cancelled
try await deviceEnumerationTask.value

I have also filed an enhancement request as FB17535021 for a synchronous API in CoreHID (i.e. Quinn's (C) suggestion).

Thanks for filing FB17535021.

I've cobbled the following together

That’s some pretty serious cobbling O-:

I suspect that you’d be able to create something nicer with Debounce from the Swift AsyncAlgorithms package. The basic idea is to use a reducer to accumulate the devices and then a debounce to wait for that to stabilise. I’ve included a tiny test program below that I think does what you want.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"


import Foundation
import AsyncAlgorithms

func main() async {
    
    // Use an async stream to simulate events arriving after specified delays.
    
    let (counter, continuation) = AsyncStream.makeStream(of: Int.self)
    Task {
        for (i, delay) in zip(0..., [1, 1, 3, 1, 1, 3, 1, 1, 3]) {
            try? await Task.sleep(for: .seconds(delay))
            continuation.yield(i)
        }
        continuation.finish()
    }
    
    // Use a reducer to accumulate events and then debounce them.
    
    let debouncedCounter = counter
        .reductions([]) { soFar, i in
            soFar + [i]
        }
        .debounce(for: .seconds(2))

    // Print the results, along with the times that they arrived.
    
    let start = ContinuousClock.now
    for await i in debouncedCounter {
        let delta = ContinuousClock.now - start
        print(delta, i)
    }
}

await main()

This prints:

4.1…  seconds [0, 1]
9.3…  seconds [0, 1, 2, 3, 4]
14.4… seconds [0, 1, 2, 3, 4, 5, 6, 7]
15.5… seconds [0, 1, 2, 3, 4, 5, 6, 7, 8]

The Debounce route is an intriguing idea, but I couldn't quite make it work.

First off, to recap for me and everyone else reading along: Debounce will only emit the last element of the sequence once the acquiescence period is reached.

That's why Quinn added the reduction to accumulate all events into an array, so the debounce of the sequence of ever expanding arrays will return the last array (with all values up to then) when reaching the acquiescence period (i.e. we're debouncing the array of events, not the events themselves).

Unfortunately, I think this doesn't quite work for my use case: Imagine I have no matching devices, which might look like (in the counter sequence)

    for (i, delay) in zip(0..., [3, 1, 1, 3, 1, 1, 3]) {
//    for (i, delay) in zip(0..., [1, 1, 3, 1, 1, 3, 1, 1, 3]) {

In this case, I would like to receive no elements and stop iteration after the 2 second "timeout". But debounce only "arms" its timer after it has received the first element from the sequence it is debouncing.

So it would work if there always it at least once element, but otherwise it will produce nothing / wait forever.

Imagine I have no matching devices

Oh, yeah, that’s annoying.

You can make this work by bringing in more AsyncAlgorithms. For example, you might use Chain to combine a prefix that yields an empty value with your real async sequence that yields the real values:

let prefix: [[Int]] = [[]]
let suffix = counter
    .reductions([Int]()) { soFar, i in
        soFar + [i]
    }
let debouncedCounter = chain(prefix.async, suffix)
    .debounce(for: .seconds(2))

However, it’s not clear whether it’s worth going this far down the AsyncAlgorithms rabbit hole. It might just be easier to use a timer and a tiny state machine.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Agreed. For now, I don't think going the AsyncAlgorithms route buys me anything.

I now have a working version with my home-made watchdog sub-task.

My original intent was to wrap this into a (blocking) non-async method, but I couldn't figure out how to do that since waiting for the result of the (async) sub-task needs an await and that's not allowed in a synchronous method.

The second problem was one that only showed up once I tried to wrap this into a (now async) helper method is the reference to the deviceEnumerationTask (line 4 in my post above) from within that task (so the watchdog can cancel that task). If it's a global variable, the task closure doesn't need to capture it, and everything is fine. But if it's a local variable, the closure wants to capture it, and refuses to do so, since it's not yet initialized (the closure is on the right-hand side of the assignment that initializes it).

This looks like

  let outer = Task {
    let inner = Task {
      outer.cancel()
    }
  }

One approach for that that I couldn't get to work was the attempt to avoid the reference to the variable storing the outer task altogether, and instead get it from within the task, but since I want the parent of the inner task, and not the inner task itself, that didn't work.

TaskGroup was another idea, since there the watchdog could cancel itself (so wouldn't need a reference to outer), leading (hopefully) to cancelling the outer task / group, but then I'm missing the ability to pause and restart the watchdog, which I've so far done by cancelling its task and replacing it with a new one to restart.

So in the end I made outer an optional (so it is initialized during the capture), and that works but makes things kind of ugly since now it's mutable and no longer a let (so I had to put everything in an actor), and of course makes an optional that really cannot be nil but needs to be used.

CoreHID: Enumerate all devices *once* (e.g. in a command-line tool)
 
 
Q