From fa7d772dcb74f49bae18eae45c53344cd4af4d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bir=C3=B3=2C=20Csaba=20Attila?= Date: Sat, 14 Feb 2026 14:34:58 +0100 Subject: [PATCH 1/2] feat(config): add shared ADS types and integration architecture doc Add PlcAdsState enum, PlcStateDto, AdsConnectionInfo, and AdsVariableSubscription to FlowForge.Shared for use across monitor and build server without requiring Beckhoff NuGet dependencies. Update PlcTargetDto with CurrentState and DeployRequestDto with AdsPort. Create doc/ADS_INTEGRATION.md documenting the decision to use direct Beckhoff.TwinCAT.Ads instead of custom MQTT ADS relay topics. Co-Authored-By: Claude Opus 4.6 --- doc/ADS_INTEGRATION.md | 212 ++++++++++++++++++ .../Models/Ads/AdsConnectionInfo.cs | 19 ++ .../Models/Ads/AdsVariableSubscription.cs | 11 + .../Models/Ads/PlcAdsState.cs | 30 +++ .../Models/Ads/PlcStateDto.cs | 15 ++ .../Models/Deploy/DeployRequestDto.cs | 1 + .../Models/Target/PlcTargetDto.cs | 3 + 7 files changed, 291 insertions(+) create mode 100644 doc/ADS_INTEGRATION.md create mode 100644 src/shared/FlowForge.Shared/Models/Ads/AdsConnectionInfo.cs create mode 100644 src/shared/FlowForge.Shared/Models/Ads/AdsVariableSubscription.cs create mode 100644 src/shared/FlowForge.Shared/Models/Ads/PlcAdsState.cs create mode 100644 src/shared/FlowForge.Shared/Models/Ads/PlcStateDto.cs diff --git a/doc/ADS_INTEGRATION.md b/doc/ADS_INTEGRATION.md new file mode 100644 index 0000000..f0b6c9a --- /dev/null +++ b/doc/ADS_INTEGRATION.md @@ -0,0 +1,212 @@ +# ADS Integration Architecture + +## Decision + +**Use Beckhoff.TwinCAT.Ads for direct ADS communication** instead of a custom ADS-over-MQTT relay protocol. + +### Context + +The original architecture assumed a custom relay where the monitor server would exchange ADS read/write commands as MQTT messages (`flowforge/ads/read/*`, `flowforge/ads/write/*`, `flowforge/ads/notification/*`). Research revealed this is unnecessary: + +- **ADS-over-MQTT is a native TwinCAT router feature** — transparent to application code. Once configured on the PLC via `TcConfig.xml`, any `AdsClient` connects normally via `AmsNetId`. +- **`Beckhoff.TwinCAT.Ads.TcpRouter`** provides a software ADS router for non-TwinCAT systems (Linux Docker containers). +- MQTT remains for **FlowForge internal messaging** (build notifications, progress updates) but is no longer used for ADS relay. + +### Consequences + +| Component | Before | After | +|-----------|--------|-------| +| **Monitor Server** | MQTT relay topics for ADS reads | `Beckhoff.TwinCAT.Ads` + `TcpRouter` for direct ADS-over-TCP | +| **Build Server** | MQTT relay for deploy commands | `Beckhoff.TwinCAT.Ads` natively (Windows/TwinCAT router) | +| **Shared MQTT Topics** | `flowforge/ads/read/*`, `write/*`, `notification/*` | Removed — MQTT for build notifications only | + +--- + +## NuGet Packages + +| Package | Version | Used By | Purpose | +|---------|---------|---------|---------| +| `Beckhoff.TwinCAT.Ads` | 7.0.* | Monitor Server, Build Server | Core ADS client (`AdsClient`) | +| `Beckhoff.TwinCAT.Ads.TcpRouter` | 7.0.* | Monitor Server only | Software ADS router for Linux/Docker | + +Both packages target .NET 8.0, .NET 10.0, and .NET Standard 2.0. They work with .NET 9.0 via the .NET Standard 2.0 target. + +--- + +## Key API Patterns + +### Connection + +```csharp +// On Windows with TwinCAT installed (build server): +var client = new AdsClient(); +client.Connect(AmsNetId.Parse("192.168.1.100.1.1"), 851); + +// On Linux/Docker with TcpRouter (monitor server): +// TcpRouter must be started first, then AdsClient connects normally. +``` + +**Port 851** = PLC Runtime 1 (default). Ports 852, 853 for additional runtimes. + +### Variable Access + +**Symbol-based read** (dynamic, for discovery): +```csharp +var loader = SymbolLoaderFactory.Create(client, settings); +var value = loader.Symbols["MAIN.nCounter"].ReadValue(); +``` + +**Handle-based read** (faster for repeated access): +```csharp +uint handle = client.CreateVariableHandle("MAIN.nCounter"); +int value = (int)client.ReadAny(handle, typeof(int)); +client.DeleteVariableHandle(handle); +``` + +**Sum Commands** (batch — critical for performance): +- 4000 individual reads = 4–8 seconds +- 4000 reads via Sum Command = ~10 ms +- Max 500 sub-commands per call + +### Notifications (Monitor Server) + +```csharp +client.AddDeviceNotificationEx( + "MAIN.nCounter", + AdsTransMode.OnChange, + cycleTime: 100, // ms — check interval + maxDelay: 0, // ms — max delay before notification + userData: null, + type: typeof(int)); +``` + +- **Max 1024 notifications per connection** +- Notifications fire on background threads +- Always unregister when done (`DeleteDeviceNotification`) + +### PLC State Management (Build Server — Deploy) + +```csharp +// Read state +StateInfo state = client.ReadState(); +// state.AdsState == AdsState.Run / Stop / Config / etc. + +// Switch to config mode (required before activation) +client.WriteControl(new StateInfo(AdsState.Reconfig, 0)); + +// Restart to run mode +client.WriteControl(new StateInfo(AdsState.Run, 0)); +``` + +--- + +## PlcAdsState Enum + +Mirrored in `FlowForge.Shared.Models.Ads.PlcAdsState` (no Beckhoff dependency in Shared): + +| Value | Name | FlowForge Meaning | +|-------|------|--------------------| +| 5 | **Run** | PLC running — deploy needs approval if production target | +| 6 | **Stop** | PLC stopped — safe for deploy | +| 11 | **Error** | PLC error — needs investigation | +| 15 | **Config** | Config mode — safe for deploy | +| 16 | **Reconfig** | Transitioning to config mode | + +Deploy lock logic: `IsSafeForDeploy = State is Stop or Config`. + +--- + +## Component Architecture + +### Monitor Server (Linux/Docker) + +``` +┌─────────────────────────────────┐ +│ Monitor Container │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ IAdsClient │ │ ADS-over-TCP +│ │ (AdsClientWrapper) │───────────────────────► PLC +│ │ Uses: AdsClient + │ │ Port 48898 +│ │ TcpRouter │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ┌──────────▼───────────────┐ │ +│ │ SubscriptionManager │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ┌──────────▼───────────────┐ │ SignalR +│ │ PlcDataHub (SignalR) │◄─────────────────── Frontend +│ └──────────────────────────┘ │ +└─────────────────────────────────┘ +``` + +- Each container gets a unique local `AmsNetId` (derived from IP or session ID). +- `TcpRouter` establishes the ADS-over-TCP connection to the target PLC. +- `AdsClient` connects through the local `TcpRouter`. + +### Build Server (Windows/TwinCAT) + +``` +┌─────────────────────────────────┐ +│ Build Server (Windows) │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ IAdsDeployClient │ │ Native ADS +│ │ (AdsDeployClient) │───────────────────────► PLC +│ │ Uses: AdsClient │ │ (via TwinCAT router) +│ └──────────────────────────┘ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ IAutomationInterface │ │ COM Interop +│ │ (ActivateConfiguration) │───────────────────────► TwinCAT XAE +│ └──────────────────────────┘ │ +└─────────────────────────────────┘ +``` + +- No `TcpRouter` needed — uses the native TwinCAT router on Windows. +- Deploy sequence: connect → read state → switch to config → activate → restart → verify. + +--- + +## Deploy Sequence (Build Server) + +1. **Connect** to target PLC via ADS (`IAdsDeployClient.ConnectAsync`) +2. **Read PLC state** — deploy lock check (`ReadPlcStateAsync`) +3. If running + production → require 4-eyes approval (handled by backend before queuing) +4. **Switch to config mode** (`SwitchToConfigModeAsync` → `AdsState.Reconfig`) +5. **Activate configuration** via Automation Interface (`IAutomationInterface.ActivateConfiguration`) +6. **Start/restart TwinCAT** via ADS (`StartRestartTwinCatAsync` → `AdsState.Run`) +7. **Verify** PLC is in Run state +8. **Disconnect** + +--- + +## MQTT Topic Changes + +### Removed +- `flowforge/ads/read/{amsNetId}` — replaced by direct ADS reads +- `flowforge/ads/write/{amsNetId}` — replaced by direct ADS writes +- `flowforge/ads/notification/{amsNetId}` — replaced by ADS notifications + +### Retained +- `flowforge/build/notify/{twincat-version}` — backend → build servers (wake-up signal) +- `flowforge/build/progress/{build-id}` — build server → backend (progress updates) + +### Added +- `flowforge/deploy/status/{deploy-id}` — build server → backend (deploy progress) + +--- + +## References + +- [Beckhoff.TwinCAT.Ads NuGet](https://www.nuget.org/packages/Beckhoff.TwinCAT.Ads) +- [Beckhoff.TwinCAT.Ads.TcpRouter NuGet](https://www.nuget.org/packages/Beckhoff.TwinCAT.Ads.TcpRouter/) +- [ADS-over-MQTT Manual](https://download.beckhoff.com/download/document/automation/twincat3/ADS-over-MQTT_en.pdf) +- [Beckhoff/ADS-over-MQTT_Samples](https://github.com/Beckhoff/ADS-over-MQTT_Samples) +- [Beckhoff/TF6000_ADS_DOTNET_V5_Samples](https://github.com/Beckhoff/TF6000_ADS_DOTNET_V5_Samples) +- [ADS Notifications](https://infosys.beckhoff.com/content/1033/tc3_adsnetref/7312578699.html) +- [ADS Sum Commands](https://infosys.beckhoff.com/content/1033/tc3_adssamples_net/185258507.html) +- [AdsState Enum](https://infosys.beckhoff.com/content/1033/tc3_adsnetref/7313023115.html) +- [ITcSysManager.ActivateConfiguration](https://infosys.beckhoff.com/content/1033/tc3_automationinterface/242759819.html) +- [Secure ADS](https://download.beckhoff.com/download/document/automation/twincat3/Secure_ADS_EN.pdf) diff --git a/src/shared/FlowForge.Shared/Models/Ads/AdsConnectionInfo.cs b/src/shared/FlowForge.Shared/Models/Ads/AdsConnectionInfo.cs new file mode 100644 index 0000000..ed39d17 --- /dev/null +++ b/src/shared/FlowForge.Shared/Models/Ads/AdsConnectionInfo.cs @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace FlowForge.Shared.Models.Ads; + +public record AdsConnectionInfo +{ + public string AmsNetId { get; init; } = string.Empty; + public int AdsPort { get; init; } = 851; + + /// + /// Hostname or IP of the target PLC. Used by TcpRouter on non-TwinCAT + /// systems (e.g. Linux Docker containers) to establish ADS-over-TCP. + /// + public string? TargetHostname { get; init; } + + /// TCP port for the ADS router on the target (default 48898). + public int TcpPort { get; init; } = 48898; +} diff --git a/src/shared/FlowForge.Shared/Models/Ads/AdsVariableSubscription.cs b/src/shared/FlowForge.Shared/Models/Ads/AdsVariableSubscription.cs new file mode 100644 index 0000000..0c161a1 --- /dev/null +++ b/src/shared/FlowForge.Shared/Models/Ads/AdsVariableSubscription.cs @@ -0,0 +1,11 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace FlowForge.Shared.Models.Ads; + +public record AdsVariableSubscription +{ + public string VariablePath { get; init; } = string.Empty; + public int CycleTimeMs { get; init; } = 100; + public int MaxDelayMs { get; init; } +} diff --git a/src/shared/FlowForge.Shared/Models/Ads/PlcAdsState.cs b/src/shared/FlowForge.Shared/Models/Ads/PlcAdsState.cs new file mode 100644 index 0000000..a64b8ec --- /dev/null +++ b/src/shared/FlowForge.Shared/Models/Ads/PlcAdsState.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace FlowForge.Shared.Models.Ads; + +/// +/// Mirrors TwinCAT.Ads.AdsState for use in shared DTOs without requiring a +/// Beckhoff NuGet dependency in the Shared library. +/// See: https://infosys.beckhoff.com/content/1033/tc3_adsnetref/7313023115.html +/// +public enum PlcAdsState +{ + Invalid = 0, + Idle = 1, + Reset = 2, + Init = 3, + Start = 4, + Run = 5, + Stop = 6, + SaveConfig = 7, + LoadConfig = 8, + PowerFailure = 9, + PowerGood = 10, + Error = 11, + Shutdown = 12, + Suspend = 13, + Resume = 14, + Config = 15, + Reconfig = 16 +} diff --git a/src/shared/FlowForge.Shared/Models/Ads/PlcStateDto.cs b/src/shared/FlowForge.Shared/Models/Ads/PlcStateDto.cs new file mode 100644 index 0000000..d8bffa4 --- /dev/null +++ b/src/shared/FlowForge.Shared/Models/Ads/PlcStateDto.cs @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace FlowForge.Shared.Models.Ads; + +public record PlcStateDto +{ + public string AmsNetId { get; init; } = string.Empty; + public PlcAdsState State { get; init; } + public DateTimeOffset Timestamp { get; init; } + + public bool IsRunning => State == PlcAdsState.Run; + public bool IsInConfigMode => State is PlcAdsState.Config or PlcAdsState.Reconfig; + public bool IsSafeForDeploy => State is PlcAdsState.Stop or PlcAdsState.Config; +} diff --git a/src/shared/FlowForge.Shared/Models/Deploy/DeployRequestDto.cs b/src/shared/FlowForge.Shared/Models/Deploy/DeployRequestDto.cs index af1ca3d..28a4d44 100644 --- a/src/shared/FlowForge.Shared/Models/Deploy/DeployRequestDto.cs +++ b/src/shared/FlowForge.Shared/Models/Deploy/DeployRequestDto.cs @@ -8,4 +8,5 @@ public record DeployRequestDto public Guid ProjectId { get; init; } public string TargetAmsNetId { get; init; } = string.Empty; public string? ApproverId { get; init; } + public int AdsPort { get; init; } = 851; } diff --git a/src/shared/FlowForge.Shared/Models/Target/PlcTargetDto.cs b/src/shared/FlowForge.Shared/Models/Target/PlcTargetDto.cs index 8bb3d03..ced51d5 100644 --- a/src/shared/FlowForge.Shared/Models/Target/PlcTargetDto.cs +++ b/src/shared/FlowForge.Shared/Models/Target/PlcTargetDto.cs @@ -1,6 +1,8 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later +using FlowForge.Shared.Models.Ads; + namespace FlowForge.Shared.Models.Target; public record PlcTargetDto @@ -13,4 +15,5 @@ public record PlcTargetDto public Guid? GroupId { get; init; } public bool IsProductionTarget { get; init; } public bool DeployLocked { get; init; } + public PlcAdsState? CurrentState { get; init; } } From 2065cc8c88be299984a672d29d832f864a89cb44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bir=C3=B3=2C=20Csaba=20Attila?= Date: Sat, 14 Feb 2026 14:37:34 +0100 Subject: [PATCH 2/2] feat(monitor-server): add direct Beckhoff ADS client Replace IMqttAdsClient/MqttAdsClient with IAdsClient/AdsClientWrapper using Beckhoff.TwinCAT.Ads + TcpRouter for direct ADS-over-TCP from Linux Docker containers. Add batch read (Sum Commands), PLC state read, and ADS notification subscription to the interface. Update PlcDataHub to use IAdsClient with SubscriptionManager. Add ADS connection config (TargetHostname, AdsPort, AdsTcpPort) to MonitorOptions. Co-Authored-By: Claude Opus 4.6 --- .../Configuration/MonitorOptions.cs | 13 +++- .../FlowForge.MonitorServer.csproj | 2 + .../Hubs/PlcDataHub.cs | 39 ++++++++-- .../src/FlowForge.MonitorServer/Program.cs | 10 ++- .../Services/AdsClientWrapper.cs | 73 +++++++++++++++++++ .../Services/IAdsClient.cs | 23 ++++++ .../Services/IMqttAdsClient.cs | 15 ---- .../Services/MqttAdsClient.cs | 39 ---------- .../AdsClientWrapperTests.cs | 19 +++++ 9 files changed, 171 insertions(+), 62 deletions(-) create mode 100644 src/monitor-server/src/FlowForge.MonitorServer/Services/AdsClientWrapper.cs create mode 100644 src/monitor-server/src/FlowForge.MonitorServer/Services/IAdsClient.cs delete mode 100644 src/monitor-server/src/FlowForge.MonitorServer/Services/IMqttAdsClient.cs delete mode 100644 src/monitor-server/src/FlowForge.MonitorServer/Services/MqttAdsClient.cs create mode 100644 test/FlowForge.MonitorServer.Tests/AdsClientWrapperTests.cs diff --git a/src/monitor-server/src/FlowForge.MonitorServer/Configuration/MonitorOptions.cs b/src/monitor-server/src/FlowForge.MonitorServer/Configuration/MonitorOptions.cs index d725e68..b949c34 100644 --- a/src/monitor-server/src/FlowForge.MonitorServer/Configuration/MonitorOptions.cs +++ b/src/monitor-server/src/FlowForge.MonitorServer/Configuration/MonitorOptions.cs @@ -17,7 +17,7 @@ public class MonitorOptions /// Short-lived auth token for frontend SignalR connections. public string AuthToken { get; set; } = string.Empty; - /// MQTT broker host for ADS over MQTT. + /// MQTT broker host for FlowForge internal messaging. public string MqttHost { get; set; } = "mqtt-broker"; /// MQTT broker port. @@ -25,4 +25,13 @@ public class MonitorOptions /// Target PLC AMS Net ID to monitor. public string TargetAmsNetId { get; set; } = string.Empty; -} \ No newline at end of file + + /// Hostname or IP of the target PLC for TcpRouter connection. + public string? TargetHostname { get; set; } + + /// ADS port on the target PLC (default 851 = PLC Runtime 1). + public int AdsPort { get; set; } = 851; + + /// TCP port for the ADS router on the target (default 48898). + public int AdsTcpPort { get; set; } = 48898; +} diff --git a/src/monitor-server/src/FlowForge.MonitorServer/FlowForge.MonitorServer.csproj b/src/monitor-server/src/FlowForge.MonitorServer/FlowForge.MonitorServer.csproj index 2470c34..d172a82 100644 --- a/src/monitor-server/src/FlowForge.MonitorServer/FlowForge.MonitorServer.csproj +++ b/src/monitor-server/src/FlowForge.MonitorServer/FlowForge.MonitorServer.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/monitor-server/src/FlowForge.MonitorServer/Hubs/PlcDataHub.cs b/src/monitor-server/src/FlowForge.MonitorServer/Hubs/PlcDataHub.cs index 9e20db6..3da769c 100644 --- a/src/monitor-server/src/FlowForge.MonitorServer/Hubs/PlcDataHub.cs +++ b/src/monitor-server/src/FlowForge.MonitorServer/Hubs/PlcDataHub.cs @@ -1,6 +1,8 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later +using FlowForge.MonitorServer.Services; +using FlowForge.Shared.Models.Ads; using Microsoft.AspNetCore.SignalR; namespace FlowForge.MonitorServer.Hubs; @@ -11,10 +13,14 @@ namespace FlowForge.MonitorServer.Hubs; /// public class PlcDataHub : Hub { + private readonly IAdsClient _adsClient; + private readonly SubscriptionManager _subscriptionManager; private readonly ILogger _logger; - public PlcDataHub(ILogger logger) + public PlcDataHub(IAdsClient adsClient, SubscriptionManager subscriptionManager, ILogger logger) { + _adsClient = adsClient; + _subscriptionManager = subscriptionManager; _logger = logger; } @@ -27,18 +33,41 @@ public override Task OnConnectedAsync() public override Task OnDisconnectedAsync(Exception? exception) { + _subscriptionManager.RemoveAllSubscriptions(Context.ConnectionId); _logger.LogInformation("Client disconnected: {ConnectionId}", Context.ConnectionId); return base.OnDisconnectedAsync(exception); } /// - /// Client subscribes to specific PLC variables. + /// Client subscribes to specific PLC variables via direct ADS notifications. /// public async Task Subscribe(string[] variablePaths) { - // TODO: Register ADS subscriptions via MQTT for requested variables + foreach (var path in variablePaths) + { + _subscriptionManager.AddSubscription(Context.ConnectionId, path); + + var subscription = new AdsVariableSubscription { VariablePath = path }; + // TODO: Wire callback to push values to this client via IPlcDataHubClient.ReceiveVariableValues + await _adsClient.SubscribeAsync(subscription, _ => { }); + } + _logger.LogInformation("Client {ConnectionId} subscribed to {Count} variables", Context.ConnectionId, variablePaths.Length); - await Task.CompletedTask; } -} \ No newline at end of file + + /// + /// Client unsubscribes from specific PLC variables. + /// + public async Task Unsubscribe(string[] variablePaths) + { + foreach (var path in variablePaths) + { + _subscriptionManager.RemoveSubscription(Context.ConnectionId, path); + await _adsClient.UnsubscribeAsync(path); + } + + _logger.LogInformation("Client {ConnectionId} unsubscribed from {Count} variables", + Context.ConnectionId, variablePaths.Length); + } +} diff --git a/src/monitor-server/src/FlowForge.MonitorServer/Program.cs b/src/monitor-server/src/FlowForge.MonitorServer/Program.cs index e009025..2c48e10 100644 --- a/src/monitor-server/src/FlowForge.MonitorServer/Program.cs +++ b/src/monitor-server/src/FlowForge.MonitorServer/Program.cs @@ -3,6 +3,7 @@ using FlowForge.MonitorServer.Configuration; using FlowForge.MonitorServer.Hubs; +using FlowForge.MonitorServer.Services; var builder = WebApplication.CreateBuilder(args); @@ -12,6 +13,13 @@ builder.Services.Configure( builder.Configuration.GetSection(MonitorOptions.Section)); +// --------------------------------------------------------------------------- +// ADS client (direct ADS-over-TCP via TcpRouter — see doc/ADS_INTEGRATION.md) +// --------------------------------------------------------------------------- +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +// TODO: Add IHostedService to establish ADS connection on startup using MonitorOptions + // --------------------------------------------------------------------------- // SignalR // --------------------------------------------------------------------------- @@ -29,4 +37,4 @@ // --------------------------------------------------------------------------- app.MapHub("/plc"); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/src/monitor-server/src/FlowForge.MonitorServer/Services/AdsClientWrapper.cs b/src/monitor-server/src/FlowForge.MonitorServer/Services/AdsClientWrapper.cs new file mode 100644 index 0000000..559d0e8 --- /dev/null +++ b/src/monitor-server/src/FlowForge.MonitorServer/Services/AdsClientWrapper.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +using FlowForge.Shared.Models.Ads; +using FlowForge.Shared.Models.Monitor; + +namespace FlowForge.MonitorServer.Services; + +/// +/// Wraps Beckhoff.TwinCAT.Ads.AdsClient with TcpRouter for use on Linux/Docker. +/// Each monitor container maintains a single long-lived ADS connection. +/// +public class AdsClientWrapper : IAdsClient +{ + private readonly ILogger _logger; + + public AdsClientWrapper(ILogger logger) + { + _logger = logger; + } + + public Task ConnectAsync(AdsConnectionInfo connectionInfo, CancellationToken ct) + { + // TODO: Start TcpRouter with unique local AmsNetId + // TODO: Connect AdsClient to target via AmsNetId.Parse(connectionInfo.AmsNetId), connectionInfo.AdsPort + throw new NotImplementedException(); + } + + public Task ReadVariableAsync(string variablePath, CancellationToken ct) + { + // TODO: CreateVariableHandle → ReadAny → DeleteVariableHandle + throw new NotImplementedException(); + } + + public Task> ReadVariablesBatchAsync( + IReadOnlyList variablePaths, CancellationToken ct) + { + // TODO: Use ADS Sum Commands for batch reads (max 500 per call) + throw new NotImplementedException(); + } + + public Task SubscribeAsync(AdsVariableSubscription subscription, Action callback, CancellationToken ct) + { + // TODO: AddDeviceNotificationEx with AdsTransMode.OnChange + // Max 1024 notifications per connection + throw new NotImplementedException(); + } + + public Task UnsubscribeAsync(string variablePath, CancellationToken ct) + { + // TODO: DeleteDeviceNotification for the given variable + throw new NotImplementedException(); + } + + public Task ReadPlcStateAsync(CancellationToken ct) + { + // TODO: client.ReadState() → map AdsState to PlcAdsState + throw new NotImplementedException(); + } + + public Task DisconnectAsync(CancellationToken ct) + { + // TODO: Disconnect AdsClient, dispose TcpRouter + throw new NotImplementedException(); + } + + public ValueTask DisposeAsync() + { + // TODO: Clean up AdsClient and TcpRouter resources + _logger.LogInformation("Disposing ADS client wrapper"); + return ValueTask.CompletedTask; + } +} diff --git a/src/monitor-server/src/FlowForge.MonitorServer/Services/IAdsClient.cs b/src/monitor-server/src/FlowForge.MonitorServer/Services/IAdsClient.cs new file mode 100644 index 0000000..7f5ba53 --- /dev/null +++ b/src/monitor-server/src/FlowForge.MonitorServer/Services/IAdsClient.cs @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +using FlowForge.Shared.Models.Ads; +using FlowForge.Shared.Models.Monitor; + +namespace FlowForge.MonitorServer.Services; + +/// +/// ADS client for reading PLC variables and subscribing to value changes. +/// Wraps Beckhoff.TwinCAT.Ads.AdsClient with TcpRouter for Linux/Docker. +/// +public interface IAdsClient : IAsyncDisposable +{ + Task ConnectAsync(AdsConnectionInfo connectionInfo, CancellationToken ct = default); + Task ReadVariableAsync(string variablePath, CancellationToken ct = default); + Task> ReadVariablesBatchAsync( + IReadOnlyList variablePaths, CancellationToken ct = default); + Task SubscribeAsync(AdsVariableSubscription subscription, Action callback, CancellationToken ct = default); + Task UnsubscribeAsync(string variablePath, CancellationToken ct = default); + Task ReadPlcStateAsync(CancellationToken ct = default); + Task DisconnectAsync(CancellationToken ct = default); +} diff --git a/src/monitor-server/src/FlowForge.MonitorServer/Services/IMqttAdsClient.cs b/src/monitor-server/src/FlowForge.MonitorServer/Services/IMqttAdsClient.cs deleted file mode 100644 index fba2c9d..0000000 --- a/src/monitor-server/src/FlowForge.MonitorServer/Services/IMqttAdsClient.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) -// SPDX-License-Identifier: AGPL-3.0-or-later - -using FlowForge.Shared.Models.Monitor; - -namespace FlowForge.MonitorServer.Services; - -public interface IMqttAdsClient -{ - Task ConnectAsync(CancellationToken ct = default); - Task ReadVariableAsync(string variablePath, CancellationToken ct = default); - Task SubscribeAsync(string variablePath, Action callback, CancellationToken ct = default); - Task UnsubscribeAsync(string variablePath, CancellationToken ct = default); - Task DisconnectAsync(CancellationToken ct = default); -} diff --git a/src/monitor-server/src/FlowForge.MonitorServer/Services/MqttAdsClient.cs b/src/monitor-server/src/FlowForge.MonitorServer/Services/MqttAdsClient.cs deleted file mode 100644 index 945c54d..0000000 --- a/src/monitor-server/src/FlowForge.MonitorServer/Services/MqttAdsClient.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) -// SPDX-License-Identifier: AGPL-3.0-or-later - -using FlowForge.Shared.Models.Monitor; - -namespace FlowForge.MonitorServer.Services; - -public class MqttAdsClient : IMqttAdsClient -{ - public Task ConnectAsync(CancellationToken ct) - { - // TODO: Connect to MQTT broker using MQTTnet - throw new NotImplementedException(); - } - - public Task ReadVariableAsync(string variablePath, CancellationToken ct) - { - // TODO: Publish ADS read request via MQTT, await response - throw new NotImplementedException(); - } - - public Task SubscribeAsync(string variablePath, Action callback, CancellationToken ct) - { - // TODO: Register ADS subscription via MQTT - throw new NotImplementedException(); - } - - public Task UnsubscribeAsync(string variablePath, CancellationToken ct) - { - // TODO: Unregister ADS subscription - throw new NotImplementedException(); - } - - public Task DisconnectAsync(CancellationToken ct) - { - // TODO: Disconnect from MQTT broker - throw new NotImplementedException(); - } -} diff --git a/test/FlowForge.MonitorServer.Tests/AdsClientWrapperTests.cs b/test/FlowForge.MonitorServer.Tests/AdsClientWrapperTests.cs new file mode 100644 index 0000000..0196ff3 --- /dev/null +++ b/test/FlowForge.MonitorServer.Tests/AdsClientWrapperTests.cs @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +using FlowForge.MonitorServer.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace FlowForge.MonitorServer.Tests; + +public class AdsClientWrapperTests +{ + [Fact] + public async Task DisposeAsync_ShouldNotThrow() + { + var wrapper = new AdsClientWrapper(NullLogger.Instance); + + await wrapper.DisposeAsync(); + } +}