UC2-REST 0.2.0.35__tar.gz → 0.2.0.38__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/PKG-INFO +1 -1
  2. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/UC2_REST.egg-info/PKG-INFO +1 -1
  3. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/UC2_REST.egg-info/SOURCES.txt +2 -0
  4. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/UC2Client.py +12 -4
  5. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/__version__.py +1 -1
  6. uc2_rest-0.2.0.38/uc2rest/can.py +282 -0
  7. uc2_rest-0.2.0.38/uc2rest/gpio.py +171 -0
  8. uc2_rest-0.2.0.38/uc2rest/i2c.py +190 -0
  9. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/motor.py +87 -14
  10. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/mserial.py +96 -7
  11. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/state.py +35 -0
  12. uc2_rest-0.2.0.35/uc2rest/can.py +0 -157
  13. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/LICENSE +0 -0
  14. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/README.md +0 -0
  15. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/UC2_REST.egg-info/dependency_links.txt +0 -0
  16. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/UC2_REST.egg-info/not-zip-safe +0 -0
  17. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/UC2_REST.egg-info/requires.txt +0 -0
  18. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/UC2_REST.egg-info/top_level.txt +0 -0
  19. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/setup.cfg +0 -0
  20. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/setup.py +0 -0
  21. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/MockSerial.py +0 -0
  22. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/__init__.py +0 -0
  23. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/analog.py +0 -0
  24. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/camera.py +0 -0
  25. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/camera_trigger.py +0 -0
  26. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/canota.py +0 -0
  27. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/cmdrecorder.py +0 -0
  28. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/digitalin.py +0 -0
  29. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/digitalout.py +0 -0
  30. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/fan.py +0 -0
  31. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/galvo.py +0 -0
  32. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/gripper.py +0 -0
  33. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/home.py +0 -0
  34. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/laser.py +0 -0
  35. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/lcddisplay.py +0 -0
  36. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/ledmatrix.py +0 -0
  37. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/logger.py +0 -0
  38. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/message.py +0 -0
  39. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/modules.py +0 -0
  40. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/motor_config.py +0 -0
  41. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/objective.py +0 -0
  42. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/pid.py +0 -0
  43. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/rotator.py +0 -0
  44. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/slm.py +0 -0
  45. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/temperature.py +0 -0
  46. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/utils.py +0 -0
  47. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.38}/uc2rest/wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UC2-REST
3
- Version: 0.2.0.35
3
+ Version: 0.2.0.38
4
4
  Summary: This pacage will help you to drive the ESP32-driven microscopy control modules from UC2
5
5
  Home-page: https://github.com/openUC2/UC2-REST
6
6
  Author: Benedict Diederich
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UC2-REST
3
- Version: 0.2.0.35
3
+ Version: 0.2.0.38
4
4
  Summary: This pacage will help you to drive the ESP32-driven microscopy control modules from UC2
5
5
  Home-page: https://github.com/openUC2/UC2-REST
6
6
  Author: Benedict Diederich
@@ -21,8 +21,10 @@ uc2rest/digitalin.py
21
21
  uc2rest/digitalout.py
22
22
  uc2rest/fan.py
23
23
  uc2rest/galvo.py
24
+ uc2rest/gpio.py
24
25
  uc2rest/gripper.py
25
26
  uc2rest/home.py
27
+ uc2rest/i2c.py
26
28
  uc2rest/laser.py
27
29
  uc2rest/lcddisplay.py
28
30
  uc2rest/ledmatrix.py
@@ -22,6 +22,8 @@ from .analog import Analog
22
22
  from .modules import Modules
23
23
  from .digitalout import DigitalOut
24
24
  from .digitalin import DigitalIn
25
+ from .gpio import GPIO
26
+ from .i2c import I2C
25
27
  from .rotator import Rotator
26
28
  from .logger import Logger
27
29
  from .cmdrecorder import cmdRecorder
@@ -47,9 +49,9 @@ class UC2Client(object):
47
49
  is_serial = False
48
50
  BAUDRATE = 115200
49
51
 
50
- def __init__(self, host=None, port=31950, serialport=None, identity="UC2_Feather", baudrate=BAUDRATE,
52
+ def __init__(self, host=None, port=31950, serialport=None, identity="UC2_Feather", baudrate=BAUDRATE,
51
53
  NLeds=64, SerialManager=None, DEBUG=False, logger=None, skipFirmwareCheck=False,
52
- isPyScript=False):
54
+ isPyScript=False, device_id=None, requireMaster=False):
53
55
  '''
54
56
  This client connects to the UC2-REST microcontroller that can be found here
55
57
  https://github.com/openUC2/UC2-REST
@@ -80,7 +82,7 @@ class UC2Client(object):
80
82
  # initialize communication channel (serial only)
81
83
  if serialport is not None:
82
84
  # use USB connection
83
- self.serial = Serial(serialport, baudrate, parent=self, identity=identity, DEBUG=DEBUG, skipFirmwareCheck=skipFirmwareCheck)
85
+ self.serial = Serial(serialport, baudrate, parent=self, identity=identity, DEBUG=DEBUG, skipFirmwareCheck=skipFirmwareCheck, device_id=device_id, requireMaster=requireMaster)
84
86
  self.is_serial = True
85
87
  self.is_connected = self.serial.is_connected
86
88
  self.serial.DEBUG = DEBUG
@@ -155,7 +157,13 @@ class UC2Client(object):
155
157
 
156
158
  # initialize digital in
157
159
  self.digitalin = DigitalIn(self)
158
-
160
+
161
+ # initialize GPIO / collision-detector slave interface
162
+ self.gpio = GPIO(self)
163
+
164
+ # initialize generic I2C passthrough (sensors on the GPIO slave bus)
165
+ self.i2c = I2C(self)
166
+
159
167
  # initialize messaging
160
168
  self.message = Message(self)
161
169
 
@@ -6,7 +6,7 @@ __version__.py
6
6
 
7
7
  __title__ = 'UC2-REST'
8
8
  __description__ = 'This pacage will help you to drive the ESP32-driven microscopy control modules from UC2'
9
- __version__ = "v0.2.0.35"
9
+ __version__ = "v0.2.0.38"
10
10
  __author__ = 'Benedict Diederich'
11
11
  __author_email__ = 'benedictdied@gmail.com'
12
12
  __license__ = 'GPL v3'
@@ -0,0 +1,282 @@
1
+ class CAN(object):
2
+ def __init__(self, parent):
3
+ """
4
+ CANController handles sending commands to a remote CAN device via the parent post_json interface.
5
+
6
+ :param parent: Parent object with post_json(path, payload, getReturn, timeout, nResponses)
7
+ """
8
+ self._parent = parent
9
+
10
+ # Store latest scan results
11
+ self.scanResults = []
12
+ self.deviceCount = 0
13
+
14
+ # Register a callback function for the CAN status on the serial loop
15
+ if hasattr(self._parent, "serial"):
16
+ self._parent.serial.register_callback(self._callback_can_status, pattern="scan")
17
+
18
+ # Announce a function that is called when we receive a CAN scan update through the callback
19
+ self._callbackPerKey = {}
20
+ self.nCallbacks = 10
21
+ self._callbackPerKey = self.init_callback_functions(nCallbacks=self.nCallbacks)
22
+ print(self._callbackPerKey)
23
+
24
+ def init_callback_functions(self, nCallbacks=10):
25
+ """Initialize the callback functions."""
26
+ _callbackPerKey = {}
27
+ self.nCallbacks = nCallbacks
28
+ for i in range(nCallbacks):
29
+ _callbackPerKey[i] = []
30
+ return _callbackPerKey
31
+
32
+ def _callback_can_status(self, data):
33
+ """
34
+ Cast the json in the form:
35
+ {
36
+ "scan": [
37
+ {"canId": 20, "deviceType": 1, "status": 0, "deviceTypeStr": "laser", "statusStr": "idle"},
38
+ {"canId": 10, "deviceType": 0, "status": 0, "deviceTypeStr": "motor", "statusStr": "idle"}
39
+ ],
40
+ "qid": 2,
41
+ "count": 1
42
+ }
43
+ into the scan results array.
44
+ """
45
+ try:
46
+ if "scan" in data:
47
+ self.scanResults = data["scan"]
48
+ self.deviceCount = data.get("count", len(self.scanResults))
49
+
50
+ # Call registered callback function with scan results
51
+ if callable(self._callbackPerKey[0]):
52
+ self._callbackPerKey[0](self.scanResults)
53
+ except Exception as e:
54
+ print("Error in _callback_can_status: ", e)
55
+
56
+ def register_callback(self, key, callbackfct):
57
+ """Register a callback function for a specific key."""
58
+ self._callbackPerKey[key] = callbackfct
59
+
60
+ def get_can_ids(self):
61
+ """Return CAN node IDs from the latest scan (e.g. for OTA flashing)."""
62
+ return [entry.get("canId") for entry in self.scanResults
63
+ if entry.get("canId") is not None]
64
+
65
+ def reboot_remote(self, qid=1, can_address=0, isBlocking=False, timeout=2):
66
+ """
67
+ Reboot a CAN device.
68
+
69
+ - ``can_address == 0`` reboots the master (this ESP32) itself.
70
+ - ``can_address in 1..127`` reboots a remote slave by SDO-writing 1
71
+ to OD index 0x2507 sub 0 on the target node. The slave's
72
+ CO_tmr_task observes the write and calls ESP.restart() ~200 ms
73
+ later.
74
+
75
+ :param qid: Query ID for the CAN command (unused by firmware, kept
76
+ for API compatibility)
77
+ :param can_address: 0 = master, 1..127 = remote slave nodeId
78
+ :param isBlocking: If True, wait for response
79
+ :param timeout: Timeout for the command in seconds
80
+ :return: Response from the device, e.g.
81
+ ``{"status":"ok","nodeId":11}`` or
82
+ ``{"status":"error","error":"SDO write failed","nodeId":11}``.
83
+ """
84
+ path = "/can_act"
85
+ payload = {
86
+ "task": path,
87
+ "restart": int(can_address)
88
+ }
89
+ nResponses = 1 if isBlocking else 0
90
+ # Send the payload to the parent, which handles the actual communication
91
+ return self._parent.post_json(
92
+ path,
93
+ payload,
94
+ getReturn=isBlocking,
95
+ timeout=timeout if isBlocking else 0,
96
+ nResponses=nResponses
97
+ )
98
+
99
+ def scan(self, qid=1, timeout=5, probe_range=False, id_from=1, id_to=127):
100
+ """
101
+ Scan the CAN bus for connected devices.
102
+
103
+ :param qid: Query ID for the CAN command (default: 1)
104
+ :param timeout: Timeout for the scan in seconds (default: 5)
105
+ :param probe_range: if True, additionally SDO-probe node-ids
106
+ ``id_from..id_to`` for their MAC so brand-new / unrouted nodes are
107
+ discovered (reported with deviceTypeStr "unrouted"). Absent ids
108
+ fast-fail on the firmware side. Use this for MAC-keyed provisioning.
109
+ :param id_from: first node-id to probe when ``probe_range`` is set
110
+ :param id_to: last node-id to probe when ``probe_range`` is set
111
+ :return: Response containing scan results with device information.
112
+ Reachable nodes are SDO-probed for their firmware build
113
+ timestamp (OD 0x2508), version (0x2500) and MAC (0x2509);
114
+ the master reports its own identity under the "master" key.
115
+ Example: {
116
+ "master": {"canId": 1, "build": "Jun 22 2026 14:30:11",
117
+ "fwVersion": "UC2-ESP v2.0", "mac": "AA:BB:CC:DD:EE:01"},
118
+ "scan": [
119
+ {"canId": 10, "deviceType": 0, "deviceTypeStr": "motor",
120
+ "status": 0, "statusStr": "idle",
121
+ "build": "Jun 22 2026 14:31:02", "fwVersion": "UC2-ESP v2.0",
122
+ "mac": "AA:BB:CC:DD:EE:10"},
123
+ {"canId": 20, "deviceType": 1, "deviceTypeStr": "laser",
124
+ "status": 1, "statusStr": "unreachable"}
125
+ ],
126
+ "qid": 1,
127
+ "count": 2
128
+ }
129
+ Note: "build"/"fwVersion"/"mac" are only present for nodes that
130
+ answered the SDO probe (i.e. statusStr == "idle").
131
+ """
132
+ path = "/can_act" # {"task":"/can_act", "scan": true}
133
+ payload = {
134
+ "task": path,
135
+ "scan": True,
136
+ "qid": qid
137
+ }
138
+ if probe_range:
139
+ payload["probeRange"] = True
140
+ payload["from"] = int(id_from)
141
+ payload["to"] = int(id_to)
142
+ return self._parent.post_json(
143
+ path,
144
+ payload,
145
+ getReturn=True,
146
+ timeout=timeout,
147
+ nResponses=1
148
+ )
149
+
150
+ def discover(self, qid=1, timeout=8, id_from=1, id_to=127):
151
+ """
152
+ Discover all nodes on the bus by SDO-probing a node-id range.
153
+
154
+ Convenience wrapper around ``scan(probe_range=True, ...)`` — finds nodes
155
+ that aren't in the master's routing table yet (e.g. freshly flashed
156
+ boards), reporting their MAC + build info so they can be provisioned by
157
+ MAC via :meth:`assign_node_id_by_mac`.
158
+
159
+ :return: same shape as :meth:`scan`; probed-but-unrouted nodes carry
160
+ deviceTypeStr "unrouted".
161
+ """
162
+ return self.scan(qid=qid, timeout=timeout,
163
+ probe_range=True, id_from=id_from, id_to=id_to)
164
+
165
+ def get_available_devices(self, timeout=2):
166
+ """
167
+ Get list of available CAN devices.
168
+
169
+ :param timeout: Timeout for the command in seconds (default: 2)
170
+ :return: Response containing available CAN IDs and other bus information
171
+ Example: {
172
+ "input": {"task": "/can_get"},
173
+ "address": 1,
174
+ "addresspref": 1,
175
+ "addressgetcan": 1,
176
+ "nonworking": [0,0,0,...],
177
+ "available": [20,0,0,...],
178
+ "rx": 18,
179
+ "tx": 17
180
+ }
181
+ """
182
+ path = "/can_act"
183
+ payload = {
184
+ "task": path,
185
+ "scan": True
186
+ }
187
+ return self._parent.post_json(
188
+ path,
189
+ payload,
190
+ getReturn=True,
191
+ timeout=timeout,
192
+ nResponses=2
193
+ )
194
+
195
+ def get_device_build_info(self):
196
+ """
197
+ Return per-node firmware build info from the latest scan results.
198
+
199
+ :return: dict keyed by CAN id, e.g.
200
+ {10: {"build": "Jun 22 2026 14:31:02",
201
+ "fwVersion": "UC2-ESP v2.0",
202
+ "mac": "AA:BB:CC:DD:EE:10"}}
203
+ Only nodes that answered the SDO probe during the last scan()
204
+ appear here. Call scan() first to refresh.
205
+ """
206
+ info = {}
207
+ for entry in self.scanResults:
208
+ cid = entry.get("canId")
209
+ if cid is None:
210
+ continue
211
+ fields = {k: entry[k] for k in ("build", "fwVersion", "mac")
212
+ if k in entry}
213
+ if fields:
214
+ info[cid] = fields
215
+ return info
216
+
217
+ def set_remote_node_id(self, new_id, target=None, by_mac=None,
218
+ expect_mac=None, qid=1, isBlocking=True, timeout=4):
219
+ """
220
+ Reassign a remote node's CAN id over the bus (SDO write to OD 0x250A).
221
+
222
+ Identify the node EITHER by its current id (``target``, optionally with
223
+ ``expect_mac`` for a safety check) OR purely by its MAC (``by_mac``), in
224
+ which case the firmware probes the bus to find which id currently has
225
+ that MAC — use this when you don't know the current id. ``new_id`` is
226
+ always the desired new id.
227
+
228
+ The slave persists the new id to NVS and performs a CANopen
229
+ communication reset, so it reappears at ``new_id`` after ~0.3 s.
230
+
231
+ :param new_id: desired CAN id (1..127)
232
+ :param target: current CAN id (1..127) of the node to reconfigure
233
+ :param by_mac: target MAC "AA:BB:CC:DD:EE:FF" — firmware finds the node
234
+ (use instead of ``target`` when the current id is unknown)
235
+ :param expect_mac: when using ``target``, verify the node's MAC matches
236
+ before committing (id only ever binds to the intended device)
237
+ :param qid: Query ID (kept for API symmetry)
238
+ :param isBlocking: wait for the firmware acknowledgement
239
+ :param timeout: command timeout in seconds
240
+ :return: firmware response, e.g. ``{"status":"ok","target":60,"newId":70}``
241
+ or ``{"status":"error","error":"MAC not found on bus","mac":"..."}``
242
+ """
243
+ if by_mac is None and target is None:
244
+ raise ValueError("set_remote_node_id requires either target or by_mac")
245
+ path = "/can_act"
246
+ payload = {
247
+ "task": path,
248
+ "setRemoteNodeId": int(new_id),
249
+ "qid": qid,
250
+ }
251
+ if by_mac is not None:
252
+ payload["byMac"] = str(by_mac)
253
+ else:
254
+ payload["target"] = int(target)
255
+ if expect_mac is not None:
256
+ payload["expectMac"] = str(expect_mac)
257
+ nResponses = 1 if isBlocking else 0
258
+ return self._parent.post_json(
259
+ path,
260
+ payload,
261
+ getReturn=isBlocking,
262
+ timeout=timeout if isBlocking else 0,
263
+ nResponses=nResponses,
264
+ )
265
+
266
+ def assign_node_id_by_mac(self, mac, new_id, qid=1, timeout=5):
267
+ """
268
+ Assign a CAN id to the node with the given MAC address.
269
+
270
+ The firmware probes the bus to locate whichever node currently
271
+ advertises ``mac`` and reassigns it to ``new_id`` in a single round-trip
272
+ (no need to know the node's current id). This is the canonical
273
+ MAC-keyed provisioning call.
274
+
275
+ :param mac: target MAC as "AA:BB:CC:DD:EE:FF" (case-insensitive)
276
+ :param new_id: desired CAN id (1..127)
277
+ :param qid: Query ID
278
+ :param timeout: request timeout in seconds
279
+ :return: firmware response, e.g. ``{"status":"ok","target":60,"newId":70,
280
+ "mac":"..."}`` or ``{"status":"error","error":"MAC not found on bus"}``
281
+ """
282
+ return self.set_remote_node_id(new_id, by_mac=mac, qid=qid, timeout=timeout)
@@ -0,0 +1,171 @@
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). MANUAL mode only — in AUTO the baseline
154
+ tracks itself. Do this while the system is idle and collision-free."""
155
+ return self._act(node=node, timeout=timeout, calibrate=1)
156
+
157
+ def set_mode(self, mode, node=None, timeout=1):
158
+ """Select the detection mode (persisted on the slave in NVS).
159
+
160
+ mode: "auto" (default, recommended) — adaptive baseline + robust
161
+ sigma z-score. Parameter-free: tracks a slowly drifting
162
+ background and trips on a fast deflection. No calibration.
163
+ mode: "manual" — fixed reference +/- threshold. Deterministic;
164
+ requires the reference to be calibrated to the idle level.
165
+
166
+ Accepts "auto"/"manual" or 0/1."""
167
+ if isinstance(mode, str):
168
+ mval = 1 if mode.lower() == "manual" else 0
169
+ else:
170
+ mval = 1 if int(mode) else 0
171
+ return self._act(node=node, timeout=timeout, mode=mval)