Compare commits
2 Commits
ac44122bf7
...
abb7579227
| Author | SHA1 | Date | |
|---|---|---|---|
| abb7579227 | |||
| efed8352c3 |
@@ -7,11 +7,11 @@ This document describes the local Docker-based test infrastructure for ScadaLink
|
|||||||
| Service | Image | Port(s) | Config | Compose File |
|
| Service | Image | Port(s) | Config | Compose File |
|
||||||
|---------|-------|---------|--------|-------------|
|
|---------|-------|---------|--------|-------------|
|
||||||
| OPC UA Server | `mcr.microsoft.com/iotedge/opc-plc:latest` | 50000 (OPC UA), 8080 (web) | `infra/opcua/nodes.json` | `infra/` |
|
| OPC UA Server | `mcr.microsoft.com/iotedge/opc-plc:latest` | 50000 (OPC UA), 8080 (web) | `infra/opcua/nodes.json` | `infra/` |
|
||||||
|
| OPC UA Server 2 | `mcr.microsoft.com/iotedge/opc-plc:latest` | 50010 (OPC UA), 8081 (web) | `infra/opcua/nodes.json` | `infra/` |
|
||||||
| LDAP Server | `glauth/glauth:latest` | 3893 | `infra/glauth/config.toml` | `infra/` |
|
| LDAP Server | `glauth/glauth:latest` | 3893 | `infra/glauth/config.toml` | `infra/` |
|
||||||
| MS SQL 2022 | `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `infra/mssql/setup.sql` | `infra/` |
|
| MS SQL 2022 | `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `infra/mssql/setup.sql` | `infra/` |
|
||||||
| SMTP (Mailpit) | `axllent/mailpit:latest` | 1025 (SMTP), 8025 (web) | Environment vars | `infra/` |
|
| SMTP (Mailpit) | `axllent/mailpit:latest` | 1025 (SMTP), 8025 (web) | Environment vars | `infra/` |
|
||||||
| REST API (Flask) | Custom build (`infra/restapi/Dockerfile`) | 5200 | `infra/restapi/app.py` | `infra/` |
|
| REST API (Flask) | Custom build (`infra/restapi/Dockerfile`) | 5200 | `infra/restapi/app.py` | `infra/` |
|
||||||
| LmxFakeProxy | Custom build (`infra/lmxfakeproxy/Dockerfile`) | 50051 (gRPC) | Environment vars | `infra/` |
|
|
||||||
| Playwright | `mcr.microsoft.com/playwright:v1.58.2-noble` | 3000 (WebSocket) | Command args | `infra/` |
|
| Playwright | `mcr.microsoft.com/playwright:v1.58.2-noble` | 3000 (WebSocket) | Command args | `infra/` |
|
||||||
| Traefik LB | `traefik:v3.4` | 9000 (proxy), 8180 (dashboard) | `docker/traefik/` | `docker/` |
|
| Traefik LB | `traefik:v3.4` | 9000 (proxy), 8180 (dashboard) | `docker/traefik/` | `docker/` |
|
||||||
|
|
||||||
@@ -43,10 +43,27 @@ Each service has a dedicated document with configuration details, verification s
|
|||||||
- [test_infra_db.md](test_infra_db.md) — MS SQL 2022 database
|
- [test_infra_db.md](test_infra_db.md) — MS SQL 2022 database
|
||||||
- [test_infra_smtp.md](test_infra_smtp.md) — SMTP test server (Mailpit)
|
- [test_infra_smtp.md](test_infra_smtp.md) — SMTP test server (Mailpit)
|
||||||
- [test_infra_restapi.md](test_infra_restapi.md) — REST API test server (Flask)
|
- [test_infra_restapi.md](test_infra_restapi.md) — REST API test server (Flask)
|
||||||
- [test_infra_lmxfakeproxy.md](test_infra_lmxfakeproxy.md) — LmxProxy fake server (OPC UA bridge)
|
|
||||||
- [test_infra_playwright.md](test_infra_playwright.md) — Playwright browser server (Central UI testing)
|
- [test_infra_playwright.md](test_infra_playwright.md) — Playwright browser server (Central UI testing)
|
||||||
- Traefik LB — see `docker/README.md` and `docker/traefik/` (runs with the cluster, not in `infra/`)
|
- Traefik LB — see `docker/README.md` and `docker/traefik/` (runs with the cluster, not in `infra/`)
|
||||||
|
|
||||||
|
## Remote Test Infrastructure
|
||||||
|
|
||||||
|
In addition to the local Docker services, the following remote services are available for testing against real AVEVA System Platform hardware.
|
||||||
|
|
||||||
|
### LmxProxy v2 (windev — 10.100.0.48)
|
||||||
|
|
||||||
|
Two LmxProxy v2 instances run as Windows services on windev, both connected to the same AVEVA System Platform via MxAccess COM. These provide the primary/backup pair for Data Connection Layer testing.
|
||||||
|
|
||||||
|
| | Instance A | Instance B |
|
||||||
|
|---|---|---|
|
||||||
|
| **gRPC Endpoint** | `10.100.0.48:50100` | `10.100.0.48:50101` |
|
||||||
|
| **HTTP Status** | `http://10.100.0.48:8081` | `http://10.100.0.48:8082` |
|
||||||
|
| **Service Name** | `ZB.MOM.WW.LmxProxy.Host.V2` | `ZB.MOM.WW.LmxProxy.Host.V2B` |
|
||||||
|
|
||||||
|
API key (ReadWrite): `c4559c7c6acc60a997135c1381162e3c30f4572ece78dd933c1a626e6fd933b4`
|
||||||
|
|
||||||
|
Full details: [`lmxproxy/instances_config.md`](../../lmxproxy/instances_config.md)
|
||||||
|
|
||||||
## Connection Strings
|
## Connection Strings
|
||||||
|
|
||||||
For use in `appsettings.Development.json`:
|
For use in `appsettings.Development.json`:
|
||||||
@@ -66,6 +83,9 @@ For use in `appsettings.Development.json`:
|
|||||||
"OpcUa": {
|
"OpcUa": {
|
||||||
"EndpointUrl": "opc.tcp://localhost:50000"
|
"EndpointUrl": "opc.tcp://localhost:50000"
|
||||||
},
|
},
|
||||||
|
"OpcUa2": {
|
||||||
|
"EndpointUrl": "opc.tcp://localhost:50010"
|
||||||
|
},
|
||||||
"Smtp": {
|
"Smtp": {
|
||||||
"Server": "localhost",
|
"Server": "localhost",
|
||||||
"Port": 1025,
|
"Port": 1025,
|
||||||
@@ -88,7 +108,7 @@ For use in `appsettings.Development.json`:
|
|||||||
```bash
|
```bash
|
||||||
cd infra
|
cd infra
|
||||||
docker compose down # stop containers, preserve SQL data volume
|
docker compose down # stop containers, preserve SQL data volume
|
||||||
docker compose stop opcua # stop a single service (also: ldap, mssql, smtp, restapi)
|
docker compose stop opcua # stop a single service (also: opcua2, ldap, mssql, smtp, restapi)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Full teardown** (removes volumes, optionally images and venv):
|
**Full teardown** (removes volumes, optionally images and venv):
|
||||||
@@ -113,7 +133,6 @@ infra/
|
|||||||
opcua/nodes.json # Custom OPC UA tag definitions
|
opcua/nodes.json # Custom OPC UA tag definitions
|
||||||
restapi/app.py # Flask REST API server
|
restapi/app.py # Flask REST API server
|
||||||
restapi/Dockerfile # REST API container build
|
restapi/Dockerfile # REST API container build
|
||||||
lmxfakeproxy/ # .NET gRPC proxy bridging LmxProxy protocol to OPC UA
|
|
||||||
tools/ # Python CLI tools (opcua, ldap, mssql, smtp, restapi)
|
tools/ # Python CLI tools (opcua, ldap, mssql, smtp, restapi)
|
||||||
README.md # Quick-start for the infra folder
|
README.md # Quick-start for the infra folder
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
# Test Infrastructure: LmxFakeProxy
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
LmxFakeProxy is a .NET gRPC server that implements the `scada.ScadaService` proto (full parity with the real LmxProxy server) but bridges to the OPC UA test server instead of System Platform MXAccess. This enables end-to-end testing of `RealLmxProxyClient` and the LmxProxy DCL adapter.
|
|
||||||
|
|
||||||
## Image & Ports
|
|
||||||
|
|
||||||
- **Image**: Custom build (`infra/lmxfakeproxy/Dockerfile`)
|
|
||||||
- **gRPC endpoint**: `localhost:50051`
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
| Environment Variable | Default | Description |
|
|
||||||
|---------------------|---------|-------------|
|
|
||||||
| `PORT` | `50051` | gRPC listen port |
|
|
||||||
| `OPC_ENDPOINT` | `opc.tcp://localhost:50000` | Backend OPC UA server |
|
|
||||||
| `OPC_PREFIX` | `ns=3;s=` | Prefix prepended to LMX tags to form OPC UA NodeIds |
|
|
||||||
| `API_KEY` | *(none)* | If set, enforces API key on all gRPC calls |
|
|
||||||
|
|
||||||
## Tag Address Mapping
|
|
||||||
|
|
||||||
LMX-style flat addresses are mapped to OPC UA NodeIds by prepending the configured prefix:
|
|
||||||
|
|
||||||
| LMX Tag | OPC UA NodeId |
|
|
||||||
|---------|--------------|
|
|
||||||
| `Motor.Speed` | `ns=3;s=Motor.Speed` |
|
|
||||||
| `Pump.FlowRate` | `ns=3;s=Pump.FlowRate` |
|
|
||||||
| `Tank.Level` | `ns=3;s=Tank.Level` |
|
|
||||||
|
|
||||||
## Supported RPCs
|
|
||||||
|
|
||||||
Full parity with the `scada.ScadaService` proto:
|
|
||||||
|
|
||||||
- **Connect / Disconnect / GetConnectionState** — Session management
|
|
||||||
- **Read / ReadBatch** — Read tag values via OPC UA
|
|
||||||
- **Write / WriteBatch / WriteBatchAndWait** — Write values via OPC UA
|
|
||||||
- **Subscribe** — Server-streaming subscriptions via OPC UA MonitoredItems
|
|
||||||
- **CheckApiKey** — API key validation
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Ensure the OPC UA test server is running:
|
|
||||||
```bash
|
|
||||||
docker ps --filter name=scadalink-opcua
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the fake proxy:
|
|
||||||
```bash
|
|
||||||
docker compose up -d lmxfakeproxy
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Check logs:
|
|
||||||
```bash
|
|
||||||
docker logs scadalink-lmxfakeproxy
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Test with the ScadaLink CLI or a gRPC client.
|
|
||||||
|
|
||||||
## Running Standalone (without Docker)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd infra/lmxfakeproxy
|
|
||||||
dotnet run -- --opc-endpoint opc.tcp://localhost:50000 --opc-prefix "ns=3;s="
|
|
||||||
```
|
|
||||||
|
|
||||||
With API key enforcement:
|
|
||||||
```bash
|
|
||||||
dotnet run -- --api-key my-secret-key
|
|
||||||
```
|
|
||||||
|
|
||||||
## Relevance to ScadaLink Components
|
|
||||||
|
|
||||||
- **Data Connection Layer** — Test `RealLmxProxyClient` and `LmxProxyDataConnection` against real OPC UA data
|
|
||||||
- **Site Runtime** — Deploy instances with LmxProxy data connections pointing at this server
|
|
||||||
- **Integration Tests** — End-to-end tests of the LmxProxy protocol path
|
|
||||||
@@ -6,9 +6,14 @@ The test OPC UA server uses [Azure IoT OPC PLC](https://github.com/Azure-Samples
|
|||||||
|
|
||||||
## Image & Ports
|
## Image & Ports
|
||||||
|
|
||||||
|
Two identical OPC UA server instances run with the same tag configuration, on different ports:
|
||||||
|
|
||||||
|
| Instance | OPC UA Endpoint | Web UI | Container |
|
||||||
|
|----------|----------------|--------|-----------|
|
||||||
|
| opcua | `opc.tcp://localhost:50000` | `http://localhost:8080` | scadalink-opcua |
|
||||||
|
| opcua2 | `opc.tcp://localhost:50010` | `http://localhost:8081` | scadalink-opcua2 |
|
||||||
|
|
||||||
- **Image**: `mcr.microsoft.com/iotedge/opc-plc:latest`
|
- **Image**: `mcr.microsoft.com/iotedge/opc-plc:latest`
|
||||||
- **OPC UA endpoint**: `opc.tcp://localhost:50000`
|
|
||||||
- **Web/config UI**: `http://localhost:8080`
|
|
||||||
|
|
||||||
## Startup Flags
|
## Startup Flags
|
||||||
|
|
||||||
@@ -43,20 +48,21 @@ The browse path from the Objects root is: `OpcPlc > ScadaLink > Motor|Pump|Tank|
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
1. Check the container is running:
|
1. Check both containers are running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker ps --filter name=scadalink-opcua
|
docker ps --filter name=scadalink-opcua
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Verify the OPC UA endpoint using any OPC UA client (e.g., UaExpert, opcua-commander):
|
2. Verify both OPC UA endpoints using any OPC UA client (e.g., UaExpert, opcua-commander):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using opcua-commander (npm install -g opcua-commander)
|
# Using opcua-commander (npm install -g opcua-commander)
|
||||||
opcua-commander -e opc.tcp://localhost:50000
|
opcua-commander -e opc.tcp://localhost:50000
|
||||||
|
opcua-commander -e opc.tcp://localhost:50010
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Check the web UI at `http://localhost:8080` for server status and node listing.
|
3. Check the web UIs at `http://localhost:8080` (opcua) and `http://localhost:8081` (opcua2) for server status and node listing.
|
||||||
|
|
||||||
## CLI Tool
|
## CLI Tool
|
||||||
|
|
||||||
@@ -89,7 +95,14 @@ python infra/tools/opcua_tool.py write --node "ns=3;s=Motor.Running" --value tru
|
|||||||
python infra/tools/opcua_tool.py monitor --nodes "ns=3;s=Motor.Speed,ns=3;s=Pump.FlowRate" --duration 15
|
python infra/tools/opcua_tool.py monitor --nodes "ns=3;s=Motor.Speed,ns=3;s=Pump.FlowRate" --duration 15
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--endpoint` to override the default endpoint (`opc.tcp://localhost:50000`). Run with `--help` for full usage.
|
Use `--endpoint` to override the default endpoint (`opc.tcp://localhost:50000`). For the second instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python infra/tools/opcua_tool.py --endpoint opc.tcp://localhost:50010 check
|
||||||
|
python infra/tools/opcua_tool.py --endpoint opc.tcp://localhost:50010 browse --path "3:OpcPlc.3:ScadaLink.3:Motor"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with `--help` for full usage.
|
||||||
|
|
||||||
## Relevance to ScadaLink Components
|
## Relevance to ScadaLink Components
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -8,16 +8,16 @@ Local Docker-based test services for ScadaLink development.
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts five services:
|
This starts the following services:
|
||||||
|
|
||||||
| Service | Port | Purpose |
|
| Service | Port | Purpose |
|
||||||
|---------|------|---------|
|
|---------|------|---------|
|
||||||
| OPC UA (Azure IoT OPC PLC) | 50000 (OPC UA), 8080 (web) | Simulated OPC UA server with ScadaLink-style tags |
|
| OPC UA (Azure IoT OPC PLC) | 50000 (OPC UA), 8080 (web) | Simulated OPC UA server with ScadaLink-style tags |
|
||||||
|
| OPC UA 2 (Azure IoT OPC PLC) | 50010 (OPC UA), 8081 (web) | Second OPC UA server instance (same tags, independent state) |
|
||||||
| LDAP (GLAuth) | 3893 | Lightweight LDAP with test users/groups matching ScadaLink roles |
|
| LDAP (GLAuth) | 3893 | Lightweight LDAP with test users/groups matching ScadaLink roles |
|
||||||
| MS SQL 2022 | 1433 | Configuration and machine data databases |
|
| MS SQL 2022 | 1433 | Configuration and machine data databases |
|
||||||
| SMTP (Mailpit) | 1025 (SMTP), 8025 (web) | Email capture for notification testing |
|
| SMTP (Mailpit) | 1025 (SMTP), 8025 (web) | Email capture for notification testing |
|
||||||
| REST API (Flask) | 5200 | External REST API for Gateway and Inbound API testing |
|
| REST API (Flask) | 5200 | External REST API for Gateway and Inbound API testing |
|
||||||
| LmxFakeProxy (.NET gRPC) | 50051 (gRPC) | LmxProxy-compatible server bridging to OPC UA test server |
|
|
||||||
|
|
||||||
## First-Time SQL Setup
|
## First-Time SQL Setup
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ docker compose down
|
|||||||
|
|
||||||
**Stop a single service** (leave the others running):
|
**Stop a single service** (leave the others running):
|
||||||
```bash
|
```bash
|
||||||
docker compose stop opcua # or: ldap, mssql, smtp, restapi
|
docker compose stop opcua # or: opcua2, ldap, mssql, smtp, restapi
|
||||||
docker compose start opcua # bring it back without recreating
|
docker compose start opcua # bring it back without recreating
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+21
-14
@@ -20,6 +20,27 @@ services:
|
|||||||
- scadalink-net
|
- scadalink-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
opcua2:
|
||||||
|
image: mcr.microsoft.com/iotedge/opc-plc:latest
|
||||||
|
container_name: scadalink-opcua2
|
||||||
|
ports:
|
||||||
|
- "50010:50010"
|
||||||
|
- "8081:8080"
|
||||||
|
volumes:
|
||||||
|
- ./opcua/nodes.json:/app/config/nodes.json:ro
|
||||||
|
command: >
|
||||||
|
--autoaccept
|
||||||
|
--unsecuretransport
|
||||||
|
--sph
|
||||||
|
--sn=5 --sr=10 --st=uint
|
||||||
|
--fn=5 --fr=1 --ft=uint
|
||||||
|
--gn=5
|
||||||
|
--nf=/app/config/nodes.json
|
||||||
|
--pn=50010
|
||||||
|
networks:
|
||||||
|
- scadalink-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
ldap:
|
ldap:
|
||||||
image: glauth/glauth:latest
|
image: glauth/glauth:latest
|
||||||
container_name: scadalink-ldap
|
container_name: scadalink-ldap
|
||||||
@@ -74,20 +95,6 @@ services:
|
|||||||
- scadalink-net
|
- scadalink-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
lmxfakeproxy:
|
|
||||||
build: ./lmxfakeproxy
|
|
||||||
container_name: scadalink-lmxfakeproxy
|
|
||||||
ports:
|
|
||||||
- "50051:50051"
|
|
||||||
environment:
|
|
||||||
OPC_ENDPOINT: "opc.tcp://opcua:50000"
|
|
||||||
OPC_PREFIX: "ns=3;s="
|
|
||||||
depends_on:
|
|
||||||
- opcua
|
|
||||||
networks:
|
|
||||||
- scadalink-net
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||||
container_name: scadalink-playwright
|
container_name: scadalink-playwright
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
tests/
|
|
||||||
bin/
|
|
||||||
obj/
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace LmxFakeProxy.Bridge;
|
|
||||||
|
|
||||||
public record OpcUaReadResult(object? Value, DateTime SourceTimestamp, uint StatusCode);
|
|
||||||
|
|
||||||
public interface IOpcUaBridge : IAsyncDisposable
|
|
||||||
{
|
|
||||||
bool IsConnected { get; }
|
|
||||||
|
|
||||||
Task ConnectAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<OpcUaReadResult> ReadAsync(string nodeId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<uint> WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<string> AddMonitoredItemsAsync(
|
|
||||||
IEnumerable<string> nodeIds,
|
|
||||||
int samplingIntervalMs,
|
|
||||||
Action<string, object?, DateTime, uint> onValueChanged,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
event Action? Disconnected;
|
|
||||||
event Action? Reconnected;
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Client;
|
|
||||||
using Opc.Ua.Configuration;
|
|
||||||
|
|
||||||
namespace LmxFakeProxy.Bridge;
|
|
||||||
|
|
||||||
public class OpcUaBridge : IOpcUaBridge
|
|
||||||
{
|
|
||||||
private readonly string _endpointUrl;
|
|
||||||
private readonly ILogger<OpcUaBridge> _logger;
|
|
||||||
private Opc.Ua.Client.ISession? _session;
|
|
||||||
private Subscription? _subscription;
|
|
||||||
private volatile bool _connected;
|
|
||||||
private volatile bool _reconnecting;
|
|
||||||
private CancellationTokenSource? _reconnectCts;
|
|
||||||
|
|
||||||
private readonly Dictionary<string, List<MonitoredItem>> _handleItems = new();
|
|
||||||
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _handleCallbacks = new();
|
|
||||||
private readonly object _lock = new();
|
|
||||||
|
|
||||||
public OpcUaBridge(string endpointUrl, ILogger<OpcUaBridge> logger)
|
|
||||||
{
|
|
||||||
_endpointUrl = endpointUrl;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsConnected => _connected;
|
|
||||||
public event Action? Disconnected;
|
|
||||||
public event Action? Reconnected;
|
|
||||||
|
|
||||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var appConfig = new ApplicationConfiguration
|
|
||||||
{
|
|
||||||
ApplicationName = "LmxFakeProxy",
|
|
||||||
ApplicationType = ApplicationType.Client,
|
|
||||||
SecurityConfiguration = new SecurityConfiguration
|
|
||||||
{
|
|
||||||
AutoAcceptUntrustedCertificates = true,
|
|
||||||
ApplicationCertificate = new CertificateIdentifier(),
|
|
||||||
TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "issuers") },
|
|
||||||
TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "trusted") },
|
|
||||||
RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "rejected") }
|
|
||||||
},
|
|
||||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
|
||||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }
|
|
||||||
};
|
|
||||||
|
|
||||||
await appConfig.Validate(ApplicationType.Client);
|
|
||||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
||||||
|
|
||||||
EndpointDescription? endpoint;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
#pragma warning disable CS0618
|
|
||||||
using var discoveryClient = DiscoveryClient.Create(new Uri(_endpointUrl));
|
|
||||||
var endpoints = discoveryClient.GetEndpoints(null);
|
|
||||||
#pragma warning restore CS0618
|
|
||||||
endpoint = endpoints
|
|
||||||
.Where(e => e.SecurityMode == MessageSecurityMode.None)
|
|
||||||
.FirstOrDefault() ?? endpoints.FirstOrDefault();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
endpoint = new EndpointDescription(_endpointUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
|
||||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
|
||||||
|
|
||||||
_session = await Session.Create(
|
|
||||||
appConfig, configuredEndpoint, false,
|
|
||||||
"LmxFakeProxy-Session", 60000, null, null, cancellationToken);
|
|
||||||
|
|
||||||
_session.KeepAlive += OnSessionKeepAlive;
|
|
||||||
|
|
||||||
_subscription = new Subscription(_session.DefaultSubscription)
|
|
||||||
{
|
|
||||||
DisplayName = "LmxFakeProxy",
|
|
||||||
PublishingEnabled = true,
|
|
||||||
PublishingInterval = 500,
|
|
||||||
KeepAliveCount = 10,
|
|
||||||
LifetimeCount = 30,
|
|
||||||
MaxNotificationsPerPublish = 1000
|
|
||||||
};
|
|
||||||
|
|
||||||
_session.AddSubscription(_subscription);
|
|
||||||
await _subscription.CreateAsync(cancellationToken);
|
|
||||||
|
|
||||||
_connected = true;
|
|
||||||
_logger.LogInformation("OPC UA bridge connected to {Endpoint}", _endpointUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<OpcUaReadResult> ReadAsync(string nodeId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
EnsureConnected();
|
|
||||||
var readValue = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value };
|
|
||||||
var response = await _session!.ReadAsync(
|
|
||||||
null, 0, TimestampsToReturn.Source,
|
|
||||||
new ReadValueIdCollection { readValue }, cancellationToken);
|
|
||||||
var result = response.Results[0];
|
|
||||||
return new OpcUaReadResult(result.Value, result.SourceTimestamp, result.StatusCode.Code);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<uint> WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
EnsureConnected();
|
|
||||||
var writeValue = new WriteValue
|
|
||||||
{
|
|
||||||
NodeId = nodeId,
|
|
||||||
AttributeId = Attributes.Value,
|
|
||||||
Value = new DataValue(new Variant(value))
|
|
||||||
};
|
|
||||||
var response = await _session!.WriteAsync(
|
|
||||||
null, new WriteValueCollection { writeValue }, cancellationToken);
|
|
||||||
return response.Results[0].Code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> AddMonitoredItemsAsync(
|
|
||||||
IEnumerable<string> nodeIds, int samplingIntervalMs,
|
|
||||||
Action<string, object?, DateTime, uint> onValueChanged,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
EnsureConnected();
|
|
||||||
var handle = Guid.NewGuid().ToString("N");
|
|
||||||
var items = new List<MonitoredItem>();
|
|
||||||
|
|
||||||
foreach (var nodeId in nodeIds)
|
|
||||||
{
|
|
||||||
var monitoredItem = new MonitoredItem(_subscription!.DefaultItem)
|
|
||||||
{
|
|
||||||
DisplayName = nodeId,
|
|
||||||
StartNodeId = nodeId,
|
|
||||||
AttributeId = Attributes.Value,
|
|
||||||
SamplingInterval = samplingIntervalMs,
|
|
||||||
QueueSize = 10,
|
|
||||||
DiscardOldest = true
|
|
||||||
};
|
|
||||||
|
|
||||||
monitoredItem.Notification += (item, e) =>
|
|
||||||
{
|
|
||||||
if (e.NotificationValue is MonitoredItemNotification notification)
|
|
||||||
{
|
|
||||||
var val = notification.Value?.Value;
|
|
||||||
var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
|
|
||||||
var sc = notification.Value?.StatusCode.Code ?? 0;
|
|
||||||
onValueChanged(nodeId, val, ts, sc);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
items.Add(monitoredItem);
|
|
||||||
_subscription!.AddItem(monitoredItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _subscription!.ApplyChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
_handleItems[handle] = items;
|
|
||||||
_handleCallbacks[handle] = onValueChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
List<MonitoredItem>? items;
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (!_handleItems.Remove(handle, out items))
|
|
||||||
return;
|
|
||||||
_handleCallbacks.Remove(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_subscription != null)
|
|
||||||
{
|
|
||||||
foreach (var item in items)
|
|
||||||
_subscription.RemoveItem(item);
|
|
||||||
try { await _subscription.ApplyChangesAsync(cancellationToken); }
|
|
||||||
catch { /* best-effort during cleanup */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSessionKeepAlive(Opc.Ua.Client.ISession session, KeepAliveEventArgs e)
|
|
||||||
{
|
|
||||||
if (ServiceResult.IsBad(e.Status))
|
|
||||||
{
|
|
||||||
if (!_connected) return;
|
|
||||||
_connected = false;
|
|
||||||
_logger.LogWarning("OPC UA backend connection lost");
|
|
||||||
Disconnected?.Invoke();
|
|
||||||
StartReconnectLoop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartReconnectLoop()
|
|
||||||
{
|
|
||||||
if (_reconnecting) return;
|
|
||||||
_reconnecting = true;
|
|
||||||
_reconnectCts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
while (!_reconnectCts.Token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
await Task.Delay(5000, _reconnectCts.Token);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Attempting OPC UA reconnection...");
|
|
||||||
if (_session != null)
|
|
||||||
{
|
|
||||||
_session.KeepAlive -= OnSessionKeepAlive;
|
|
||||||
try { await _session.CloseAsync(); } catch { }
|
|
||||||
_session = null;
|
|
||||||
_subscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ConnectAsync(_reconnectCts.Token);
|
|
||||||
|
|
||||||
// Re-add monitored items for active handles
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
foreach (var (handle, callback) in _handleCallbacks)
|
|
||||||
{
|
|
||||||
if (_handleItems.TryGetValue(handle, out var oldItems))
|
|
||||||
{
|
|
||||||
var nodeIds = oldItems.Select(i => i.StartNodeId.ToString()).ToList();
|
|
||||||
var newItems = new List<MonitoredItem>();
|
|
||||||
foreach (var nodeId in nodeIds)
|
|
||||||
{
|
|
||||||
var monitoredItem = new MonitoredItem(_subscription!.DefaultItem)
|
|
||||||
{
|
|
||||||
DisplayName = nodeId,
|
|
||||||
StartNodeId = nodeId,
|
|
||||||
AttributeId = Attributes.Value,
|
|
||||||
SamplingInterval = oldItems[0].SamplingInterval,
|
|
||||||
QueueSize = 10,
|
|
||||||
DiscardOldest = true
|
|
||||||
};
|
|
||||||
var capturedNodeId = nodeId;
|
|
||||||
var capturedCallback = callback;
|
|
||||||
monitoredItem.Notification += (item, ev) =>
|
|
||||||
{
|
|
||||||
if (ev.NotificationValue is MonitoredItemNotification notification)
|
|
||||||
{
|
|
||||||
var val = notification.Value?.Value;
|
|
||||||
var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
|
|
||||||
var sc = notification.Value?.StatusCode.Code ?? 0;
|
|
||||||
capturedCallback(capturedNodeId, val, ts, sc);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
newItems.Add(monitoredItem);
|
|
||||||
_subscription!.AddItem(monitoredItem);
|
|
||||||
}
|
|
||||||
_handleItems[handle] = newItems;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_subscription != null)
|
|
||||||
await _subscription.ApplyChangesAsync();
|
|
||||||
|
|
||||||
_reconnecting = false;
|
|
||||||
_logger.LogInformation("OPC UA reconnection successful");
|
|
||||||
Reconnected?.Invoke();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "OPC UA reconnection attempt failed, retrying in 5s");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, _reconnectCts.Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureConnected()
|
|
||||||
{
|
|
||||||
if (!_connected || _session == null)
|
|
||||||
throw new InvalidOperationException("OPC UA backend unavailable");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
_reconnectCts?.Cancel();
|
|
||||||
_reconnectCts?.Dispose();
|
|
||||||
if (_subscription != null)
|
|
||||||
{
|
|
||||||
try { await _subscription.DeleteAsync(true); } catch { }
|
|
||||||
_subscription = null;
|
|
||||||
}
|
|
||||||
if (_session != null)
|
|
||||||
{
|
|
||||||
_session.KeepAlive -= OnSessionKeepAlive;
|
|
||||||
try { await _session.CloseAsync(); } catch { }
|
|
||||||
_session = null;
|
|
||||||
}
|
|
||||||
_connected = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Build stage forced to amd64: Grpc.Tools protoc crashes on linux/arm64 (Apple Silicon)
|
|
||||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|
||||||
WORKDIR /src
|
|
||||||
COPY LmxFakeProxy.csproj .
|
|
||||||
RUN dotnet restore
|
|
||||||
COPY . .
|
|
||||||
RUN dotnet publish -c Release -o /app
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=build /app .
|
|
||||||
EXPOSE 50051
|
|
||||||
ENTRYPOINT ["dotnet", "LmxFakeProxy.dll"]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<RootNamespace>LmxFakeProxy</RootNamespace>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Remove="tests\**\*" />
|
|
||||||
<Content Remove="tests\**\*" />
|
|
||||||
<None Remove="tests\**\*" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Protobuf Include="Protos/scada.proto" GrpcServices="Server" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
|
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
using LmxFakeProxy;
|
|
||||||
using LmxFakeProxy.Bridge;
|
|
||||||
using LmxFakeProxy.Services;
|
|
||||||
using LmxFakeProxy.Sessions;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Configuration: env vars take precedence over CLI args
|
|
||||||
var port = Environment.GetEnvironmentVariable("PORT") ?? GetArg(args, "--port") ?? "50051";
|
|
||||||
var opcEndpoint = Environment.GetEnvironmentVariable("OPC_ENDPOINT") ?? GetArg(args, "--opc-endpoint") ?? "opc.tcp://localhost:50000";
|
|
||||||
var opcPrefix = Environment.GetEnvironmentVariable("OPC_PREFIX") ?? GetArg(args, "--opc-prefix") ?? "ns=3;s=";
|
|
||||||
var apiKey = Environment.GetEnvironmentVariable("API_KEY") ?? GetArg(args, "--api-key");
|
|
||||||
|
|
||||||
builder.WebHost.ConfigureKestrel(options =>
|
|
||||||
{
|
|
||||||
options.ListenAnyIP(int.Parse(port), listenOptions =>
|
|
||||||
{
|
|
||||||
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register services
|
|
||||||
builder.Services.AddSingleton(new SessionManager(apiKey));
|
|
||||||
builder.Services.AddSingleton(new TagMapper(opcPrefix));
|
|
||||||
builder.Services.AddSingleton<IOpcUaBridge>(sp =>
|
|
||||||
new OpcUaBridge(opcEndpoint, sp.GetRequiredService<ILogger<OpcUaBridge>>()));
|
|
||||||
builder.Services.AddGrpc();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
app.MapGrpcService<ScadaServiceImpl>();
|
|
||||||
app.MapGet("/", () => "LmxFakeProxy is running");
|
|
||||||
|
|
||||||
// Connect to OPC UA backend
|
|
||||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
|
||||||
logger.LogInformation("LmxFakeProxy starting on port {Port}", port);
|
|
||||||
logger.LogInformation("OPC UA endpoint: {Endpoint}, prefix: {Prefix}", opcEndpoint, opcPrefix);
|
|
||||||
logger.LogInformation("API key enforcement: {Enforced}", apiKey != null ? "enabled" : "disabled (accept all)");
|
|
||||||
|
|
||||||
var bridge = app.Services.GetRequiredService<IOpcUaBridge>();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ((OpcUaBridge)bridge).ConnectAsync();
|
|
||||||
logger.LogInformation("OPC UA bridge connected");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Initial OPC UA connection failed — will retry when first request arrives");
|
|
||||||
}
|
|
||||||
|
|
||||||
await app.RunAsync();
|
|
||||||
|
|
||||||
static string? GetArg(string[] args, string name)
|
|
||||||
{
|
|
||||||
var idx = Array.IndexOf(args, name);
|
|
||||||
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
option csharp_namespace = "LmxFakeProxy.Grpc";
|
|
||||||
|
|
||||||
package scada;
|
|
||||||
|
|
||||||
// The SCADA service definition
|
|
||||||
service ScadaService {
|
|
||||||
// Connection management
|
|
||||||
rpc Connect(ConnectRequest) returns (ConnectResponse);
|
|
||||||
rpc Disconnect(DisconnectRequest) returns (DisconnectResponse);
|
|
||||||
rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse);
|
|
||||||
|
|
||||||
// Read operations
|
|
||||||
rpc Read(ReadRequest) returns (ReadResponse);
|
|
||||||
rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse);
|
|
||||||
|
|
||||||
// Write operations
|
|
||||||
rpc Write(WriteRequest) returns (WriteResponse);
|
|
||||||
rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse);
|
|
||||||
rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse);
|
|
||||||
|
|
||||||
// Subscription operations (server streaming) - now streams VtqMessage directly
|
|
||||||
rpc Subscribe(SubscribeRequest) returns (stream VtqMessage);
|
|
||||||
|
|
||||||
// Authentication
|
|
||||||
rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === CONNECTION MESSAGES ===
|
|
||||||
|
|
||||||
message ConnectRequest {
|
|
||||||
string client_id = 1;
|
|
||||||
string api_key = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ConnectResponse {
|
|
||||||
bool success = 1;
|
|
||||||
string message = 2;
|
|
||||||
string session_id = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DisconnectRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DisconnectResponse {
|
|
||||||
bool success = 1;
|
|
||||||
string message = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message GetConnectionStateRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message GetConnectionStateResponse {
|
|
||||||
bool is_connected = 1;
|
|
||||||
string client_id = 2;
|
|
||||||
int64 connected_since_utc_ticks = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === VTQ MESSAGE ===
|
|
||||||
|
|
||||||
message VtqMessage {
|
|
||||||
string tag = 1;
|
|
||||||
string value = 2;
|
|
||||||
int64 timestamp_utc_ticks = 3;
|
|
||||||
string quality = 4; // "Good", "Uncertain", "Bad"
|
|
||||||
}
|
|
||||||
|
|
||||||
// === READ MESSAGES ===
|
|
||||||
|
|
||||||
message ReadRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
string tag = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ReadResponse {
|
|
||||||
bool success = 1;
|
|
||||||
string message = 2;
|
|
||||||
VtqMessage vtq = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ReadBatchRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
repeated string tags = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ReadBatchResponse {
|
|
||||||
bool success = 1;
|
|
||||||
string message = 2;
|
|
||||||
repeated VtqMessage vtqs = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === WRITE MESSAGES ===
|
|
||||||
|
|
||||||
message WriteRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
string tag = 2;
|
|
||||||
string value = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message WriteResponse {
|
|
||||||
bool success = 1;
|
|
||||||
string message = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message WriteItem {
|
|
||||||
string tag = 1;
|
|
||||||
string value = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message WriteResult {
|
|
||||||
string tag = 1;
|
|
||||||
bool success = 2;
|
|
||||||
string message = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message WriteBatchRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
repeated WriteItem items = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message WriteBatchResponse {
|
|
||||||
bool success = 1;
|
|
||||||
string message = 2;
|
|
||||||
repeated WriteResult results = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message WriteBatchAndWaitRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
repeated WriteItem items = 2;
|
|
||||||
string flag_tag = 3;
|
|
||||||
string flag_value = 4;
|
|
||||||
int32 timeout_ms = 5;
|
|
||||||
int32 poll_interval_ms = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
message WriteBatchAndWaitResponse {
|
|
||||||
bool success = 1;
|
|
||||||
string message = 2;
|
|
||||||
repeated WriteResult write_results = 3;
|
|
||||||
bool flag_reached = 4;
|
|
||||||
int32 elapsed_ms = 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === SUBSCRIPTION MESSAGES ===
|
|
||||||
|
|
||||||
message SubscribeRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
repeated string tags = 2;
|
|
||||||
int32 sampling_ms = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Subscribe RPC now streams VtqMessage directly (defined above)
|
|
||||||
|
|
||||||
// === AUTHENTICATION MESSAGES ===
|
|
||||||
|
|
||||||
message CheckApiKeyRequest {
|
|
||||||
string api_key = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message CheckApiKeyResponse {
|
|
||||||
bool is_valid = 1;
|
|
||||||
string message = 2;
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
using Grpc.Core;
|
|
||||||
using LmxFakeProxy.Bridge;
|
|
||||||
using LmxFakeProxy.Grpc;
|
|
||||||
using LmxFakeProxy.Sessions;
|
|
||||||
|
|
||||||
namespace LmxFakeProxy.Services;
|
|
||||||
|
|
||||||
public class ScadaServiceImpl : ScadaService.ScadaServiceBase
|
|
||||||
{
|
|
||||||
private readonly SessionManager _sessions;
|
|
||||||
private readonly IOpcUaBridge _bridge;
|
|
||||||
private readonly TagMapper _tagMapper;
|
|
||||||
|
|
||||||
public ScadaServiceImpl(SessionManager sessions, IOpcUaBridge bridge, TagMapper tagMapper)
|
|
||||||
{
|
|
||||||
_sessions = sessions;
|
|
||||||
_bridge = bridge;
|
|
||||||
_tagMapper = tagMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<ConnectResponse> Connect(ConnectRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
var (success, message, sessionId) = _sessions.Connect(request.ClientId, request.ApiKey);
|
|
||||||
return Task.FromResult(new ConnectResponse { Success = success, Message = message, SessionId = sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<DisconnectResponse> Disconnect(DisconnectRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
var ok = _sessions.Disconnect(request.SessionId);
|
|
||||||
return Task.FromResult(new DisconnectResponse
|
|
||||||
{
|
|
||||||
Success = ok,
|
|
||||||
Message = ok ? "Disconnected" : "Session not found"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<GetConnectionStateResponse> GetConnectionState(
|
|
||||||
GetConnectionStateRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
var (found, clientId, ticks) = _sessions.GetConnectionState(request.SessionId);
|
|
||||||
return Task.FromResult(new GetConnectionStateResponse
|
|
||||||
{
|
|
||||||
IsConnected = found, ClientId = clientId, ConnectedSinceUtcTicks = ticks
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<CheckApiKeyResponse> CheckApiKey(CheckApiKeyRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
var valid = _sessions.CheckApiKey(request.ApiKey);
|
|
||||||
return Task.FromResult(new CheckApiKeyResponse
|
|
||||||
{
|
|
||||||
IsValid = valid, Message = valid ? "Valid" : "Invalid API key"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<ReadResponse> Read(ReadRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
if (!_sessions.ValidateSession(request.SessionId))
|
|
||||||
return new ReadResponse { Success = false, Message = "Invalid or expired session" };
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var nodeId = _tagMapper.ToOpcNodeId(request.Tag);
|
|
||||||
var result = await _bridge.ReadAsync(nodeId, context.CancellationToken);
|
|
||||||
return new ReadResponse
|
|
||||||
{
|
|
||||||
Success = true,
|
|
||||||
Vtq = TagMapper.ToVtqMessage(request.Tag, result.Value, result.SourceTimestamp, result.StatusCode)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return new ReadResponse { Success = false, Message = ex.Message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<ReadBatchResponse> ReadBatch(ReadBatchRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
if (!_sessions.ValidateSession(request.SessionId))
|
|
||||||
return new ReadBatchResponse { Success = false, Message = "Invalid or expired session" };
|
|
||||||
|
|
||||||
var response = new ReadBatchResponse { Success = true };
|
|
||||||
foreach (var tag in request.Tags)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var nodeId = _tagMapper.ToOpcNodeId(tag);
|
|
||||||
var result = await _bridge.ReadAsync(nodeId, context.CancellationToken);
|
|
||||||
response.Vtqs.Add(TagMapper.ToVtqMessage(tag, result.Value, result.SourceTimestamp, result.StatusCode));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
response.Vtqs.Add(new VtqMessage
|
|
||||||
{
|
|
||||||
Tag = tag, Value = "", Quality = "Bad", TimestampUtcTicks = DateTime.UtcNow.Ticks
|
|
||||||
});
|
|
||||||
response.Message = ex.Message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<WriteResponse> Write(WriteRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
if (!_sessions.ValidateSession(request.SessionId))
|
|
||||||
return new WriteResponse { Success = false, Message = "Invalid or expired session" };
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var nodeId = _tagMapper.ToOpcNodeId(request.Tag);
|
|
||||||
var value = TagMapper.ParseWriteValue(request.Value);
|
|
||||||
var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken);
|
|
||||||
return statusCode == 0
|
|
||||||
? new WriteResponse { Success = true }
|
|
||||||
: new WriteResponse { Success = false, Message = $"OPC UA write failed: 0x{statusCode:X8}" };
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return new WriteResponse { Success = false, Message = ex.Message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<WriteBatchResponse> WriteBatch(WriteBatchRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
if (!_sessions.ValidateSession(request.SessionId))
|
|
||||||
return new WriteBatchResponse { Success = false, Message = "Invalid or expired session" };
|
|
||||||
|
|
||||||
var response = new WriteBatchResponse { Success = true };
|
|
||||||
foreach (var item in request.Items)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var nodeId = _tagMapper.ToOpcNodeId(item.Tag);
|
|
||||||
var value = TagMapper.ParseWriteValue(item.Value);
|
|
||||||
var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken);
|
|
||||||
response.Results.Add(new Grpc.WriteResult
|
|
||||||
{
|
|
||||||
Tag = item.Tag, Success = statusCode == 0,
|
|
||||||
Message = statusCode == 0 ? "" : $"0x{statusCode:X8}"
|
|
||||||
});
|
|
||||||
if (statusCode != 0) response.Success = false;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
response.Results.Add(new Grpc.WriteResult { Tag = item.Tag, Success = false, Message = ex.Message });
|
|
||||||
response.Success = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<WriteBatchAndWaitResponse> WriteBatchAndWait(
|
|
||||||
WriteBatchAndWaitRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
if (!_sessions.ValidateSession(request.SessionId))
|
|
||||||
return new WriteBatchAndWaitResponse { Success = false, Message = "Invalid or expired session" };
|
|
||||||
|
|
||||||
var startTime = DateTime.UtcNow;
|
|
||||||
var writeResults = new List<Grpc.WriteResult>();
|
|
||||||
var allWritesOk = true;
|
|
||||||
|
|
||||||
foreach (var item in request.Items)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var nodeId = _tagMapper.ToOpcNodeId(item.Tag);
|
|
||||||
var value = TagMapper.ParseWriteValue(item.Value);
|
|
||||||
var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken);
|
|
||||||
writeResults.Add(new Grpc.WriteResult
|
|
||||||
{
|
|
||||||
Tag = item.Tag, Success = statusCode == 0,
|
|
||||||
Message = statusCode == 0 ? "" : $"0x{statusCode:X8}"
|
|
||||||
});
|
|
||||||
if (statusCode != 0) allWritesOk = false;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
writeResults.Add(new Grpc.WriteResult { Tag = item.Tag, Success = false, Message = ex.Message });
|
|
||||||
allWritesOk = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allWritesOk)
|
|
||||||
{
|
|
||||||
var failResp = new WriteBatchAndWaitResponse { Success = false, Message = "Write failed" };
|
|
||||||
failResp.WriteResults.AddRange(writeResults);
|
|
||||||
return failResp;
|
|
||||||
}
|
|
||||||
|
|
||||||
var flagNodeId = _tagMapper.ToOpcNodeId(request.FlagTag);
|
|
||||||
var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000;
|
|
||||||
var pollMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100;
|
|
||||||
var deadline = startTime.AddMilliseconds(timeoutMs);
|
|
||||||
|
|
||||||
while (DateTime.UtcNow < deadline)
|
|
||||||
{
|
|
||||||
context.CancellationToken.ThrowIfCancellationRequested();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var readResult = await _bridge.ReadAsync(flagNodeId, context.CancellationToken);
|
|
||||||
if (readResult.Value?.ToString() == request.FlagValue)
|
|
||||||
{
|
|
||||||
var elapsed = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
|
|
||||||
var resp = new WriteBatchAndWaitResponse { Success = true, FlagReached = true, ElapsedMs = elapsed };
|
|
||||||
resp.WriteResults.AddRange(writeResults);
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
await Task.Delay(pollMs, context.CancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
var finalResp = new WriteBatchAndWaitResponse
|
|
||||||
{
|
|
||||||
Success = true, FlagReached = false,
|
|
||||||
ElapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds,
|
|
||||||
Message = "Timeout waiting for flag value"
|
|
||||||
};
|
|
||||||
finalResp.WriteResults.AddRange(writeResults);
|
|
||||||
return finalResp;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task Subscribe(
|
|
||||||
SubscribeRequest request, IServerStreamWriter<VtqMessage> responseStream, ServerCallContext context)
|
|
||||||
{
|
|
||||||
if (!_sessions.ValidateSession(request.SessionId))
|
|
||||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid or expired session"));
|
|
||||||
|
|
||||||
var nodeIds = request.Tags.Select(t => _tagMapper.ToOpcNodeId(t)).ToList();
|
|
||||||
var tagByNodeId = request.Tags.Zip(nodeIds).ToDictionary(p => p.Second, p => p.First);
|
|
||||||
|
|
||||||
var handle = await _bridge.AddMonitoredItemsAsync(
|
|
||||||
nodeIds, request.SamplingMs,
|
|
||||||
(nodeId, value, timestamp, statusCode) =>
|
|
||||||
{
|
|
||||||
if (tagByNodeId.TryGetValue(nodeId, out var tag))
|
|
||||||
{
|
|
||||||
var vtq = TagMapper.ToVtqMessage(tag, value, timestamp, statusCode);
|
|
||||||
try { responseStream.WriteAsync(vtq).Wait(); }
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
context.CancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(Timeout.Infinite, context.CancellationToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { }
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await _bridge.RemoveMonitoredItemsAsync(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
|
|
||||||
namespace LmxFakeProxy.Sessions;
|
|
||||||
|
|
||||||
public record SessionInfo(string ClientId, long ConnectedSinceUtcTicks);
|
|
||||||
|
|
||||||
public class SessionManager
|
|
||||||
{
|
|
||||||
private readonly string? _requiredApiKey;
|
|
||||||
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
|
|
||||||
|
|
||||||
public SessionManager(string? requiredApiKey)
|
|
||||||
{
|
|
||||||
_requiredApiKey = requiredApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public (bool Success, string Message, string SessionId) Connect(string clientId, string apiKey)
|
|
||||||
{
|
|
||||||
if (!CheckApiKey(apiKey))
|
|
||||||
return (false, "Invalid API key", string.Empty);
|
|
||||||
|
|
||||||
var sessionId = Guid.NewGuid().ToString("N");
|
|
||||||
var info = new SessionInfo(clientId, DateTime.UtcNow.Ticks);
|
|
||||||
_sessions[sessionId] = info;
|
|
||||||
return (true, "Connected", sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Disconnect(string sessionId)
|
|
||||||
{
|
|
||||||
return _sessions.TryRemove(sessionId, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool ValidateSession(string sessionId)
|
|
||||||
{
|
|
||||||
return _sessions.ContainsKey(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public (bool Found, string ClientId, long ConnectedSinceUtcTicks) GetConnectionState(string sessionId)
|
|
||||||
{
|
|
||||||
if (_sessions.TryGetValue(sessionId, out var info))
|
|
||||||
return (true, info.ClientId, info.ConnectedSinceUtcTicks);
|
|
||||||
return (false, string.Empty, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool CheckApiKey(string apiKey)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(_requiredApiKey))
|
|
||||||
return true;
|
|
||||||
return apiKey == _requiredApiKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
using System.Collections;
|
|
||||||
using System.Text.Json;
|
|
||||||
using LmxFakeProxy.Grpc;
|
|
||||||
|
|
||||||
namespace LmxFakeProxy;
|
|
||||||
|
|
||||||
public class TagMapper
|
|
||||||
{
|
|
||||||
private readonly string _prefix;
|
|
||||||
|
|
||||||
public TagMapper(string prefix)
|
|
||||||
{
|
|
||||||
_prefix = prefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ToOpcNodeId(string lmxTag) => $"{_prefix}{lmxTag}";
|
|
||||||
|
|
||||||
public static object ParseWriteValue(string value)
|
|
||||||
{
|
|
||||||
if (double.TryParse(value, System.Globalization.NumberStyles.Float,
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture, out var d))
|
|
||||||
return d;
|
|
||||||
if (bool.TryParse(value, out var b))
|
|
||||||
return b;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string MapQuality(uint statusCode)
|
|
||||||
{
|
|
||||||
if (statusCode == 0) return "Good";
|
|
||||||
if ((statusCode & 0x80000000) != 0) return "Bad";
|
|
||||||
return "Uncertain";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string FormatValue(object? value)
|
|
||||||
{
|
|
||||||
if (value is null) return string.Empty;
|
|
||||||
if (value is Array or IList)
|
|
||||||
return JsonSerializer.Serialize(value);
|
|
||||||
return value.ToString() ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static VtqMessage ToVtqMessage(string tag, object? value, DateTime timestampUtc, uint statusCode)
|
|
||||||
{
|
|
||||||
return new VtqMessage
|
|
||||||
{
|
|
||||||
Tag = tag,
|
|
||||||
Value = FormatValue(value),
|
|
||||||
TimestampUtcTicks = timestampUtc.Ticks,
|
|
||||||
Quality = MapQuality(statusCode)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
global using Xunit;
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using ScadaLink.DataConnectionLayer.Adapters;
|
|
||||||
|
|
||||||
namespace LmxFakeProxy.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// End-to-end smoke test connecting RealLmxProxyClient to LmxFakeProxy.
|
|
||||||
/// Requires both OPC UA test server and LmxFakeProxy to be running.
|
|
||||||
/// Run manually: dotnet test --filter "Category=Integration"
|
|
||||||
/// </summary>
|
|
||||||
[Trait("Category", "Integration")]
|
|
||||||
public class IntegrationSmokeTest
|
|
||||||
{
|
|
||||||
private const string Host = "localhost";
|
|
||||||
private const int Port = 50051;
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ConnectReadWriteSubscribe_EndToEnd()
|
|
||||||
{
|
|
||||||
var factory = new RealLmxProxyClientFactory();
|
|
||||||
var client = factory.Create(Host, Port, null);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Connect
|
|
||||||
await client.ConnectAsync();
|
|
||||||
Assert.True(client.IsConnected);
|
|
||||||
|
|
||||||
// Read initial value
|
|
||||||
var vtq = await client.ReadAsync("Motor.Speed");
|
|
||||||
Assert.Equal(LmxQuality.Good, vtq.Quality);
|
|
||||||
|
|
||||||
// Write a value
|
|
||||||
await client.WriteAsync("Motor.Speed", 42.5);
|
|
||||||
|
|
||||||
// Read back
|
|
||||||
var vtq2 = await client.ReadAsync("Motor.Speed");
|
|
||||||
Assert.Equal(42.5, (double)vtq2.Value!);
|
|
||||||
|
|
||||||
// ReadBatch
|
|
||||||
var batch = await client.ReadBatchAsync(new[] { "Motor.Speed", "Pump.FlowRate" });
|
|
||||||
Assert.Equal(2, batch.Count);
|
|
||||||
|
|
||||||
// Subscribe briefly
|
|
||||||
LmxVtq? lastUpdate = null;
|
|
||||||
var sub = await client.SubscribeAsync(
|
|
||||||
new[] { "Motor.Speed" },
|
|
||||||
(tag, v) => lastUpdate = v);
|
|
||||||
|
|
||||||
// Write to trigger subscription update
|
|
||||||
await client.WriteAsync("Motor.Speed", 99.0);
|
|
||||||
await Task.Delay(2000);
|
|
||||||
|
|
||||||
await sub.DisposeAsync();
|
|
||||||
Assert.NotNull(lastUpdate);
|
|
||||||
|
|
||||||
// Disconnect
|
|
||||||
await client.DisconnectAsync();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await client.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<RootNamespace>LmxFakeProxy.Tests</RootNamespace>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
|
|
||||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
|
||||||
<PackageReference Include="Grpc.Core" Version="2.46.6" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="../../LmxFakeProxy.csproj" />
|
|
||||||
<ProjectReference Include="../../../../src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
using Grpc.Core;
|
|
||||||
using NSubstitute;
|
|
||||||
using LmxFakeProxy.Bridge;
|
|
||||||
using LmxFakeProxy.Grpc;
|
|
||||||
using LmxFakeProxy.Sessions;
|
|
||||||
using LmxFakeProxy.Services;
|
|
||||||
|
|
||||||
namespace LmxFakeProxy.Tests;
|
|
||||||
|
|
||||||
public class ScadaServiceTests
|
|
||||||
{
|
|
||||||
private readonly IOpcUaBridge _mockBridge;
|
|
||||||
private readonly SessionManager _sessionMgr;
|
|
||||||
private readonly TagMapper _tagMapper;
|
|
||||||
private readonly ScadaServiceImpl _service;
|
|
||||||
|
|
||||||
public ScadaServiceTests()
|
|
||||||
{
|
|
||||||
_mockBridge = Substitute.For<IOpcUaBridge>();
|
|
||||||
_mockBridge.IsConnected.Returns(true);
|
|
||||||
_sessionMgr = new SessionManager(null);
|
|
||||||
_tagMapper = new TagMapper("ns=3;s=");
|
|
||||||
_service = new ScadaServiceImpl(_sessionMgr, _mockBridge, _tagMapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ConnectClient(string clientId = "test-client")
|
|
||||||
{
|
|
||||||
var (_, _, sessionId) = _sessionMgr.Connect(clientId, "");
|
|
||||||
return sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ServerCallContext MockContext()
|
|
||||||
{
|
|
||||||
return new TestServerCallContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Connect_ReturnsSessionId()
|
|
||||||
{
|
|
||||||
var resp = await _service.Connect(
|
|
||||||
new ConnectRequest { ClientId = "c1", ApiKey = "" }, MockContext());
|
|
||||||
Assert.True(resp.Success);
|
|
||||||
Assert.NotEmpty(resp.SessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Read_ValidSession_ReturnsVtq()
|
|
||||||
{
|
|
||||||
var sid = ConnectClient();
|
|
||||||
_mockBridge.ReadAsync("ns=3;s=Motor.Speed", Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new OpcUaReadResult(42.5, DateTime.UtcNow, 0));
|
|
||||||
|
|
||||||
var resp = await _service.Read(
|
|
||||||
new ReadRequest { SessionId = sid, Tag = "Motor.Speed" }, MockContext());
|
|
||||||
|
|
||||||
Assert.True(resp.Success);
|
|
||||||
Assert.Equal("42.5", resp.Vtq.Value);
|
|
||||||
Assert.Equal("Good", resp.Vtq.Quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Read_InvalidSession_ReturnsFailure()
|
|
||||||
{
|
|
||||||
var resp = await _service.Read(
|
|
||||||
new ReadRequest { SessionId = "bogus", Tag = "Motor.Speed" }, MockContext());
|
|
||||||
Assert.False(resp.Success);
|
|
||||||
Assert.Contains("Invalid", resp.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ReadBatch_ReturnsAllTags()
|
|
||||||
{
|
|
||||||
var sid = ConnectClient();
|
|
||||||
_mockBridge.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new OpcUaReadResult(1.0, DateTime.UtcNow, 0));
|
|
||||||
|
|
||||||
var req = new ReadBatchRequest { SessionId = sid };
|
|
||||||
req.Tags.AddRange(new[] { "Motor.Speed", "Pump.FlowRate" });
|
|
||||||
|
|
||||||
var resp = await _service.ReadBatch(req, MockContext());
|
|
||||||
|
|
||||||
Assert.True(resp.Success);
|
|
||||||
Assert.Equal(2, resp.Vtqs.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Write_ValidSession_Succeeds()
|
|
||||||
{
|
|
||||||
var sid = ConnectClient();
|
|
||||||
_mockBridge.WriteAsync("ns=3;s=Motor.Speed", Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns(0u);
|
|
||||||
|
|
||||||
var resp = await _service.Write(
|
|
||||||
new WriteRequest { SessionId = sid, Tag = "Motor.Speed", Value = "42.5" }, MockContext());
|
|
||||||
|
|
||||||
Assert.True(resp.Success);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Write_InvalidSession_ReturnsFailure()
|
|
||||||
{
|
|
||||||
var resp = await _service.Write(
|
|
||||||
new WriteRequest { SessionId = "bogus", Tag = "Motor.Speed", Value = "42.5" }, MockContext());
|
|
||||||
Assert.False(resp.Success);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task WriteBatch_ReturnsPerItemResults()
|
|
||||||
{
|
|
||||||
var sid = ConnectClient();
|
|
||||||
_mockBridge.WriteAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns(0u);
|
|
||||||
|
|
||||||
var req = new WriteBatchRequest { SessionId = sid };
|
|
||||||
req.Items.Add(new WriteItem { Tag = "Motor.Speed", Value = "42.5" });
|
|
||||||
req.Items.Add(new WriteItem { Tag = "Pump.FlowRate", Value = "10.0" });
|
|
||||||
|
|
||||||
var resp = await _service.WriteBatch(req, MockContext());
|
|
||||||
|
|
||||||
Assert.True(resp.Success);
|
|
||||||
Assert.Equal(2, resp.Results.Count);
|
|
||||||
Assert.All(resp.Results, r => Assert.True(r.Success));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CheckApiKey_Valid_ReturnsTrue()
|
|
||||||
{
|
|
||||||
var resp = await _service.CheckApiKey(
|
|
||||||
new CheckApiKeyRequest { ApiKey = "anything" }, MockContext());
|
|
||||||
Assert.True(resp.IsValid);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CheckApiKey_Invalid_ReturnsFalse()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager("secret");
|
|
||||||
var svc = new ScadaServiceImpl(mgr, _mockBridge, _tagMapper);
|
|
||||||
|
|
||||||
var resp = await svc.CheckApiKey(
|
|
||||||
new CheckApiKeyRequest { ApiKey = "wrong" }, MockContext());
|
|
||||||
Assert.False(resp.IsValid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimal ServerCallContext for unit testing gRPC services.
|
|
||||||
/// </summary>
|
|
||||||
internal class TestServerCallContext : ServerCallContext
|
|
||||||
{
|
|
||||||
protected override string MethodCore => "test";
|
|
||||||
protected override string HostCore => "localhost";
|
|
||||||
protected override string PeerCore => "test-peer";
|
|
||||||
protected override DateTime DeadlineCore => DateTime.MaxValue;
|
|
||||||
protected override Metadata RequestHeadersCore => new();
|
|
||||||
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
|
|
||||||
protected override Metadata ResponseTrailersCore => new();
|
|
||||||
protected override Status StatusCore { get; set; }
|
|
||||||
protected override WriteOptions? WriteOptionsCore { get; set; }
|
|
||||||
protected override AuthContext AuthContextCore => new("test", new Dictionary<string, List<AuthProperty>>());
|
|
||||||
|
|
||||||
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
|
||||||
throw new NotImplementedException();
|
|
||||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
namespace LmxFakeProxy.Tests;
|
|
||||||
|
|
||||||
using LmxFakeProxy.Sessions;
|
|
||||||
|
|
||||||
public class SessionManagerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Connect_ReturnsUniqueSessionId()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager(null);
|
|
||||||
var (ok1, _, id1) = mgr.Connect("client1", "");
|
|
||||||
var (ok2, _, id2) = mgr.Connect("client2", "");
|
|
||||||
Assert.True(ok1);
|
|
||||||
Assert.True(ok2);
|
|
||||||
Assert.NotEqual(id1, id2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Connect_WithValidApiKey_Succeeds()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager("secret");
|
|
||||||
var (ok, _, _) = mgr.Connect("client1", "secret");
|
|
||||||
Assert.True(ok);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Connect_WithInvalidApiKey_Fails()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager("secret");
|
|
||||||
var (ok, msg, id) = mgr.Connect("client1", "wrong");
|
|
||||||
Assert.False(ok);
|
|
||||||
Assert.Empty(id);
|
|
||||||
Assert.Contains("Invalid API key", msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Connect_WithNoKeyConfigured_AcceptsAnyKey()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager(null);
|
|
||||||
var (ok1, _, _) = mgr.Connect("c1", "anykey");
|
|
||||||
var (ok2, _, _) = mgr.Connect("c2", "");
|
|
||||||
Assert.True(ok1);
|
|
||||||
Assert.True(ok2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Disconnect_RemovesSession()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager(null);
|
|
||||||
var (_, _, id) = mgr.Connect("client1", "");
|
|
||||||
Assert.True(mgr.ValidateSession(id));
|
|
||||||
var ok = mgr.Disconnect(id);
|
|
||||||
Assert.True(ok);
|
|
||||||
Assert.False(mgr.ValidateSession(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Disconnect_UnknownSession_ReturnsFalse()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager(null);
|
|
||||||
Assert.False(mgr.Disconnect("nonexistent"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ValidateSession_ValidId_ReturnsTrue()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager(null);
|
|
||||||
var (_, _, id) = mgr.Connect("client1", "");
|
|
||||||
Assert.True(mgr.ValidateSession(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ValidateSession_InvalidId_ReturnsFalse()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager(null);
|
|
||||||
Assert.False(mgr.ValidateSession("bogus"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetConnectionState_ReturnsCorrectInfo()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager(null);
|
|
||||||
var (_, _, id) = mgr.Connect("myClient", "");
|
|
||||||
var (found, clientId, ticks) = mgr.GetConnectionState(id);
|
|
||||||
Assert.True(found);
|
|
||||||
Assert.Equal("myClient", clientId);
|
|
||||||
Assert.True(ticks > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetConnectionState_UnknownSession_ReturnsNotConnected()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager(null);
|
|
||||||
var (found, clientId, ticks) = mgr.GetConnectionState("unknown");
|
|
||||||
Assert.False(found);
|
|
||||||
Assert.Empty(clientId);
|
|
||||||
Assert.Equal(0, ticks);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CheckApiKey_NoKeyConfigured_AlwaysValid()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager(null);
|
|
||||||
Assert.True(mgr.CheckApiKey("anything"));
|
|
||||||
Assert.True(mgr.CheckApiKey(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CheckApiKey_WithKeyConfigured_ValidatesCorrectly()
|
|
||||||
{
|
|
||||||
var mgr = new SessionManager("mykey");
|
|
||||||
Assert.True(mgr.CheckApiKey("mykey"));
|
|
||||||
Assert.False(mgr.CheckApiKey("wrong"));
|
|
||||||
Assert.False(mgr.CheckApiKey(""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace LmxFakeProxy.Tests;
|
|
||||||
|
|
||||||
public class TagMappingTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void ToOpcNodeId_PrependsPrefix()
|
|
||||||
{
|
|
||||||
var mapper = new TagMapper("ns=3;s=");
|
|
||||||
Assert.Equal("ns=3;s=Motor.Speed", mapper.ToOpcNodeId("Motor.Speed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToOpcNodeId_CustomPrefix()
|
|
||||||
{
|
|
||||||
var mapper = new TagMapper("ns=2;s=MyFolder.");
|
|
||||||
Assert.Equal("ns=2;s=MyFolder.Pump.Pressure", mapper.ToOpcNodeId("Pump.Pressure"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToOpcNodeId_EmptyPrefix_PassesThrough()
|
|
||||||
{
|
|
||||||
var mapper = new TagMapper("");
|
|
||||||
Assert.Equal("Motor.Speed", mapper.ToOpcNodeId("Motor.Speed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseWriteValue_Double()
|
|
||||||
{
|
|
||||||
Assert.Equal(42.5, TagMapper.ParseWriteValue("42.5"));
|
|
||||||
Assert.IsType<double>(TagMapper.ParseWriteValue("42.5"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseWriteValue_Bool()
|
|
||||||
{
|
|
||||||
Assert.Equal(true, TagMapper.ParseWriteValue("true"));
|
|
||||||
Assert.Equal(false, TagMapper.ParseWriteValue("False"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseWriteValue_Uint()
|
|
||||||
{
|
|
||||||
// "100" parses as double first (double.TryParse succeeds for integers)
|
|
||||||
var result = TagMapper.ParseWriteValue("100");
|
|
||||||
Assert.IsType<double>(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseWriteValue_FallsBackToString()
|
|
||||||
{
|
|
||||||
Assert.Equal("hello", TagMapper.ParseWriteValue("hello"));
|
|
||||||
Assert.IsType<string>(TagMapper.ParseWriteValue("hello"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void MapStatusCode_Good()
|
|
||||||
{
|
|
||||||
Assert.Equal("Good", TagMapper.MapQuality(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void MapStatusCode_Bad()
|
|
||||||
{
|
|
||||||
Assert.Equal("Bad", TagMapper.MapQuality(0x80000000));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void MapStatusCode_Uncertain()
|
|
||||||
{
|
|
||||||
Assert.Equal("Uncertain", TagMapper.MapQuality(0x40000000));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToVtqMessage_ConvertsCorrectly()
|
|
||||||
{
|
|
||||||
var vtq = TagMapper.ToVtqMessage("Motor.Speed", 42.5, DateTime.UtcNow, 0);
|
|
||||||
Assert.Equal("Motor.Speed", vtq.Tag);
|
|
||||||
Assert.Equal("42.5", vtq.Value);
|
|
||||||
Assert.Equal("Good", vtq.Quality);
|
|
||||||
Assert.True(vtq.TimestampUtcTicks > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -460,7 +460,6 @@ The server now returns specific quality codes instead of generic `"Bad"`:
|
|||||||
|
|
||||||
This is appropriate because:
|
This is appropriate because:
|
||||||
- The LmxProxy is an internal protocol between ScadaLink components, not a public API
|
- The LmxProxy is an internal protocol between ScadaLink components, not a public API
|
||||||
- The TagSim simulator is a new implementation, not an upgrade of an existing server
|
|
||||||
- The number of clients is small and controlled
|
- The number of clients is small and controlled
|
||||||
- Maintaining dual formats adds complexity with no long-term benefit
|
- Maintaining dual formats adds complexity with no long-term benefit
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user