diff --git a/README.md b/README.md index fe1612a..57fbf2a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ Below is a list of all tutorials available in this repository: - 📊 [Visualize](https://app.foxglove.dev/~/view?ds=foxglove-sample-stream&ds.recordingId=vqKKQcot421Kwg84&ds.overrideLayoutId=b7513959-1d46-4a89-bc24-1584d9677ca1&ds.start=2023-09-01T13:19:45.047438263Z&ds.end=2023-09-01T13:20:15.047438263Z) ## Foxglove SDK +### [Using Foxglove to Visualize Ethernet/IP data](foxglove_sdk/ethernet_ip_integration/README.md) +- 📝 Using Foxglove data, it's easier than ever to stream time series data. In this project, we show you how. +- 🔗 [Related Blog Post](https://foxglove.dev/blog/use-foxglove-sdk-for-real-time-industrial-plc-data-visualization-and-playback) ### [Visualizing LeRobot (SO-100) using Foxglove](foxglove_sdk/foxglove_so_100/README.md) - 📝 Use LeRobot API and Foxglove SDK to visualize SO-100 state in real-time - 🔗 [Related Blog Post](https://foxglove.dev/blog/visualizing-lerobot-so-100-using-foxglove) diff --git a/foxglove_sdk/ethernet_ip_integration/README.md b/foxglove_sdk/ethernet_ip_integration/README.md new file mode 100644 index 0000000..3e652ee --- /dev/null +++ b/foxglove_sdk/ethernet_ip_integration/README.md @@ -0,0 +1,39 @@ +--- +title: "Using Foxglove to Visualize Ethernet/IP data" +blog_post_url: "https://foxglove.dev/blog/use-foxglove-sdk-for-real-time-industrial-plc-data-visualization-and-playback" +short_description: "Using Foxglove data, it's easier than ever to stream time series data. In this project, we show you how." +--- + +![Ethernet/IP data visualization in Foxglove](media/eip_layout.png) + +# Use Foxglove for Real-Time Industrial PLC Data Visualization and Playback + +In this tutorial, we implement a simple script to stream values of Ethernet/IP tags to Foxglove. The example includes a simulated plant to demonstrate this proof of concept. + +## Dependencies +Run `pip install -r requirements.txt` to install dependencies for this tutorial. We depend on `cpppo` for simulating an Ethernet/IP device, and `pylogix` to run the simulated plant and read the tag values for our Foxglove bridge. + +## Running the example +Make sure you’ve cloned the repository and installed the dependencies from the previous section. + +Navigate to the server directory (`foxglove_sdk/ethernet_ip_integration/server`) and execute these two scripts in separate terminal windows: +``` +python3 eip_server.py +python3 run_plant.py +``` +After the plant is started, run the foxglove_eip_bridge.py (located in `foxglove_sdk/ethernet_ip_integration/`): +``` +python3 foxglove_eip_bridge.py +``` +If successful, it should start Foxglove server and show the following output: +``` +2025-07-04 18:18:52,547 [INFO] Started server on 127.0.0.1:8765 +2025-07-04 18:18:52,548 [INFO] Starting EtherNet/IP to Foxglove bridge +2025-07-04 18:18:52,548 [INFO] Connecting to PLC at 0.0.0.0 +2025-07-04 18:18:52,548 [INFO] Streaming 35 tags to Foxglove +``` + +## Viewing the data +To view the data, start Foxglove and open a new connection to `ws://localhost:8765`. If everything is well, you will see several topics derived from EIP tags. Now, you should be able to load different panels and view the data. You can use a layout in `foxglove_sdk/ethernet_ip_integration/layouts/eip_layout.json`. + +Alternatively, you can open our [logged MCAP file](foxglove_sdk/ethernet_ip_integration/mcap/foxglove_eip_bridge_2025-07-04_17-53-27.mcap) and view our recording of a simulated run. diff --git a/foxglove_sdk/ethernet_ip_integration/foxglove_eip_bridge.py b/foxglove_sdk/ethernet_ip_integration/foxglove_eip_bridge.py new file mode 100644 index 0000000..01cd236 --- /dev/null +++ b/foxglove_sdk/ethernet_ip_integration/foxglove_eip_bridge.py @@ -0,0 +1,157 @@ + +import datetime +import json +import logging +import time +from typing import Dict + +import foxglove +from foxglove import Channel, Schema +from pylogix import PLC + + +# Define schema for timeseries data +timeseries_schema = { + "type": "object", + "properties": { + "timestamp": {"type": "number"}, + "value": {"type": "number"}, + "tag_name": {"type": "string"}, + }, +} + +# All tags from the EtherNet/IP server configuration +ALL_TAGS = [ + # Process variables + "Temperature", + "Pressure", + "Flow_Rate", + "Level", + "Vibration", + + # Control variables + "Motor_Running", + "Pump_Speed", + "Valve_Position", + "Pump_Enable", + "Emergency_Stop", + + # Status and alarms + "High_Temp_Alarm", + "Low_Pressure_Alarm", + "System_Status", + "Error_Code", + "Alarm_Count", + + # Production data + "Production_Count", + "Runtime_Hours", + "Batch_Number", + "Recipe_Number", + "Quality_Rating", + + # Setpoints + "Setpoint_Temp", + "Setpoint_Pressure", + "Setpoint_Flow", + + # Tank levels + "Tank_Level_1", + "Tank_Level_2", + "Tank_Level_3", + "Tank_Level_4", + + # Sensor arrays + "Sensor_1", + "Sensor_2", + "Sensor_3", + "Sensor_4", + + # Motor data + "Motor_Current", + "Motor_Voltage", + "Motor_Power", + "Motor_Temp", +] + +def create_channels() -> Dict[str, Channel]: + """Create Foxglove channels for all tags""" + channels = {} + + for tag in ALL_TAGS: + # Create channel with topic name based on tag + topic = f"/eip/{tag.lower().replace(' ', '_')}" + + channels[tag] = Channel( + topic=topic, + message_encoding="json", + schema=Schema( + name=f"{tag}_data", + encoding="jsonschema", + data=json.dumps(timeseries_schema).encode("utf-8"), + ), + ) + return channels + + +def main() -> None: + foxglove.set_log_level(logging.INFO) + + server = foxglove.start_server() + now_str = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + file_name = f"foxglove_eip_bridge_{now_str}.mcap" + writer = foxglove.open_mcap(file_name) # Comment out if not logging to mcap + + # Create channels for all tags + channels = create_channels() + + # Initialize PLC connection + plc_host = '0.0.0.0' # EtherNet/IP server address + + logging.info(f"Starting EtherNet/IP to Foxglove bridge") + logging.info(f"Connecting to PLC at {plc_host}") + logging.info(f"Streaming {len(ALL_TAGS)} tags to Foxglove") + + try: + with PLC() as comm: + comm.IPAddress = plc_host + comm.Micro800 = True + + while True: + now = time.time() + + # Read and stream all tags + for tag_name in ALL_TAGS: + try: + # Read value from PLC + result = comm.Read(tag_name) + + if result.Status == "Success" and result.Value is not None: + message = { + "timestamp": now, + "value": result.Value, + "tag_name": tag_name, + } + + # Log to channel + if tag_name in channels: + channels[tag_name].log(json.dumps(message).encode("utf-8")) + + else: + logging.warning(f"Failed to read {tag_name}: {result.Status}") + + except Exception as e: + logging.error(f"Error reading tag {tag_name}: {e}") + + time.sleep(0.1) + except KeyboardInterrupt: + logging.info("Shutting down bridge...") + except Exception as e: + logging.error(f"Bridge error: {e}") + finally: + server.stop() + writer.close() + logging.info("Bridge stopped") + +if __name__ == "__main__": + main() diff --git a/foxglove_sdk/ethernet_ip_integration/layouts/eip_layout.json b/foxglove_sdk/ethernet_ip_integration/layouts/eip_layout.json new file mode 100644 index 0000000..15bd638 --- /dev/null +++ b/foxglove_sdk/ethernet_ip_integration/layouts/eip_layout.json @@ -0,0 +1,204 @@ +{ + "configById": { + "Plot!184cdea": { + "paths": [ + { + "value": "/eip/temperature.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#4e98e2" + }, + { + "value": "/eip/setpoint_temp.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#f5774d" + } + ], + "showXAxisLabels": true, + "showYAxisLabels": true, + "showLegend": true, + "legendDisplay": "floating", + "showPlotValuesInLegend": false, + "isSynced": true, + "xAxisVal": "timestamp", + "sidebarDimension": 240, + "foxglovePanelTitle": "Temperature" + }, + "Plot!1av206g": { + "paths": [ + { + "value": "/eip/pressure.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#4e98e2" + }, + { + "value": "/eip/setpoint_pressure.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#f5774d" + } + ], + "showXAxisLabels": true, + "showYAxisLabels": true, + "showLegend": true, + "legendDisplay": "floating", + "showPlotValuesInLegend": false, + "isSynced": true, + "xAxisVal": "timestamp", + "sidebarDimension": 240, + "foxglovePanelTitle": "Pressure" + }, + "Plot!1jhdc3c": { + "paths": [ + { + "value": "/eip/flow_rate.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#4e98e2" + }, + { + "value": "/eip/setpoint_flow.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#f5774d" + } + ], + "showXAxisLabels": true, + "showYAxisLabels": true, + "showLegend": true, + "legendDisplay": "floating", + "showPlotValuesInLegend": false, + "isSynced": true, + "xAxisVal": "timestamp", + "sidebarDimension": 240, + "foxglovePanelTitle": "Flow Rate" + }, + "Plot!4is19vl": { + "paths": [ + { + "value": "/eip/tank_level_1.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#4e98e2" + }, + { + "value": "/eip/tank_level_2.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#f5774d" + }, + { + "value": "/eip/tank_level_3.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#f7df71" + }, + { + "value": "/eip/tank_level_4.value", + "enabled": true, + "timestampMethod": "receiveTime", + "color": "#5cd6a9" + } + ], + "showXAxisLabels": true, + "showYAxisLabels": true, + "showLegend": true, + "legendDisplay": "floating", + "showPlotValuesInLegend": false, + "isSynced": true, + "xAxisVal": "timestamp", + "sidebarDimension": 240, + "foxglovePanelTitle": "Tank levels" + }, + "Gauge!253gadd": { + "path": "/eip/temperature.value", + "minValue": 63, + "maxValue": 81, + "colorMap": "red-yellow-green", + "colorMode": "colormap", + "gradient": [ + "#0000ff", + "#ff00ff" + ], + "reverse": true, + "foxglovePanelTitle": "Temperature" + }, + "Indicator!4ftxi1h": { + "path": "/eip/emergency_stop.value", + "style": "bulb", + "fontSize": 36, + "fallbackColor": "#1bbf33", + "fallbackLabel": "ESTOP OFF", + "rules": [ + { + "operator": "=", + "rawValue": "true", + "color": "#e34b4b", + "label": "ESTOP ON" + } + ], + "foxglovePanelTitle": "ESTOP" + }, + "StateTransitions!9ldf0u": { + "paths": [ + { + "value": "/eip/low_pressure_alarm.value", + "timestampMethod": "receiveTime", + "label": "Low Pressure" + }, + { + "value": "/eip/high_temp_alarm.value", + "timestampMethod": "receiveTime", + "label": "Temperature Alarm" + }, + { + "value": "/eip/emergency_stop.value", + "timestampMethod": "receiveTime", + "label": "Emergency STOP" + }, + { + "value": "/eip/error_code.value", + "timestampMethod": "receiveTime", + "label": "ERROR CODE" + } + ], + "isSynced": true, + "timeWindowMode": "automatic", + "showPoints": true + } + }, + "globalVariables": {}, + "userNodes": {}, + "playbackConfig": { + "speed": 1 + }, + "layout": { + "first": { + "first": { + "first": "Plot!184cdea", + "second": "Plot!1av206g", + "direction": "column" + }, + "second": "Plot!1jhdc3c", + "direction": "column", + "splitPercentage": 65.98579040852576 + }, + "second": { + "first": { + "first": "Plot!4is19vl", + "second": { + "first": "Gauge!253gadd", + "second": "Indicator!4ftxi1h", + "direction": "column" + }, + "direction": "row", + "splitPercentage": 49.588347055098154 + }, + "second": "StateTransitions!9ldf0u", + "direction": "column" + }, + "direction": "row" + } +} \ No newline at end of file diff --git a/foxglove_sdk/ethernet_ip_integration/mcap/foxglove_eip_bridge_2025-07-04_17-53-27.mcap b/foxglove_sdk/ethernet_ip_integration/mcap/foxglove_eip_bridge_2025-07-04_17-53-27.mcap new file mode 100644 index 0000000..36ec9e6 Binary files /dev/null and b/foxglove_sdk/ethernet_ip_integration/mcap/foxglove_eip_bridge_2025-07-04_17-53-27.mcap differ diff --git a/foxglove_sdk/ethernet_ip_integration/media/eip_layout.png b/foxglove_sdk/ethernet_ip_integration/media/eip_layout.png new file mode 100644 index 0000000..4773af7 Binary files /dev/null and b/foxglove_sdk/ethernet_ip_integration/media/eip_layout.png differ diff --git a/foxglove_sdk/ethernet_ip_integration/requirements.txt b/foxglove_sdk/ethernet_ip_integration/requirements.txt new file mode 100644 index 0000000..05f248e --- /dev/null +++ b/foxglove_sdk/ethernet_ip_integration/requirements.txt @@ -0,0 +1,4 @@ +foxglove-sdk +cpppo +pylogix +numpy diff --git a/foxglove_sdk/ethernet_ip_integration/server/eip_server.cfg b/foxglove_sdk/ethernet_ip_integration/server/eip_server.cfg new file mode 100644 index 0000000..3c6f6a1 --- /dev/null +++ b/foxglove_sdk/ethernet_ip_integration/server/eip_server.cfg @@ -0,0 +1,51 @@ +[Simulator] +# Process variables +Temperature = REAL = 75.5 +Pressure = REAL = 14.7 +Flow_Rate = REAL = 50.0 +Level = REAL = 80.0 +Vibration = REAL = 0.25 + +# Control variables +Motor_Running = BOOL = True +Pump_Speed = INT = 1750 +Valve_Position = REAL = 50.0 +Pump_Enable = BOOL = True +Emergency_Stop = BOOL = False + +# Status and alarms +High_Temp_Alarm = BOOL = False +Low_Pressure_Alarm = BOOL = False +System_Status = INT = 1 +Error_Code = INT = 0 +Alarm_Count = INT = 0 + +# Production data +Production_Count = DINT = 12345 +Runtime_Hours = REAL = 1234.5 +Batch_Number = DINT = 1001 +Recipe_Number = INT = 5 +Quality_Rating = REAL = 95.5 + +# Setpoints +Setpoint_Temp = REAL = 80.0 +Setpoint_Pressure = REAL = 15.0 +Setpoint_Flow = REAL = 55.0 + +# Tank levels +Tank_Level_1 = REAL = 71.0 +Tank_Level_2 = REAL = 60.0 +Tank_Level_3 = REAL = 45.0 +Tank_Level_4 = REAL = 85.0 + +# Sensor arrays +Sensor_1 = REAL = 25.5 +Sensor_2 = REAL = 28.0 +Sensor_3 = REAL = 30.2 +Sensor_4 = REAL = 27.8 + +# Motor data +Motor_Current = REAL = 12.5 +Motor_Voltage = REAL = 480.0 +Motor_Power = REAL = 8.5 +Motor_Temp = REAL = 65.0 diff --git a/foxglove_sdk/ethernet_ip_integration/server/eip_server.py b/foxglove_sdk/ethernet_ip_integration/server/eip_server.py new file mode 100644 index 0000000..d7dba5f --- /dev/null +++ b/foxglove_sdk/ethernet_ip_integration/server/eip_server.py @@ -0,0 +1,78 @@ +import csv +import sys + +from cpppo.server import enip +from cpppo.server.enip import client, Object, config_files +from cpppo.server.enip.main import tags, main as enip_main + +""" +Ethernet/IP Server Simulator +Based on cpppo server example: +https://github.com/pjkundert/cpppo/blob/master/server/enip/simulator_example.py +""" + +def main( argv=None, idle_service=None, **kwds ): + if argv is None: + argv = sys.argv[1:] + + # Remember any tags we find w/ optional values CSV. We won't know the type 'til after the + # Attribute is created by enip.main, so just store the raw CSV for now. Use the Object class' + # config_loader, and only look for [Simulator] entries (don't use DEFAULT). Iterate thru all + # the entries, adding an entry to kwds for each. + values = {} # All collected Tags w/ a CSV of initial values + + # Load config_files early (with case sensitivity); enip.main will re-do it, case-insensitively + optionxform = Object.config_loader.optionxform + Object.config_loader.optionxform = str # Case-sensitive + Object.config_loader.read( config_files ) + + if 'Simulator' in Object.config_loader: + for nam,typ in Object.config_loader['Simulator'].items(): + val = None + if '=' in typ: + # Optional value(s) + typ,val = typ.split( '=', 1 ) + typ,val = typ.strip(),val.strip() + argv += [ '='.join( (nam,typ) ) ] # eg. Tag@123/4/5=REAL[100] + if val: + # A non-empty initial value was provided; strip off any optional CIP address and + # save the initial values provided. + if '@' in nam: + nam = nam[:nam.index( '@' )] + values[nam] = val + + Object.config_loader.optionxform = optionxform + Object.config_loader.clear() + + def idle_init(): + """First time thru, set up any initia values; subsequently, perform original idle_service.""" + if idle_init.complete: + if idle_service: + print(values) + idle_service() + return + idle_init.complete = True + for nam in values: + # Got initial value(s) for this one. + val_list = [] + try: + val_list, = csv.reader( + [ values[nam] ], quotechar='"', delimiter=',', quoting=csv.QUOTE_ALL, skipinitialspace=True ) + ent = dict.__getitem__( tags, nam ) # may be 'Tag.SubTag'; avoid dotdict '.' resolution + typ = ent.attribute.parser.__class__.__name__ # eg. 'REAL' + _,_,cast = client.CIP_TYPES[typ] + ent.attribute[0:len( val_list )] \ + = [ cast( v ) for v in val_list ] + except Exception: + print( "Failed to set %s[0:%d] = %r" % ( nam, len( val_list ), val_list )) + raise + idle_init.complete = False + + # Establish Identity, TCPIP, etc. objects, and any custom [Simulator] tags from the config file(s). + return enip_main( argv=argv, idle_service=idle_init, **kwds ) + + +if __name__ == "__main__": + # For demonstration, use .../simulator_example.cfg + enip.config_files += [ __file__.replace( '.py', '.cfg' ) ] + sys.exit( main() ) diff --git a/foxglove_sdk/ethernet_ip_integration/server/run_plant.py b/foxglove_sdk/ethernet_ip_integration/server/run_plant.py new file mode 100644 index 0000000..8fd121f --- /dev/null +++ b/foxglove_sdk/ethernet_ip_integration/server/run_plant.py @@ -0,0 +1,181 @@ +import time +import math +import random +from pylogix import PLC + +def run(comm): + counter = 0 + emergency_stop_timer = 0 # Simple countdown timer + emergency_stop_duration = 10 # 15 cycles (3 seconds at 0.2s intervals) + + while True: + # Temperature simulation with sinusoidal pattern + noise + base_temp = 75.0 + 8.0 * math.sin(counter * 0.05) + random.uniform(-3, 3) + + # Occasionally create error conditions (1% chance) + error_state = random.random() < 0.01 + if error_state: + # Push base temp higher to trigger alarm states + base_temp += random.uniform(15, 25) + + temp = max(60, min(100, base_temp)) + comm.Write("Temperature", round(temp, 2)) + + # Pressure with small variations + base_pressure = 14.7 + random.uniform(-1, 1) + + # Occasionally create low pressure conditions (1% chance) + if random.random() < 0.01: + # Drop pressure below alarm threshold + base_pressure -= random.uniform(3, 6) + + pressure = max(10, min(20, base_pressure)) + comm.Write("Pressure", round(pressure, 2)) + + # Flow rate with occasional spikes + flow_var = random.uniform(-8, 8) + if random.random() < 0.03: # 3% chance of spike + flow_var += random.uniform(15, 25) + flow = max(0, min(100, 50.0 + flow_var)) + comm.Write("Flow_Rate", round(flow, 1)) + + # Tank levels with slow changes + for i in range(1, 5): + tag_name = f"Tank_Level_{i}" + current = comm.Read(tag_name) + current_val = current.Value if current.Value is not None else 50.0 + change = random.uniform(-1.5, 1.5) + new_level = max(0, min(100, current_val + change)) + comm.Write(tag_name, round(new_level, 1)) + + # Sensor array simulation + for i in range(1, 5): # Updated to match config (Sensor_1 to Sensor_4) + sensor_name = f"Sensor_{i}" + base_val = 28.0 + i * 0.5 + variation = random.uniform(-2, 2) + sensor_val = max(20, min(35, base_val + variation)) + comm.Write(sensor_name, round(sensor_val, 1)) + + # Check alarm conditions early for emergency stop logic + high_temp_alarm = temp > 90.0 + + # Simple emergency stop logic + if high_temp_alarm: + emergency_stop_timer = emergency_stop_duration # Reset timer when alarm triggers + elif emergency_stop_timer > 0: + emergency_stop_timer -= 1 # Count down when temp is OK + + emergency_stop = emergency_stop_timer > 0 # Emergency stop active while timer > 0 + + # Motor parameters + motor_running_result = comm.Read("Motor_Running") + motor_running = motor_running_result.Value if motor_running_result.Value is not None else True + + # Override motor running during emergency stop + if emergency_stop: + motor_running = False + comm.Write("Motor_Running", False) + comm.Write("Valve_Position", 0.0) # Close valve during emergency + else: + # Restore motor to running when emergency stop clears + if not motor_running: + motor_running = True + comm.Write("Motor_Running", True) + comm.Write("Valve_Position", 50.0) # Restore valve position + + if motor_running and not emergency_stop: + # Motor current varies with load + current = 12.5 + random.uniform(-2, 3) + comm.Write("Motor_Current", round(current, 1)) + + # Motor power correlates with current + power = current * 0.68 + random.uniform(-0.5, 0.5) + comm.Write("Motor_Power", round(power, 2)) + + # Motor voltage - normal operating voltage + comm.Write("Motor_Voltage", 480.0) + + # Motor temperature correlates with base temp and error states + temp_result = comm.Read("Motor_Temp") + motor_temp = temp_result.Value if temp_result.Value is not None else 65.0 + + # Normal temperature change + temp_change = random.uniform(-0.5, 1.5) + + # Correlate with base temperature - when base temp is high, motor temp increases more + if base_temp > 85: + temp_change += (base_temp - 85) * 0.3 # Amplify heating when base temp is high + + # During error states, motor temp can spike unpredictably + if error_state: + temp_change += random.uniform(5, 15) # Motor overheating during system errors + + # Occasionally create independent motor overheating (2% chance) + if random.random() < 0.02: + temp_change += random.uniform(8, 20) + + new_motor_temp = min(120, motor_temp + temp_change*random.uniform(-0.2, 0.3)) + comm.Write("Motor_Temp", round(new_motor_temp, 1)) + + # Pump speed varies slightly + speed = 1750 + random.randint(-75, 75) + comm.Write("Pump_Speed", speed) + else: + comm.Write("Motor_Current", 0.0) + comm.Write("Motor_Power", 0.0) + comm.Write("Pump_Speed", 0) + comm.Write("Motor_Voltage", 0.0) + + # Vibration simulation + vib_base = 0.25 + 0.1 * math.sin(counter * 0.3) + vibration = max(0, vib_base + random.uniform(-0.05, 0.15)) + comm.Write("Vibration", round(vibration, 3)) + + # Pressure alarm check (temperature alarm already checked above) + pressure_result = comm.Read("Pressure") + pressure_val = pressure_result.Value if pressure_result.Value is not None else 14.7 + low_pressure_alarm = pressure_val < 12.0 + + comm.Write("Emergency_Stop", emergency_stop) + comm.Write("High_Temp_Alarm", high_temp_alarm) + comm.Write("Low_Pressure_Alarm", low_pressure_alarm) + + # Set error codes based on alarm conditions + error_code = 0 # Default: no error + if emergency_stop: + error_code = 10 # Emergency stop error code + elif high_temp_alarm: + error_code = 34 # Temperature error code + elif low_pressure_alarm: + error_code = 65 # Pressure error code + + comm.Write("Error_Code", error_code) + + # Increment alarm count for any error condition + if error_code != 0: + alarm_count_result = comm.Read("Alarm_Count") + alarm_count = alarm_count_result.Value if alarm_count_result.Value is not None else 0 + comm.Write("Alarm_Count", alarm_count + 1) + + # Update counters + if counter % 60 == 0: # Every minute + prod_count_result = comm.Read("Production_Count") + prod_count = prod_count_result.Value if prod_count_result.Value is not None else 0 + comm.Write("Production_Count", prod_count + 1) + + # Runtime hours + runtime_result = comm.Read("Runtime_Hours") + runtime = runtime_result.Value if runtime_result.Value is not None else 0 + comm.Write("Runtime_Hours", round(runtime + 1/3600, 3)) + + counter += 1 + time.sleep(0.2) + +with PLC() as comm: + comm.IPAddress = '0.0.0.0' + try: + run(comm) + except KeyboardInterrupt: + print("Exiting...") + except Exception as e: + print(f"An error occurred: {e}")