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/build-server/src/FlowForge.BuildServer/FlowForge.BuildServer.csproj b/src/build-server/src/FlowForge.BuildServer/FlowForge.BuildServer.csproj index 79c4d4c..a551558 100644 --- a/src/build-server/src/FlowForge.BuildServer/FlowForge.BuildServer.csproj +++ b/src/build-server/src/FlowForge.BuildServer/FlowForge.BuildServer.csproj @@ -11,6 +11,7 @@ + diff --git a/src/build-server/src/FlowForge.BuildServer/Pipeline/BuildContext.cs b/src/build-server/src/FlowForge.BuildServer/Pipeline/BuildContext.cs index bfc5503..0a825d5 100644 --- a/src/build-server/src/FlowForge.BuildServer/Pipeline/BuildContext.cs +++ b/src/build-server/src/FlowForge.BuildServer/Pipeline/BuildContext.cs @@ -1,6 +1,7 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later +using FlowForge.Shared.Models.Ads; using FlowForge.Shared.Models.Build; using FlowForge.Shared.Models.Flow; @@ -11,6 +12,7 @@ public class BuildContext public required BuildJobDto Job { get; init; } public string WorkspacePath { get; set; } = string.Empty; public FlowDocument? FlowDocument { get; set; } + public AdsConnectionInfo? TargetConnectionInfo { get; set; } public List GeneratedFiles { get; } = []; public List Errors { get; } = []; public Dictionary Timings { get; } = []; diff --git a/src/build-server/src/FlowForge.BuildServer/Pipeline/Steps/DeployStep.cs b/src/build-server/src/FlowForge.BuildServer/Pipeline/Steps/DeployStep.cs index bde77d3..fcfa13e 100644 --- a/src/build-server/src/FlowForge.BuildServer/Pipeline/Steps/DeployStep.cs +++ b/src/build-server/src/FlowForge.BuildServer/Pipeline/Steps/DeployStep.cs @@ -1,15 +1,41 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later +using FlowForge.BuildServer.TwinCat; + namespace FlowForge.BuildServer.Pipeline.Steps; public class DeployStep : IBuildStep { + private readonly IAdsDeployClient _adsClient; + private readonly IAutomationInterface _automationInterface; + private readonly ILogger _logger; + + public DeployStep(IAdsDeployClient adsClient, IAutomationInterface automationInterface, ILogger logger) + { + _adsClient = adsClient; + _automationInterface = automationInterface; + _logger = logger; + } + public string Name => "Deploy"; public Task ExecuteAsync(BuildContext context, CancellationToken ct) { - // TODO: ActivateConfiguration() + StartRestartTwinCAT() via ITcSysManager (conditional — deploy jobs only) + if (!context.Job.IncludeDeploy || context.TargetConnectionInfo is null) + { + _logger.LogInformation("Deploy skipped — build-only job"); + return Task.CompletedTask; + } + + // TODO: Implement deploy sequence (see doc/ADS_INTEGRATION.md): + // 1. Connect to target PLC via ADS (_adsClient.ConnectAsync) + // 2. Read PLC state — deploy lock check (_adsClient.ReadPlcStateAsync) + // 3. Switch to config mode (_adsClient.SwitchToConfigModeAsync) + // 4. Activate configuration via Automation Interface (_automationInterface.ActivateConfiguration) + // 5. Start/restart TwinCAT via ADS (_adsClient.StartRestartTwinCatAsync) + // 6. Verify PLC is in Run state (_adsClient.ReadPlcStateAsync) + // 7. Disconnect (_adsClient.DisconnectAsync) throw new NotImplementedException(); } } diff --git a/src/build-server/src/FlowForge.BuildServer/Program.cs b/src/build-server/src/FlowForge.BuildServer/Program.cs index 0777494..da5c103 100644 --- a/src/build-server/src/FlowForge.BuildServer/Program.cs +++ b/src/build-server/src/FlowForge.BuildServer/Program.cs @@ -4,6 +4,7 @@ using FlowForge.BuildServer; using FlowForge.BuildServer.Configuration; using FlowForge.BuildServer.Services; +using FlowForge.BuildServer.TwinCat; var builder = WebApplication.CreateBuilder(args); @@ -22,6 +23,11 @@ client.BaseAddress = new Uri(backendUrl); }); +// --------------------------------------------------------------------------- +// ADS deploy client (direct ADS via native TwinCAT router — see doc/ADS_INTEGRATION.md) +// --------------------------------------------------------------------------- +builder.Services.AddTransient(); + // --------------------------------------------------------------------------- // Background worker (build job polling) // --------------------------------------------------------------------------- diff --git a/src/build-server/src/FlowForge.BuildServer/Services/MqttHandler.cs b/src/build-server/src/FlowForge.BuildServer/Services/MqttHandler.cs index 9ad2b7c..c507265 100644 --- a/src/build-server/src/FlowForge.BuildServer/Services/MqttHandler.cs +++ b/src/build-server/src/FlowForge.BuildServer/Services/MqttHandler.cs @@ -3,8 +3,11 @@ namespace FlowForge.BuildServer.Services; +/// +/// MQTT handler for FlowForge internal messaging only. +/// ADS communication uses Beckhoff.TwinCAT.Ads directly — see doc/ADS_INTEGRATION.md. +/// public class MqttHandler { - // TODO: MQTT subscribe for build notifications, publish progress updates, - // relay ADS commands for deployment + // TODO: MQTT subscribe for build notifications, publish progress/deploy status updates } diff --git a/src/build-server/src/FlowForge.BuildServer/TwinCat/AdsDeployClient.cs b/src/build-server/src/FlowForge.BuildServer/TwinCat/AdsDeployClient.cs new file mode 100644 index 0000000..16a4039 --- /dev/null +++ b/src/build-server/src/FlowForge.BuildServer/TwinCat/AdsDeployClient.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +using FlowForge.Shared.Models.Ads; + +namespace FlowForge.BuildServer.TwinCat; + +/// +/// Wraps Beckhoff.TwinCAT.Ads.AdsClient for deploy operations. +/// Uses the native TwinCAT router (no TcpRouter needed on Windows). +/// +public class AdsDeployClient : IAdsDeployClient +{ + private readonly ILogger _logger; + + public AdsDeployClient(ILogger logger) + { + _logger = logger; + } + + public Task ConnectAsync(AdsConnectionInfo connectionInfo, CancellationToken ct) + { + // TODO: Connect via AmsNetId.Parse(connectionInfo.AmsNetId), connectionInfo.AdsPort + throw new NotImplementedException(); + } + + public Task ReadPlcStateAsync(CancellationToken ct) + { + // TODO: client.ReadState() → map AdsState to PlcAdsState + throw new NotImplementedException(); + } + + public Task SwitchToConfigModeAsync(CancellationToken ct) + { + // TODO: client.WriteControl(new StateInfo(AdsState.Reconfig, 0)) + throw new NotImplementedException(); + } + + public Task StartRestartTwinCatAsync(CancellationToken ct) + { + // TODO: client.WriteControl(new StateInfo(AdsState.Run, 0)) + throw new NotImplementedException(); + } + + public Task ReadTwinCatVersionAsync(CancellationToken ct) + { + // TODO: Read TwinCAT version from target via ADS device info + throw new NotImplementedException(); + } + + public Task DisconnectAsync(CancellationToken ct) + { + // TODO: Disconnect AdsClient + throw new NotImplementedException(); + } + + public ValueTask DisposeAsync() + { + _logger.LogInformation("Disposing ADS deploy client"); + return ValueTask.CompletedTask; + } +} diff --git a/src/build-server/src/FlowForge.BuildServer/TwinCat/IAdsDeployClient.cs b/src/build-server/src/FlowForge.BuildServer/TwinCat/IAdsDeployClient.cs new file mode 100644 index 0000000..edbc502 --- /dev/null +++ b/src/build-server/src/FlowForge.BuildServer/TwinCat/IAdsDeployClient.cs @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +using FlowForge.Shared.Models.Ads; + +namespace FlowForge.BuildServer.TwinCat; + +/// +/// ADS client for deploy-time operations on target PLCs. +/// Uses the native TwinCAT router on the Windows build server. +/// +public interface IAdsDeployClient : IAsyncDisposable +{ + Task ConnectAsync(AdsConnectionInfo connectionInfo, CancellationToken ct = default); + Task ReadPlcStateAsync(CancellationToken ct = default); + Task SwitchToConfigModeAsync(CancellationToken ct = default); + Task StartRestartTwinCatAsync(CancellationToken ct = default); + Task ReadTwinCatVersionAsync(CancellationToken ct = default); + Task DisconnectAsync(CancellationToken ct = default); +} 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; } }