Skip to content

Commit c0957f1

Browse files
committed
Merge remote-tracking branch 'origin/mergemaster'
2 parents 097fb34 + 5058b41 commit c0957f1

2 files changed

Lines changed: 160 additions & 0 deletions

File tree

uc2rest/UC2Client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .digitalout import DigitalOut
2424
from .digitalin import DigitalIn
2525
from .gpio import GPIO
26+
from .ptz import PTZ
2627
from .i2c import I2C
2728
from .rotator import Rotator
2829
from .logger import Logger
@@ -161,6 +162,9 @@ def __init__(self, host=None, port=31950, serialport=None, identity="UC2_Feather
161162
# initialize GPIO / collision-detector slave interface
162163
self.gpio = GPIO(self)
163164

165+
# initialize PTZ keyboard bridge interface (CAN node-id 61)
166+
self.ptz = PTZ(self)
167+
164168
# initialize generic I2C passthrough (sensors on the GPIO slave bus)
165169
self.i2c = I2C(self)
166170

uc2rest/ptz.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""PTZ keyboard interface for the UC2 CAN PTZ-bridge slave.
2+
3+
A low-cost CCTV PTZ joystick keyboard (Pelco-D/P over RS-485) is read by a
4+
dedicated CAN bridge node (XIAO, default CAN node-id 61). The bridge maps the
5+
joystick to motor moves directly on the bus, and forwards every DISCRETE key
6+
(presets, AUX, iris) to the master, which prints an asynchronous frame:
7+
8+
{"ptz":{"event":1,"node":61,"type":6,"name":"iris_open","arg":0,"seq":21}}
9+
10+
type/name: 1 preset_call 2 preset_set 3 preset_clear
11+
4 aux_on 5 aux_off 6 iris_open
12+
7 iris_close 8 camera_onoff 9 autoscan
13+
arg: preset number (1..255) or AUX number (1..6); 0 otherwise
14+
seq: wraps 0..255, one per key event (the master de-dupes on it)
15+
16+
This mirrors the collision "gpio" event plumbing (see gpio.py): a single
17+
serial callback on the top-level "ptz" key, an "event":1 marker to tell a
18+
pushed key from a /ptz_get response, and a list of user callbacks. ImSwitch
19+
registers one such callback and maps each key to a microscope function
20+
(snap image, switch objective, toggle an LED …) — see UC2ConfigController.
21+
22+
Debug / config endpoints (handled locally on the bridge node):
23+
{"task":"/ptz_act","debug":2} raw RS-485 hex dump (wiring bring-up)
24+
{"task":"/ptz_act","debug":1} decoded frame JSON
25+
{"task":"/ptz_act","addr":3} accept only camera address 3 (0=any)
26+
{"task":"/ptz_act","timeout":2000} motion watchdog ms (0=off)
27+
{"task":"/ptz_get"} parser stats + last frame + motion
28+
"""
29+
30+
31+
class PTZ(object):
32+
def __init__(self, parent):
33+
self._parent = parent
34+
35+
# last known state (merged from /ptz_get responses and async events)
36+
self.status = {}
37+
# last discrete key event pushed by the bridge
38+
self.last_event = {}
39+
40+
# register a callback for any {"ptz":{...}} frame on the serial loop
41+
if hasattr(self._parent, "serial"):
42+
self._parent.serial.register_callback(self._callback_ptz, pattern="ptz")
43+
44+
# user-registered callbacks
45+
self._event_callbacks = [] # fired on async key events (event==1)
46+
self._status_callbacks = [] # fired on every ptz frame
47+
48+
# ─────────────────────────────────────────────────────────────────
49+
# Async event plumbing
50+
# ─────────────────────────────────────────────────────────────────
51+
def _callback_ptz(self, data):
52+
"""Handle any serial frame with a top-level "ptz" key.
53+
54+
Pushed key events carry "event":1 and fire the event callbacks;
55+
/ptz_get responses only refresh the cached status."""
56+
try:
57+
ptz = data.get("ptz", None)
58+
if not isinstance(ptz, dict):
59+
return
60+
self.status.update(ptz)
61+
62+
for cb in self._status_callbacks:
63+
try:
64+
cb(dict(self.status))
65+
except Exception as e:
66+
print(f"Error in ptz status callback: {e}")
67+
68+
if ptz.get("event", 0):
69+
self.last_event = dict(ptz)
70+
for cb in self._event_callbacks:
71+
try:
72+
cb(dict(ptz))
73+
except Exception as e:
74+
print(f"Error in ptz event callback: {e}")
75+
except Exception as e:
76+
print("Error in _callback_ptz: ", e)
77+
78+
def register_event_callback(self, callbackfct):
79+
"""Register a function called on every asynchronously pushed PTZ key
80+
event (preset call/set/clear, AUX on/off, iris, …). The callback
81+
receives the event dict, e.g.
82+
{"event":1,"node":61,"type":1,"name":"preset_call","arg":1,"seq":7}.
83+
Use ["name"] and ["arg"] to decide which microscope function to run."""
84+
if callbackfct not in self._event_callbacks:
85+
self._event_callbacks.append(callbackfct)
86+
87+
def register_status_callback(self, callbackfct):
88+
"""Register a function called on EVERY ptz frame (async key events and
89+
/ptz_get responses alike) with the merged status dict."""
90+
if callbackfct not in self._status_callbacks:
91+
self._status_callbacks.append(callbackfct)
92+
93+
# ─────────────────────────────────────────────────────────────────
94+
# Query / configuration
95+
# ─────────────────────────────────────────────────────────────────
96+
@staticmethod
97+
def _extract_ptz(response):
98+
"""Unwrap a post_json return into the "ptz" dict.
99+
100+
mserial.sendMessage returns a LIST of response dicts on success, a
101+
plain string on timeout, or an int qid — handle all shapes (same
102+
defensive pattern as GPIO._extract_gpio)."""
103+
candidates = response if isinstance(response, list) else [response]
104+
for item in candidates:
105+
if isinstance(item, dict):
106+
ptz = item.get("ptz")
107+
if isinstance(ptz, dict):
108+
return ptz
109+
return {}
110+
111+
def get_status(self, node=None, timeout=1):
112+
"""Poll the bridge: parser stats (bytesIn/framesOk/resyncs), the last
113+
decoded frame and the current motion snapshot. Returns the "ptz" dict.
114+
Note: discrete key events are PUSHED (see register_event_callback);
115+
polling is only for diagnostics."""
116+
path = "/ptz_get"
117+
payload = {"task": path}
118+
if node is not None:
119+
payload["node"] = node
120+
r = self._parent.post_json(path, payload, getReturn=True, timeout=timeout)
121+
ptz = self._extract_ptz(r)
122+
if ptz:
123+
self.status.update(ptz)
124+
return ptz
125+
return dict(self.status)
126+
127+
def _act(self, node=None, timeout=1, **fields):
128+
path = "/ptz_act"
129+
payload = {"task": path}
130+
if node is not None:
131+
payload["node"] = node
132+
payload.update(fields)
133+
return self._parent.post_json(path, payload, getReturn=True, timeout=timeout)
134+
135+
def set_debug(self, level, node=None, timeout=1):
136+
"""Bridge serial debug verbosity: 0 quiet, 1 decoded frame JSON,
137+
2 + raw RS-485 hex dump (use for wiring / baud / A-B-swap bring-up)."""
138+
return self._act(node=node, timeout=timeout, debug=int(level))
139+
140+
def set_address(self, addr, node=None, timeout=1):
141+
"""Only accept keyboard frames for this Pelco camera address
142+
(0 = accept any). Handy when several bridges share one keyboard."""
143+
return self._act(node=node, timeout=timeout, addr=int(addr))
144+
145+
def set_motion_timeout(self, timeout_ms, node=None, serial_timeout=1):
146+
"""Watchdog (ms): stop all axes if the keyboard goes silent while an
147+
axis is moving (0 = off). Guards against a lost stop frame.
148+
149+
Built explicitly (not via _act) because the firmware field is itself
150+
named "timeout", which would clash with the serial-call timeout kwarg."""
151+
path = "/ptz_act"
152+
payload = {"task": path, "timeout": int(timeout_ms)}
153+
if node is not None:
154+
payload["node"] = node
155+
return self._parent.post_json(path, payload, getReturn=True,
156+
timeout=serial_timeout)

0 commit comments

Comments
 (0)