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