Add DeviceController: headless threaded wrapper for any PhysicalDevice#99
Merged
Conversation
Building an interactive app on a PhysicalDevice means re-solving the same problems each time: device calls block (the Millennia confirms ON/OFF over ~seconds) so they can't run on a UI thread; status polling and user actions share one port and must be serialized; connections drop and want to recover; and none of this should know about the UI toolkit. DeviceController wraps any PhysicalDevice and runs ALL device access — submitted commands and the periodic status poll — through a single worker thread, so there are no races and ordering is preserved. It reports through the existing NotificationCenter (didConnect / didDisconnect / connectionLost / connectionFailed / status / commandFailed), so any front-end (mytk, Qt, CLI, tests) just observes; marshalling to a UI thread is the front-end's job. It imports only the standard library + NotificationCenter — no toolkit dependency. Features: submit(action) async commands; connect/disconnect with intent; auto-reconnect with configurable interval; status polling via doGetStatusUserInfo() that disables itself for devices without a snapshot; and a toolkit-agnostic connection_error_reason() that classifies busy/missing/ permission off the exception chain. Adds a full unittest suite (9 tests) driven by the Debug Millennia device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6LVhfM3d9CcJdkuSMMqag
Generality fix: submit() was fire-and-forget, which suits move/turnOn but is
useless for query-style devices (powermeter/oscilloscope reads return data).
It now returns a concurrent.futures.Future that resolves with the action's
return value or its exception, so any PhysicalDevice — not just actuators —
is fully usable:
reading = controller.submit(lambda d: d.power()).result()
commandFailed is still posted for observers that don't hold the Future.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6LVhfM3d9CcJdkuSMMqag
Collaborator
Author
|
Follow-up for full generality: |
DeviceController was written in PEP 8 style (leading-underscore "privates", snake_case), which clashes with the rest of PyHardwareLibrary (camelCase, no underscore prefixing — cf. PhysicalDevice.startBackgroundStatusUpdates, self.refreshInterval, NotificationCenter.postNotification). Renamed throughout: isConnected/isRunning, pollInterval/reconnectInterval/autoReconnect, commandQueue/stopEvent/lock/thread/connected/wantConnected/supportsStatus/ nextPoll/nextReconnect/lastFailureSignature, and methods runLoop/handleTask/ attemptConnect/poll/doCommand/handleConnectionLost/doDisconnect/safeShutdown/ postFailureIfNew/teardown/post. Module helper connection_error_reason -> connectionErrorReason. No behaviour change; tests updated, 10/10 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6LVhfM3d9CcJdkuSMMqag
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every interactive app built on a
PhysicalDevicere-solves the same problems:ON/OFFby write→settle→read, up to several seconds, so device calls can't run on a UI thread.writeCommandisn't undertransactionLock), or bytes interleave.I just hand-wrote exactly this controller for a Millennia GUI; it's generic enough to live in the library so the next device app (Cobolt, Sutter, …) doesn't redo it.
What
DeviceControllerwraps anyPhysicalDeviceand runs all device access — submitted commands and the periodic status poll — through one worker thread. No races, ordering preserved. It reports through the existingNotificationCenter, so any front-end just observes notifications (marshalling to a UI thread is the front-end's job). The module imports only the stdlib +NotificationCenter— no toolkit dependency, and it's wired into the top-level package.Notifications:
didConnect/didDisconnect/connectionLost/connectionFailed/status/commandFailed.Features:
submit(action)— async commands on the worker thread; failures postcommandFailed; a status poll is forced after a successful command so observers see new state promptly.connect()/disconnect()with intent, and auto-reconnect (configurable interval) that an explicitdisconnect()cancels.doGetStatusUserInfo()that disables itself for devices without a snapshot (returnsNone).connection_error_reason(error)— toolkit-agnostic helper that classifiesbusy/missing/permissionoff the exception chain (pairs with the preserved init error from Millennia eV: status snapshot + preserve init error #98).Design boundary
This is the GUI-agnostic half of an app controller. The Tk/mytk half (observe these notifications, marshal to the main thread, bind widgets) deliberately stays out of this library so PyHardwareLibrary never depends on a UI toolkit. Scoped per discussion to just the headless controller; the mytk binding / capability-driven panels are a possible follow-up once validated against a second device.
Tests
Adds
testDeviceController.py— 9 unittest cases driven by the Debug Millennia device (connect/status, command success + failure, command-while-disconnected, drop→auto-reconnect, explicit-disconnect-no-reconnect, no-auto-reconnect-gives-up, statusless device, error classification). All pass; existingtestMillenniasuite unaffected.🤖 Generated with Claude Code