Skip to content

Commit aea38aa

Browse files
authored
Merge pull request #99 from DCC-Lab/device-controller
Add DeviceController: headless threaded wrapper for any PhysicalDevice
2 parents b9c9d9b + 7949c55 commit aea38aa

3 files changed

Lines changed: 555 additions & 0 deletions

File tree

hardwarelibrary/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from hardwarelibrary.physicaldevice import *
2828
from hardwarelibrary.notificationcenter import *
29+
from hardwarelibrary.devicecontroller import *
2930
from hardwarelibrary.communication import *
3031

3132
# We want these modules in their namespace
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
"""A headless, GUI-agnostic controller around any PhysicalDevice.
2+
3+
Building an interactive app (e.g. a GUI) on top of a PhysicalDevice means
4+
solving the same problems every time:
5+
6+
* device calls block (the Millennia eV confirms ON/OFF by writing, settling,
7+
and reading back -- up to several seconds), so they cannot run on a UI
8+
thread;
9+
* status polling and user actions both touch the same port, so they must be
10+
serialized to avoid interleaving bytes (writeCommand is not under the
11+
port's transactionLock);
12+
* connections drop -- the cable is pulled, the port disappears -- and the app
13+
wants to recover automatically;
14+
* none of this should know anything about the UI toolkit.
15+
16+
DeviceController owns a single worker thread through which *all* device access
17+
flows -- submitted commands and the periodic status poll alike -- so there are
18+
no races and ordering is preserved. It reports everything through the existing
19+
NotificationCenter, so any front-end (Tk/mytk, Qt, a CLI, a test) just observes
20+
notifications; marshalling them onto a UI thread is the front-end's job.
21+
22+
It is deliberately decoupled from any toolkit: this module imports only the
23+
standard library and NotificationCenter.
24+
25+
Example::
26+
27+
from hardwarelibrary.devicecontroller import (
28+
DeviceController, DeviceControllerNotification as N)
29+
from hardwarelibrary.notificationcenter import NotificationCenter
30+
from hardwarelibrary.sources.millennia import MillenniaDevice
31+
32+
controller = DeviceController(MillenniaDevice(portPath="/dev/cu.usbmodem1"))
33+
34+
def onStatus(notification):
35+
print(notification.userInfo) # {'power': .., 'isLaserOn': .., ...}
36+
37+
NotificationCenter().addObserver(self, onStatus, N.status)
38+
controller.start()
39+
controller.connect()
40+
controller.submit(lambda device: device.turnOn()) # fire-and-forget
41+
reading = controller.submit(lambda device: device.power()).result() # query
42+
...
43+
controller.stop()
44+
"""
45+
46+
import queue
47+
import time
48+
from concurrent.futures import Future
49+
from enum import Enum
50+
from threading import Event, RLock, Thread
51+
52+
from hardwarelibrary.notificationcenter import NotificationCenter
53+
54+
__all__ = [
55+
"DeviceController",
56+
"DeviceControllerNotification",
57+
"connectionErrorReason",
58+
]
59+
60+
61+
class DeviceControllerNotification(Enum):
62+
"""Notifications posted by a DeviceController (the controller is the object).
63+
64+
userInfo payloads:
65+
didConnect -> the device
66+
didDisconnect -> None
67+
connectionLost -> the exception that revealed the drop
68+
connectionFailed -> the exception from the failed (re)connect attempt
69+
status -> the dict from device.doGetStatusUserInfo()
70+
commandFailed -> the exception raised by the submitted command
71+
"""
72+
didConnect = "didConnect"
73+
didDisconnect = "didDisconnect"
74+
connectionLost = "connectionLost"
75+
connectionFailed = "connectionFailed"
76+
status = "status"
77+
commandFailed = "commandFailed"
78+
79+
80+
def connectionErrorReason(error):
81+
"""Classify a connection exception as 'busy'/'permission'/'missing'/None.
82+
83+
Walks the exception's __cause__/__context__ chain (PhysicalDevice drivers
84+
are encouraged to preserve the underlying transport error) and matches it
85+
against common serial-port failure strings. Toolkit-agnostic and free of
86+
any pyserial import, so a front-end can turn a connectionFailed/lost
87+
notification into a human message without re-probing the port.
88+
"""
89+
parts, seen, current = [], set(), error
90+
while current is not None and id(current) not in seen:
91+
seen.add(id(current))
92+
text = str(current).strip()
93+
if text:
94+
parts.append(text)
95+
current = current.__cause__ or current.__context__
96+
text = " | ".join(parts).lower()
97+
if "resource busy" in text or "errno 16" in text or "busy" in text:
98+
return "busy"
99+
if "permission" in text or "errno 13" in text or "access is denied" in text:
100+
return "permission"
101+
if ("no such file" in text or "errno 2" in text or "could not open" in text
102+
or "filenotfounderror" in text):
103+
return "missing"
104+
return None
105+
106+
107+
class DeviceController:
108+
"""Drive a PhysicalDevice from a single worker thread, reporting via notifications.
109+
110+
Args:
111+
device: any PhysicalDevice instance.
112+
pollInterval: seconds between status polls while connected. The poll
113+
calls device.doGetStatusUserInfo(); if the device has none (returns
114+
None), polling is disabled automatically.
115+
reconnectInterval: seconds between reconnection attempts while
116+
disconnected-but-wanted.
117+
autoReconnect: when True, an unexpected drop (or a connect that fails
118+
because the device is not yet present) is retried until it succeeds
119+
or disconnect() is called. When False, a single failure gives up.
120+
"""
121+
122+
def __init__(self, device, pollInterval=1.0, reconnectInterval=2.0,
123+
autoReconnect=True):
124+
self.device = device
125+
self.pollInterval = pollInterval
126+
self.reconnectInterval = reconnectInterval
127+
self.autoReconnect = autoReconnect
128+
129+
self.commandQueue = queue.Queue()
130+
self.stopEvent = Event()
131+
self.lock = RLock()
132+
self.thread = Thread(target=self.runLoop, name="DeviceController",
133+
daemon=True)
134+
135+
self.connected = False
136+
self.wantConnected = False
137+
self.supportsStatus = True # set False if doGetStatusUserInfo()->None
138+
self.nextPoll = 0.0
139+
self.nextReconnect = 0.0
140+
self.lastFailureSignature = None
141+
142+
# -- public API (any thread) --
143+
144+
def start(self):
145+
"""Start the worker thread."""
146+
self.thread.start()
147+
148+
def connect(self):
149+
"""Request connection (and, with autoReconnect, keep it connected)."""
150+
self.commandQueue.put(("connect", None))
151+
152+
def disconnect(self):
153+
"""Request disconnection; clears the reconnect intent."""
154+
self.commandQueue.put(("disconnect", None))
155+
156+
def submit(self, action, name=None):
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.
165+
"""
166+
future = Future()
167+
self.commandQueue.put(("command", (action, name, future)))
168+
return future
169+
170+
def stop(self):
171+
"""Stop the worker thread and shut the device down."""
172+
self.stopEvent.set()
173+
self.commandQueue.put(("disconnect", None))
174+
if self.thread.is_alive():
175+
self.thread.join(timeout=5.0)
176+
177+
@property
178+
def isConnected(self):
179+
with self.lock:
180+
return self.connected
181+
182+
@property
183+
def isRunning(self):
184+
return self.thread.is_alive()
185+
186+
# -- worker thread --
187+
188+
def post(self, name, userInfo=None):
189+
NotificationCenter().postNotification(name, notifyingObject=self,
190+
userInfo=userInfo)
191+
192+
def runLoop(self):
193+
while not self.stopEvent.is_set():
194+
try:
195+
task = self.commandQueue.get(timeout=self.secondsUntilDue())
196+
except queue.Empty:
197+
task = None
198+
199+
if task is not None:
200+
self.handleTask(task)
201+
202+
now = time.monotonic()
203+
if self.connected and self.supportsStatus and now >= self.nextPoll:
204+
self.nextPoll = now + self.pollInterval
205+
self.poll()
206+
elif (not self.connected and self.wantConnected
207+
and now >= self.nextReconnect):
208+
self.nextReconnect = now + self.reconnectInterval
209+
self.attemptConnect()
210+
211+
self.teardown()
212+
213+
def secondsUntilDue(self):
214+
now = time.monotonic()
215+
if self.connected:
216+
target = self.nextPoll if self.supportsStatus else now + 0.5
217+
elif self.wantConnected:
218+
target = self.nextReconnect
219+
else:
220+
return None # nothing scheduled; block until a task arrives
221+
return max(0.0, target - now)
222+
223+
def handleTask(self, task):
224+
kind, payload = task
225+
if kind == "connect":
226+
self.wantConnected = True
227+
self.nextReconnect = 0.0
228+
self.attemptConnect()
229+
elif kind == "disconnect":
230+
self.wantConnected = False
231+
self.doDisconnect()
232+
elif kind == "command":
233+
self.doCommand(*payload)
234+
235+
def attemptConnect(self):
236+
with self.lock:
237+
if self.connected:
238+
return
239+
try:
240+
self.device.initializeDevice()
241+
except Exception as error:
242+
self.connected = False
243+
self.postFailureIfNew(
244+
DeviceControllerNotification.connectionFailed, error)
245+
if not self.autoReconnect:
246+
self.wantConnected = False
247+
return
248+
249+
with self.lock:
250+
self.connected = True
251+
self.supportsStatus = True
252+
self.nextPoll = time.monotonic() # poll immediately
253+
self.lastFailureSignature = None
254+
self.post(DeviceControllerNotification.didConnect, userInfo=self.device)
255+
256+
def poll(self):
257+
try:
258+
info = self.device.doGetStatusUserInfo()
259+
except Exception as error:
260+
self.handleConnectionLost(error)
261+
return
262+
if info is None:
263+
# Device exposes no status snapshot; stop polling for it.
264+
self.supportsStatus = False
265+
return
266+
self.post(DeviceControllerNotification.status, userInfo=info)
267+
268+
def doCommand(self, action, name, future):
269+
if not future.set_running_or_notify_cancel():
270+
return # the caller cancelled the Future before it ran
271+
if not self.isConnected:
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)
276+
return
277+
try:
278+
result = action(self.device)
279+
except Exception as error:
280+
# A failed command may mean the link dropped; verify with a poll.
281+
self.post(DeviceControllerNotification.commandFailed, userInfo=error)
282+
future.set_exception(error)
283+
self.poll()
284+
return
285+
future.set_result(result)
286+
# Reflect the new state promptly.
287+
if self.supportsStatus:
288+
self.nextPoll = time.monotonic()
289+
self.poll()
290+
291+
def handleConnectionLost(self, error):
292+
wasConnected = self.connected
293+
with self.lock:
294+
self.connected = False
295+
self.safeShutdown()
296+
self.nextReconnect = time.monotonic() + self.reconnectInterval
297+
if not self.autoReconnect:
298+
self.wantConnected = False
299+
if wasConnected:
300+
self.post(DeviceControllerNotification.connectionLost, userInfo=error)
301+
302+
def doDisconnect(self):
303+
with self.lock:
304+
self.connected = False
305+
self.safeShutdown()
306+
self.post(DeviceControllerNotification.didDisconnect)
307+
308+
def safeShutdown(self):
309+
try:
310+
self.device.shutdownDevice()
311+
except Exception:
312+
pass
313+
314+
def postFailureIfNew(self, name, error):
315+
# Avoid flooding identical connectionFailed notifications every interval
316+
# while a device stays unavailable; re-post only when the reason changes.
317+
signature = (type(error).__name__, connectionErrorReason(error) or str(error))
318+
if signature != self.lastFailureSignature:
319+
self.lastFailureSignature = signature
320+
self.post(name, userInfo=error)
321+
322+
def teardown(self):
323+
if self.connected:
324+
with self.lock:
325+
self.connected = False
326+
self.safeShutdown()

0 commit comments

Comments
 (0)