Architecture Documentation — opcgw

Generated: 2026-04-01 Scan Level: Exhaustive

Executive Summary

opcgw is a Rust-based gateway that bridges ChirpStack 4 (LoRaWAN Network Server) with OPC UA industrial automation clients. It runs two concurrent async tasks — a ChirpStack gRPC poller and an OPC UA server — that communicate through shared in-memory storage.

System Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                          opcgw Process                              │
│                                                                     │
│  ┌──────────────────┐    ┌──────────────┐    ┌───────────────────┐  │
│  │  ChirpstackPoller│    │   Storage    │    │    OPC UA Server  │  │
│  │  (tokio task)    │───►│ Arc<Mutex<>> │◄───│    (tokio task)   │  │
│  │                  │    │              │    │                   │  │
│  │  - poll_metrics()│    │ - devices    │    │  - read callbacks │  │
│  │  - store_metric()│    │   HashMap    │    │  - write callbacks│  │
│  │  - process_cmds()│    │ - cmd queue  │    │  - address space  │  │
│  └────────┬─────────┘    │ - CS status  │    └─────────┬─────────┘  │
│           │              └──────────────┘              │            │
└───────────┼────────────────────────────────────────────┼────────────┘
            │                                            │
            ▼                                            ▼
  ┌───────────────────┐                       ┌───────────────────┐
  │  ChirpStack 4     │                       │  OPC UA Clients   │
  │  gRPC API         │                       │  (FUXA, etc.)     │
  └───────────────────┘                       └───────────────────┘

Module Breakdown

main.rs — Entry Point

chirpstack.rs — ChirpStack Poller (~1225 lines)

Responsibility: Polls ChirpStack gRPC API for device metrics at configurable intervals and processes outbound device commands.

Key types:

Data flow:

  1. run() loops forever, calling poll_metrics() every polling_frequency seconds
  2. poll_metrics() first processes the command queue, then iterates all configured devices
  3. For each device: calls get_device_metrics_from_server()store_metric()
  4. store_metric() converts ChirpStack metric values to typed MetricType and writes to storage
  5. Server availability is checked via TCP connection before each gRPC call, with retry logic

Command processing:

storage.rs — In-Memory Storage (~1097 lines)

Responsibility: Thread-safe in-memory data store for device metrics and ChirpStack status.

Key types:

Initialization: Storage::new() pre-allocates all devices and metrics from config with type-appropriate defaults (false, 0, 0.0, “”).

Thread safety: Wrapped in Arc<Mutex<Storage>> at the application level. Not internally synchronized.

Database persistence (Story 2-2x): Can be backed by SQLite with per-task connection pooling:

opc_ua.rs — OPC UA Server (~873 lines)

Responsibility: Exposes device metrics as an OPC UA 1.04 server using async-opcua.

Key type: OpcUa — Holds config, storage ref, host IP/port.

Server setup (create_server):

  1. Builds server via ServerBuilder with application identity, network, PKI, user tokens, endpoints
  2. Creates SimpleNodeManager with custom namespace urn:UpcUaG
  3. Calls add_nodes() to populate address space

Address space structure:

Objects/
├── {Application_Name}/           (folder)
│   ├── {Device_Name}/            (folder)
│   │   ├── {Metric_Name}         (variable, read callback)
│   │   ├── {Command_Name}        (variable, read+write, writable)
│   │   └── ...
│   └── ...
└── ...

Read path: Read callbacks → get_value() → locks storage → get_metric_value()convert_metric_to_variant() Write path: Write callbacks → set_command()convert_variant_to_metric() → creates DeviceCommandpush_command() to storage queue

Security endpoints:

config.rs — Configuration (~913 lines)

Responsibility: Load and expose hierarchical TOML configuration via figment.

Key types:

Loading: Figment::new().merge(Toml::file(...)).merge(Env::prefixed("OPCGW_")) with CONFIG_PATH env override.

Lookup methods: get_application_name(), get_application_id(), get_device_name(), get_device_id(), get_metric_list(), get_metric_type() — all linear scans over config vectors.

utils.rs — Utilities (~365 lines)

Constants:

Error type: OpcGwError enum with variants: Configuration, ChirpStack, OpcUa, Storage — using thiserror.

Build System

build.rs compiles 10 ChirpStack API .proto files from proto/chirpstack/api/ using tonic_build::configure().build_server(true).compile_protos(...). The generated Rust code provides typed gRPC client stubs.

Makefile.toml (cargo-make) defines:

Deployment

Docker: Multi-stage build (rust:1.87 builder → ubuntu:latest runtime). Exposes port 4855. Mounts log/, config/, pki/ as volumes.

docker-compose.yml: Single service opcgw, restart always, port 4855:4855.

Testing Strategy

Known Architectural Considerations

  1. Incomplete OPC UA feature set: The OPC UA server currently supports basic Browse/Read/Write. Many OPC UA features are missing: subscriptions and data change notifications, historical data access, alarms and conditions, method nodes, complex type support, and monitored items tuning. These are required for full industrial SCADA interoperability.
  2. File-only configuration: All configuration is done via TOML files. A future web-based configuration interface is planned to allow managing applications, devices, and metric mappings without editing files and restarting the service.
  3. In-memory storage only: All device metrics and state are stored in a HashMap and lost on restart. A local database (e.g., SQLite) is planned for persistent storage of metrics, configuration, and historical data.
  4. Concurrency: In-memory storage uses Arc<Mutex<Storage>>. For persistent SQLite storage, Story 2-2x implements per-task connection pooling (see storage.rs) which eliminates Rust-level Mutex bottleneck by leveraging SQLite WAL concurrency model.
  5. Panic behavior: Several methods (store_metric, set_metric_value) panic on missing devices — production code should handle these gracefully.
  6. Linear config lookups: get_device_name(), get_metric_type() etc. do O(n) scans — acceptable for small configs but won’t scale to thousands of devices.
  7. Single metric type support: Only ChirpStack “Gauge” metric type is supported; Counter, Absolute, Unknown are not handled.
  8. Command queue is LIFO: Vec::pop() processes most-recent first — may need FIFO semantics (VecDeque).