Periphery.Treehopper.Control 4.2.0-alpha.1

This is a prerelease version of Periphery.Treehopper.Control.
dotnet add package Periphery.Treehopper.Control --version 4.2.0-alpha.1
                    
NuGet\Install-Package Periphery.Treehopper.Control -Version 4.2.0-alpha.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Periphery.Treehopper.Control" Version="4.2.0-alpha.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Periphery.Treehopper.Control" Version="4.2.0-alpha.1" />
                    
Directory.Packages.props
<PackageReference Include="Periphery.Treehopper.Control" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Periphery.Treehopper.Control --version 4.2.0-alpha.1
                    
#r "nuget: Periphery.Treehopper.Control, 4.2.0-alpha.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Periphery.Treehopper.Control@4.2.0-alpha.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Periphery.Treehopper.Control&version=4.2.0-alpha.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Periphery.Treehopper.Control&version=4.2.0-alpha.1&prerelease
                    
Install as a Cake Tool

Periphery

A modern, cross-platform .NET library for discovering hardware devices — USB, Bluetooth, network adapters, displays, and more — with a clean, LINQ-friendly API.

Windows, Linux, and macOS providers are complete.

Why Periphery?

Enumerating connected hardware today means reaching for platform-specific APIs — SetupAPI on Windows, udev on Linux, IOKit on macOS — each with its own conventions and quirks. Periphery provides a single high-level surface that abstracts those differences away so you can:

  • Discover devices by category (USB, Bluetooth, Display, Network, ...) with one API.
  • Query devices using familiar LINQ expressions.
  • Monitor arrival, departure, activation, and deactivation via events.
  • Write cross-platform code that just works on Windows, Linux, and macOS.

The core library focuses on discovery — it tells you what's plugged in. Protocol-level I/O lives in companion extension libraries layered on the same device model. Keeping the core enumeration-only holds its runtime dependency surface to a single abstractions package (Microsoft.Extensions.Logging.Abstractions); the extensions opt into real device communication when you need it.

Package What it does
Periphery Core: enumeration, watching, tracking. The only runtime dependency is Microsoft.Extensions.Logging.Abstractions
Periphery.Camera Frame capture (Media Foundation / V4L2)
Periphery.Camera.Avalonia CameraPreview control for Avalonia UI
Periphery.Camera.OpenCvSharp Captured frames as an OpenCV Mat, without VideoCapture
Periphery.Camera.Testing Hardware-free camera test seam (ADR-0065)
Periphery.Hid HID reports (e.g. battery levels)
Periphery.Monitor DDC/CI brightness / power / input, resolution, orientation
Periphery.Usb Raw USB I/O via WinUSB / libusb backends
Periphery.Treehopper Treehopper board SDK on a pure core (ADR-0052)
Periphery.Firmware + Periphery.Bootloader Firmware images and the bootloader contract (ADR-0061)
Periphery.Bootloader.Efm8.Usb EFM8 USB bootloader backend
Periphery.Treehopper.Control Board control surface
Periphery.Treehopper.Control.Cli Command-line front end for board control
Periphery.Treehopper.Firmware Treehopper firmware images
Periphery.Treehopper.Libraries Peripheral drivers on the Treehopper board (LED strips, displays)
Periphery.Cli periphery command-line device tooling

Extensions are Windows + Linux. Enumeration works on all three platforms, but the I/O extensions ship Windows and Linux backends only — CameraDevice, HidDevice, UsbDevice, and MonitorDevice throw PlatformNotSupportedException on macOS. The AVFoundation and IOKit HID/USB backends are planned, not written.

Quick Look

// One-shot: all active USB devices
var usb = await Devices.Enumerate()
    .OfCategory(DeviceCategory.Usb)
    .Active()
    .ToListAsync();

foreach (var device in usb)
    Console.WriteLine($"{device.Name} ({device.Id})");

// Fluent query with LINQ
var mice = await Devices.Enumerate()
    .OfCategory(DeviceCategory.Hid)
    .WithName("Mouse")
    .ByManufacturer("Logitech")
    .OrderBy(d => d.Name)
    .Take(5)
    .ToListAsync();

// USB VID/PID lookup
var specific = await Devices.Enumerate()
    .OfCategory(DeviceCategory.Usb)
    .WithUsbId("1234", "5678")
    .FirstOrDefaultAsync();

// IAsyncEnumerable — works with await foreach
await foreach (var device in Devices.Enumerate().OfCategory(DeviceCategory.Network))
    Console.WriteLine($"{device.Name} ({device.BusType})");
