Skip to content

Commit 0a9b35e

Browse files
authored
Merge pull request #143 from openUC2/mergemaster
Add CAN discovery, probing and node ID APIs
2 parents 9d6154e + e809504 commit 0a9b35e

1 file changed

Lines changed: 130 additions & 5 deletions

File tree

uc2rest/can.py

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,28 +96,49 @@ def reboot_remote(self, qid=1, can_address=0, isBlocking=False, timeout=2):
9696
nResponses=nResponses
9797
)
9898

99-
def scan(self, qid=1, timeout=5):
99+
def scan(self, qid=1, timeout=5, probe_range=False, id_from=1, id_to=127):
100100
"""
101101
Scan the CAN bus for connected devices.
102102
103103
:param qid: Query ID for the CAN command (default: 1)
104104
:param timeout: Timeout for the scan in seconds (default: 5)
105-
:return: Response containing scan results with device information
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.
106115
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"},
107118
"scan": [
108-
{"canId": 10, "deviceType": 0, "deviceTypeStr": "motor", "status": 0, "statusStr": "idle"},
109-
{"canId": 20, "deviceType": 1, "deviceTypeStr": "laser", "status": 0, "statusStr": "idle"}
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"}
110125
],
111126
"qid": 1,
112127
"count": 2
113128
}
129+
Note: "build"/"fwVersion"/"mac" are only present for nodes that
130+
answered the SDO probe (i.e. statusStr == "idle").
114131
"""
115132
path = "/can_act" # {"task":"/can_act", "scan": true}
116133
payload = {
117134
"task": path,
118135
"scan": True,
119136
"qid": qid
120137
}
138+
if probe_range:
139+
payload["probeRange"] = True
140+
payload["from"] = int(id_from)
141+
payload["to"] = int(id_to)
121142
return self._parent.post_json(
122143
path,
123144
payload,
@@ -126,6 +147,21 @@ def scan(self, qid=1, timeout=5):
126147
nResponses=1
127148
)
128149

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+
129165
def get_available_devices(self, timeout=2):
130166
"""
131167
Get list of available CAN devices.
@@ -154,4 +190,93 @@ def get_available_devices(self, timeout=2):
154190
getReturn=True,
155191
timeout=timeout,
156192
nResponses=2
157-
)
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 commit comments

Comments
 (0)