APIs, concepts, guides, and more
Recorder: Network Status

Learn how to record every EtherCAT network and node status value continuously, so that when a network fails unexpectedly the final records show what happened. The application spreads the NetworkStatus and per-node status addresses over as many Recorders as needed, keeps recording until the network leaves OPERATIONAL (or a key is pressed), then prints a change log and the newest records, and writes the last seconds of every value to a CSV file and the network firmware log to a text file.

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.


In this page:


📜 Recorder: Network Status

""" Record EtherCAT network and node status continuously, to explain a network failure.
"The network died and we do not know why." This sample keeps Recorders running on
every dynamic network value (RSIControllerAddressType NETWORK_STATE and
NETWORK_STATUS_*), every sync group's status (NETWORK_SYNC_GROUP_*) and every node's
status (RSINetworkNodeAddressType). When the network leaves OPERATIONAL it keeps
recording for a few seconds so the whole shutdown is captured, then prints:
- a timeline: every value that changed, in order, timed from the first change
(normally the moment the cable was pulled),
- a summary: which nodes stopped responding, what the sync groups saw, and when
the network state changed,
- the newest records of the values that changed, side by side.
The last seconds of every recorded value are written to a time-stamped CSV file, and the
network firmware's log to a text file, for further study.
A Recorder holds at most 32 addresses, so the values are spread over as many
Recorders as needed. Every Recorder also records the controller's sample counter so
the records of different Recorders can be lined up. A Recorder's buffer holds 512
records of 32 values, so the drain interval is derived from the sample rate (about
128 ms at 1 kHz, 32 ms at 4 kHz); the buffer is never resized, since that reconfigures
the controller and invalidates every RapidCode object in every process. Every recorded
value costs one call into RapidCode when it is drained (see helpers.recorder_value_reader),
so a large network at a high sample rate may need RECORD_PERIOD_SAMPLES raised: the
sample says so if a Recorder's buffer fills before it is drained.
The sample runs until the network leaves OPERATIONAL (it may run for days), so it
keeps a bounded history and a bounded log of changes: memory does not grow with time.
It also stops when a key is pressed (Enter on Linux/macOS), or after the number of
seconds given on the command line: python recorder-network-status.py 30
Requirements: an OPERATIONAL EtherCAT network and enough Recorders on the
MotionController (RecorderCountSet). If either is missing the sample prints how to
fix that and exits successfully, so automated runs without hardware stay green.
"""
import csv
import math
import sys
import time
from collections import deque, namedtuple
from operator import itemgetter
from _imports import RapidCode, helpers, constants
print("⬤ Recorder: Network and Node Status")
RECORD_PERIOD_SAMPLES = 1 # record every RMP sample
RETRIEVE_INTERVAL_MAXIMUM_SECONDS = 0.25 # how often the loop drains the Recorders, at most (see BUFFER_FILL_PER_RETRIEVE)
BUFFER_FILL_PER_RETRIEVE = 0.25 # drain often enough that a Recorder's buffer is at most this full each time
RECORDS_PER_RETRIEVE_MAXIMUM = 1024 # RecorderRecordDataRetrieveBulk limit
HISTORY_SECONDS = 10.0 # how many seconds of records to keep for the timeline
NOT_OPERATIONAL_GRACE_SECONDS = 4.0 # keep recording this long after the network leaves OPERATIONAL
NON_INTERACTIVE_RECORD_SECONDS = 5.0 # stdin is not a terminal (automated run): record this long
CHANGES_TO_KEEP = 10000 # the change log is bounded too: the oldest changes are dropped
CHANGES_TO_PRINT = 60 # how many of the newest changes to print in the timeline
FINAL_RECORDS_TO_PRINT = 8 # how many of the newest records to print side by side
SAMPLE_COUNTER_MODULUS = 2 ** 32 # the sample counter is 32 bits: it wraps after 2^32 samples (49.7 days at 1 kHz)
ADDRESSES_PER_RECORDER_MAXIMUM = 32 # a Recorder records at most this many addresses
NETWORK_LOG_FILE_PREFIX = "network-log-" # followed by the date and time, then .txt
RECORDS_FILE_PREFIX = "network-records-" # followed by the date and time, then .csv
# ┌───────────────────────────────────┐
# │ WHAT TO RECORD │
# └───────────────────────────────────┘
def controller_address_type(name):
return getattr(RapidCode, "RSIControllerAddressType_RSIControllerAddressType" + name)
def node_address_type(name):
return getattr(RapidCode, "RSINetworkNodeAddressType_RSINetworkNodeAddressType" + name)
def enum_name(enum):
"""Formatter that prints a value by its RSI enum member name, e.g. OPERATIONAL."""
return lambda value: helpers.enum_to_name(value, enum)
def node_list(mask):
"""Formatter for a node bitmask, e.g. Node1,4."""
nodes = [str(index) for index in range(64) if mask & (1 << index)]
return "Node" + ",".join(nodes) if nodes else "none"
# One value to record. "continuous" values change every sample (timing) and are left out of
# the timeline. "text" formats the value for printing; the default prints the number.
class Value(namedtuple("Value", "label address_type continuous text")):
def __new__(cls, label, address_type, continuous=False, text=str):
return super().__new__(cls, label, address_type, continuous, text)
# Network-wide values. See MotionController::NetworkStatus.
NETWORK_VALUES = [
Value("State", controller_address_type("NETWORK_STATE"), text=enum_name("RSINetworkState")),
Value("LastStartError", controller_address_type("NETWORK_LAST_START_ERROR"), text=enum_name("RSINetworkStartError")),
Value("ActiveNodeCount", controller_address_type("NETWORK_STATUS_ACTIVE_NODE_COUNT")),
Value("AlStatus", controller_address_type("NETWORK_STATUS_AL_STATUS"), text=helpers.decode_al_status_ored),
Value("MissedFrames", controller_address_type("NETWORK_STATUS_MISSED_CYCLIC_FRAME_COUNT")),
Value("FramePeriodUs", controller_address_type("NETWORK_STATUS_CYCLIC_FRAME_PERIOD_US"), continuous=True),
Value("SyncErrorNs", controller_address_type("NETWORK_STATUS_SYNCHRONIZATION_ERROR_NS"), continuous=True),
Value("EoeHostToNode", controller_address_type("NETWORK_STATUS_EOE_HOST_TO_NODE_FRAME_COUNT")),
Value("EoeNodeToHost", controller_address_type("NETWORK_STATUS_EOE_NODE_TO_HOST_FRAME_COUNT")),
]
# Per sync group; the group id is the object index. See MotionController::NetworkSyncGroupStatus.
SYNC_GROUP_VALUES = [
Value("State", controller_address_type("NETWORK_SYNC_GROUP_STATE"), text=enum_name("RSINetworkSyncGroupState")),
Value("ExpectedWkc", controller_address_type("NETWORK_SYNC_GROUP_EXPECTED_WKC")),
Value("ActualWkc", controller_address_type("NETWORK_SYNC_GROUP_ACTUAL_WKC")),
Value("DataValid", controller_address_type("NETWORK_SYNC_GROUP_DATA_VALID")),
Value("ToleratedWkcCount", controller_address_type("NETWORK_SYNC_GROUP_TOLERATED_WKC_COUNT")),
Value("OperationalNodeCount", controller_address_type("NETWORK_SYNC_GROUP_OPERATIONAL_NODE_COUNT")),
Value("NonOperationalNodes", controller_address_type("NETWORK_SYNC_GROUP_NON_OPERATIONAL_NODE_MASK"), text=node_list),
]
# Per node. See RapidCodeNetworkNode::Status.
NODE_VALUES = [
Value("AlStatus", node_address_type("AL_STATUS"), text=helpers.decode_al_status),
Value("AlStatusCode", node_address_type("AL_STATUS_CODE")),
Value("CoeEmergency", node_address_type("COE_EMERGENCY_MESSAGE")),
Value("CoeEmergencyCounter", node_address_type("COE_EMERGENCY_MESSAGE_NETWORK_COUNTER")),
Value("EoeHostToNode", node_address_type("EOE_HOST_TO_NODE_FRAME_COUNT")),
Value("EoeNodeToHost", node_address_type("EOE_NODE_TO_HOST_FRAME_COUNT")),
Value("Present", node_address_type("PRESENT")),
Value("InitState", node_address_type("INITIALIZATION_STATE"), text=enum_name("RSINetworkNodeInitializationState")),
]
SAMPLE_COUNTER = Value("SampleCounter", controller_address_type("SAMPLE_COUNTER"), continuous=True)
# A value resolved to a host address, plus the reader that gets it back out of a retrieved
# record with one RapidCode call (chosen from its RSIDataType, see helpers.recorder_value_reader).
Point = namedtuple("Point", "label address reader continuous text")
def resolve(controller, prefix, value, rsi_object, *address_args):
"""Ask a MotionController or RapidCodeNetworkNode for the value's address and data type."""
address = rsi_object.AddressGet(value.address_type, *address_args)
data_type = rsi_object.AddressDataTypeGet(value.address_type)
return Point(f"{prefix}.{value.label}", address, helpers.recorder_value_reader(controller, data_type),
value.continuous, value.text)
def gather_points(controller):
"""Every network value, every sync group's status, and every node's status."""
points = [resolve(controller, "Network", value, controller) for value in NETWORK_VALUES]
for group_id in range(controller.NetworkSyncGroupCountGet()):
points += [resolve(controller, f"SyncGroup{group_id}", value, controller, group_id) for value in SYNC_GROUP_VALUES]
for node_index in range(controller.NetworkNodeCountGet()):
node = controller.NetworkNodeGet(node_index)
helpers.check_errors(node)
points += [resolve(controller, f"Node{node_index}", value, node) for value in NODE_VALUES]
return points
def recorders_needed(sync_group_count, node_count) -> int:
"""Each Recorder also records the sample counter, so it holds one fewer value."""
values = (len(NETWORK_VALUES) + sync_group_count * len(SYNC_GROUP_VALUES)
+ node_count * len(NODE_VALUES))
return math.ceil(values / (ADDRESSES_PER_RECORDER_MAXIMUM - 1))
# ┌───────────────────────────────────┐
# │ RECORDING │
# └───────────────────────────────────┘
# One value changing: at which sample, and from what to what.
Change = namedtuple("Change", "sample point old new")
def samples_between(earlier, later) -> int:
"""later - earlier, allowing for the 32-bit sample counter wrapping around.
Two samples are ordered correctly only while they are less than half the modulus apart
(2^31 samples: 24.8 days at 1 kHz, 6.2 days at 4 kHz), so the timeline is in order as long
as the retained changes span less than that; with CHANGES_TO_KEEP bounding the log, older
changes are normally long gone by then."""
half = SAMPLE_COUNTER_MODULUS // 2
return (later - earlier + half) % SAMPLE_COUNTER_MODULUS - half
class Recording:
"""One Recorder and the records drained from it.
Data index 0 is always the controller's sample counter, so records from different
Recorders can be matched up by sample. Records are kept in a bounded history, and every
change of a (non-continuous) value is appended to the shared, bounded change log as the
records are drained, so a change hours before the failure is still reported."""
def __init__(self, controller, recorder_number, points, history_length, changes):
self.controller = controller
self.number = recorder_number
self.points = [resolve(controller, "Controller", SAMPLE_COUNTER, controller)] + points
self.columns = list(enumerate(point.reader for point in self.points)) # (index, reader(recorder, record, index))
self.watched = [(index, point) for index, point in enumerate(self.points) if not point.continuous]
# One C-level call picks every watched value out of a record, so the records that changed
# nothing (nearly all of them) cost one comparison instead of a Python loop over the columns.
self.watched_values = itemgetter(*[index for index, _ in self.watched]) if self.watched else (lambda record: None)
self.history = deque(maxlen=history_length) # newest last; each record is [sample counter, value, ...]
self.changes = changes
self.total_records = 0
self.overflows = 0
def configure(self):
controller, number = self.controller, self.number
if controller.RecorderEnabledGet(number):
controller.RecorderStop(number)
controller.RecorderReset(number)
controller.RecorderPeriodSet(number, RECORD_PERIOD_SAMPLES)
controller.RecorderCircularBufferSet(number, True) # never stop: old unread records are overwritten
controller.RecorderDataCountSet(number, len(self.points))
for index, point in enumerate(self.points):
controller.RecorderDataAddressSet(number, index, point.address)
# Once the buffer overflows, retrieved records come back out of order and repeated, so the
# drain interval is derived from this capacity (see retrieve_interval) and drain() restarts
# the Recorder if it happens anyway.
self.capacity = controller.RecorderRecordMaxCountGet(number)
def start(self):
self.controller.RecorderStart(self.number)
def stop(self):
self.controller.RecorderStop(self.number)
self.controller.RecorderReset(self.number)
def drain(self):
"""Retrieve every available record into the history."""
controller, number = self.controller, self.number
available = controller.RecorderRecordCountGet(number)
if available >= self.capacity:
# The unread records are a mix of old and new: diffing them would log changes that never
# happened, so they are dropped and the Recorder restarted. The change log keeps what was
# seen before; the history starts over, so the report may not line this Recorder up.
self.overflows += 1
print(f"Recorder {number} filled its buffer of {self.capacity} records before it was drained "
f"(overflow {self.overflows}): its records were lost and it was restarted. "
"Lower BUFFER_FILL_PER_RETRIEVE or raise RECORD_PERIOD_SAMPLES.")
self.configure()
self.start()
self.history.clear()
return
columns, watched_values = self.columns, self.watched_values
previous = self.history[-1] if self.history else None
while available > 0:
retrieved = controller.RecorderRecordDataRetrieveBulk(number, min(available, RECORDS_PER_RETRIEVE_MAXIMUM))
if retrieved <= 0:
break
for record_index in range(retrieved):
record = [read(number, record_index, data_index) for data_index, read in columns]
if previous is not None and watched_values(record) != watched_values(previous):
self.note_changes(previous, record)
self.history.append(record)
previous = record
available -= retrieved
self.total_records += retrieved
def note_changes(self, previous, record):
for index, point in self.watched:
if record[index] != previous[index]:
self.changes.append(Change(record[0], point, previous[index], record[index]))
def create_recordings(controller, points, changes):
"""Spread the points over as many Recorders as needed; they share one change log."""
points_per_recorder = ADDRESSES_PER_RECORDER_MAXIMUM - 1 # index 0 is the sample counter
history_length = int(HISTORY_SECONDS * controller.SampleRateGet() / RECORD_PERIOD_SAMPLES)
return [Recording(controller, recorder_number, points[start:start + points_per_recorder], history_length, changes)
for recorder_number, start in enumerate(range(0, len(points), points_per_recorder))]
def retrieve_interval(controller, recordings) -> float:
"""Seconds between drains: the fullest Recorder's buffer is at most BUFFER_FILL_PER_RETRIEVE full."""
records_per_second = controller.SampleRateGet() / RECORD_PERIOD_SAMPLES
capacity = min(recording.capacity for recording in recordings)
return min(RETRIEVE_INTERVAL_MAXIMUM_SECONDS, capacity * BUFFER_FILL_PER_RETRIEVE / records_per_second)
def network_is_operational(controller) -> bool:
return controller.NetworkStateGet() == RapidCode.RSINetworkState_RSINetworkStateOPERATIONAL
def network_state_name(controller) -> str:
return helpers.enum_to_name(controller.NetworkStateGet(), "RSINetworkState")
def record_until_stopped(controller, recordings, record_seconds):
"""Record continuously; stop on a key press (record_seconds None), after record_seconds,
or a few seconds after the network leaves OPERATIONAL, so the whole shutdown is captured."""
interactive = record_seconds is None
interval = retrieve_interval(controller, recordings)
for recording in recordings:
recording.start()
started = time.monotonic()
not_operational_since = None
print(f"Recording {sum(len(recording.points) - 1 for recording in recordings)} values on "
f"{len(recordings)} Recorder(s), every {RECORD_PERIOD_SAMPLES} sample(s) at "
f"{controller.SampleRateGet():g} Hz, draining every {interval * 1000:.0f} ms, "
"until the network leaves OPERATIONAL.")
print("Press a key to stop (Enter on Linux terminals)." if interactive
else f"Recording for {record_seconds} seconds.")
next_drain = time.monotonic()
while True:
# Sleep until the next deadline rather than for the interval, so the time the drains take
# does not stretch the period and let a buffer fill past BUFFER_FILL_PER_RETRIEVE.
next_drain += interval
time.sleep(max(0.0, next_drain - time.monotonic()))
for recording in recordings:
recording.drain()
if interactive and helpers.key_pressed():
print("Key pressed, stopping.")
break
if not interactive and time.monotonic() - started >= record_seconds:
break
if network_is_operational(controller):
not_operational_since = None
elif not_operational_since is None:
not_operational_since = time.monotonic()
print(f"Network left OPERATIONAL (now {network_state_name(controller)}), "
f"recording the shutdown for {NOT_OPERATIONAL_GRACE_SECONDS} more seconds.")
elif time.monotonic() - not_operational_since >= NOT_OPERATIONAL_GRACE_SECONDS:
break
for recording in recordings:
recording.drain()
recording.stop()
# ┌───────────────────────────────────┐
# │ REPORTING │
# └───────────────────────────────────┘
# The history lined up across Recorders: samples[i] is the sample counter of the record
# whose values are values[label][i], oldest first. newest[label] is the last value each
# Recorder drained for that point, available even when the histories cannot be lined up.
Records = namedtuple("Records", "points samples values newest")
def merge_records(recordings):
"""Line up the records of every Recorder by sample counter, in recording order.
With RECORD_PERIOD_SAMPLES above 1 each Recorder records on its own phase of the period
(the sample its RecorderStart landed in), so each Recorder's counters are shifted down by
that phase before matching and the samples reported are the first Recorder's. Only samples
present in every Recorder's history are kept, so if one Recorder was restarted after an
overflow or drifted, samples (and every values list) can be empty; the report then falls
back to the change log and newest."""
points = [point for recording in recordings for point in recording.points[1:]]
phases = [recording.history[0][0] % RECORD_PERIOD_SAMPLES if recording.history else 0 for recording in recordings]
by_sample = [{record[0] - phase: record[1:] for record in recording.history}
for recording, phase in zip(recordings, phases)]
keys = [key for key in by_sample[0] if all(key in records for records in by_sample)]
samples = [key + phases[0] for key in keys]
values = {}
newest = {}
for records, recording in zip(by_sample, recordings):
for column, point in enumerate(recording.points[1:]):
values[point.label] = [records[key][column] for key in keys]
if recording.history:
newest[point.label] = recording.history[-1][1 + column]
return Records(points, samples, values, newest)
class Report:
"""The narrative of what changed, timed in seconds from the first change."""
def __init__(self, records, changes, node_names, sample_rate):
self.records = records
self.changes = changes
self.node_names = node_names
self.sample_rate = sample_rate
self.first_sample = changes[0].sample
self.latest = {} # label -> the newest Change of that value
for change in changes:
self.latest[change.point.label] = change
def seconds(self, sample) -> str:
return f"t {samples_between(self.first_sample, sample) / self.sample_rate:+.3f} s"
def transition(self, change) -> str:
return f"{change.point.text(change.old)} -> {change.point.text(change.new)}"
def print_timeline(self):
shown = self.changes[-CHANGES_TO_PRINT:]
print(f"\nTimeline: {len(self.changes)} change(s) while recording"
+ (f", the newest {len(shown)} shown" if len(shown) < len(self.changes) else "")
+ f". t = 0 is sample {self.first_sample}, the first change.")
for change in shown:
print(f" {self.seconds(change.sample):>14} {change.point.label:<32} {self.transition(change)}")
def print_summary(self):
print("\nSummary:")
self.print_node_summary()
self.print_sync_group_summary()
self.print_network_summary()
def print_node_summary(self):
node_count = len(self.node_names)
lost = [index for index in range(node_count)
if f"Node{index}.Present" in self.latest and self.latest[f"Node{index}.Present"].new == 0]
for index in lost:
change = self.latest[f"Node{index}.Present"]
print(f" Node{index} ({self.node_names[index]}) stopped responding at {self.seconds(change.sample)}.")
if lost and lost == list(range(lost[0], node_count)) and lost[0] > 0:
print(f" Every node from Node{lost[0]} on is gone while Node{lost[0] - 1} still answers: "
f"the break is between Node{lost[0] - 1} and Node{lost[0]}.")
if not lost:
print(" No node stopped responding (Present stayed 1 for every node).")
for index in range(node_count):
change = self.latest.get(f"Node{index}.AlStatus")
if change:
print(f" Node{index} ({self.node_names[index]}) AL state {self.transition(change)} "
f"at {self.seconds(change.sample)}.")
def print_sync_group_summary(self):
for label, change in sorted(self.latest.items()):
if label.endswith(".NonOperationalNodes"):
group = label.split(".")[0]
expected = self.records.newest.get(f"{group}.ExpectedWkc", "?")
actual = self.records.newest.get(f"{group}.ActualWkc", "?")
print(f" {group}: non-operational nodes {change.point.text(change.new)} at {self.seconds(change.sample)}; "
f"working counter {actual} of {expected} expected.")
def print_network_summary(self):
states = [change for change in self.changes if change.point.label == "Network.State"]
if states:
print(" Network state: " + ", ".join(f"{self.transition(change)} at {self.seconds(change.sample)}"
for change in states) + ".")
missed = self.latest.get("Network.MissedFrames")
if missed and missed.new > 0:
print(f" Missed cyclic frames rose to {missed.new}.")
def print_newest_records(self):
"""The newest records, side by side, for the values that changed (the rest would be noise)."""
if not self.records.samples:
print("\nThe Recorders' histories share no sample counter (a Recorder was restarted after "
"an overflow), so the newest records cannot be shown side by side.")
return
newest = slice(-FINAL_RECORDS_TO_PRINT, None)
print(f"\nNewest {len(self.records.samples[newest])} records of the values that changed (one column per record):")
print(f"{'sample':>32}: " + " ".join(f"{sample:>14}" for sample in self.records.samples[newest]))
for point in self.records.points:
if point.label in self.latest:
print(f"{point.label:>32}: " + " ".join(f"{point.text(value):>14}"
for value in self.records.values[point.label][newest]))
def write_records_csv(records, recordings):
"""Write the last HISTORY_SECONDS of every recorded value to a time-stamped CSV file:
one row per record, a sample counter column, then one column per value. When the
Recorders' histories cannot be lined up, each Recorder's history goes to its own file."""
stamp = time.strftime("%Y%m%d-%H%M%S")
if records.samples:
file_name = f"{RECORDS_FILE_PREFIX}{stamp}.csv"
with open(file_name, "w", newline="", encoding="utf-8") as csv_file:
writer = csv.writer(csv_file)
writer.writerow(["SampleCounter"] + [point.label for point in records.points])
for row, sample in enumerate(records.samples):
writer.writerow([sample] + [records.values[point.label][row] for point in records.points])
print(f"Wrote {len(records.samples)} records of {len(records.points)} values to {file_name}.")
return
for recording in recordings:
if not recording.history:
continue
file_name = f"{RECORDS_FILE_PREFIX}{stamp}-recorder{recording.number}.csv"
with open(file_name, "w", newline="", encoding="utf-8") as csv_file:
writer = csv.writer(csv_file)
writer.writerow([point.label for point in recording.points])
writer.writerows(recording.history)
print(f"Wrote Recorder {recording.number}: {len(recording.history)} records to {file_name}.")
def write_network_log(controller):
"""Write the network firmware's log to a time-stamped file; it may hold further clues.
The firmware publishes its log when the network shuts down, so there is nothing to
write while the network is still running."""
count = controller.NetworkLogMessageCountGet()
if count == 0:
return
file_name = NETWORK_LOG_FILE_PREFIX + time.strftime("%Y%m%d-%H%M%S") + ".txt"
with open(file_name, "w", encoding="utf-8") as log_file:
for index in range(count):
log_file.write(controller.NetworkLogMessageGet(index) + "\n")
print(f"Wrote {count} network firmware log messages to {file_name}.")
def print_results(controller, recordings, changes, node_names):
records = merge_records(recordings)
changes = sorted(changes, key=lambda change: samples_between(changes[0].sample, change.sample))
total = sum(recording.total_records for recording in recordings)
print(f"\nRetrieved {total} records; {len(records.samples)} lined-up records kept in history. "
f"Network state now: {network_state_name(controller)}.")
if changes:
report = Report(records, changes, node_names, controller.SampleRateGet())
report.print_timeline()
report.print_summary()
report.print_newest_records()
else:
print("No status value changed while recording.")
print()
write_records_csv(records, recordings)
write_network_log(controller)
# ┌───────────────────────────────────┐
# │ MAIN │
# └───────────────────────────────────┘
exit_code = constants.EXIT_FAILURE
# Optional: how many seconds to record. Without it the sample records until a key is pressed,
# or for NON_INTERACTIVE_RECORD_SECONDS when stdin is not a terminal (automated runs).
if len(sys.argv) > 1:
record_seconds = float(sys.argv[1])
else:
record_seconds = None if helpers.stdin_is_interactive else NON_INTERACTIVE_RECORD_SECONDS
creation_params: RapidCode.CreationParameters = helpers.get_creation_parameters()
controller: RapidCode.MotionController = RapidCode.MotionController.Create(creation_params)
try:
helpers.check_errors(controller)
node_count = controller.NetworkNodeCountGet()
needed = recorders_needed(controller.NetworkSyncGroupCountGet(), node_count)
if not network_is_operational(controller):
print(f"The network state is {network_state_name(controller)}, but this sample needs an OPERATIONAL "
"EtherCAT network so it has network and node status to record.\n"
"Start the network first (RapidSetup, or MotionController.NetworkStart()) "
"and run this sample again. Exiting successfully so automated test runs "
"without EtherCAT hardware stay green.")
exit_code = constants.EXIT_SUCCESS
elif controller.RecorderCountGet() < needed:
print(f"This controller has {controller.RecorderCountGet()} Recorder(s) configured, but recording the "
f"status of {node_count} node(s) needs {needed} "
f"(a Recorder holds at most {ADDRESSES_PER_RECORDER_MAXIMUM} addresses).\n"
f"Configure the recorder count before other objects are created (call "
f"MotionController.RecorderCountSet({needed}) early in your application, or set the "
"recorder count in RapidSetup) and run this sample again. "
"Exiting successfully so automated test runs stay green.")
exit_code = constants.EXIT_SUCCESS
else:
node_names = [controller.NetworkNodeGet(index).NameGet() for index in range(node_count)]
print("Nodes:")
for index, name in enumerate(node_names):
print(f" Node{index}: {name}")
changes = deque(maxlen=CHANGES_TO_KEEP)
recordings = create_recordings(controller, gather_points(controller), changes)
for recording in recordings:
recording.configure()
record_until_stopped(controller, recordings, record_seconds)
print_results(controller, recordings, changes, node_names)
exit_code = constants.EXIT_SUCCESS
except Exception as e:
print(f"❌ Error: {e}")
exit_code = constants.EXIT_FAILURE
finally:
controller.Delete()
sys.exit(exit_code)