// Real-time monitoring
await using var watcher = Devices.Watch()
    .OfCategory(DeviceCategory.Bluetooth);

// Two orthogonal transitions. Presence is whether the OS knows the device at
// all; activity is whether it is usable right now. For Bluetooth that is
// exactly the difference between paired and connected.
watcher.Appeared    += (_, e) => Console.WriteLine($"+ paired:       {e.Device.Name}");
watcher.Activated   += (_, e) => Console.WriteLine($"+ connected:    {e.Device.Name}");
watcher.Deactivated += (_, e) => Console.WriteLine($"- disconnected: {e.Device.Name}");
watcher.Disappeared += (_, e) => Console.WriteLine($"- unpaired:     {e.Device.Name}");

await watcher.StartAsync();

Most categories collapse the two. A USB device becomes present and active on the same plug event, so Appeared and Activated arrive together and either one will do. Bluetooth is where they come apart: a paired speaker that is switched off stays present and goes inactive, and a single IsConnected flag would either hide it or claim it is gone. Network adapters behave the same way when disabled. See ADR-0004.

On Windows, read Bluetooth activity by polling, not from these two events. The state is right: a paired device that switches off stays present and its IsActive goes false, and comes back true on reconnect. The events are not delivered. cfgmgr32 pushes no notification when a link goes up or down on a device that is already paired and installed, so Activated and Deactivated do not fire for a Bluetooth link transition. Measured against a paired HID keyboard: the devnode stayed enumerable across a power cycle and IsActive tracked the link in both directions, while the watcher raised no edge either way. This is specific to the link going up and down, and to Windows; Linux (udev bind/unbind) and macOS (IOKit) deliver both events from OS push. See ADR-0054.

DeviceInfo is an immutable snapshot, so a device you already hold never changes. Enumerate again on each poll and compare by Id:

var wasActive = new Dictionary<DeviceId, bool>();

while (!ct.IsCancellationRequested)
{
    foreach (var device in await Devices.Enumerate()
                 .OfCategory(DeviceCategory.Bluetooth)
                 .ToListAsync(ct))
    {
        if (wasActive.TryGetValue(device.Id, out var before) && before != device.IsActive)
            Console.WriteLine($"{device.Name}: {(device.IsActive ? "connected" : "disconnected")}");

        wasActive[device.Id] = device.IsActive;
    }

    await Task.Delay(TimeSpan.FromSeconds(2), ct);
}

Note also that a Bluetooth peripheral enumerates as several devnodes, and only the BTHENUM\DEV_… one carries link state. Its profile-service siblings — including the DeviceCategory.Hid node for a keyboard — do not track the link, so filter on DeviceCategory.Bluetooth when you want the device's own activity.

// Per-device tracking — each tracker has dual state (IsPresent + IsActive)
await using var watcher = Devices.Watch();

var mouse   = watcher.AddTracker(t => t.OfCategory(DeviceCategory.Usb).WithUsbId("046D", "C52B"), name: "Mouse");
var airpods = watcher.AddTracker(t => t.OfCategory(DeviceCategory.Bluetooth).WithName("AirPods"), name: "AirPods");

// ActivityStatus starts at Unknown and settles once initial enumeration completes (ADR-0056).
mouse.StateChanged += (_, _) => Console.WriteLine($"Mouse: {mouse.ActivityStatus}");

await watcher.StartAsync();
// Bind a serial device by identity, not by COM number. The OS assigns the port
// name, and it moves across reboots and re-plugs; the VID/PID and serial number
// do not. The proxy reopens the port wherever it lands next.
var scanner = new DeviceProfile(
    f => f.OfCategory(DeviceCategory.Ports)
          .WithUsbId("0403", "6001")
          .WithSerialNumber("A9012XYZ"),   // drop this if only one such device is ever attached
    name: "Scanner");

SerialPort? port = null;   // needs the System.IO.Ports package

await using var handle = await DeviceProxy.OpenAsync(
    scanner,
    onActivated: (info, ct) =>
    {
        port = new SerialPort(info.PortName!.Value.Value, baudRate: 115_200);
        port.Open();
        return Task.CompletedTask;
    },
    onDeactivated: _ =>
    {
        port?.Dispose();
        port = null;
        return Task.CompletedTask;
    });

DeviceProxy also takes whileOpen for a read loop, and a retry policy for devices that enumerate before they are ready. See examples/scripts/serial-device-handle.cs.

// Trackers can be created upfront (e.g. from configuration) and attached later
var tracker = new DeviceTracker(t => t.OfCategory(DeviceCategory.Usb).WithUsbId("046D", "C52B"), name: "Mouse");
tracker.StateChanged += (_, _) => UpdateDashboard();

