Skip to content

Add DeviceController: headless threaded wrapper for any PhysicalDevice#99

Merged
dccote merged 3 commits into
masterfrom
device-controller
Jun 27, 2026
Merged

Add DeviceController: headless threaded wrapper for any PhysicalDevice#99
dccote merged 3 commits into
masterfrom
device-controller

Conversation

@dccote

@dccote dccote commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Why

Every interactive app built on a PhysicalDevice re-solves the same problems:

  • Blocking calls — e.g. the Millennia confirms ON/OFF by write→settle→read, up to several seconds, so device calls can't run on a UI thread.
  • Shared port — status polling and user actions touch the same port and must be serialized (writeCommand isn't under transactionLock), or bytes interleave.
  • Drops — cables get pulled, ports vanish; apps want to recover.
  • Toolkit independence — none of the above should know about Tk/Qt/etc.

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

DeviceController wraps any PhysicalDevice and runs all device access — submitted commands and the periodic status poll — through one worker thread. No races, ordering preserved. It reports through the existing NotificationCenter, so any front-end just observes notifications (marshalling to a UI thread is the front-end's job). The module imports only the stdlib + NotificationCenterno toolkit dependency, and it's wired into the top-level package.

from hardwarelibrary.devicecontroller import DeviceController, DeviceControllerNotification as N
from hardwarelibrary.notificationcenter import NotificationCenter

controller = DeviceController(MillenniaDevice(portPath="/dev/cu.usbmodem1"))
NotificationCenter().addObserver(self, on_status, N.status)   # userInfo = doGetStatusUserInfo()
controller.start(); controller.connect()
controller.submit(lambda device: device.turnOn())

Notifications: didConnect / didDisconnect / connectionLost / connectionFailed / status / commandFailed.

Features:

  • submit(action) — async commands on the worker thread; failures post commandFailed; 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 explicit disconnect() cancels.
  • Status polling via doGetStatusUserInfo() that disables itself for devices without a snapshot (returns None).
  • connection_error_reason(error) — toolkit-agnostic helper that classifies busy/missing/permission off 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; existing testMillennia suite unaffected.

🤖 Generated with Claude Code

dccote and others added 2 commits June 27, 2026 17:32
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
@dccote

dccote commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up for full generality: submit() now returns a concurrent.futures.Future resolving with the action's return value (or exception), so query-style devices (powermeters, oscilloscopes) can read data back — not just actuators. commandFailed is still posted for observers that don't hold the Future. Added two tests (result via Future, failure raises in Future); suite is now 10/10.

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
@dccote
dccote merged commit aea38aa into master Jun 27, 2026
14 checks passed
@dccote
dccote deleted the device-controller branch June 27, 2026 22:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant