APIs, concepts, guides, and more
recorder-network-status.py
Note
See Recorder: Network Status 📜 for a detailed explanation of this sample code.
Warning
This is a sample program to assist in the integration of the RMP motion controller with your application. It may not contain all of the logic and safety features that your application requires. We recommend that you wire an external hardware emergency stop (e-stop) button for safety when using our code sample apps. Doing so will help ensure the safety of you and those around you and will prevent potential injury or damage.

The sample apps assume that the system (network, axes, I/O) are configured prior to running the code featured in the sample app. See the Configuration page for more information.
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 # record every RMP sample
49RETRIEVE_INTERVAL_MAXIMUM_SECONDS = 0.25 # how often the loop drains the Recorders, at most (see BUFFER_FILL_PER_RETRIEVE)
50BUFFER_FILL_PER_RETRIEVE = 0.25 # drain often enough that a Recorder's buffer is at most this full each time
51RECORDS_PER_RETRIEVE_MAXIMUM = 1024 # RecorderRecordDataRetrieveBulk limit
52HISTORY_SECONDS = 10.0 # how many seconds of records to keep for the timeline
53NOT_OPERATIONAL_GRACE_SECONDS = 4.0 # keep recording this long after the network leaves OPERATIONAL
54NON_INTERACTIVE_RECORD_SECONDS = 5.0 # stdin is not a terminal (automated run): record this long
55CHANGES_TO_KEEP = 10000 # the change log is bounded too: the oldest changes are dropped
56CHANGES_TO_PRINT = 60 # how many of the newest changes to print in the timeline
57FINAL_RECORDS_TO_PRINT = 8 # how many of the newest records to print side by side
58SAMPLE_COUNTER_MODULUS = 2 ** 32 # the sample counter is 32 bits: it wraps after 2^32 samples (49.7 days at 1 kHz)
59ADDRESSES_PER_RECORDER_MAXIMUM = 32 # a Recorder records at most this many addresses
60NETWORK_LOG_FILE_PREFIX = "network-log-" # followed by the date and time, then .txt
61RECORDS_FILE_PREFIX = "network-records-" # followed by the date and time, then .csv
62
63
64# ┌───────────────────────────────────┐
65# │ WHAT TO RECORD │
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# One value to record. "continuous" values change every sample (timing) and are left out of
88# the timeline. "text" formats the value for printing; the default prints the number.
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# Network-wide values. See MotionController::NetworkStatus.
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# Per sync group; the group id is the object index. See MotionController::NetworkSyncGroupStatus.
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# Per node. See RapidCodeNetworkNode::Status.
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# A value resolved to a host address, plus the reader that gets it back out of a retrieved
133# record with one RapidCode call (chosen from its RSIDataType, see helpers.recorder_value_reader).
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# │ RECORDING │
166# └───────────────────────────────────┘
167
168# One value changing: at which sample, and from what to what.
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)) # (index, reader(recorder, record, index))
196 self.watched = [(index, point) for index, point in enumerate(self.points) if not point.continuous]
197 # One C-level call picks every watched value out of a record, so the records that changed
198 # nothing (nearly all of them) cost one comparison instead of a Python loop over the columns.
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) # newest last; each record is [sample counter, value, ...]
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) # never stop: old unread records are overwritten
212 controller.RecorderDataCountSet(number, len(self.points))
213 for index, point in enumerate(self.points):
214 controller.RecorderDataAddressSet(number, index, point.address)
215 # Once the buffer overflows, retrieved records come back out of order and repeated, so the
216 # drain interval is derived from this capacity (see retrieve_interval) and drain() restarts
217 # the Recorder if it happens anyway.
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 # The unread records are a mix of old and new: diffing them would log changes that never
233 # happened, so they are dropped and the Recorder restarted. The change log keeps what was
234 # seen before; the history starts over, so the report may not line this Recorder up.
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 # index 0 is the sample counter
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 # Sleep until the next deadline rather than for the interval, so the time the drains take
307 # does not stretch the period and let a buffer fill past BUFFER_FILL_PER_RETRIEVE.
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# │ REPORTING │
335# └───────────────────────────────────┘
336
337# The history lined up across Recorders: samples[i] is the sample counter of the record
338# whose values are values[label][i], oldest first. newest[label] is the last value each
339# Recorder drained for that point, available even when the histories cannot be lined up.
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 = {} # label -> the newest Change of that value
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# │ MAIN │
511# └───────────────────────────────────┘
512
513exit_code = constants.EXIT_FAILURE
514
515# Optional: how many seconds to record. Without it the sample records until a key is pressed,
516# or for NON_INTERACTIVE_RECORD_SECONDS when stdin is not a terminal (automated runs).
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)