await using var watcher = Devices.Watch().AddTracker(tracker);
await watcher.StartAsync();

Requirements

  • .NET 10 or later (libraries also ship a net8.0 target, offered best-effort — it is built but not covered by the test suite; see ADR-0069)
  • Windows: No additional dependencies (uses SetupAPI and cfgmgr32 via P/Invoke)
  • Linux: Requires libudev.so.1. Systemd-based distros already have it; on a minimal image install libudev-dev or eudev-dev.
    • Periphery.Usb also needs libusb-1.0.so.0 1.0.23 or newer — libusb-1.0-0 on Debian and Ubuntu.
    • Periphery.Hid and Periphery.Camera need nothing extra. They call the kernel ABIs directly, hidraw and V4L2.
    • Opening a device node usually takes a udev rule or a group membership: video for cameras, hidraw and usbfs rules for HID and USB. See ADR-0057.
  • macOS: No additional dependencies (uses IOKit.framework and CoreFoundation.framework via P/Invoke)

Getting Started

git clone https://github.com/charles8051/periphery.git
cd periphery
dotnet build

Packages are published to nuget.org. Every release so far is a prerelease, so the flag is required — without it NuGet reports "There are no stable versions available" and adds nothing:

dotnet add package Periphery --prerelease

Device Categories

A category answers which OS subsystem surfaced this device — it's single-valued and drives enumeration routing (SetupAPI class GUID / udev subsystem / IOKit class). All providers are complete on all three platforms.

Category Windows Linux macOS
USB
Bluetooth
Network Adapters
Display (GPU / adapter)
Monitor (screen)
HID
Keyboard
Mouse
Audio
Storage
Ports (Serial)
Battery
Camera

Capability Tags

A tag answers a different question — what can this device do? Tags are multi-valued, cross-cutting, and added by enrichers during enumeration; query them with WithTag(...). Five identifiers that used to be categories are now tags (ADR-0051), because each describes a capability a device has rather than the subsystem that surfaced it:

// "any scanner / still-image device", whichever subsystem it enumerated under
var scanners = await Devices.Enumerate().WithTag(DeviceTags.Imaging).ToListAsync();

// compose with a category to narrow the scan first
var receiptPrinter = await Devices.Enumerate()
    .OfCategory(DeviceCategory.Ports)      // a serial-attached printer
    .WithTag(DeviceTags.Printer)
    .FirstOrDefaultAsync();
Tag Windows Linux macOS Detection signal
Sensor Sensor class / iio subsystem / HID usage page 0x20
SmartCard 🟡 SmartCardReader class / IOUSBSmartCardController / USB class 0x0B
Imaging 🟡 🟡 Image class / USB class 0x06
Printer 🟡 🟡 Printer / PnpPrinters / PrintQueue classes / USB class 0x07
Biometric Biometric class (Windows-only — USB biometric readers are vendor-specific)

🟡 Windows-first. The Windows class-GUID signals are live now, so each tag works on Windows exactly as the old category did. The Linux/macOS USB-class paths are written and dormant — they light up when cross-platform DeviceInfo.UsbClassCode population lands (deferred while Periphery builds out Windows depth first; see ADR-0051). Hid, Audio, and Battery are also available as capability tags emitted by enrichers (e.g. HID battery levels via Periphery.Hid).

OpenCV without VideoCapture(0)

VideoCapture(0) is an index into whatever order the OS enumerated in, and it moves when a device is replugged or a virtual camera installs itself. On a machine with two identical cameras, no argument means the one on the left. Periphery answers that for every category on all three platforms; Periphery.Camera.OpenCvSharp hands the pixels to OpenCV without a copy.

Worked examples, the three entry points, the native-payload choice and the lease-lifetime trap are in that package's README.

One camera, several consumers

A preview, an inference graph and an encoder want different latency, different queue depths and different drop behaviour. Periphery.Camera ships no router, on purpose — it gives you refcounted frames instead, and the fan-out is a producer loop plus one bounded channel per consumer.

The recipe, the per-consumer policy table, how to size BufferCount to the fan-out, and the retention trap that makes a replay buffer exhaust the pool are in the Periphery.Camera README.

Repository Layout

periphery/
├── Periphery.slnx                  # Solution — 28 src, 21 test, 7 example, 1 benchmark project
├── src/
│   ├── Periphery/                  # Core: enumeration, watching, tracking (net8.0;net10.0)
│   ├── Periphery.Camera[.Avalonia|.OpenCvSharp|.Testing]/
│   ├── Periphery.Hid/  Periphery.Monitor/  Periphery.Usb/
│   ├── Periphery.Treehopper[.Control|.Firmware|.Flasher|.Libraries][.Cli|.Gui]/
│   ├── Periphery.Firmware/  Periphery.Bootloader[.Efm8.Usb|.Stm32.Usb]/
│   ├── Periphery.FlashAnything[.Cli|.Gui][.Core]/
│   └── Periphery.Cli/  Periphery.Diagnostics/
├── tests/                          # One test project per src package, plus
│                                   #   *.Interop.Tests where a suite needs a
│                                   #   native payload (Category=Integration)
├── examples/                       # Runnable samples + single-file `dotnet run` scripts
├── benchmarks/                     # BenchmarkDotNet suites
└── docs/
    ├── ARCHITECTURE.md             # Detailed architecture & design decisions
    ├── adr/                        # Architecture Decision Records
    ├── patterns/                   # Cross-cutting conventions
    ├── surface/                    # Consumer-facing usage guides
    ├── plans/  feature-specs/      # Point-in-time design work
    └── explorations/  investigations/

Inside the core library:

src/Periphery/
├── Devices.cs                      # Static entry point: Enumerate(), Watch()
├── DeviceQuery.cs                  # Fluent, composable query (IAsyncEnumerable)
├── DeviceWatcher.cs                # Real-time Appeared/Activated/Deactivated/Disappeared monitor
├── DeviceTracker.cs                # Per-device observable state handle
├── MultiDeviceTracker.cs           # Observable set of matching devices
├── DeviceSessionHost.cs            # Session publication over a reconnecting handle
├── DeviceProxy[Base].cs            # Reconnect-resilient device handles (ADR-0027)
├── DeviceProfile.cs                # Named candidate in a multi-profile tracker
├── DeviceInfo.cs                   # Immutable device snapshot record
├── DeviceCategory.cs  DeviceTags.cs
├── DeviceFilter.cs                 # Filter predicate composition — the one source of truth
├── IDeviceProvider.cs              # Provider interfaces + runtime factory
├── *Enricher.cs                    # Tag-emitting enrichers (ADR-0026, ADR-0051)
├── DeviceReset.cs  Reset*.cs  *RecoveryPolicy.cs   # Reset/recovery escalation (ADR-0060)
├── Windows/                        # SetupAPI + cfgmgr32 + DisplayConfig
├── Linux/                          # libudev
├── MacOS/                          # IOKit / CoreFoundation
└── Serialization/                  # System.Text.Json converters for the BCL-typed fields

Design Principles

  1. Discovery in the core, interaction in extensions. The core tells you what's connected; protocol-level communication (camera capture, HID reports, raw USB, DDC/CI, …) lives in companion extension libraries layered on the core's device model.
  2. Platform parity. Every device category exposed in the public API must be supportable on all target platforms, even if implementations ship incrementally.
  3. LINQ-native. Device queries compose naturally with Where, Select, OrderBy, and friends.
  4. No third-party dependencies in the core or the I/O extensions. Platform back-ends use only built-in OS APIs (SetupAPI/cfgmgr32, udev, IOKit) via P/Invoke or native interop. Microsoft.Extensions.Logging.Abstractions is the one exception. An opt-in integration package is where a third-party dependency belongs: Periphery.Camera.Avalonia references Avalonia and Periphery.Camera.OpenCvSharp references OpenCvSharp4, and you take either package only if you want it. See docs/patterns/integration-package-placement.md.
  5. Async-first. Hardware enumeration can be slow; all public entry points return Task or IAsyncEnumerable.

Contributing

Contributions are welcome. Start with CONTRIBUTING.md for how to build, test and format, and ARCHITECTURE.md for a deeper look at the design before opening a PR.

Security reports go through SECURITY.md, not the issue tracker.

Deferred work, bugs and design questions are tracked as GitHub issues — that is the only backlog. Architectural decisions live in docs/adr/; an issue that needs one references it by number.

Formatting

CSharpier, pinned as a local tool. The tree is not formatted yet, so format only the files you touch — see CONTRIBUTING.md for the commands and why a repo-wide pass is its own change.

License

PolyForm Small Business 1.0.0 - see LICENSE.md.

Source-available, not open source. Every right the licence grants - including making changes and redistributing - is granted only for a permitted purpose, and use for the benefit of a company is a permitted purpose only below the employee-count and revenue thresholds the licence sets.

LICENSE.md is the authoritative statement of the terms. This paragraph points at it and is not a summary of it.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
4.2.0-alpha.1 40 9/9/2026
4.1.0-alpha.2 64 8/30/2026