From abb7579227d30a4dc72949170048cc13c01ede6b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 22 Mar 2026 07:42:13 -0400 Subject: [PATCH] =?UTF-8?q?chore(infra):=20remove=20LmxFakeProxy=20?= =?UTF-8?q?=E2=80=94=20replaced=20by=20real=20LmxProxy=20v2=20instances=20?= =?UTF-8?q?on=20windev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/test_infra/test_infra.md | 23 +- docs/test_infra/test_infra_lmxfakeproxy.md | 76 ----- infra/README.md | 1 - infra/docker-compose.yml | 14 - infra/lmxfakeproxy/.dockerignore | 3 - infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs | 25 -- infra/lmxfakeproxy/Bridge/OpcUaBridge.cs | 300 ------------------ infra/lmxfakeproxy/Dockerfile | 13 - infra/lmxfakeproxy/LmxFakeProxy.csproj | 23 -- infra/lmxfakeproxy/Program.cs | 57 ---- infra/lmxfakeproxy/Protos/scada.proto | 166 ---------- .../lmxfakeproxy/Services/ScadaServiceImpl.cs | 255 --------------- infra/lmxfakeproxy/Sessions/SessionManager.cs | 51 --- infra/lmxfakeproxy/TagMapper.cs | 53 ---- .../tests/LmxFakeProxy.Tests/GlobalUsings.cs | 1 - .../IntegrationSmokeTest.cs | 64 ---- .../LmxFakeProxy.Tests.csproj | 22 -- .../LmxFakeProxy.Tests/ScadaServiceTests.cs | 164 ---------- .../LmxFakeProxy.Tests/SessionManagerTests.cs | 116 ------- .../LmxFakeProxy.Tests/TagMappingTests.cs | 84 ----- lmxproxy/docs/lmxproxy_updates.md | 1 - 21 files changed, 19 insertions(+), 1493 deletions(-) delete mode 100644 docs/test_infra/test_infra_lmxfakeproxy.md delete mode 100644 infra/lmxfakeproxy/.dockerignore delete mode 100644 infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs delete mode 100644 infra/lmxfakeproxy/Bridge/OpcUaBridge.cs delete mode 100644 infra/lmxfakeproxy/Dockerfile delete mode 100644 infra/lmxfakeproxy/LmxFakeProxy.csproj delete mode 100644 infra/lmxfakeproxy/Program.cs delete mode 100644 infra/lmxfakeproxy/Protos/scada.proto delete mode 100644 infra/lmxfakeproxy/Services/ScadaServiceImpl.cs delete mode 100644 infra/lmxfakeproxy/Sessions/SessionManager.cs delete mode 100644 infra/lmxfakeproxy/TagMapper.cs delete mode 100644 infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/GlobalUsings.cs delete mode 100644 infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs delete mode 100644 infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj delete mode 100644 infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/ScadaServiceTests.cs delete mode 100644 infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/SessionManagerTests.cs delete mode 100644 infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/TagMappingTests.cs diff --git a/docs/test_infra/test_infra.md b/docs/test_infra/test_infra.md index f657888..e4ec9e9 100644 --- a/docs/test_infra/test_infra.md +++ b/docs/test_infra/test_infra.md @@ -1,6 +1,6 @@ # 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 @@ -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/` | | 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/` | -| 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/` | | 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_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_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) - 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 For use in `appsettings.Development.json`: @@ -117,7 +133,6 @@ infra/ opcua/nodes.json # Custom OPC UA tag definitions restapi/app.py # Flask REST API server 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) README.md # Quick-start for the infra folder diff --git a/docs/test_infra/test_infra_lmxfakeproxy.md b/docs/test_infra/test_infra_lmxfakeproxy.md deleted file mode 100644 index aba8ae6..0000000 --- a/docs/test_infra/test_infra_lmxfakeproxy.md +++ /dev/null @@ -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 diff --git a/infra/README.md b/infra/README.md index 8918827..4788783 100644 --- a/infra/README.md +++ b/infra/README.md @@ -18,7 +18,6 @@ This starts the following services: | MS SQL 2022 | 1433 | Configuration and machine data databases | | SMTP (Mailpit) | 1025 (SMTP), 8025 (web) | Email capture for notification 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 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index efe8da0..65f8a71 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -95,20 +95,6 @@ services: - scadalink-net 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: image: mcr.microsoft.com/playwright:v1.58.2-noble container_name: scadalink-playwright diff --git a/infra/lmxfakeproxy/.dockerignore b/infra/lmxfakeproxy/.dockerignore deleted file mode 100644 index e9ea55f..0000000 --- a/infra/lmxfakeproxy/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -tests/ -bin/ -obj/ diff --git a/infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs b/infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs deleted file mode 100644 index 23d59a7..0000000 --- a/infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs +++ /dev/null @@ -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 ReadAsync(string nodeId, CancellationToken cancellationToken = default); - - Task WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default); - - Task AddMonitoredItemsAsync( - IEnumerable nodeIds, - int samplingIntervalMs, - Action onValueChanged, - CancellationToken cancellationToken = default); - - Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default); - - event Action? Disconnected; - event Action? Reconnected; -} diff --git a/infra/lmxfakeproxy/Bridge/OpcUaBridge.cs b/infra/lmxfakeproxy/Bridge/OpcUaBridge.cs deleted file mode 100644 index 4f19e28..0000000 --- a/infra/lmxfakeproxy/Bridge/OpcUaBridge.cs +++ /dev/null @@ -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 _logger; - private Opc.Ua.Client.ISession? _session; - private Subscription? _subscription; - private volatile bool _connected; - private volatile bool _reconnecting; - private CancellationTokenSource? _reconnectCts; - - private readonly Dictionary> _handleItems = new(); - private readonly Dictionary> _handleCallbacks = new(); - private readonly object _lock = new(); - - public OpcUaBridge(string endpointUrl, ILogger 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 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 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 AddMonitoredItemsAsync( - IEnumerable nodeIds, int samplingIntervalMs, - Action onValueChanged, - CancellationToken cancellationToken = default) - { - EnsureConnected(); - var handle = Guid.NewGuid().ToString("N"); - var items = new List(); - - 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? 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(); - 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; - } -} diff --git a/infra/lmxfakeproxy/Dockerfile b/infra/lmxfakeproxy/Dockerfile deleted file mode 100644 index 47139b3..0000000 --- a/infra/lmxfakeproxy/Dockerfile +++ /dev/null @@ -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"] diff --git a/infra/lmxfakeproxy/LmxFakeProxy.csproj b/infra/lmxfakeproxy/LmxFakeProxy.csproj deleted file mode 100644 index 59a4378..0000000 --- a/infra/lmxfakeproxy/LmxFakeProxy.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - net10.0 - LmxFakeProxy - enable - enable - - - - - - - - - - - - - - - - - diff --git a/infra/lmxfakeproxy/Program.cs b/infra/lmxfakeproxy/Program.cs deleted file mode 100644 index c5a8fa0..0000000 --- a/infra/lmxfakeproxy/Program.cs +++ /dev/null @@ -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(sp => - new OpcUaBridge(opcEndpoint, sp.GetRequiredService>())); -builder.Services.AddGrpc(); - -var app = builder.Build(); - -app.MapGrpcService(); -app.MapGet("/", () => "LmxFakeProxy is running"); - -// Connect to OPC UA backend -var logger = app.Services.GetRequiredService>(); -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(); -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; -} diff --git a/infra/lmxfakeproxy/Protos/scada.proto b/infra/lmxfakeproxy/Protos/scada.proto deleted file mode 100644 index fc327aa..0000000 --- a/infra/lmxfakeproxy/Protos/scada.proto +++ /dev/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; -} diff --git a/infra/lmxfakeproxy/Services/ScadaServiceImpl.cs b/infra/lmxfakeproxy/Services/ScadaServiceImpl.cs deleted file mode 100644 index aaa0bcf..0000000 --- a/infra/lmxfakeproxy/Services/ScadaServiceImpl.cs +++ /dev/null @@ -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 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 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 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 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 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 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 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 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 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(); - 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 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); - } - } -} diff --git a/infra/lmxfakeproxy/Sessions/SessionManager.cs b/infra/lmxfakeproxy/Sessions/SessionManager.cs deleted file mode 100644 index 6a390c7..0000000 --- a/infra/lmxfakeproxy/Sessions/SessionManager.cs +++ /dev/null @@ -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 _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; - } -} diff --git a/infra/lmxfakeproxy/TagMapper.cs b/infra/lmxfakeproxy/TagMapper.cs deleted file mode 100644 index 340c9b5..0000000 --- a/infra/lmxfakeproxy/TagMapper.cs +++ /dev/null @@ -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) - }; - } -} diff --git a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/GlobalUsings.cs b/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/GlobalUsings.cs deleted file mode 100644 index c802f44..0000000 --- a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; diff --git a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs b/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs deleted file mode 100644 index 4948cd2..0000000 --- a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs +++ /dev/null @@ -1,64 +0,0 @@ -using ScadaLink.DataConnectionLayer.Adapters; - -namespace LmxFakeProxy.Tests; - -/// -/// 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" -/// -[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(); - } - } -} diff --git a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj b/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj deleted file mode 100644 index 398e4f0..0000000 --- a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - net10.0 - LmxFakeProxy.Tests - enable - enable - false - - - - - - - - - - - - - - - diff --git a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/ScadaServiceTests.cs b/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/ScadaServiceTests.cs deleted file mode 100644 index df5d86c..0000000 --- a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/ScadaServiceTests.cs +++ /dev/null @@ -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(); - _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()) - .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(), Arg.Any()) - .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(), Arg.Any()) - .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(), Arg.Any(), Arg.Any()) - .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); - } -} - -/// -/// Minimal ServerCallContext for unit testing gRPC services. -/// -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>()); - - protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => - throw new NotImplementedException(); - protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; -} diff --git a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/SessionManagerTests.cs b/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/SessionManagerTests.cs deleted file mode 100644 index 6fe47b9..0000000 --- a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/SessionManagerTests.cs +++ /dev/null @@ -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("")); - } -} diff --git a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/TagMappingTests.cs b/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/TagMappingTests.cs deleted file mode 100644 index 69cf2e6..0000000 --- a/infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/TagMappingTests.cs +++ /dev/null @@ -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(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(result); - } - - [Fact] - public void ParseWriteValue_FallsBackToString() - { - Assert.Equal("hello", TagMapper.ParseWriteValue("hello")); - Assert.IsType(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); - } -} diff --git a/lmxproxy/docs/lmxproxy_updates.md b/lmxproxy/docs/lmxproxy_updates.md index f199186..66fb279 100644 --- a/lmxproxy/docs/lmxproxy_updates.md +++ b/lmxproxy/docs/lmxproxy_updates.md @@ -460,7 +460,6 @@ The server now returns specific quality codes instead of generic `"Bad"`: This is appropriate because: - 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 - Maintaining dual formats adds complexity with no long-term benefit