1""" Record EtherCAT network and node status continuously, to explain a network failure.
2
3 "The network died and we do not know why." This sample keeps Recorders running on
4 every dynamic network value (RSIControllerAddressType NETWORK_STATE and
5 NETWORK_STATUS_*), every sync group's status (NETWORK_SYNC_GROUP_*) and every node's
6 status (RSINetworkNodeAddressType). When the network leaves OPERATIONAL it keeps
7 recording for a few seconds so the whole shutdown is captured, then prints:
8
9 - a timeline: every value that changed, in order, timed from the first change
10 (normally the moment the cable was pulled),
11 - a summary: which nodes stopped responding, what the sync groups saw, and when
12 the network state changed,
13 - the newest records of the values that changed, side by side.
14 The last seconds of every recorded value are written to a time-stamped CSV file, and the
15 network firmware's log to a text file, for further study.
16
17 A Recorder holds at most 32 addresses, so the values are spread over as many
18 Recorders as needed. Every Recorder also records the controller's sample counter so
19 the records of different Recorders can be lined up. A Recorder's buffer holds 512
20 records of 32 values, so the drain interval is derived from the sample rate (about
21 128 ms at 1 kHz, 32 ms at 4 kHz); the buffer is never resized, since that reconfigures
22 the controller and invalidates every RapidCode object in every process. Every recorded
23 value costs one call into RapidCode when it is drained (see helpers.recorder_value_reader),
24 so a large network at a high sample rate may need RECORD_PERIOD_SAMPLES raised: the
25 sample says so if a Recorder's buffer fills before it is drained.
26
27 The sample runs until the network leaves OPERATIONAL (it may run for days), so it
28 keeps a bounded history and a bounded log of changes: memory does not grow with time.
29 It also stops when a key is pressed (Enter on Linux/macOS), or after the number of
30 seconds given on the command line: python recorder-network-status.py 30
31
32 Requirements: an OPERATIONAL EtherCAT network and enough Recorders on the
33 MotionController (RecorderCountSet). If either is missing the sample prints how to
34 fix that and exits successfully, so automated runs without hardware stay green.
35"""
36
37import csv
38import math
39import sys
40import time
41from collections import deque, namedtuple
42from operator import itemgetter
43
44from _imports import RapidCode, helpers, constants
45
46print("⬤ Recorder: Network and Node Status")
47
48RECORD_PERIOD_SAMPLES = 1
49RETRIEVE_INTERVAL_MAXIMUM_SECONDS = 0.25
50BUFFER_FILL_PER_RETRIEVE = 0.25
51RECORDS_PER_RETRIEVE_MAXIMUM = 1024
52HISTORY_SECONDS = 10.0
53NOT_OPERATIONAL_GRACE_SECONDS = 4.0
54NON_INTERACTIVE_RECORD_SECONDS = 5.0
55CHANGES_TO_KEEP = 10000
56CHANGES_TO_PRINT = 60
57FINAL_RECORDS_TO_PRINT = 8
58SAMPLE_COUNTER_MODULUS = 2 ** 32
59ADDRESSES_PER_RECORDER_MAXIMUM = 32
60NETWORK_LOG_FILE_PREFIX = "network-log-"
61RECORDS_FILE_PREFIX = "network-records-"
62
63
64
65
66
67
68def controller_address_type(name):
69 return getattr(RapidCode, "RSIControllerAddressType_RSIControllerAddressType" + name)
70
71
72def node_address_type(name):
73 return getattr(RapidCode, "RSINetworkNodeAddressType_RSINetworkNodeAddressType" + name)
74
75
76def enum_name(enum):
77 """Formatter that prints a value by its RSI enum member name, e.g. OPERATIONAL."""
78 return lambda value: helpers.enum_to_name(value, enum)
79
80
81def node_list(mask):
82 """Formatter for a node bitmask, e.g. Node1,4."""
83 nodes = [str(index) for index in range(64) if mask & (1 << index)]
84 return "Node" + ",".join(nodes) if nodes else "none"
85
86
87
88
89class Value(namedtuple("Value", "label address_type continuous text")):
90 def __new__(cls, label, address_type, continuous=False, text=str):
91 return super().__new__(cls, label, address_type, continuous, text)
92
93
94
95NETWORK_VALUES = [
96 Value("State", controller_address_type("NETWORK_STATE"), text=enum_name("RSINetworkState")),
97 Value("LastStartError", controller_address_type("NETWORK_LAST_START_ERROR"), text=enum_name("RSINetworkStartError")),
98 Value("ActiveNodeCount", controller_address_type("NETWORK_STATUS_ACTIVE_NODE_COUNT")),
99 Value("AlStatus", controller_address_type("NETWORK_STATUS_AL_STATUS"), text=helpers.decode_al_status_ored),
100 Value("MissedFrames", controller_address_type("NETWORK_STATUS_MISSED_CYCLIC_FRAME_COUNT")),
101 Value("FramePeriodUs", controller_address_type("NETWORK_STATUS_CYCLIC_FRAME_PERIOD_US"), continuous=True),
102 Value("SyncErrorNs", controller_address_type("NETWORK_STATUS_SYNCHRONIZATION_ERROR_NS"), continuous=True),
103 Value("EoeHostToNode", controller_address_type("NETWORK_STATUS_EOE_HOST_TO_NODE_FRAME_COUNT")),
104 Value("EoeNodeToHost", controller_address_type("NETWORK_STATUS_EOE_NODE_TO_HOST_FRAME_COUNT")),
105]
106
107
108SYNC_GROUP_VALUES = [
109 Value("State", controller_address_type("NETWORK_SYNC_GROUP_STATE"), text=enum_name("RSINetworkSyncGroupState")),
110 Value("ExpectedWkc", controller_address_type("NETWORK_SYNC_GROUP_EXPECTED_WKC")),
111 Value("ActualWkc", controller_address_type("NETWORK_SYNC_GROUP_ACTUAL_WKC")),
112 Value("DataValid", controller_address_type("NETWORK_SYNC_GROUP_DATA_VALID")),
113 Value("ToleratedWkcCount", controller_address_type("NETWORK_SYNC_GROUP_TOLERATED_WKC_COUNT")),
114 Value("OperationalNodeCount", controller_address_type("NETWORK_SYNC_GROUP_OPERATIONAL_NODE_COUNT")),
115 Value("NonOperationalNodes", controller_address_type("NETWORK_SYNC_GROUP_NON_OPERATIONAL_NODE_MASK"), text=node_list),
116]
117
118
119NODE_VALUES = [
120 Value("AlStatus", node_address_type("AL_STATUS"), text=helpers.decode_al_status),
121 Value("AlStatusCode", node_address_type("AL_STATUS_CODE")),
122 Value("CoeEmergency", node_address_type("COE_EMERGENCY_MESSAGE")),
123 Value("CoeEmergencyCounter", node_address_type("COE_EMERGENCY_MESSAGE_NETWORK_COUNTER")),
124 Value("EoeHostToNode", node_address_type("EOE_HOST_TO_NODE_FRAME_COUNT")),
125 Value("EoeNodeToHost", node_address_type("EOE_NODE_TO_HOST_FRAME_COUNT")),
126 Value("Present", node_address_type("PRESENT")),
127 Value("InitState", node_address_type("INITIALIZATION_STATE"), text=enum_name("RSINetworkNodeInitializationState")),
128]
129
130SAMPLE_COUNTER = Value("SampleCounter", controller_address_type("SAMPLE_COUNTER"), continuous=True)
131
132
133
134Point = namedtuple("Point", "label address reader continuous text")
135
136
137def resolve(controller, prefix, value, rsi_object, *address_args):
138 """Ask a MotionController or RapidCodeNetworkNode for the value's address and data type."""
139 address = rsi_object.AddressGet(value.address_type, *address_args)
140 data_type = rsi_object.AddressDataTypeGet(value.address_type)
141 return Point(f"{prefix}.{value.label}", address, helpers.recorder_value_reader(controller, data_type),
142 value.continuous, value.text)
143
144
145def gather_points(controller):
146 """Every network value, every sync group's status, and every node's status."""
147 points = [resolve(controller, "Network", value, controller) for value in NETWORK_VALUES]
148 for group_id in range(controller.NetworkSyncGroupCountGet()):
149 points += [resolve(controller, f"SyncGroup{group_id}", value, controller, group_id) for value in SYNC_GROUP_VALUES]
150 for node_index in range(controller.NetworkNodeCountGet()):
151 node = controller.NetworkNodeGet(node_index)
152 helpers.check_errors(node)
153 points += [resolve(controller, f"Node{node_index}", value, node) for value in NODE_VALUES]
154 return points
155
156
157def recorders_needed(sync_group_count, node_count) -> int:
158 """Each Recorder also records the sample counter, so it holds one fewer value."""
159 values = (len(NETWORK_VALUES) + sync_group_count * len(SYNC_GROUP_VALUES)
160 + node_count * len(NODE_VALUES))
161 return math.ceil(values / (ADDRESSES_PER_RECORDER_MAXIMUM - 1))
162
163
164
165
166
167
168
169Change = namedtuple("Change", "sample point old new")
170
171
172def samples_between(earlier, later) -> int:
173 """later - earlier, allowing for the 32-bit sample counter wrapping around.
174
175 Two samples are ordered correctly only while they are less than half the modulus apart
176 (2^31 samples: 24.8 days at 1 kHz, 6.2 days at 4 kHz), so the timeline is in order as long
177 as the retained changes span less than that; with CHANGES_TO_KEEP bounding the log, older
178 changes are normally long gone by then."""
179 half = SAMPLE_COUNTER_MODULUS // 2
180 return (later - earlier + half) % SAMPLE_COUNTER_MODULUS - half
181
182
183class Recording:
184 """One Recorder and the records drained from it.
185
186 Data index 0 is always the controller's sample counter, so records from different
187 Recorders can be matched up by sample. Records are kept in a bounded history, and every
188 change of a (non-continuous) value is appended to the shared, bounded change log as the
189 records are drained, so a change hours before the failure is still reported."""
190
191 def __init__(self, controller, recorder_number, points, history_length, changes):
192 self.controller = controller
193 self.number = recorder_number
194 self.points = [resolve(controller, "Controller", SAMPLE_COUNTER, controller)] + points
195 self.columns = list(enumerate(point.reader for point in self.points))
196 self.watched = [(index, point) for index, point in enumerate(self.points) if not point.continuous]
197
198
199 self.watched_values = itemgetter(*[index for index, _ in self.watched]) if self.watched else (lambda record: None)
200 self.history = deque(maxlen=history_length)
201 self.changes = changes
202 self.total_records = 0
203 self.overflows = 0
204
205 def configure(self):
206 controller, number = self.controller, self.number
207 if controller.RecorderEnabledGet(number):
208 controller.RecorderStop(number)
209 controller.RecorderReset(number)
210 controller.RecorderPeriodSet(number, RECORD_PERIOD_SAMPLES)
211 controller.RecorderCircularBufferSet(number, True)
212 controller.RecorderDataCountSet(number, len(self.points))
213 for index, point in enumerate(self.points):
214 controller.RecorderDataAddressSet(number, index, point.address)
215
216
217
218 self.capacity = controller.RecorderRecordMaxCountGet(number)
219
220 def start(self):
221 self.controller.RecorderStart(self.number)
222
223 def stop(self):
224 self.controller.RecorderStop(self.number)
225 self.controller.RecorderReset(self.number)
226
227 def drain(self):
228 """Retrieve every available record into the history."""
229 controller, number = self.controller, self.number
230 available = controller.RecorderRecordCountGet(number)
231 if available >= self.capacity:
232
233
234
235 self.overflows += 1
236 print(f"Recorder {number} filled its buffer of {self.capacity} records before it was drained "
237 f"(overflow {self.overflows}): its records were lost and it was restarted. "
238 "Lower BUFFER_FILL_PER_RETRIEVE or raise RECORD_PERIOD_SAMPLES.")
239 self.configure()
240 self.start()
241 self.history.clear()
242 return
243 columns, watched_values = self.columns, self.watched_values
244 previous = self.history[-1] if self.history else None
245 while available > 0:
246 retrieved = controller.RecorderRecordDataRetrieveBulk(number, min(available, RECORDS_PER_RETRIEVE_MAXIMUM))
247 if retrieved <= 0:
248 break
249 for record_index in range(retrieved):
250 record = [read(number, record_index, data_index) for data_index, read in columns]
251 if previous is not None and watched_values(record) != watched_values(previous):
252 self.note_changes(previous, record)
253 self.history.append(record)
254 previous = record
255 available -= retrieved
256 self.total_records += retrieved
257
258 def note_changes(self, previous, record):
259 for index, point in self.watched:
260 if record[index] != previous[index]:
261 self.changes.append(Change(record[0], point, previous[index], record[index]))
262
263
264def create_recordings(controller, points, changes):
265 """Spread the points over as many Recorders as needed; they share one change log."""
266 points_per_recorder = ADDRESSES_PER_RECORDER_MAXIMUM - 1
267 history_length = int(HISTORY_SECONDS * controller.SampleRateGet() / RECORD_PERIOD_SAMPLES)
268 return [Recording(controller, recorder_number, points[start:start + points_per_recorder], history_length, changes)
269 for recorder_number, start in enumerate(range(0, len(points), points_per_recorder))]
270
271
272def retrieve_interval(controller, recordings) -> float:
273 """Seconds between drains: the fullest Recorder's buffer is at most BUFFER_FILL_PER_RETRIEVE full."""
274 records_per_second = controller.SampleRateGet() / RECORD_PERIOD_SAMPLES
275 capacity = min(recording.capacity for recording in recordings)
276 return min(RETRIEVE_INTERVAL_MAXIMUM_SECONDS, capacity * BUFFER_FILL_PER_RETRIEVE / records_per_second)
277
278
279def network_is_operational(controller) -> bool:
280 return controller.NetworkStateGet() == RapidCode.RSINetworkState_RSINetworkStateOPERATIONAL
281
282
283def network_state_name(controller) -> str:
284 return helpers.enum_to_name(controller.NetworkStateGet(), "RSINetworkState")
285
286
287def record_until_stopped(controller, recordings, record_seconds):
288 """Record continuously; stop on a key press (record_seconds None), after record_seconds,
289 or a few seconds after the network leaves OPERATIONAL, so the whole shutdown is captured."""
290 interactive = record_seconds is None
291 interval = retrieve_interval(controller, recordings)
292 for recording in recordings:
293 recording.start()
294 started = time.monotonic()
295 not_operational_since = None
296
297 print(f"Recording {sum(len(recording.points) - 1 for recording in recordings)} values on "
298 f"{len(recordings)} Recorder(s), every {RECORD_PERIOD_SAMPLES} sample(s) at "
299 f"{controller.SampleRateGet():g} Hz, draining every {interval * 1000:.0f} ms, "
300 "until the network leaves OPERATIONAL.")
301 print("Press a key to stop (Enter on Linux terminals)." if interactive
302 else f"Recording for {record_seconds} seconds.")
303
304 next_drain = time.monotonic()
305 while True:
306
307
308 next_drain += interval
309 time.sleep(max(0.0, next_drain - time.monotonic()))
310 for recording in recordings:
311 recording.drain()
312
313 if interactive and helpers.key_pressed():
314 print("Key pressed, stopping.")
315 break
316 if not interactive and time.monotonic() - started >= record_seconds:
317 break
318
319 if network_is_operational(controller):
320 not_operational_since = None
321 elif not_operational_since is None:
322 not_operational_since = time.monotonic()
323 print(f"Network left OPERATIONAL (now {network_state_name(controller)}), "
324 f"recording the shutdown for {NOT_OPERATIONAL_GRACE_SECONDS} more seconds.")
325 elif time.monotonic() - not_operational_since >= NOT_OPERATIONAL_GRACE_SECONDS:
326 break
327
328 for recording in recordings:
329 recording.drain()
330 recording.stop()
331
332
333
334
335
336
337
338
339
340Records = namedtuple("Records", "points samples values newest")
341
342
343def merge_records(recordings):
344 """Line up the records of every Recorder by sample counter, in recording order.
345
346 With RECORD_PERIOD_SAMPLES above 1 each Recorder records on its own phase of the period
347 (the sample its RecorderStart landed in), so each Recorder's counters are shifted down by
348 that phase before matching and the samples reported are the first Recorder's. Only samples
349 present in every Recorder's history are kept, so if one Recorder was restarted after an
350 overflow or drifted, samples (and every values list) can be empty; the report then falls
351 back to the change log and newest."""
352 points = [point for recording in recordings for point in recording.points[1:]]
353 phases = [recording.history[0][0] % RECORD_PERIOD_SAMPLES if recording.history else 0 for recording in recordings]
354 by_sample = [{record[0] - phase: record[1:] for record in recording.history}
355 for recording, phase in zip(recordings, phases)]
356 keys = [key for key in by_sample[0] if all(key in records for records in by_sample)]
357 samples = [key + phases[0] for key in keys]
358 values = {}
359 newest = {}
360 for records, recording in zip(by_sample, recordings):
361 for column, point in enumerate(recording.points[1:]):
362 values[point.label] = [records[key][column] for key in keys]
363 if recording.history:
364 newest[point.label] = recording.history[-1][1 + column]
365 return Records(points, samples, values, newest)
366
367
368class Report:
369 """The narrative of what changed, timed in seconds from the first change."""
370
371 def __init__(self, records, changes, node_names, sample_rate):
372 self.records = records
373 self.changes = changes
374 self.node_names = node_names
375 self.sample_rate = sample_rate
376 self.first_sample = changes[0].sample
377 self.latest = {}
378 for change in changes:
379 self.latest[change.point.label] = change
380
381 def seconds(self, sample) -> str:
382 return f"t {samples_between(self.first_sample, sample) / self.sample_rate:+.3f} s"
383
384 def transition(self, change) -> str:
385 return f"{change.point.text(change.old)} -> {change.point.text(change.new)}"
386
387 def print_timeline(self):
388 shown = self.changes[-CHANGES_TO_PRINT:]
389 print(f"\nTimeline: {len(self.changes)} change(s) while recording"
390 + (f", the newest {len(shown)} shown" if len(shown) < len(self.changes) else "")
391 + f". t = 0 is sample {self.first_sample}, the first change.")
392 for change in shown:
393 print(f" {self.seconds(change.sample):>14} {change.point.label:<32} {self.transition(change)}")
394
395 def print_summary(self):
396 print("\nSummary:")
397 self.print_node_summary()
398 self.print_sync_group_summary()
399 self.print_network_summary()
400
401 def print_node_summary(self):
402 node_count = len(self.node_names)
403 lost = [index for index in range(node_count)
404 if f"Node{index}.Present" in self.latest and self.latest[f"Node{index}.Present"].new == 0]
405 for index in lost:
406 change = self.latest[f"Node{index}.Present"]
407 print(f" Node{index} ({self.node_names[index]}) stopped responding at {self.seconds(change.sample)}.")
408 if lost and lost == list(range(lost[0], node_count)) and lost[0] > 0:
409 print(f" Every node from Node{lost[0]} on is gone while Node{lost[0] - 1} still answers: "
410 f"the break is between Node{lost[0] - 1} and Node{lost[0]}.")
411 if not lost:
412 print(" No node stopped responding (Present stayed 1 for every node).")
413 for index in range(node_count):
414 change = self.latest.get(f"Node{index}.AlStatus")
415 if change:
416 print(f" Node{index} ({self.node_names[index]}) AL state {self.transition(change)} "
417 f"at {self.seconds(change.sample)}.")
418
419 def print_sync_group_summary(self):
420 for label, change in sorted(self.latest.items()):
421 if label.endswith(".NonOperationalNodes"):
422 group = label.split(".")[0]
423 expected = self.records.newest.get(f"{group}.ExpectedWkc", "?")
424 actual = self.records.newest.get(f"{group}.ActualWkc", "?")
425 print(f" {group}: non-operational nodes {change.point.text(change.new)} at {self.seconds(change.sample)}; "
426 f"working counter {actual} of {expected} expected.")
427
428 def print_network_summary(self):
429 states = [change for change in self.changes if change.point.label == "Network.State"]
430 if states:
431 print(" Network state: " + ", ".join(f"{self.transition(change)} at {self.seconds(change.sample)}"
432 for change in states) + ".")
433 missed = self.latest.get("Network.MissedFrames")
434 if missed and missed.new > 0:
435 print(f" Missed cyclic frames rose to {missed.new}.")
436
437 def print_newest_records(self):
438 """The newest records, side by side, for the values that changed (the rest would be noise)."""
439 if not self.records.samples:
440 print("\nThe Recorders' histories share no sample counter (a Recorder was restarted after "
441 "an overflow), so the newest records cannot be shown side by side.")
442 return
443 newest = slice(-FINAL_RECORDS_TO_PRINT, None)
444 print(f"\nNewest {len(self.records.samples[newest])} records of the values that changed (one column per record):")
445 print(f"{'sample':>32}: " + " ".join(f"{sample:>14}" for sample in self.records.samples[newest]))
446 for point in self.records.points:
447 if point.label in self.latest:
448 print(f"{point.label:>32}: " + " ".join(f"{point.text(value):>14}"
449 for value in self.records.values[point.label][newest]))
450
451
452def write_records_csv(records, recordings):
453 """Write the last HISTORY_SECONDS of every recorded value to a time-stamped CSV file:
454 one row per record, a sample counter column, then one column per value. When the
455 Recorders' histories cannot be lined up, each Recorder's history goes to its own file."""
456 stamp = time.strftime("%Y%m%d-%H%M%S")
457 if records.samples:
458 file_name = f"{RECORDS_FILE_PREFIX}{stamp}.csv"
459 with open(file_name, "w", newline="", encoding="utf-8") as csv_file:
460 writer = csv.writer(csv_file)
461 writer.writerow(["SampleCounter"] + [point.label for point in records.points])
462 for row, sample in enumerate(records.samples):
463 writer.writerow([sample] + [records.values[point.label][row] for point in records.points])
464 print(f"Wrote {len(records.samples)} records of {len(records.points)} values to {file_name}.")
465 return
466 for recording in recordings:
467 if not recording.history:
468 continue
469 file_name = f"{RECORDS_FILE_PREFIX}{stamp}-recorder{recording.number}.csv"
470 with open(file_name, "w", newline="", encoding="utf-8") as csv_file:
471 writer = csv.writer(csv_file)
472 writer.writerow([point.label for point in recording.points])
473 writer.writerows(recording.history)
474 print(f"Wrote Recorder {recording.number}: {len(recording.history)} records to {file_name}.")
475
476
477def write_network_log(controller):
478 """Write the network firmware's log to a time-stamped file; it may hold further clues.
479 The firmware publishes its log when the network shuts down, so there is nothing to
480 write while the network is still running."""
481 count = controller.NetworkLogMessageCountGet()
482 if count == 0:
483 return
484 file_name = NETWORK_LOG_FILE_PREFIX + time.strftime("%Y%m%d-%H%M%S") + ".txt"
485 with open(file_name, "w", encoding="utf-8") as log_file:
486 for index in range(count):
487 log_file.write(controller.NetworkLogMessageGet(index) + "\n")
488 print(f"Wrote {count} network firmware log messages to {file_name}.")
489
490
491def print_results(controller, recordings, changes, node_names):
492 records = merge_records(recordings)
493 changes = sorted(changes, key=lambda change: samples_between(changes[0].sample, change.sample))
494 total = sum(recording.total_records for recording in recordings)
495 print(f"\nRetrieved {total} records; {len(records.samples)} lined-up records kept in history. "
496 f"Network state now: {network_state_name(controller)}.")
497 if changes:
498 report = Report(records, changes, node_names, controller.SampleRateGet())
499 report.print_timeline()
500 report.print_summary()
501 report.print_newest_records()
502 else:
503 print("No status value changed while recording.")
504 print()
505 write_records_csv(records, recordings)
506 write_network_log(controller)
507
508
509
510
511
512
513exit_code = constants.EXIT_FAILURE
514
515
516
517if len(sys.argv) > 1:
518 record_seconds = float(sys.argv[1])
519else:
520 record_seconds = None if helpers.stdin_is_interactive else NON_INTERACTIVE_RECORD_SECONDS
521
522creation_params: RapidCode.CreationParameters = helpers.get_creation_parameters()
523controller: RapidCode.MotionController = RapidCode.MotionController.Create(creation_params)
524
525try:
526 helpers.check_errors(controller)
527 node_count = controller.NetworkNodeCountGet()
528 needed = recorders_needed(controller.NetworkSyncGroupCountGet(), node_count)
529
530 if not network_is_operational(controller):
531 print(f"The network state is {network_state_name(controller)}, but this sample needs an OPERATIONAL "
532 "EtherCAT network so it has network and node status to record.\n"
533 "Start the network first (RapidSetup, or MotionController.NetworkStart()) "
534 "and run this sample again. Exiting successfully so automated test runs "
535 "without EtherCAT hardware stay green.")
536 exit_code = constants.EXIT_SUCCESS
537 elif controller.RecorderCountGet() < needed:
538 print(f"This controller has {controller.RecorderCountGet()} Recorder(s) configured, but recording the "
539 f"status of {node_count} node(s) needs {needed} "
540 f"(a Recorder holds at most {ADDRESSES_PER_RECORDER_MAXIMUM} addresses).\n"
541 f"Configure the recorder count before other objects are created (call "
542 f"MotionController.RecorderCountSet({needed}) early in your application, or set the "
543 "recorder count in RapidSetup) and run this sample again. "
544 "Exiting successfully so automated test runs stay green.")
545 exit_code = constants.EXIT_SUCCESS
546 else:
547 node_names = [controller.NetworkNodeGet(index).NameGet() for index in range(node_count)]
548 print("Nodes:")
549 for index, name in enumerate(node_names):
550 print(f" Node{index}: {name}")
551 changes = deque(maxlen=CHANGES_TO_KEEP)
552 recordings = create_recordings(controller, gather_points(controller), changes)
553 for recording in recordings:
554 recording.configure()
555 record_until_stopped(controller, recordings, record_seconds)
556 print_results(controller, recordings, changes, node_names)
557 exit_code = constants.EXIT_SUCCESS
558except Exception as e:
559 print(f"❌ Error: {e}")
560 exit_code = constants.EXIT_FAILURE
561finally:
562 controller.Delete()
563
564sys.exit(exit_code)