From 23c36b7150097da659d9f6b7c95470eae9f2da04 Mon Sep 17 00:00:00 2001 From: Aldon Smith Date: Fri, 22 May 2026 12:31:58 -0400 Subject: [PATCH] feat: add Dockerized OPC UA demo simulator --- docs/LEARNING_LOG.md | 48 +++++ docs/demo/MANUFACTURER_DEMO_RUNBOOK.md | 3 + docs/demo/OPC_UA_DEMO_NAMESPACE.md | 11 + infra/docker/docker-compose.yml | 14 +- infra/docker/opcua-simulator.Dockerfile | 21 ++ pyproject.toml | 2 +- requirements-dev.txt | 2 +- services/simulator/README.md | 42 ++++ .../simulator/factory_simulator/opcua_demo.py | 192 ++++++++++++++++++ .../factory_simulator/opcua_server.py | 189 +++++++++++++++++ .../simulator/tests/test_opcua_demo_server.py | 148 ++++++++++++++ 11 files changed, 669 insertions(+), 3 deletions(-) create mode 100644 infra/docker/opcua-simulator.Dockerfile create mode 100644 services/simulator/factory_simulator/opcua_demo.py create mode 100644 services/simulator/factory_simulator/opcua_server.py create mode 100644 services/simulator/tests/test_opcua_demo_server.py diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index c664bec..a2830f2 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -22,6 +22,54 @@ This file should be updated by Codex after each meaningful change. ### What to learn next ``` +## 2026-05-22 - Dockerized OPC UA demo simulator + +### What changed + +Added a Docker Compose service for a local OPC UA demo simulator. The service +starts in normal mode and exposes the manufacturer-demo site, line, +filler/checkweigher assets, process tags, and demo state tags. + +### Why it matters + +The manufacturer demo can now show an OPC UA-style local source without +connecting to a real plant or implying production connector readiness. + +### How it works + +The simulator uses a small static namespace for `fill_weight`, +`filler_nozzle_pressure`, `line_speed`, `scenario`, and `drift_active`. +Docker Compose builds the service from the repo and exposes +`opc.tcp://localhost:4840/ofi/demo`. + +### How to run it + +```bash +docker compose -f infra/docker/docker-compose.yml up --build opcua-simulator +``` + +### How to test it + +```bash +.venv/bin/python -m pytest services/simulator/tests/test_opcua_demo_server.py +docker compose -f infra/docker/docker-compose.yml config +``` + +### Key files + +- `services/simulator/factory_simulator/opcua_demo.py` +- `services/simulator/factory_simulator/opcua_server.py` +- `infra/docker/docker-compose.yml` +- `infra/docker/opcua-simulator.Dockerfile` +- `services/simulator/tests/test_opcua_demo_server.py` +- `services/simulator/README.md` + +### What to learn next + +Use this read-only demo server as the source for a future ingestion adapter or +smoke test, keeping production security, certificates, and connector framework +work in separate issues. + ## 2026-05-22 - OPC UA demo namespace contract ### What changed diff --git a/docs/demo/MANUFACTURER_DEMO_RUNBOOK.md b/docs/demo/MANUFACTURER_DEMO_RUNBOOK.md index 31e05f2..8dba71f 100644 --- a/docs/demo/MANUFACTURER_DEMO_RUNBOOK.md +++ b/docs/demo/MANUFACTURER_DEMO_RUNBOOK.md @@ -312,6 +312,9 @@ For the intentionally small OPC UA-style demo namespace and mapping from demo tags to FactoryEvent process and quality events, see `docs/demo/OPC_UA_DEMO_NAMESPACE.md`. +For the Dockerized OPC UA demo simulator endpoint, startup command, and tag +list, see `services/simulator/README.md`. + For the screen-by-screen user goal, component, data dependency, safety language, risk, and success-criteria map, see `docs/demo/MANUFACTURER_DEMO_USER_JOURNEY.md`. diff --git a/docs/demo/OPC_UA_DEMO_NAMESPACE.md b/docs/demo/OPC_UA_DEMO_NAMESPACE.md index 6e24386..812565b 100644 --- a/docs/demo/OPC_UA_DEMO_NAMESPACE.md +++ b/docs/demo/OPC_UA_DEMO_NAMESPACE.md @@ -52,8 +52,19 @@ payload itself. | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `filler_f_201.fill_weight` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight` | `FillWeight` | `filler_f_201` | `g` | `495.0` to `505.0` | `500.0` | `500.12` | `506.81` | Stable baseline for the first 8 samples, then about `+0.33 g` per sample with small noise. | `process.measurement.recorded` with `payload.signal_id=fill_weight`, `payload.signal_name=Fill Weight`, and `payload.tag_name=filler_f_201.fill_weight` | | `filler_f_201.filler_nozzle_pressure` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.NozzlePressure` | `NozzlePressure` | `filler_f_201` | `bar` | `1.9` to `2.4` | `2.1` | `2.10` | `2.31` | Stable baseline for the first 8 samples, then about `+0.01 bar` per sample with small noise. | `process.measurement.recorded` with `payload.signal_id=filler_nozzle_pressure`, `payload.signal_name=Filler Nozzle Pressure`, and `payload.tag_name=filler_f_201.filler_nozzle_pressure` | +| `line_2.line_speed` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.LineSpeed` | `LineSpeed` | `line_2` | `bottles_per_min` | `115.0` to `125.0` | `120.0` | `120.0` | `120.0` | Held steady at startup for normal-mode OPC UA demo operation. | OPC UA demo support tag; no current Process Sentinel rule consumes it. | | `checkweigher_cw_201.final_fill_weight` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.CheckweigherCW201.FinalFillWeight` | `FinalFillWeight` | `checkweigher_cw_201` | `g` | `495.0` to `505.0` | none | `500.20 pass` | `506.70 fail` | Recorded every third sample as the inline quality marker that confirms fill-weight drift is visible in quality results. | `quality.measurement.recorded` with `payload.quality_check_type=inline_check` and `payload.measurement_name=Final Fill Weight` | +## Demo State Tags + +The Dockerized OPC UA demo server starts in normal operation mode and exposes +these state tags: + +| Demo tag name | Node ID | Startup value | Purpose | +| --- | --- | --- | --- | +| `scenario` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.Scenario` | `fill_weight_drift_demo` | Identifies the deterministic manufacturer demo scenario. | +| `drift_active` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.DriftActive` | `false` | Confirms the OPC UA server starts in normal mode before any future drift behavior is enabled. | + ## Mapping Rules 1. OPC UA source values are normalized into existing `FactoryEvent` envelopes. diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 03b6e03..2497aba 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -16,6 +16,18 @@ services: timeout: 5s retries: 10 + opcua-simulator: + build: + context: ../.. + dockerfile: infra/docker/opcua-simulator.Dockerfile + environment: + OPCUA_DEMO_HOST: 0.0.0.0 + OPCUA_DEMO_PORT: "4840" + OPCUA_DEMO_ENDPOINT_PATH: /ofi/demo + OPCUA_DEMO_SCENARIO: fill_weight_drift_demo + OPCUA_DEMO_DRIFT_ACTIVE: "false" + ports: + - "4840:4840" + volumes: factory_postgres_data: - diff --git a/infra/docker/opcua-simulator.Dockerfile b/infra/docker/opcua-simulator.Dockerfile new file mode 100644 index 0000000..0fe1019 --- /dev/null +++ b/infra/docker/opcua-simulator.Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements-dev.txt pyproject.toml ./ +RUN python -m pip install --no-cache-dir --upgrade pip \ + && python -m pip install --no-cache-dir -r requirements-dev.txt + +COPY packages ./packages +COPY services/simulator ./services/simulator + +ENV PYTHONPATH=/app/packages/factory-events:/app/services/simulator +ENV OPCUA_DEMO_HOST=0.0.0.0 +ENV OPCUA_DEMO_PORT=4840 +ENV OPCUA_DEMO_ENDPOINT_PATH=/ofi/demo +ENV OPCUA_DEMO_SCENARIO=fill_weight_drift_demo +ENV OPCUA_DEMO_DRIFT_ACTIVE=false + +EXPOSE 4840 + +CMD ["python", "-m", "factory_simulator.opcua_server"] diff --git a/pyproject.toml b/pyproject.toml index d172b52..ecdabbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "Simulator-backed Factory Intelligence Platform MVP skeleton." requires-python = ">=3.12" dependencies = [ + "asyncua>=1.1.5", "fastapi>=0.115.0", "httpx>=0.27.0", "psycopg[binary]>=3.2.0", @@ -35,4 +36,3 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] - diff --git a/requirements-dev.txt b/requirements-dev.txt index b6c7ec1..742ae5f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ +asyncua>=1.1.5 fastapi>=0.115.0 httpx>=0.27.0 psycopg[binary]>=3.2.0 @@ -5,4 +6,3 @@ pydantic>=2.8.0 pytest>=8.3.0 ruff>=0.6.0 uvicorn>=0.30.0 - diff --git a/services/simulator/README.md b/services/simulator/README.md index 1dd9800..ee0667b 100644 --- a/services/simulator/README.md +++ b/services/simulator/README.md @@ -178,6 +178,48 @@ The expected demo detection lookup is: det_fill_weight_gradual_drift ``` +## Dockerized OPC UA Demo Simulator + +The local Docker Compose stack includes a small OPC UA demo simulator for the +manufacturer demo. It exposes normal-mode demo tags for one site, one line, one +filler asset, and one checkweigher asset. It is simulator-backed demo +infrastructure, not a production OPC UA connector. + +Start only the OPC UA demo server: + +```bash +docker compose -f infra/docker/docker-compose.yml up --build opcua-simulator +``` + +Endpoint from the host: + +```text +opc.tcp://localhost:4840/ofi/demo +``` + +The service starts in normal operation mode: + +- `scenario`: `fill_weight_drift_demo` +- `drift_active`: `false` + +Required demo process tags: + +| Tag | Node ID | Normal value | Unit | +| --- | --- | --- | --- | +| `filler_f_201.fill_weight` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight` | `500.12` | `g` | +| `filler_f_201.filler_nozzle_pressure` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.NozzlePressure` | `2.1` | `bar` | +| `line_2.line_speed` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.LineSpeed` | `120.0` | `bottles_per_min` | + +Required demo state tags: + +| Tag | Node ID | Startup value | +| --- | --- | --- | +| `scenario` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.Scenario` | `fill_weight_drift_demo` | +| `drift_active` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.DriftActive` | `false` | + +Startup logs include the endpoint, namespace URI, scenario name, drift flag, and +a warning that the service is simulator-backed demo infrastructure. + ## Connect Simulator Output To Ingestion After generating JSONL, pass the same path to ingestion: diff --git a/services/simulator/factory_simulator/opcua_demo.py b/services/simulator/factory_simulator/opcua_demo.py new file mode 100644 index 0000000..b43d631 --- /dev/null +++ b/services/simulator/factory_simulator/opcua_demo.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +NAMESPACE_URI = "urn:open-factory-initiative:factory-intelligence-platform:demo" +DEFAULT_ENDPOINT_PATH = "/ofi/demo" +DEFAULT_SCENARIO = "fill_weight_drift_demo" +DEFAULT_DRIFT_ACTIVE = False + +DEMO_SITE_ID = "greenville_demo_site" +DEMO_AREA_ID = "packaging_area" +DEMO_LINE_ID = "line_2" +DEMO_WORK_ORDER_ID = "WO-DEMO-1007" +DEMO_BATCH_ID = "BATCH-DEMO-1007" +DEMO_PRODUCT_ID = "ofi_demo_beverage" +DEMO_PRODUCT_NAME = "OFI Demo Beverage" + + +DemoValueType = Literal["float", "bool", "string"] + + +@dataclass(frozen=True) +class OpcUaDemoNode: + node_id: str + browse_name: str + tag_name: str + value: float | bool | str + value_type: DemoValueType + category: Literal["process", "quality", "state", "context"] + asset_id: str | None = None + unit: str | None = None + normal_min: float | None = None + normal_max: float | None = None + target_value: float | None = None + description: str = "" + + +DEMO_CONTEXT_NODES: tuple[OpcUaDemoNode, ...] = ( + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.SiteId", + browse_name="SiteId", + tag_name="site_id", + value=DEMO_SITE_ID, + value_type="string", + category="context", + description="Demo site identifier.", + ), + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.AreaId", + browse_name="AreaId", + tag_name="area_id", + value=DEMO_AREA_ID, + value_type="string", + category="context", + description="Demo production area identifier.", + ), + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.LineId", + browse_name="LineId", + tag_name="line_id", + value=DEMO_LINE_ID, + value_type="string", + category="context", + description="Demo line identifier.", + ), + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.WorkOrderId", + browse_name="WorkOrderId", + tag_name="work_order_id", + value=DEMO_WORK_ORDER_ID, + value_type="string", + category="context", + description="Demo work order identifier.", + ), + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.BatchId", + browse_name="BatchId", + tag_name="batch_id", + value=DEMO_BATCH_ID, + value_type="string", + category="context", + description="Demo batch identifier.", + ), + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.ProductId", + browse_name="ProductId", + tag_name="product_id", + value=DEMO_PRODUCT_ID, + value_type="string", + category="context", + description="Demo product identifier.", + ), + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.ProductName", + browse_name="ProductName", + tag_name="product_name", + value=DEMO_PRODUCT_NAME, + value_type="string", + category="context", + description="Demo product name.", + ), +) + +DEMO_PROCESS_NODES: tuple[OpcUaDemoNode, ...] = ( + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight", + browse_name="FillWeight", + tag_name="filler_f_201.fill_weight", + value=500.12, + value_type="float", + category="process", + asset_id="filler_f_201", + unit="g", + normal_min=495.0, + normal_max=505.0, + target_value=500.0, + description="Normal-mode demo fill weight signal.", + ), + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.FillerF201.NozzlePressure", + browse_name="NozzlePressure", + tag_name="filler_f_201.filler_nozzle_pressure", + value=2.1, + value_type="float", + category="process", + asset_id="filler_f_201", + unit="bar", + normal_min=1.9, + normal_max=2.4, + target_value=2.1, + description="Normal-mode demo filler nozzle pressure signal.", + ), + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.LineSpeed", + browse_name="LineSpeed", + tag_name="line_2.line_speed", + value=120.0, + value_type="float", + category="process", + asset_id="line_2", + unit="bottles_per_min", + normal_min=115.0, + normal_max=125.0, + target_value=120.0, + description="Normal-mode demo line speed signal.", + ), +) + +DEMO_QUALITY_NODES: tuple[OpcUaDemoNode, ...] = ( + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.CheckweigherCW201.FinalFillWeight", + browse_name="FinalFillWeight", + tag_name="checkweigher_cw_201.final_fill_weight", + value=500.2, + value_type="float", + category="quality", + asset_id="checkweigher_cw_201", + unit="g", + normal_min=495.0, + normal_max=505.0, + description="Normal-mode inline quality marker for final fill weight.", + ), +) + +DEMO_STATE_NODES: tuple[OpcUaDemoNode, ...] = ( + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.State.Scenario", + browse_name="Scenario", + tag_name="scenario", + value=DEFAULT_SCENARIO, + value_type="string", + category="state", + description="Current demo scenario name.", + ), + OpcUaDemoNode( + node_id="OFI.Demo.Greenville.Packaging.Line2.State.DriftActive", + browse_name="DriftActive", + tag_name="drift_active", + value=DEFAULT_DRIFT_ACTIVE, + value_type="bool", + category="state", + description="Normal-mode drift flag. False at startup.", + ), +) + +DEMO_OPC_UA_NODES: tuple[OpcUaDemoNode, ...] = ( + *DEMO_CONTEXT_NODES, + *DEMO_PROCESS_NODES, + *DEMO_QUALITY_NODES, + *DEMO_STATE_NODES, +) diff --git a/services/simulator/factory_simulator/opcua_server.py b/services/simulator/factory_simulator/opcua_server.py new file mode 100644 index 0000000..5039c8d --- /dev/null +++ b/services/simulator/factory_simulator/opcua_server.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +from collections.abc import Sequence +from dataclasses import replace + +from factory_simulator.opcua_demo import ( + DEFAULT_DRIFT_ACTIVE, + DEFAULT_ENDPOINT_PATH, + DEFAULT_SCENARIO, + DEMO_OPC_UA_NODES, + NAMESPACE_URI, + OpcUaDemoNode, +) + +LOGGER = logging.getLogger("factory_simulator.opcua_server") + + +def _bool_from_env(value: str | None, *, default: bool) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _endpoint(host: str, port: int, path: str) -> str: + normalized_path = path if path.startswith("/") else f"/{path}" + return f"opc.tcp://{host}:{port}{normalized_path}" + + +def _configured_nodes(*, scenario: str, drift_active: bool) -> tuple[OpcUaDemoNode, ...]: + values: list[OpcUaDemoNode] = [] + for node in DEMO_OPC_UA_NODES: + if node.tag_name == "scenario": + values.append(replace(node, value=scenario)) + elif node.tag_name == "drift_active": + values.append(replace(node, value=drift_active)) + else: + values.append(node) + return tuple(values) + + +async def _add_properties(variable: object, namespace_index: int, node: OpcUaDemoNode) -> None: + if node.unit is not None: + await variable.add_property(namespace_index, "EngineeringUnit", node.unit) + if node.normal_min is not None: + await variable.add_property(namespace_index, "NormalMin", node.normal_min) + if node.normal_max is not None: + await variable.add_property(namespace_index, "NormalMax", node.normal_max) + if node.target_value is not None: + await variable.add_property(namespace_index, "TargetValue", node.target_value) + if node.asset_id is not None: + await variable.add_property(namespace_index, "AssetId", node.asset_id) + await variable.add_property(namespace_index, "TagName", node.tag_name) + await variable.add_property(namespace_index, "Category", node.category) + + +async def build_server( + *, + host: str, + port: int, + endpoint_path: str, + scenario: str, + drift_active: bool, +): + from asyncua import Server, ua + + server = Server() + await server.init() + server.set_endpoint(_endpoint(host, port, endpoint_path)) + server.set_server_name("Factory Intelligence Platform Demo OPC UA Simulator") + namespace_index = await server.register_namespace(NAMESPACE_URI) + + objects = server.nodes.objects + root = await objects.add_folder(namespace_index, "OpenFactoryInitiative") + demo = await root.add_folder(namespace_index, "Demo") + site = await demo.add_folder(namespace_index, "GreenvilleDemoSite") + area = await site.add_folder(namespace_index, "PackagingArea") + line = await area.add_folder(namespace_index, "Line2") + filler = await line.add_folder(namespace_index, "FillerF201") + checkweigher = await line.add_folder(namespace_index, "CheckweigherCW201") + state = await line.add_folder(namespace_index, "State") + context = await line.add_folder(namespace_index, "Context") + + parents = { + "context": context, + "state": state, + "quality": checkweigher, + "process": line, + } + process_asset_parents = { + "filler_f_201": filler, + "line_2": line, + } + + for node in _configured_nodes(scenario=scenario, drift_active=drift_active): + parent = process_asset_parents.get(node.asset_id, parents[node.category]) + variable = await parent.add_variable( + ua.NodeId(node.node_id, namespace_index), + node.browse_name, + node.value, + ) + await _add_properties(variable, namespace_index, node) + LOGGER.info( + "exposed demo OPC UA node node_id=ns=%s;s=%s tag_name=%s value=%r", + namespace_index, + node.node_id, + node.tag_name, + node.value, + ) + + return server + + +async def run_server( + *, + host: str = "0.0.0.0", + port: int = 4840, + endpoint_path: str = DEFAULT_ENDPOINT_PATH, + scenario: str = DEFAULT_SCENARIO, + drift_active: bool = DEFAULT_DRIFT_ACTIVE, + ready_event: asyncio.Event | None = None, + stop_event: asyncio.Event | None = None, +) -> None: + endpoint = _endpoint(host, port, endpoint_path) + LOGGER.warning( + "OPC UA demo simulator is simulator-backed demo infrastructure, " + "not a production OPC UA connector." + ) + LOGGER.info( + "starting OPC UA demo simulator endpoint=%s namespace_uri=%s scenario=%s " + "drift_active=%s", + endpoint, + NAMESPACE_URI, + scenario, + drift_active, + ) + server = await build_server( + host=host, + port=port, + endpoint_path=endpoint_path, + scenario=scenario, + drift_active=drift_active, + ) + + async with server: + LOGGER.info("OPC UA demo simulator listening endpoint=%s", endpoint) + if ready_event is not None: + ready_event.set() + if stop_event is None: + stop_event = asyncio.Event() + await stop_event.wait() + + +def main(argv: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="Run the demo OPC UA simulator server.") + parser.add_argument("--host", default=os.getenv("OPCUA_DEMO_HOST", "0.0.0.0")) + parser.add_argument("--port", type=int, default=int(os.getenv("OPCUA_DEMO_PORT", "4840"))) + parser.add_argument( + "--endpoint-path", + default=os.getenv("OPCUA_DEMO_ENDPOINT_PATH", DEFAULT_ENDPOINT_PATH), + ) + parser.add_argument("--scenario", default=os.getenv("OPCUA_DEMO_SCENARIO", DEFAULT_SCENARIO)) + parser.add_argument( + "--drift-active", + action="store_true", + default=_bool_from_env(os.getenv("OPCUA_DEMO_DRIFT_ACTIVE"), default=False), + ) + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + try: + asyncio.run( + run_server( + host=args.host, + port=args.port, + endpoint_path=args.endpoint_path, + scenario=args.scenario, + drift_active=args.drift_active, + ) + ) + except KeyboardInterrupt: + LOGGER.info("OPC UA demo simulator stopped") + + +if __name__ == "__main__": + main() diff --git a/services/simulator/tests/test_opcua_demo_server.py b/services/simulator/tests/test_opcua_demo_server.py new file mode 100644 index 0000000..1e365ec --- /dev/null +++ b/services/simulator/tests/test_opcua_demo_server.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import asyncio +import logging +import socket +from collections.abc import Iterator +from pathlib import Path + +import pytest +from factory_simulator.opcua_demo import ( + DEFAULT_SCENARIO, + DEMO_OPC_UA_NODES, + NAMESPACE_URI, +) +from factory_simulator.opcua_server import run_server + +REPO_ROOT = Path(__file__).resolve().parents[3] +COMPOSE_FILE = REPO_ROOT / "infra" / "docker" / "docker-compose.yml" +DOCKERFILE = REPO_ROOT / "infra" / "docker" / "opcua-simulator.Dockerfile" +SIMULATOR_README = REPO_ROOT / "services" / "simulator" / "README.md" +NAMESPACE_DOC = REPO_ROOT / "docs" / "demo" / "OPC_UA_DEMO_NAMESPACE.md" + + +def _free_port() -> Iterator[int]: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + yield sock.getsockname()[1] + + +def _node_ids() -> set[str]: + return {node.node_id for node in DEMO_OPC_UA_NODES} + + +def test_opcua_demo_metadata_covers_required_normal_mode_namespace() -> None: + node_ids = _node_ids() + tag_names = {node.tag_name for node in DEMO_OPC_UA_NODES} + values = {node.tag_name: node.value for node in DEMO_OPC_UA_NODES} + + assert NAMESPACE_URI == "urn:open-factory-initiative:factory-intelligence-platform:demo" + assert "OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight" in node_ids + assert "OFI.Demo.Greenville.Packaging.Line2.FillerF201.NozzlePressure" in node_ids + assert "OFI.Demo.Greenville.Packaging.Line2.LineSpeed" in node_ids + assert "OFI.Demo.Greenville.Packaging.Line2.CheckweigherCW201.FinalFillWeight" in node_ids + assert "OFI.Demo.Greenville.Packaging.Line2.State.Scenario" in node_ids + assert "OFI.Demo.Greenville.Packaging.Line2.State.DriftActive" in node_ids + + assert { + "filler_f_201.fill_weight", + "filler_f_201.filler_nozzle_pressure", + "line_2.line_speed", + "checkweigher_cw_201.final_fill_weight", + "scenario", + "drift_active", + }.issubset(tag_names) + assert values["filler_f_201.fill_weight"] == 500.12 + assert values["filler_f_201.filler_nozzle_pressure"] == 2.1 + assert values["line_2.line_speed"] == 120.0 + assert values["scenario"] == DEFAULT_SCENARIO + assert values["drift_active"] is False + + +def test_opcua_demo_compose_service_is_documented_and_demo_scoped() -> None: + compose = COMPOSE_FILE.read_text(encoding="utf-8") + dockerfile = DOCKERFILE.read_text(encoding="utf-8") + readme = SIMULATOR_README.read_text(encoding="utf-8") + namespace_doc = NAMESPACE_DOC.read_text(encoding="utf-8") + + required_terms = [ + "opcua-simulator", + "infra/docker/opcua-simulator.Dockerfile", + "4840:4840", + "OPCUA_DEMO_SCENARIO: fill_weight_drift_demo", + "OPCUA_DEMO_DRIFT_ACTIVE: \"false\"", + ] + for term in required_terms: + assert term in compose + + assert '"python", "-m", "factory_simulator.opcua_server"' in dockerfile + assert "docker compose -f infra/docker/docker-compose.yml up --build opcua-simulator" in readme + assert "opc.tcp://localhost:4840/ofi/demo" in readme + assert "simulator-backed demo" in readme + assert "not a production OPC UA connector" in readme + + for term in [ + "line_2.line_speed", + "drift_active", + "scenario", + "normal operation mode", + ]: + assert term in namespace_doc + + +def test_opcua_demo_server_serves_normal_mode_values(caplog: pytest.LogCaptureFixture) -> None: + pytest.importorskip("asyncua") + caplog.set_level(logging.INFO, logger="factory_simulator.opcua_server") + + async def exercise_server() -> None: + from asyncua import Client + + port = next(_free_port()) + ready = asyncio.Event() + stop = asyncio.Event() + task = asyncio.create_task( + run_server( + host="127.0.0.1", + port=port, + endpoint_path="/ofi/demo-test", + ready_event=ready, + stop_event=stop, + ) + ) + + try: + await asyncio.wait_for(ready.wait(), timeout=5) + async with Client(f"opc.tcp://127.0.0.1:{port}/ofi/demo-test") as client: + values = { + "fill_weight": await client.get_node( + "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight" + ).read_value(), + "nozzle_pressure": await client.get_node( + "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.NozzlePressure" + ).read_value(), + "line_speed": await client.get_node( + "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.LineSpeed" + ).read_value(), + "scenario": await client.get_node( + "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.Scenario" + ).read_value(), + "drift_active": await client.get_node( + "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.DriftActive" + ).read_value(), + } + finally: + stop.set() + await asyncio.wait_for(task, timeout=5) + + assert values == { + "fill_weight": 500.12, + "nozzle_pressure": 2.1, + "line_speed": 120.0, + "scenario": DEFAULT_SCENARIO, + "drift_active": False, + } + + asyncio.run(exercise_server()) + assert "simulator-backed demo infrastructure" in caplog.text + assert "OPC UA demo simulator listening endpoint=opc.tcp://127.0.0.1:" in caplog.text + assert "scenario=fill_weight_drift_demo drift_active=False" in caplog.text