@@ -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