diff --git a/demos/openplc_tank/.gitignore b/demos/openplc_tank/.gitignore new file mode 100644 index 0000000..3e57ad9 --- /dev/null +++ b/demos/openplc_tank/.gitignore @@ -0,0 +1 @@ +demos/openplc_tank/openplc/generated/ diff --git a/demos/openplc_tank/README.md b/demos/openplc_tank/README.md new file mode 100644 index 0000000..dcd48c2 --- /dev/null +++ b/demos/openplc_tank/README.md @@ -0,0 +1,103 @@ +# OpenPLC Tank Demo + +PLC diagnostics demo using OpenPLC v4 + ros2_medkit OPC-UA plugin. + +## Architecture + +``` +OpenPLC v4 Runtime (Docker) ros2_medkit Gateway + [Tank ST program] [opcua_plugin.so] + OPC-UA Server :4840 <-- sub --> open62541pp client + | + OpcuaPoller + | + +---------+---------+ + | | | + IntrospectionProvider REST FaultManager + (PLC entity tree) routes (alarm->fault) +``` + +## SOVD Entity Tree + +``` +Area: plc_systems + Component: openplc_runtime + App: tank_process (level, temperature, pressure + alarms) + App: fill_pump (speed control) + App: drain_valve (position control) +``` + +## Quick Start + +```bash +./scripts/run-demo.sh + +# Wait ~30s for startup, then: +curl http://localhost:8080/api/v1/health +curl http://localhost:8080/api/v1/apps | jq '.items[].id' +curl http://localhost:8080/api/v1/apps/tank_process/x-plc-data | jq . +``` + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/areas` | List areas (plc_systems) | +| GET | `/api/v1/components` | List components (openplc_runtime) | +| GET | `/api/v1/apps` | List apps (tank_process, fill_pump, drain_valve) | +| GET | `/apps/{id}/x-plc-data` | All OPC-UA values for entity | +| GET | `/apps/{id}/x-plc-data/{node}` | Single value | +| POST | `/apps/{id}/x-plc-operations/{op}` | Write value `{"value": 75.0}` | +| GET | `/components/{id}/x-plc-status` | Connection state, stats | +| GET | `/apps/{id}/faults` | SOVD faults (from PLC alarms) | + +## Fault Injection + +```bash +# Inject high temperature (>80C threshold) +./scripts/inject-fault.sh high_temp + +# Inject low level (<100mm threshold) +./scripts/inject-fault.sh low_level + +# Inject overpressure (>5bar threshold) +./scripts/inject-fault.sh overpressure + +# Restore normal operation +./scripts/restore-normal.sh +``` + +## Control PLC via REST + +```bash +# Set pump speed to 75% +curl -X POST http://localhost:8080/api/v1/apps/fill_pump/x-plc-operations/set_pump_speed \ + -H "Content-Type: application/json" -d '{"value": 75.0}' + +# Set valve position to 50% +curl -X POST http://localhost:8080/api/v1/apps/drain_valve/x-plc-operations/set_valve_position \ + -H "Content-Type: application/json" -d '{"value": 50.0}' +``` + +## Integration Tests + +```bash +./scripts/run_integration_tests.sh +``` + +Tests cover: entity discovery, live data, control writes, fault lifecycle, connection resilience, error responses. + +## Ports + +| Service | Port | Protocol | +|---------|------|----------| +| OpenPLC OPC-UA | 4840 | OPC-UA TCP | +| Gateway REST | 8080 | HTTP | +| Web UI | 3000 | HTTP | + +## Stopping + +```bash +./scripts/stop-demo.sh # stop containers +./scripts/stop-demo.sh --volumes # stop + remove volumes +``` diff --git a/demos/openplc_tank/config/gateway_params.yaml b/demos/openplc_tank/config/gateway_params.yaml new file mode 100644 index 0000000..bd25665 --- /dev/null +++ b/demos/openplc_tank/config/gateway_params.yaml @@ -0,0 +1,57 @@ +# Gateway parameters for OpenPLC Tank Demo +# Namespace: diagnostics/ros2_medkit_gateway +diagnostics: + ros2_medkit_gateway: + ros__parameters: + server: + host: "0.0.0.0" + port: 8080 + + cors: + allowed_origins: ["*"] + allowed_methods: [GET, PUT, POST, DELETE, OPTIONS] + allowed_headers: [Content-Type, Accept] + allow_credentials: false + + discovery: + mode: "hybrid" + manifest_path: "/config/tank_manifest.yaml" + manifest_strict_validation: true + runtime: + create_synthetic_components: false + + max_parallel_topic_samples: 10 + + # Plugin configuration + plugins: ["opcua"] + plugins.opcua.path: "/plugin_ws/install/opcua_gateway_plugin/lib/opcua_gateway_plugin/opcua_gateway_plugin.so" + plugins.opcua.endpoint_url: "opc.tcp://openplc:4840" + plugins.opcua.node_map_path: "/config/tank_nodes.yaml" + plugins.opcua.prefer_subscriptions: true + plugins.opcua.subscription_interval_ms: 500.0 + plugins.opcua.poll_interval_ms: 1000 + + # Triggers + triggers: + enabled: true + max_triggers: 100 + + # Documentation + docs: + enabled: true + +# Fault Manager parameters +fault_manager: + ros__parameters: + storage_type: "sqlite" + database_path: "/var/lib/ros2_medkit/faults.db" + + confirmation_threshold: 0 + healing_enabled: false + auto_confirm_after_sec: 0.0 + + snapshots: + enabled: false + + rosbag: + enabled: false diff --git a/demos/openplc_tank/config/tank_manifest.yaml b/demos/openplc_tank/config/tank_manifest.yaml new file mode 100644 index 0000000..a304c65 --- /dev/null +++ b/demos/openplc_tank/config/tank_manifest.yaml @@ -0,0 +1,47 @@ +# SOVD Entity Manifest for OpenPLC Tank Demo +# Defines the static entity tree structure +# Dynamic data comes from the OPC-UA plugin at runtime + +areas: + - id: plc_systems + name: PLC Systems + description: PLC systems connected via OPC-UA + +components: + - id: openplc_runtime + name: OpenPLC Runtime + area: plc_systems + description: OpenPLC v4 runtime with OPC-UA server + tags: + - plc + - opcua + - simulation + +apps: + - id: tank_process + name: Tank Process + component_id: openplc_runtime + external: true + description: Tank simulation - level, temperature, pressure sensors with alarms + tags: + - sensors + - alarms + - tank + + - id: fill_pump + name: Fill Pump + component_id: openplc_runtime + external: true + description: Fill pump with speed control (0-100%) + tags: + - actuator + - pump + + - id: drain_valve + name: Drain Valve + component_id: openplc_runtime + external: true + description: Drain valve with position control (0-100%) + tags: + - actuator + - valve diff --git a/demos/openplc_tank/config/tank_nodes.yaml b/demos/openplc_tank/config/tank_nodes.yaml new file mode 100644 index 0000000..3d9b889 --- /dev/null +++ b/demos/openplc_tank/config/tank_nodes.yaml @@ -0,0 +1,84 @@ +# OPC-UA Node Map for OpenPLC Tank Demo +# Maps OPC-UA NodeIds to SOVD entity data points + +area_id: plc_systems +area_name: PLC Systems +component_id: openplc_runtime +component_name: OpenPLC Runtime +auto_browse: false + +nodes: + # === Tank Process - Sensor Readings === + - node_id: "ns=1;s=TankLevel" + entity_id: tank_process + data_name: tank_level + display_name: Tank Level + unit: mm + data_type: float + writable: false + alarm: + fault_code: PLC_LOW_LEVEL + severity: WARNING + message: Tank level below minimum (100mm) + threshold: 100.0 + above_threshold: false + + - node_id: "ns=1;s=TankTemperature" + entity_id: tank_process + data_name: tank_temperature + display_name: Tank Temperature + unit: C + data_type: float + writable: false + alarm: + fault_code: PLC_HIGH_TEMP + severity: ERROR + message: Tank temperature above maximum (80C) + threshold: 80.0 + above_threshold: true + + - node_id: "ns=1;s=TankPressure" + entity_id: tank_process + data_name: tank_pressure + display_name: Tank Pressure + unit: bar + data_type: float + writable: false + alarm: + fault_code: PLC_OVERPRESSURE + severity: ERROR + message: Tank pressure above maximum (5 bar) + threshold: 5.0 + above_threshold: true + + # === Fill Pump - Speed Control === + - node_id: "ns=1;s=PumpSpeed" + entity_id: fill_pump + data_name: pump_speed + display_name: Pump Speed Setpoint + unit: "%" + data_type: float + writable: true + + - node_id: "ns=1;s=PumpRunning" + entity_id: fill_pump + data_name: pump_running + display_name: Pump Running Status + data_type: bool + writable: false + + # === Drain Valve - Position Control === + - node_id: "ns=1;s=ValvePosition" + entity_id: drain_valve + data_name: valve_position + display_name: Valve Position Setpoint + unit: "%" + data_type: float + writable: true + + - node_id: "ns=1;s=ValveOpen" + entity_id: drain_valve + data_name: valve_open + display_name: Valve Open Status + data_type: bool + writable: false diff --git a/demos/openplc_tank/docker-compose.yml b/demos/openplc_tank/docker-compose.yml new file mode 100644 index 0000000..e7543aa --- /dev/null +++ b/demos/openplc_tank/docker-compose.yml @@ -0,0 +1,75 @@ +services: + # OpenPLC v4 runtime with tank simulation program + openplc: + build: + context: openplc/ + dockerfile: Dockerfile + container_name: openplc_tank + ports: + - "4840:4840" # OPC-UA server + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8080/api/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + # ros2_medkit Gateway with OPC-UA plugin + gateway: + build: + context: gateway/ + dockerfile: Dockerfile + additional_contexts: + ros2_medkit: ../../../ros2_medkit + opcua_gateway_plugin: ../../../platform/commercial/opcua_gateway_plugin + config: config/ + container_name: openplc_gateway + environment: + - ROS_DOMAIN_ID=50 + ports: + - "8080:8080" + depends_on: + openplc: + condition: service_started + restart: unless-stopped + + # SOVD Web UI + sovd-web-ui: + image: ghcr.io/selfpatch/sovd_web_ui:latest + container_name: sovd_web_ui + ports: + - "3000:80" + depends_on: + - gateway + + # CI testing profile - headless validation + gateway-ci: + profiles: ["ci"] + build: + context: gateway/ + dockerfile: Dockerfile + additional_contexts: + ros2_medkit: ../../../ros2_medkit + opcua_gateway_plugin: ../../../platform/commercial/opcua_gateway_plugin + config: config/ + container_name: openplc_gateway_ci + environment: + - ROS_DOMAIN_ID=50 + ports: + - "8080:8080" + depends_on: + openplc: + condition: service_started + command: > + bash -c "mkdir -p /var/lib/ros2_medkit/rosbags && + source /opt/ros/jazzy/setup.bash && + source /medkit_ws/install/setup.bash && + source /plugin_ws/install/setup.bash && + ros2 run ros2_medkit_gateway gateway_node \ + --ros-args --params-file /config/gateway_params.yaml & + sleep 15 && + curl -sf http://localhost:8080/api/v1/health && + curl -sf http://localhost:8080/api/v1/areas | jq '.items[] | .id' && + curl -sf http://localhost:8080/api/v1/apps | jq '.items[] | .id' && + echo 'CI validation passed!'" diff --git a/demos/openplc_tank/gateway/Dockerfile b/demos/openplc_tank/gateway/Dockerfile new file mode 100644 index 0000000..510fa8c --- /dev/null +++ b/demos/openplc_tank/gateway/Dockerfile @@ -0,0 +1,80 @@ +# Multi-stage build: ros2_medkit gateway + OPC-UA plugin +# Stage 1: Build ros2_medkit +# Stage 2: Build OPC-UA plugin against installed medkit headers +# Stage 3: Runtime + +# ---- Stage 1: Build ros2_medkit ---- +FROM ros:jazzy AS medkit-builder + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + python3-colcon-common-extensions \ + python3-rosdep \ + libsqlite3-dev \ + libssl-dev \ + nlohmann-json3-dev \ + libyaml-cpp-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy ros2_medkit source +COPY ros2_medkit/ /medkit_ws/src/ros2_medkit/ + +WORKDIR /medkit_ws + +# Build ros2_medkit +RUN . /opt/ros/jazzy/setup.sh && \ + colcon build \ + --cmake-args -DCMAKE_BUILD_TYPE=Release \ + --packages-up-to ros2_medkit_gateway ros2_medkit_fault_manager ros2_medkit_msgs ros2_medkit_cmake + +# ---- Stage 2: Build OPC-UA plugin ---- +FROM medkit-builder AS plugin-builder + +RUN apt-get update && apt-get install -y \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Copy plugin source +COPY opcua_gateway_plugin/ /plugin_ws/src/opcua_gateway_plugin/ + +WORKDIR /plugin_ws + +# Build plugin against installed medkit +RUN . /opt/ros/jazzy/setup.sh && \ + . /medkit_ws/install/setup.sh && \ + colcon build \ + --cmake-args -DCMAKE_BUILD_TYPE=Release + +# ---- Stage 3: Runtime ---- +FROM ros:jazzy + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + libsqlite3-0 \ + libssl3 \ + libyaml-cpp0.8 \ + curl \ + jq \ + && rm -rf /var/lib/apt/lists/* + +# Copy medkit install +COPY --from=medkit-builder /medkit_ws/install/ /medkit_ws/install/ + +# Copy plugin install +COPY --from=plugin-builder /plugin_ws/install/ /plugin_ws/install/ + +# Copy configuration files +COPY config/ /config/ + +EXPOSE 8080 + +# Start gateway with plugin +CMD ["bash", "-c", \ + "mkdir -p /var/lib/ros2_medkit/rosbags && \ + source /opt/ros/jazzy/setup.bash && \ + source /medkit_ws/install/setup.bash && \ + source /plugin_ws/install/setup.bash && \ + ros2 run ros2_medkit_gateway gateway_node \ + --ros-args --params-file /config/gateway_params.yaml"] diff --git a/demos/openplc_tank/openplc/Dockerfile b/demos/openplc_tank/openplc/Dockerfile new file mode 100644 index 0000000..2dd1ba9 --- /dev/null +++ b/demos/openplc_tank/openplc/Dockerfile @@ -0,0 +1,88 @@ +# OpenPLC Runtime v4 (Autonomy-Logic) with tank demo +# Strategy: Build runtime, then at startup use the web API to upload, +# compile and start the ST program through OpenPLC's full pipeline. + +FROM debian:bookworm-slim AS matiec-builder + +RUN apt-get update && apt-get install -y \ + git build-essential flex bison autoconf automake \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone --depth 1 https://github.com/beremiz/matiec.git /matiec && \ + cd /matiec && autoreconf -i && ./configure && make -j$(nproc) && make install + + +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive +WORKDIR /openplc + +RUN apt-get update && apt-get install -y git ca-certificates curl jq && \ + git clone --depth 1 https://github.com/Autonomy-Logic/openplc-runtime.git /openplc && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /var/run/runtime && \ + chmod +x install.sh scripts/* start_openplc.sh && \ + rm -rf build/ venvs/ .venv/ 2>/dev/null || true && \ + ./install.sh && \ + rm -rf /var/lib/apt/lists/* + +# Enable OPC-UA plugin and install config +RUN sed -i 's/^opcua,\(.*\),0,0,/opcua,\1,1,0,/' plugins.conf +COPY opcua_config.json /openplc/core/src/drivers/plugins/python/opcua/opcua.json + +# Copy matiec for ST -> C compilation +COPY --from=matiec-builder /usr/local/bin/iec2c /usr/local/bin/iec2c +COPY --from=matiec-builder /matiec/lib /matiec/lib + +# Copy tank demo ST program +COPY tank_demo.st /openplc/programs/tank_demo.st + +# Pre-compile ST -> C and place in core/generated with proper IEC lib +RUN mkdir -p /openplc/core/generated && \ + iec2c -I /matiec/lib -T /openplc/core/generated /openplc/programs/tank_demo.st && \ + cp -r /matiec/lib/C/* /openplc/core/generated/lib/ 2>/dev/null; \ + mkdir -p /openplc/core/generated/lib && \ + cp -r /matiec/lib/C/* /openplc/core/generated/lib/ + +# Copy hand-written glueVars that implements all required runtime symbols +COPY glueVars_tank.c /openplc/core/generated/glueVars.c + +# debug.c stub +RUN cat > /openplc/core/generated/debug.c << 'EOF' +#include "iec_std_lib.h" +int __debug_tick; +void __cleanup(void) {} +void __retrieve(void) {} +void __publish(void) {} +EOF + +# Empty c_blocks_code +RUN echo '// no custom C blocks' > /openplc/core/generated/c_blocks_code.cpp + +# Create entrypoint +RUN cat > /entrypoint.sh << 'BASH' +#!/bin/bash +set -e +cd /openplc + +# Try to compile the PLC program +echo "Compiling PLC program..." +if bash scripts/compile.sh 2>&1; then + # Rename to pattern expected by runtime + if [ -f build/new_libplc.so ]; then + mv build/new_libplc.so build/libplc_tank.so + echo "PLC program compiled successfully" + fi +else + echo "WARNING: Compilation failed, runtime will start without PLC program" +fi + +echo "Starting OpenPLC runtime..." +exec bash start_openplc.sh +BASH +RUN chmod +x /entrypoint.sh + +EXPOSE 8443 4840 + +CMD ["/entrypoint.sh"] diff --git a/demos/openplc_tank/openplc/glueVars_tank.c b/demos/openplc_tank/openplc/glueVars_tank.c new file mode 100644 index 0000000..bad0f1d --- /dev/null +++ b/demos/openplc_tank/openplc/glueVars_tank.c @@ -0,0 +1,118 @@ +/* glueVars.c - Generated for tank_demo.st + * Provides LOCATED variable storage and buffer glue for OpenPLC v4 runtime. + */ + +#include "iec_std_lib.h" + +#define BUFFER_SIZE 1024 + +// Buffer declarations (defined in image_tables.c via plcapp_manager) +extern IEC_UINT *int_memory[]; +extern IEC_UDINT *dint_memory[]; +extern IEC_ULINT *lint_memory[]; +extern IEC_BOOL *bool_memory[][8]; + +// Backing storage for LOCATED variables +// These are the globals that __INIT_LOCATED references via extern +REAL __md0_storage; +REAL __md1_storage; +REAL __md2_storage; +REAL __md3_storage; +REAL __md5_storage; +BOOL __mx16_0_storage; +BOOL __mx24_0_storage; +BOOL __mx24_1_storage; +BOOL __mx24_2_storage; +BOOL __mx24_3_storage; + +// Global pointers that POUS.c __INIT_LOCATED expects +REAL *__MD0 = &__md0_storage; +REAL *__MD1 = &__md1_storage; +REAL *__MD2 = &__md2_storage; +REAL *__MD3 = &__md3_storage; +REAL *__MD5 = &__md5_storage; +BOOL *__MX16_0 = &__mx16_0_storage; +BOOL *__MX24_0 = &__mx24_0_storage; +BOOL *__MX24_1 = &__mx24_1_storage; +BOOL *__MX24_2 = &__mx24_2_storage; +BOOL *__MX24_3 = &__mx24_3_storage; + +void glueVars() +{ + // Map LOCATED variables to runtime memory buffers + // REAL vars -> dint_memory (32-bit) + if (dint_memory[0]) __MD0 = (REAL *)dint_memory[0]; + if (dint_memory[1]) __MD1 = (REAL *)dint_memory[1]; + if (dint_memory[2]) __MD2 = (REAL *)dint_memory[2]; + if (dint_memory[3]) __MD3 = (REAL *)dint_memory[3]; + if (dint_memory[5]) __MD5 = (REAL *)dint_memory[5]; + + // BOOL vars -> bool_memory + if (bool_memory[16][0]) __MX16_0 = bool_memory[16][0]; + if (bool_memory[24][0]) __MX24_0 = bool_memory[24][0]; + if (bool_memory[24][1]) __MX24_1 = bool_memory[24][1]; + if (bool_memory[24][2]) __MX24_2 = bool_memory[24][2]; + if (bool_memory[24][3]) __MX24_3 = bool_memory[24][3]; +} + +void updateTime() {} +void updateBuffersIn() {} +void updateBuffersOut() {} + +// --- Required symbols for OpenPLC v4 runtime --- + +void setBufferPointers( + IEC_BOOL *bi[][8], IEC_BOOL *bo[][8], + IEC_BYTE *ib[], IEC_BYTE *ob[], + IEC_UINT *ii[], IEC_UINT *io[], + IEC_UDINT *di[], IEC_UDINT *do_[], + IEC_ULINT *li[], IEC_ULINT *lo[], + IEC_UINT *im[], IEC_UDINT *dm[], + IEC_ULINT *lm[]) +{ + (void)bi; (void)bo; (void)ib; (void)ob; + (void)ii; (void)io; (void)di; (void)do_; + (void)li; (void)lo; (void)im; (void)dm; (void)lm; +} + +void setBufferPointers_v4( + IEC_BOOL *bi[][8], IEC_BOOL *bo[][8], + IEC_BYTE *ib[], IEC_BYTE *ob[], + IEC_UINT *ii[], IEC_UINT *io[], + IEC_UDINT *di[], IEC_UDINT *do_[], + IEC_ULINT *li[], IEC_ULINT *lo[], + IEC_UINT *im[], IEC_UDINT *dm[], + IEC_ULINT *lm[], IEC_BOOL *bm[][8]) +{ + (void)bi; (void)bo; (void)ib; (void)ob; + (void)ii; (void)io; (void)di; (void)do_; + (void)li; (void)lo; (void)im; (void)dm; (void)lm; (void)bm; +} + +const char *plc_program_md5(void) { return "tank_demo_v1"; } +void set_endianness(int e) { (void)e; } +int get_var_count(void) { return 10; } +int get_var_size(int idx) { return (idx < 6) ? 4 : 1; } + +// Debug/trace symbols required by runtime +void *get_var_addr(int idx) +{ + switch (idx) { + case 0: return __MD0; + case 1: return __MD1; + case 2: return __MD2; + case 3: return __MD3; + case 4: return __MD5; + case 5: return __MX16_0; + case 6: return __MX24_0; + case 7: return __MX24_1; + case 8: return __MX24_2; + case 9: return __MX24_3; + default: return (void *)0; + } +} + +void set_trace(int idx, int force, void *val) +{ + (void)idx; (void)force; (void)val; +} diff --git a/demos/openplc_tank/openplc/opcua_config.json b/demos/openplc_tank/openplc/opcua_config.json new file mode 100644 index 0000000..36804a3 --- /dev/null +++ b/demos/openplc_tank/openplc/opcua_config.json @@ -0,0 +1,95 @@ +[ + { + "name": "opcua_server", + "protocol": "OPC-UA", + "config": { + "server": { + "name": "OpenPLC Tank Demo", + "application_uri": "urn:selfpatch:openplc:tank", + "product_uri": "urn:selfpatch:openplc:tank", + "endpoint_url": "opc.tcp://0.0.0.0:4840/openplc/opcua", + "security_profiles": [ + { + "name": "insecure", + "enabled": true, + "security_policy": "None", + "security_mode": "None", + "auth_methods": ["Anonymous"] + } + ] + }, + "security": { + "server_certificate_strategy": "auto_self_signed", + "server_certificate_custom": null, + "server_private_key_custom": null, + "trusted_client_certificates": [] + }, + "users": [ + { + "type": "password", + "username": "admin", + "password_hash": "$2b$12$dummy_hash_for_demo_only", + "role": "engineer" + } + ], + "cycle_time_ms": 100, + "address_space": { + "namespace_uri": "urn:selfpatch:openplc:tank:ns", + "variables": [ + { + "node_id": "PLC.Tank.TankLevel", + "browse_name": "TankLevel", + "display_name": "Tank Level", + "datatype": "REAL", + "initial_value": 500.0, + "description": "Tank level in mm (0-1000)", + "index": 0, + "permissions": {"viewer": "rw", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Tank.TankTemperature", + "browse_name": "TankTemperature", + "display_name": "Tank Temperature", + "datatype": "REAL", + "initial_value": 25.0, + "description": "Tank temperature in C (0-120)", + "index": 1, + "permissions": {"viewer": "rw", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Tank.TankPressure", + "browse_name": "TankPressure", + "display_name": "Tank Pressure", + "datatype": "REAL", + "initial_value": 1.0, + "description": "Tank pressure in bar (0-10)", + "index": 2, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + }, + { + "node_id": "PLC.Tank.PumpSpeed", + "browse_name": "PumpSpeed", + "display_name": "Pump Speed", + "datatype": "REAL", + "initial_value": 0.0, + "description": "Pump speed setpoint % (0-100)", + "index": 3, + "permissions": {"viewer": "rw", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Tank.ValvePosition", + "browse_name": "ValvePosition", + "display_name": "Valve Position", + "datatype": "REAL", + "initial_value": 0.0, + "description": "Valve position setpoint % (0-100)", + "index": 5, + "permissions": {"viewer": "rw", "operator": "rw", "engineer": "rw"} + } + ], + "structures": [], + "arrays": [] + } + } + } +] diff --git a/demos/openplc_tank/openplc/tank_demo.st b/demos/openplc_tank/openplc/tank_demo.st new file mode 100644 index 0000000..fb1d11b --- /dev/null +++ b/demos/openplc_tank/openplc/tank_demo.st @@ -0,0 +1,80 @@ +PROGRAM TankSimulation + VAR + TankLevel AT %MD0 : REAL := 500.0; + TankTemperature AT %MD1 : REAL := 25.0; + TankPressure AT %MD2 : REAL := 1.0; + PumpSpeed AT %MD3 : REAL := 0.0; + PumpRunning AT %MX16.0 : BOOL := FALSE; + ValvePosition AT %MD5 : REAL := 0.0; + ValveOpen AT %MX24.0 : BOOL := FALSE; + HighTempAlarm AT %MX24.1 : BOOL := FALSE; + LowLevelAlarm AT %MX24.2 : BOOL := FALSE; + OverpressAlarm AT %MX24.3 : BOOL := FALSE; + END_VAR + VAR + flow_in : REAL; + flow_out : REAL; + heat_rate : REAL; + END_VAR + + PumpRunning := PumpSpeed > 0.5; + + IF PumpRunning THEN + flow_in := PumpSpeed * 0.5; + ELSE + flow_in := 0.0; + END_IF; + + ValveOpen := ValvePosition > 0.5; + + IF ValveOpen THEN + flow_out := ValvePosition * 0.3 * TankLevel / 1000.0; + ELSE + flow_out := 0.0; + END_IF; + + TankLevel := TankLevel + (flow_in - flow_out) * 0.1; + + IF TankLevel > 1000.0 THEN + TankLevel := 1000.0; + ELSIF TankLevel < 0.0 THEN + TankLevel := 0.0; + END_IF; + + heat_rate := 0.05; + IF flow_in > 0.0 THEN + heat_rate := heat_rate - flow_in * 0.002; + END_IF; + IF flow_out > 0.0 THEN + heat_rate := heat_rate - flow_out * 0.001; + END_IF; + + TankTemperature := TankTemperature + heat_rate * 0.1; + + IF TankTemperature > 120.0 THEN + TankTemperature := 120.0; + ELSIF TankTemperature < 0.0 THEN + TankTemperature := 0.0; + END_IF; + + TankPressure := 1.0 + TankLevel / 1000.0 * 2.0 + TankTemperature / 100.0 * 3.0; + + IF TankPressure > 10.0 THEN + TankPressure := 10.0; + ELSIF TankPressure < 0.0 THEN + TankPressure := 0.0; + END_IF; + + HighTempAlarm := TankTemperature > 80.0; + LowLevelAlarm := TankLevel < 100.0; + OverpressAlarm := TankPressure > 5.0; + +END_PROGRAM + + +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK MainTask(INTERVAL := T#100ms, PRIORITY := 0); + PROGRAM Inst0 WITH MainTask : TankSimulation; + END_RESOURCE +END_CONFIGURATION diff --git a/demos/openplc_tank/scripts/inject-fault.sh b/demos/openplc_tank/scripts/inject-fault.sh new file mode 100755 index 0000000..34287b0 --- /dev/null +++ b/demos/openplc_tank/scripts/inject-fault.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Inject faults into the OpenPLC Tank Demo +# Writes values directly to PLC via the x-plc-operations endpoint +set -eu + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:8080}" +API_BASE="${GATEWAY_URL}/api/v1" + +FAULT_TYPE="${1:-high_temp}" + +usage() { + echo "Usage: $0 [FAULT_TYPE]" + echo "" + echo "Fault types:" + echo " high_temp - Set temperature to 95C (triggers PLC_HIGH_TEMP alarm)" + echo " low_level - Stop pump + open drain (triggers PLC_LOW_LEVEL alarm)" + echo " overpressure - High pump speed to increase pressure (triggers PLC_OVERPRESSURE)" + echo " all - Inject all faults simultaneously" + echo "" + echo "Default: high_temp" +} + +inject_high_temp() { + echo "Injecting HIGH_TEMP fault..." + echo " Setting pump speed to 0% (stop cooling)" + curl -s -X POST "${API_BASE}/apps/fill_pump/x-plc-operations/set_pump_speed" \ + -H "Content-Type: application/json" \ + -d '{"value": 0.0}' | jq . + + echo " Temperature will rise above 80C threshold..." + echo " Watch: curl ${API_BASE}/apps/tank_process/x-plc-data/tank_temperature" + echo " Fault: curl ${API_BASE}/apps/tank_process/faults" +} + +inject_low_level() { + echo "Injecting LOW_LEVEL fault..." + echo " Stopping pump and opening drain valve to 100%" + curl -s -X POST "${API_BASE}/apps/fill_pump/x-plc-operations/set_pump_speed" \ + -H "Content-Type: application/json" \ + -d '{"value": 0.0}' | jq . + + curl -s -X POST "${API_BASE}/apps/drain_valve/x-plc-operations/set_valve_position" \ + -H "Content-Type: application/json" \ + -d '{"value": 100.0}' | jq . + + echo " Tank level will drop below 100mm threshold..." + echo " Watch: curl ${API_BASE}/apps/tank_process/x-plc-data/tank_level" +} + +inject_overpressure() { + echo "Injecting OVERPRESSURE fault..." + echo " Setting pump to 100% and closing drain valve" + curl -s -X POST "${API_BASE}/apps/fill_pump/x-plc-operations/set_pump_speed" \ + -H "Content-Type: application/json" \ + -d '{"value": 100.0}' | jq . + + curl -s -X POST "${API_BASE}/apps/drain_valve/x-plc-operations/set_valve_position" \ + -H "Content-Type: application/json" \ + -d '{"value": 0.0}' | jq . + + echo " Pressure will rise above 5 bar threshold..." + echo " Watch: curl ${API_BASE}/apps/tank_process/x-plc-data/tank_pressure" +} + +case "$FAULT_TYPE" in + high_temp) inject_high_temp ;; + low_level) inject_low_level ;; + overpressure) inject_overpressure ;; + all) + inject_high_temp + echo "" + inject_low_level + echo "" + inject_overpressure + ;; + -h|--help) usage ;; + *) echo "Unknown fault type: $FAULT_TYPE"; usage; exit 1 ;; +esac + +echo "" +echo "Check faults: curl ${API_BASE}/faults | jq ." diff --git a/demos/openplc_tank/scripts/restore-normal.sh b/demos/openplc_tank/scripts/restore-normal.sh new file mode 100755 index 0000000..1c7c16b --- /dev/null +++ b/demos/openplc_tank/scripts/restore-normal.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Restore normal operation of the OpenPLC Tank Demo +# Sets pump and valve to safe operating values +set -eu + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:8080}" +API_BASE="${GATEWAY_URL}/api/v1" + +echo "=== Restoring Normal Operation ===" + +echo "Setting pump speed to 50%..." +curl -s -X POST "${API_BASE}/apps/fill_pump/x-plc-operations/set_pump_speed" \ + -H "Content-Type: application/json" \ + -d '{"value": 50.0}' > /dev/null + +echo "Setting valve position to 30%..." +curl -s -X POST "${API_BASE}/apps/drain_valve/x-plc-operations/set_valve_position" \ + -H "Content-Type: application/json" \ + -d '{"value": 30.0}' > /dev/null + +echo "" +echo "Normal operating parameters set." +echo " Pump: 50% (filling)" +echo " Valve: 30% (partial drain)" +echo "" +echo "Tank will stabilize to normal levels." +echo "Alarms will clear automatically when values return to safe range." +echo "" + +# Clear any existing faults from fault manager +echo "Clearing existing faults..." +for code in PLC_HIGH_TEMP PLC_LOW_LEVEL PLC_OVERPRESSURE; do + curl -s -X DELETE "${API_BASE}/apps/tank_process/faults/${code}" > /dev/null 2>&1 || true +done + +echo "Done. Check status:" +echo " curl ${API_BASE}/apps/tank_process/x-plc-data | jq ." +echo " curl ${API_BASE}/faults | jq ." diff --git a/demos/openplc_tank/scripts/run-demo.sh b/demos/openplc_tank/scripts/run-demo.sh new file mode 100755 index 0000000..4536604 --- /dev/null +++ b/demos/openplc_tank/scripts/run-demo.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Run the OpenPLC Tank Demo +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_DIR="$(dirname "$SCRIPT_DIR")" +cd "$DEMO_DIR" + +# Defaults +DETACH_MODE=true +UPDATE=false +NO_CACHE=false + +usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --attached Run in foreground (default: detached)" + echo " --update Pull latest images before starting" + echo " --no-cache Build without Docker cache" + echo " -h, --help Show this help" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --attached) DETACH_MODE=false; shift ;; + --update) UPDATE=true; shift ;; + --no-cache) NO_CACHE=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1"; usage; exit 1 ;; + esac +done + +# Detect compose command +if docker compose version &> /dev/null; then + COMPOSE_CMD="docker compose" +else + COMPOSE_CMD="docker-compose" +fi + +echo "=== OpenPLC Tank Demo ===" +echo "Components:" +echo " - OpenPLC v4 Runtime (OPC-UA :4840)" +echo " - ros2_medkit Gateway + OPC-UA Plugin (REST :8080)" +echo " - SOVD Web UI (:3000)" +echo "" + +# Optional: pull latest web UI +if [[ "$UPDATE" == "true" ]]; then + echo "Pulling latest images..." + $COMPOSE_CMD pull sovd-web-ui +fi + +# Build +BUILD_ARGS="" +if [[ "$NO_CACHE" == "true" ]]; then + BUILD_ARGS="--no-cache" +fi + +echo "Building containers..." +$COMPOSE_CMD build $BUILD_ARGS || { + echo "Build failed!" + exit 1 +} + +# Run +DETACH_FLAG="" +if [[ "$DETACH_MODE" == "true" ]]; then + DETACH_FLAG="-d" +fi + +echo "Starting demo..." +$COMPOSE_CMD up $DETACH_FLAG + +if [[ "$DETACH_MODE" == "true" ]]; then + echo "" + echo "Demo started in background." + echo "" + echo "Next steps:" + echo " - Web UI: http://localhost:3000" + echo " - REST API: http://localhost:8080/api/v1" + echo " - OPC-UA: opc.tcp://localhost:4840" + echo "" + echo " - Check: curl http://localhost:8080/api/v1/health" + echo " - PLC data: curl http://localhost:8080/api/v1/apps/tank_process/x-plc-data" + echo " - Inject: ./scripts/inject-fault.sh" + echo " - Restore: ./scripts/restore-normal.sh" + echo " - Stop: ./scripts/stop-demo.sh" +fi diff --git a/demos/openplc_tank/scripts/run_integration_tests.sh b/demos/openplc_tank/scripts/run_integration_tests.sh new file mode 100755 index 0000000..31443f3 --- /dev/null +++ b/demos/openplc_tank/scripts/run_integration_tests.sh @@ -0,0 +1,378 @@ +#!/usr/bin/env bash +# Integration Tests for OpenPLC Tank Demo +# Tests: entity discovery, live data, control, fault lifecycle, resilience, errors +set -euo pipefail + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:8080}" +API="${GATEWAY_URL}/api/v1" +CURL_OPTS="--max-time 10 -sf" +PASSED=0 +FAILED=0 +TOTAL=0 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +cleanup() { + echo "" + echo "===============================" + echo -e "Results: ${GREEN}${PASSED} passed${NC} / ${RED}${FAILED} failed${NC} / ${TOTAL} total" + echo "===============================" + if [[ $FAILED -gt 0 ]]; then + exit 1 + fi +} +trap cleanup EXIT + +# --- Helpers --- + +assert_response() { + local description="$1" + local method="$2" + local url="$3" + local expected_status="$4" + local body="${5:-}" + local jq_filter="${6:-}" + local expected_value="${7:-}" + + TOTAL=$((TOTAL + 1)) + + local curl_args=("--max-time" "10" "-s" "-o" "/tmp/test_body.json" "-w" "%{http_code}") + + if [[ "$method" == "POST" ]]; then + curl_args+=("-X" "POST" "-H" "Content-Type: application/json") + if [[ -n "$body" ]]; then + curl_args+=("-d" "$body") + fi + elif [[ "$method" == "DELETE" ]]; then + curl_args+=("-X" "DELETE") + fi + + curl_args+=("$url") + + local status + status=$(curl "${curl_args[@]}" 2>/dev/null) || status="000" + + if [[ "$status" != "$expected_status" ]]; then + FAILED=$((FAILED + 1)) + echo -e "${RED}FAIL${NC} $description" + echo " Expected HTTP $expected_status, got $status" + echo " URL: $method $url" + if [[ -f /tmp/test_body.json ]]; then + echo " Body: $(head -c 200 /tmp/test_body.json)" + fi + return 1 + fi + + if [[ -n "$jq_filter" && -n "$expected_value" ]]; then + local actual + actual=$(jq -r "$jq_filter" /tmp/test_body.json 2>/dev/null) || actual="" + + if [[ "$actual" != "$expected_value" ]]; then + FAILED=$((FAILED + 1)) + echo -e "${RED}FAIL${NC} $description" + echo " jq '$jq_filter' = '$actual', expected '$expected_value'" + return 1 + fi + fi + + PASSED=$((PASSED + 1)) + echo -e "${GREEN}PASS${NC} $description" + return 0 +} + +assert_contains() { + local description="$1" + local url="$2" + local jq_filter="$3" + local expected_value="$4" + + TOTAL=$((TOTAL + 1)) + + local body + body=$(curl $CURL_OPTS "$url" 2>/dev/null) || body="" + + local result + result=$(echo "$body" | jq -r "$jq_filter" 2>/dev/null) || result="" + + if echo "$result" | grep -q "$expected_value"; then + PASSED=$((PASSED + 1)) + echo -e "${GREEN}PASS${NC} $description" + return 0 + else + FAILED=$((FAILED + 1)) + echo -e "${RED}FAIL${NC} $description" + echo " '$expected_value' not found in jq output of '$jq_filter'" + return 1 + fi +} + +wait_for_gateway() { + echo "Waiting for gateway..." + for i in $(seq 1 30); do + if curl -sf "${API}/health" > /dev/null 2>&1; then + echo "Gateway is up!" + return 0 + fi + sleep 1 + done + echo "Gateway did not start within 30s" + exit 1 +} + +# --- Start --- + +echo "========================================" +echo "OpenPLC Tank Demo - Integration Tests" +echo "Gateway: $GATEWAY_URL" +echo "========================================" +echo "" + +wait_for_gateway + +# ============================================================================= +# 1. Entity Discovery (SOVD Tree) +# ============================================================================= +echo "" +echo -e "${YELLOW}--- 1. Entity Discovery ---${NC}" + +assert_contains "Area plc_systems exists" \ + "${API}/areas" '.items[].id' "plc_systems" + +assert_contains "Component openplc_runtime exists" \ + "${API}/components" '.items[].id' "openplc_runtime" + +assert_contains "App tank_process exists" \ + "${API}/apps" '.items[].id' "tank_process" + +assert_contains "App fill_pump exists" \ + "${API}/apps" '.items[].id' "fill_pump" + +assert_contains "App drain_valve exists" \ + "${API}/apps" '.items[].id' "drain_valve" + +assert_response "tank_process detail returns external=true" \ + "GET" "${API}/apps/tank_process" "200" "" '.external' "true" + +assert_response "openplc_runtime is in plc_systems area" \ + "GET" "${API}/components/openplc_runtime" "200" "" '."x-medkit".area' "plc_systems" + +# ============================================================================= +# 2. Live Data (sensor readings) +# ============================================================================= +echo "" +echo -e "${YELLOW}--- 2. Live Data ---${NC}" + +assert_response "tank_process x-plc-data returns 200" \ + "GET" "${API}/apps/tank_process/x-plc-data" "200" + +assert_contains "tank_process has tank_level data" \ + "${API}/apps/tank_process/x-plc-data" '.items[].name' "tank_level" + +assert_contains "tank_process has tank_temperature data" \ + "${API}/apps/tank_process/x-plc-data" '.items[].name' "tank_temperature" + +assert_contains "tank_process has tank_pressure data" \ + "${API}/apps/tank_process/x-plc-data" '.items[].name' "tank_pressure" + +assert_response "fill_pump x-plc-data returns 200" \ + "GET" "${API}/apps/fill_pump/x-plc-data" "200" + +assert_contains "fill_pump has pump_speed data" \ + "${API}/apps/fill_pump/x-plc-data" '.items[].name' "pump_speed" + +assert_contains "fill_pump has pump_running data" \ + "${API}/apps/fill_pump/x-plc-data" '.items[].name' "pump_running" + +assert_response "drain_valve x-plc-data returns 200" \ + "GET" "${API}/apps/drain_valve/x-plc-data" "200" + +assert_contains "drain_valve has valve_position data" \ + "${API}/apps/drain_valve/x-plc-data" '.items[].name' "valve_position" + +assert_response "Single node query works" \ + "GET" "${API}/apps/tank_process/x-plc-data/tank_level" "200" + +# Verify values are numeric (not null) +TOTAL=$((TOTAL + 1)) +LEVEL=$(curl $CURL_OPTS "${API}/apps/tank_process/x-plc-data/tank_level" 2>/dev/null | jq '.value' 2>/dev/null) +if [[ "$LEVEL" != "null" && -n "$LEVEL" ]]; then + PASSED=$((PASSED + 1)) + echo -e "${GREEN}PASS${NC} tank_level has numeric value: $LEVEL" +else + FAILED=$((FAILED + 1)) + echo -e "${RED}FAIL${NC} tank_level value is null or missing" +fi + +# ============================================================================= +# 3. Control (write setpoints) +# ============================================================================= +echo "" +echo -e "${YELLOW}--- 3. Control Operations ---${NC}" + +assert_response "Set pump speed to 75%" \ + "POST" "${API}/apps/fill_pump/x-plc-operations/set_pump_speed" "200" \ + '{"value": 75.0}' + +sleep 1 # Wait for PLC cycle + +# Verify pump speed was written +TOTAL=$((TOTAL + 1)) +PUMP_SPEED=$(curl $CURL_OPTS "${API}/apps/fill_pump/x-plc-data/pump_speed" 2>/dev/null | jq '.value' 2>/dev/null) +if [[ -n "$PUMP_SPEED" ]] && (( $(echo "$PUMP_SPEED > 70 && $PUMP_SPEED < 80" | bc -l 2>/dev/null || echo 0) )); then + PASSED=$((PASSED + 1)) + echo -e "${GREEN}PASS${NC} pump_speed reads back ~75.0 (got $PUMP_SPEED)" +else + FAILED=$((FAILED + 1)) + echo -e "${RED}FAIL${NC} pump_speed readback: expected ~75.0, got $PUMP_SPEED" +fi + +assert_response "Set valve position to 50%" \ + "POST" "${API}/apps/drain_valve/x-plc-operations/set_valve_position" "200" \ + '{"value": 50.0}' + +sleep 1 + +# Verify valve position +TOTAL=$((TOTAL + 1)) +VALVE_POS=$(curl $CURL_OPTS "${API}/apps/drain_valve/x-plc-data/valve_position" 2>/dev/null | jq '.value' 2>/dev/null) +if [[ -n "$VALVE_POS" ]] && (( $(echo "$VALVE_POS > 45 && $VALVE_POS < 55" | bc -l 2>/dev/null || echo 0) )); then + PASSED=$((PASSED + 1)) + echo -e "${GREEN}PASS${NC} valve_position reads back ~50.0 (got $VALVE_POS)" +else + FAILED=$((FAILED + 1)) + echo -e "${RED}FAIL${NC} valve_position readback: expected ~50.0, got $VALVE_POS" +fi + +# ============================================================================= +# 4. Fault Lifecycle (alarm injection + SOVD faults) +# ============================================================================= +echo "" +echo -e "${YELLOW}--- 4. Fault Lifecycle ---${NC}" + +# First, restore normal state +curl -s -X POST "${API}/apps/fill_pump/x-plc-operations/set_pump_speed" \ + -H "Content-Type: application/json" -d '{"value": 0.0}' > /dev/null 2>&1 || true + +echo "Waiting for temperature to rise above 80C (alarm threshold)..." +# Stop pump to let temperature rise +ALARM_DETECTED=0 +for i in $(seq 1 60); do + TEMP=$(curl $CURL_OPTS "${API}/apps/tank_process/x-plc-data/tank_temperature" 2>/dev/null | jq '.value' 2>/dev/null || echo "0") + if (( $(echo "${TEMP:-0} > 80" | bc -l 2>/dev/null || echo 0) )); then + echo " Temperature reached ${TEMP}C" + ALARM_DETECTED=1 + break + fi + if (( i % 10 == 0 )); then + echo " Temperature at ${TEMP}C..." + fi + sleep 2 +done + +if [[ $ALARM_DETECTED -eq 1 ]]; then + sleep 2 # Wait for poller + fault report + + assert_contains "PLC_HIGH_TEMP fault reported" \ + "${API}/apps/tank_process/faults" '.faults[].fault_code' "PLC_HIGH_TEMP" + + assert_contains "PLC_HIGH_TEMP in global fault list" \ + "${API}/faults" '.faults[].fault_code' "PLC_HIGH_TEMP" + + # Clear alarm by restoring pump (cooling) + echo "Clearing alarm - restoring pump to cool tank..." + curl -s -X POST "${API}/apps/fill_pump/x-plc-operations/set_pump_speed" \ + -H "Content-Type: application/json" -d '{"value": 80.0}' > /dev/null + + ALARM_CLEARED=0 + for i in $(seq 1 60); do + TEMP=$(curl $CURL_OPTS "${API}/apps/tank_process/x-plc-data/tank_temperature" 2>/dev/null | jq '.value' 2>/dev/null || echo "100") + if (( $(echo "${TEMP:-100} < 80" | bc -l 2>/dev/null || echo 0) )); then + echo " Temperature dropped to ${TEMP}C" + ALARM_CLEARED=1 + break + fi + if (( i % 10 == 0 )); then + echo " Temperature at ${TEMP}C..." + fi + sleep 2 + done + + if [[ $ALARM_CLEARED -eq 1 ]]; then + sleep 2 + TOTAL=$((TOTAL + 1)) + FAULTS=$(curl $CURL_OPTS "${API}/apps/tank_process/faults" 2>/dev/null | jq '[.faults[] | select(.fault_code == "PLC_HIGH_TEMP" and .status == "active")] | length' 2>/dev/null || echo "1") + if [[ "$FAULTS" == "0" ]]; then + PASSED=$((PASSED + 1)) + echo -e "${GREEN}PASS${NC} PLC_HIGH_TEMP fault cleared after temperature drop" + else + FAILED=$((FAILED + 1)) + echo -e "${RED}FAIL${NC} PLC_HIGH_TEMP fault still active after temperature drop" + fi + else + TOTAL=$((TOTAL + 1)) + FAILED=$((FAILED + 1)) + echo -e "${RED}FAIL${NC} Temperature did not drop below 80C within timeout" + fi +else + echo -e "${YELLOW}SKIP${NC} Fault lifecycle tests - temperature did not reach threshold" + echo " (This may happen if PLC simulation rate differs from expected)" +fi + +# Restore normal operation for remaining tests +curl -s -X POST "${API}/apps/fill_pump/x-plc-operations/set_pump_speed" \ + -H "Content-Type: application/json" -d '{"value": 50.0}' > /dev/null 2>&1 || true +curl -s -X POST "${API}/apps/drain_valve/x-plc-operations/set_valve_position" \ + -H "Content-Type: application/json" -d '{"value": 30.0}' > /dev/null 2>&1 || true + +# ============================================================================= +# 5. Connection Status +# ============================================================================= +echo "" +echo -e "${YELLOW}--- 5. Connection Status ---${NC}" + +assert_response "x-plc-status endpoint returns 200" \ + "GET" "${API}/components/openplc_runtime/x-plc-status" "200" + +assert_response "x-plc-status shows connected=true" \ + "GET" "${API}/components/openplc_runtime/x-plc-status" "200" "" '.connected' "true" + +TOTAL=$((TOTAL + 1)) +POLL_COUNT=$(curl $CURL_OPTS "${API}/components/openplc_runtime/x-plc-status" 2>/dev/null | jq '.poll_count' 2>/dev/null || echo "0") +if [[ -n "$POLL_COUNT" ]] && (( POLL_COUNT > 0 )); then + PASSED=$((PASSED + 1)) + echo -e "${GREEN}PASS${NC} poll_count > 0 (got $POLL_COUNT)" +else + FAILED=$((FAILED + 1)) + echo -e "${RED}FAIL${NC} poll_count should be > 0, got $POLL_COUNT" +fi + +# ============================================================================= +# 6. Error Responses (SOVD conformance) +# ============================================================================= +echo "" +echo -e "${YELLOW}--- 6. Error Responses ---${NC}" + +assert_response "404 for nonexistent app x-plc-data" \ + "GET" "${API}/apps/nonexistent/x-plc-data" "404" + +assert_response "404 for nonexistent operation" \ + "POST" "${API}/apps/fill_pump/x-plc-operations/nonexistent" "404" \ + '{"value": 42}' + +assert_response "400 for invalid JSON body" \ + "POST" "${API}/apps/fill_pump/x-plc-operations/set_pump_speed" "400" \ + 'not json' + +assert_response "400 for missing value field" \ + "POST" "${API}/apps/fill_pump/x-plc-operations/set_pump_speed" "400" \ + '{"wrong": 42}' + +assert_response "400 for read-only data point write" \ + "POST" "${API}/apps/fill_pump/x-plc-operations/set_pump_running" "404" + +assert_response "404 for nonexistent single data point" \ + "GET" "${API}/apps/tank_process/x-plc-data/nonexistent" "404" diff --git a/demos/openplc_tank/scripts/stop-demo.sh b/demos/openplc_tank/scripts/stop-demo.sh new file mode 100755 index 0000000..3fba002 --- /dev/null +++ b/demos/openplc_tank/scripts/stop-demo.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Stop the OpenPLC Tank Demo +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_DIR="$(dirname "$SCRIPT_DIR")" +cd "$DEMO_DIR" + +VOLUMES=false + +while [[ $# -gt 0 ]]; do + case $1 in + --volumes|-v) VOLUMES=true; shift ;; + *) shift ;; + esac +done + +if docker compose version &> /dev/null; then + COMPOSE_CMD="docker compose" +else + COMPOSE_CMD="docker-compose" +fi + +DOWN_ARGS="" +if [[ "$VOLUMES" == "true" ]]; then + DOWN_ARGS="-v" +fi + +echo "Stopping OpenPLC Tank Demo..." +$COMPOSE_CMD down $DOWN_ARGS +echo "Done."