chore(infra): remove LmxFakeProxy — replaced by real LmxProxy v2 instances on windev

LmxFakeProxy is no longer needed now that two real LmxProxy v2 instances
are available for testing. Added remote test infra section to test_infra.md
documenting the windev instances. Removed tagsim (never committed).
This commit is contained in:
Joseph Doherty
2026-03-22 07:42:13 -04:00
parent efed8352c3
commit abb7579227
21 changed files with 19 additions and 1493 deletions

View File

@@ -1,6 +1,6 @@
# Test Infrastructure # Test Infrastructure
This document describes the local Docker-based test infrastructure for ScadaLink development. Eight services provide the external dependencies needed to run and test the system locally. The first eight run in `infra/docker-compose.yml`; Traefik runs alongside the cluster nodes in `docker/docker-compose.yml`. This document describes the local Docker-based test infrastructure for ScadaLink development. Seven services provide the external dependencies needed to run and test the system locally. The first seven run in `infra/docker-compose.yml`; Traefik runs alongside the cluster nodes in `docker/docker-compose.yml`.
## Services ## Services
@@ -12,7 +12,6 @@ This document describes the local Docker-based test infrastructure for ScadaLink
| 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/` |
@@ -44,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`:
@@ -117,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

View File

@@ -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

View File

@@ -18,7 +18,6 @@ This starts the following services:
| 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

View File

@@ -95,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

View File

@@ -1,3 +0,0 @@
tests/
bin/
obj/

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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"]

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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)
};
}
}

View File

@@ -1 +0,0 @@
global using Xunit;

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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(""));
}
}

View File

@@ -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);
}
}

View File

@@ -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