Skip to content

Commit 299dba2

Browse files
authored
Merge pull request #146 from openUC2/mergemaster
Add GPIO collision detector client
2 parents 2c1a400 + 0bf917b commit 299dba2

3 files changed

Lines changed: 161 additions & 2 deletions

File tree

uc2rest/UC2Client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .modules import Modules
2323
from .digitalout import DigitalOut
2424
from .digitalin import DigitalIn
25+
from .gpio import GPIO
2526
from .rotator import Rotator
2627
from .logger import Logger
2728
from .cmdrecorder import cmdRecorder
@@ -155,7 +156,10 @@ def __init__(self, host=None, port=31950, serialport=None, identity="UC2_Feather
155156

156157
# initialize digital in
157158
self.digitalin = DigitalIn(self)
158-
159+
160+
# initialize GPIO / collision-detector slave interface
161+
self.gpio = GPIO(self)
162+
159163
# initialize messaging
160164
self.message = Message(self)
161165

uc2rest/gpio.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""GPIO / collision-detector interface for the UC2 CAN GPIO slave.
2+
3+
The CAN master exposes the GPIO slave (XIAO node, default CAN node-id 60)
4+
through two serial endpoints:
5+
6+
{"task":"/gpio_get"}
7+
-> {"gpio":{"node":60,"mean":585,"filtered":584,"raw":581,
8+
"reference":585,"threshold":150,"sensitivity":4,
9+
"trip":0,"estop":0},"ok":true,"qid":N}
10+
11+
{"task":"/gpio_act","threshold":150,"sensitivity":4,
12+
"reference":585,"calibrate":1} (all value fields optional)
13+
14+
Collision detection runs ON THE SLAVE: the sensor has an idle value (the
15+
"reference"); a collision = `sensitivity` consecutive samples deviating more
16+
than `threshold` ADC counts from the reference (rising OR falling). Sensor
17+
values are never streamed on the CAN bus — polling /gpio_get triggers SDO
18+
reads on demand. Only the collision/E-stop EVENT is pushed asynchronously;
19+
the master then prints:
20+
21+
{"gpio":{"event":1,"node":60,"trip":1,"estop":0,"flags":3,
22+
"filtered":2365,"raw":2937,...}}
23+
24+
The "event":1 marker distinguishes pushed edges from /gpio_get responses
25+
(both share the top-level "gpio" key and therefore both arrive at the same
26+
serial callback).
27+
"""
28+
29+
30+
class GPIO(object):
31+
def __init__(self, parent):
32+
self._parent = parent
33+
34+
# last known state (merged from get_status responses and async events)
35+
self.status = {}
36+
37+
# register a callback for any {"gpio":{...}} frame on the serial loop
38+
if hasattr(self._parent, "serial"):
39+
self._parent.serial.register_callback(self._callback_gpio, pattern="gpio")
40+
41+
# user-registered callbacks
42+
self._collision_callbacks = [] # fired on async trip/clear events
43+
self._status_callbacks = [] # fired on every gpio frame
44+
45+
# ─────────────────────────────────────────────────────────────────
46+
# Async event plumbing
47+
# ─────────────────────────────────────────────────────────────────
48+
def _callback_gpio(self, data):
49+
"""Handle any serial frame with a top-level "gpio" key.
50+
51+
Pushed events carry "event":1 and fire the collision callbacks;
52+
/gpio_get responses only refresh the cached status."""
53+
try:
54+
gpio = data.get("gpio", None)
55+
if not isinstance(gpio, dict):
56+
return
57+
self.status.update(gpio)
58+
59+
for cb in self._status_callbacks:
60+
try:
61+
cb(dict(self.status))
62+
except Exception as e:
63+
print(f"Error in gpio status callback: {e}")
64+
65+
if gpio.get("event", 0):
66+
for cb in self._collision_callbacks:
67+
try:
68+
cb(dict(gpio))
69+
except Exception as e:
70+
print(f"Error in gpio collision callback: {e}")
71+
except Exception as e:
72+
print("Error in _callback_gpio: ", e)
73+
74+
def register_collision_callback(self, callbackfct):
75+
"""Register a function called on every asynchronously pushed GPIO
76+
event (collision trip/clear, E-stop press/release). The callback
77+
receives the event dict, e.g. {"event":1,"trip":1,"estop":0,...}.
78+
Check ["trip"] to distinguish collision-began from collision-cleared."""
79+
if callbackfct not in self._collision_callbacks:
80+
self._collision_callbacks.append(callbackfct)
81+
82+
def register_status_callback(self, callbackfct):
83+
"""Register a function called on EVERY gpio frame (async events and
84+
/gpio_get responses alike) with the merged status dict."""
85+
if callbackfct not in self._status_callbacks:
86+
self._status_callbacks.append(callbackfct)
87+
88+
# ─────────────────────────────────────────────────────────────────
89+
# Query / configuration
90+
# ─────────────────────────────────────────────────────────────────
91+
@staticmethod
92+
def _extract_gpio(response):
93+
"""Unwrap a post_json return into the "gpio" dict.
94+
95+
mserial.sendMessage returns a LIST of response dicts on success
96+
(self.responses[qid] is a per-frame list), a plain string on
97+
timeout/interrupt, or an int qid when getReturn=False — so handle
98+
all shapes defensively."""
99+
candidates = response if isinstance(response, list) else [response]
100+
for item in candidates:
101+
if isinstance(item, dict):
102+
gpio = item.get("gpio")
103+
if isinstance(gpio, dict):
104+
return gpio
105+
return {}
106+
107+
def get_status(self, node=None, timeout=1):
108+
"""Poll the collision detector (SDO reads on the CAN bus).
109+
110+
Returns the "gpio" dict: mean (rolling average), filtered, raw,
111+
baseline, sigma, mode (0=auto/1=manual), reference, threshold,
112+
sensitivity, trip, estop."""
113+
path = "/gpio_get"
114+
payload = {"task": path}
115+
if node is not None:
116+
payload["node"] = node
117+
r = self._parent.post_json(path, payload, getReturn=True, timeout=timeout)
118+
gpio = self._extract_gpio(r)
119+
if gpio:
120+
self.status.update(gpio)
121+
return gpio
122+
# Response didn't parse (timeout/odd shape) — fall back to the cache
123+
# maintained by the async serial callback, which sees every frame.
124+
return dict(self.status)
125+
126+
def _act(self, node=None, timeout=1, **fields):
127+
path = "/gpio_act"
128+
payload = {"task": path}
129+
if node is not None:
130+
payload["node"] = node
131+
payload.update(fields)
132+
return self._parent.post_json(path, payload, getReturn=True, timeout=timeout)
133+
134+
def set_threshold(self, threshold, node=None, timeout=1):
135+
"""Deviation band (ADC counts) around the reference outside which a
136+
sample votes for "collision". Persisted in the slave's NVS."""
137+
return self._act(node=node, timeout=timeout, threshold=int(threshold))
138+
139+
def set_sensitivity(self, sensitivity, node=None, timeout=1):
140+
"""Number of CONSECUTIVE out-of-band samples (at 50 Hz) required to
141+
trip — and in-band samples to clear. 3-4 rejects single-sample spikes
142+
while confirming a real collision within ~60-80 ms."""
143+
return self._act(node=node, timeout=timeout, sensitivity=int(sensitivity))
144+
145+
def set_reference(self, reference, node=None, timeout=1):
146+
"""Explicitly set the idle baseline (ADC counts). Typically you poll
147+
get_status()["mean"] while the system is idle and write that value
148+
here — or simply use calibrate()."""
149+
return self._act(node=node, timeout=timeout, reference=int(reference))
150+
151+
def calibrate(self, node=None, timeout=1):
152+
"""Tell the slave to take its CURRENT rolling mean as the new
153+
reference (persisted in NVS). Do this while the system is idle and
154+
collision-free."""
155+
return self._act(node=node, timeout=timeout, calibrate=1)

uc2rest/motor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1098,7 +1098,7 @@ def set_joystick_direction(self, axis, inverted=False, timeout=1):
10981098
'''
10991099
if type(axis) != int:
11001100
axis = self.xyztTo1230(axis)
1101-
1101+
# {"task":"/motor_act", "joystickdir": {"steppers": [{"stepperid": 1, "inverted": 1}]}}
11021102
path = "/motor_act"
11031103
payload = {
11041104
"task": path,

0 commit comments

Comments
 (0)