Skip to content

Commit f9f2290

Browse files
dccoteclaude
andcommitted
DeviceController.submit() returns a Future with the action's result
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
1 parent 8467f92 commit f9f2290

2 files changed

Lines changed: 36 additions & 14 deletions

File tree

hardwarelibrary/devicecontroller.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ def on_status(notification):
3737
NotificationCenter().addObserver(self, on_status, N.status)
3838
controller.start()
3939
controller.connect()
40-
controller.submit(lambda device: device.turnOn())
40+
controller.submit(lambda device: device.turnOn()) # fire-and-forget
41+
reading = controller.submit(lambda device: device.power()).result() # query
4142
...
4243
controller.stop()
4344
"""
4445

4546
import queue
4647
import time
48+
from concurrent.futures import Future
4749
from enum import Enum
4850
from threading import Event, RLock, Thread
4951

@@ -152,13 +154,18 @@ def disconnect(self):
152154
self._queue.put(("disconnect", None))
153155

154156
def submit(self, action, name=None):
155-
"""Run ``action(device)`` on the worker thread.
156-
157-
Returns immediately. On failure a commandFailed notification is posted
158-
with the exception. A status poll is forced right after a successful
159-
command so observers see the new state promptly.
157+
"""Run ``action(device)`` on the worker thread; return a Future.
158+
159+
The Future resolves with the action's return value (so query-style
160+
devices can read a measurement back: ``controller.submit(lambda d:
161+
d.power()).result()``) or with its exception. On failure a commandFailed
162+
notification is also posted, for observers that don't hold the Future.
163+
A status poll is forced after a successful command so observers see the
164+
new state promptly. Do not block on ``.result()`` from a UI thread.
160165
"""
161-
self._queue.put(("command", (action, name)))
166+
future = Future()
167+
self._queue.put(("command", (action, name, future)))
168+
return future
162169

163170
def stop(self):
164171
"""Stop the worker thread and shut the device down."""
@@ -258,19 +265,24 @@ def _poll(self):
258265
return
259266
self._post(DeviceControllerNotification.status, userInfo=info)
260267

261-
def _do_command(self, action, name):
268+
def _do_command(self, action, name, future):
269+
if not future.set_running_or_notify_cancel():
270+
return # the caller cancelled the Future before it ran
262271
if not self.is_connected:
263-
self._post(DeviceControllerNotification.commandFailed,
264-
userInfo=RuntimeError(
265-
"Cannot run command {0!r}: not connected".format(name)))
272+
error = RuntimeError(
273+
"Cannot run command {0!r}: not connected".format(name))
274+
self._post(DeviceControllerNotification.commandFailed, userInfo=error)
275+
future.set_exception(error)
266276
return
267277
try:
268-
action(self.device)
278+
result = action(self.device)
269279
except Exception as error:
270280
# A failed command may mean the link dropped; verify with a poll.
271281
self._post(DeviceControllerNotification.commandFailed, userInfo=error)
282+
future.set_exception(error)
272283
self._poll()
273284
return
285+
future.set_result(result)
274286
# Reflect the new state promptly.
275287
if self._supports_status:
276288
self._next_poll = time.monotonic()

hardwarelibrary/tests/testDeviceController.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,15 +131,25 @@ def testSubmittedCommandRunsAndStatusReflectsIt(self):
131131
self.assertTrue(status["isLaserOn"])
132132
self.assertTrue(status["isShutterOpen"])
133133

134-
def testFailedCommandPostsCommandFailed(self):
134+
def testSubmitReturnsResultViaFuture(self):
135+
controller, rec = self.make()
136+
controller.connect()
137+
rec.wait_for(DeviceControllerNotification.didConnect)
138+
controller.submit(lambda device: device.setPower(8.0)).result(timeout=3.0)
139+
reading = controller.submit(lambda device: device.power()).result(timeout=3.0)
140+
self.assertEqual(reading, 8.0)
141+
142+
def testFailedCommandPostsCommandFailedAndRaisesInFuture(self):
135143
controller, rec = self.make()
136144
controller.connect()
137145
rec.wait_for(DeviceControllerNotification.didConnect)
138146

139147
def boom(device):
140148
raise ValueError("nope")
141149

142-
controller.submit(boom)
150+
future = controller.submit(boom)
151+
with self.assertRaises(ValueError):
152+
future.result(timeout=3.0)
143153
info = rec.wait_for(DeviceControllerNotification.commandFailed)
144154
self.assertIsInstance(info, ValueError)
145155

0 commit comments

Comments
 (0)