feat: continuous/stateful discovery (ContinuousDeviceFinder + DeviceLost) - #248
Conversation
…245) Wraps any IDeviceFinder and turns its per-call discovery into a continuous, stateful scan: it owns the scan cadence, maintains a deduplicated live set across passes, raises DeviceDiscovered the first time each device appears, and raises DeviceLost once a device has been absent for a configurable number of consecutive passes. This lets a UI bind directly instead of writing its own polling loops plus per-transport dedup/stale-removal, unblocking the daqifi-desktop cleanup (daqifi-desktop#616). - One instance wraps one finder (one transport's cadence + live set); compose multiple for WiFi/Serial/HID. - Per-transport identity: WiFi->MAC, Serial->serial number (survives COM-port reassignment), HID->device path (bootloaders may report empty/duplicate serials), each prefixed by ConnectionType so the same physical unit on two transports stays distinct. Override via ContinuousDiscoveryOptions.IdentitySelector. - MissThreshold (default 2) tolerates a dropped UDP reply before reporting lost. - Resilient loop: a throwing discovery pass, identity selector, or event subscriber is surfaced via ScanError and never kills the background loop, matching the existing finders' "swallow subscriber exceptions" contract. - Reconcile logic is unit-tested deterministically (no wall-clock dependence); loop wiring is covered by event-driven integration tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR Summary by QodoAdd ContinuousDeviceFinder for stateful continuous device discovery Description
Diagram
High-Level Assessment
Files changed (6)
|
Code Review by Qodo
Context used 1.
|
…s, lifecycle race, null identity) - Make each discovery pass cancellable: run the inner finder under a CTS linked to the loop token and CancelAfter(PassTimeout). Stop/Dispose now interrupt an in-flight pass promptly instead of waiting out the timeout, and Dispose waits deterministically (bounded by PassTimeout) before disposing the inner finder, so no pass runs against a disposed finder and no events fire post-dispose. Pass-timeout cancellation is treated as a skipped pass (no fabricated losses); outer cancellation exits the loop. Fixes the PassTimeout>5s Dispose gap. - Fix Start/Dispose race: check _disposed inside _lifecycleLock (Dispose now sets it under the same lock), so Start can't launch the loop on a disposed instance. - Fix null-identity collapse: a custom IdentitySelector returning null/empty now falls back to DefaultIdentity instead of an empty key that merged distinct devices. - Add tests: null-selector fallback, prompt StopAsync/Dispose cancellation of an in-flight pass via a blocking finder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Qodo review — all 3 bugs addressed in 31b61b51. Dispose may outlive loop ✅ Fixed (details in the inline reply). Passes are now cancellable via a linked CTS + 2. Start/Dispose race ✅ Fixed. 3. Null identity collapses devices ✅ Fixed. Full suite: 1046 passed, 2 skipped, 0 failed on both net9.0 and net10.0 (warnings-as-errors clean). |
Bench test against real hardware ✅Built the DAQiFi Core example CLI against this branch's local Confirmed the branch build was loaded: the example app's Command Result
This validates that the branch loads and drives real hardware end-to-end (connect → initialize → stream → stop → disconnect). The new Note: the bench build required a temporary throwing shim for |
|
Follow-up tracked for exercising this API in the example CLI: daqifi/daqifi-core-example-app#29 ( |
…emo) (#30) * feat: add --watch continuous-discovery mode (ContinuousDeviceFinder demo) Add `--watch` (WiFi) and `--watch-serial` (serial) flags that demonstrate the new `ContinuousDeviceFinder` API from Daqifi.Core: a stateful live device set with `DeviceDiscovered` / `DeviceLost` events. Unlike one-shot `--discover`/`--discover-serial`, watch mode keeps a deduplicated live set and prints `[+] discovered` / `[-] lost` transitions as devices appear and disappear, bounded by `--duration` and stoppable with Ctrl+C. On exit it prints the final live set. All changes are in Program.cs (new CliOptions flags, parse arms, Main dispatch, --help lines, RunWatchAsync + DescribeEndpoint handlers) plus README docs. Bench-tested against a real device over USB/serial (built against the core feature branch): discovered -> lost (unplug) -> re-discovered (replug) -> final live set, exit 0. Note: blocked from merging until Daqifi.Core ships ContinuousDeviceFinder in a published package (daqifi/daqifi-core#248) and the PackageReference is bumped off 0.20.0. The default package build fails today; the feature only compiles against a local core working copy via -p:DaqifiCoreProjectPath. Closes #29 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address Qodo review of #30 (watch-mode cleanup + mutual exclusion) - RunWatchAsync: unsubscribe the Console.CancelKeyPress handler and stop the watcher in a finally block, and guard stopCts.Cancel() against a late Ctrl+C racing disposal. CancelKeyPress is process-global, so a handler left subscribed would leak and could fire against a disposed token if watch mode runs more than once in-process; StopAsync now runs even if the wait throws. - Main: reject --watch + --watch-serial together (exit 1), mirroring the existing --ip/--serial mutual-exclusion check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address Qodo review of #30 (dispatch order, exit code, Ctrl+C, duration) - Dispatch --watch/--watch-serial before one-shot --discover/--discover-serial so watch mode is a dedicated path and never falls through into discovery. - Surface ScanError and StopAsync failures via the exit code (return 1) instead of always reporting success. - Register the Console.CancelKeyPress handler before watcher.Start() so an early Ctrl+C performs graceful shutdown rather than killing the process. - Treat --duration <= 0 as "run until Ctrl+C" (no auto-stop), matching streaming and SD-logging modes, instead of coercing it back to the default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Closes #245.
Adds
ContinuousDeviceFinder— a stateful primitive that wraps anyIDeviceFinderand turns its per-call discovery into a continuous scan. It owns the scan cadence, maintains a deduplicated live set across passes, raisesDeviceDiscoveredthe first time each device appears, and raisesDeviceLostonce a device has been absent for a configurable number of consecutive passes. A UI can bind directly toDevices+ these events instead of writing its own polling loops and stale-removal.This unblocks the daqifi-desktop cleanup (daqifi/daqifi-desktop#616), which today wraps the finders in three hand-written polling loops with magic delays plus per-transport dedup/stale-removal.
Design
ContinuousDiscoveryOptions.IdentitySelector):ConnectionType, so the same physical unit seen over two transports appears as two distinct connection options.MissThreshold(default 2) tolerates a dropped UDP reply before reporting a device lost.ScanErrorand never kills the background loop — matching the existing finders' "swallow subscriber exceptions; keep receiving" contract.API
Files
ContinuousDeviceFinder.cs— scan loop, identity-keyed live set,DeviceDiscovered/DeviceLost/ScanError,Devicessnapshot,Start()/StopAsync(),IDisposable.ContinuousDiscoveryOptions.cs—Interval,PassTimeout,MissThreshold,IdentitySelector,LeaveInnerFinderOpen.DeviceLostEventArgs.cs,ContinuousDiscoveryErrorEventArgs.cs.ContinuousDeviceFinderTests.cs— 30 tests (deterministic reconcile logic + event-driven loop integration).docs/DEVICE_INTERFACES.md— "Continuous Discovery" usage section.Testing
dotnet test— 1043 passed, 2 skipped (pre-existing), 0 failed on both net9.0 and net10.0.TreatWarningsAsErrorsis on, so the clean build also confirms lint/XML-doc coverage.Notes for the desktop adoption (no change here)
The
MissThresholddefault of 2 means a device is reported lost after ~2 passes absent (≈2 × (PassTimeout + Interval)). That's conservative for lossy WiFi UDP; the reliable Serial/HID instances may wantMissThreshold = 1to drop ghosts faster.🤖 Generated with Claude Code