Skip to content

Commit 22afbd5

Browse files
committed
feat: Introduce task-specific logging for PLC protocols and integrate log viewing into the task manager UI.
1 parent eccfd2a commit 22afbd5

File tree

14 files changed

+316
-24
lines changed

14 files changed

+316
-24
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
id: task-execution-workflow
3+
title: Task Execution Workflow
4+
sidebar_position: 8
5+
---
6+
7+
# Application Task Execution Workflow
8+
9+
This document details the complete end-to-end task execution workflow for the PLC Memory Dump process, focusing on task cancellation propagation and connection lifecycle management.
10+
11+
## Components Involved
12+
- **Job Wizard (UI)**: Initiates and manages the UX of the workflow.
13+
- **BootloaderService**: Handles connection proxying and starts the orchestration.
14+
- **SocatProcessManager**: Manages the underlying `socat` proxy processes mapping TCP/IP to serial over SSH/Telnet.
15+
- **MemoryDumpOrchestrator**: Orchestrates the multi-segment memory dump process, tracking memory blocks and dispatching them to the UI thread.
16+
- **DumperService**: Handles the low-level data packet framing and parsing from the PLC.
17+
18+
## Workflow Execution Sequence
19+
20+
1. **Initialization (`StartDumpSession`)**
21+
The JobWizard triggers `BootloaderService.StartDumpSessionAsync()`. This call invokes the `SocatProcessManager` to spawn the necessary `socat` proxies to bridge the connection to the PLC.
22+
2. **Session Pipeline Creation (`StartSessionAsync`)**
23+
The `BootloaderService` passes the `CancellationToken` to `MemoryDumpOrchestrator.StartSessionAsync()`. It creates a linked `CancellationTokenSource` and spawns the `DumperService` loop (`_dumpTask`) and the Consumer loop (`_consumptionTask`).
24+
3. **Data Acquisition Loop (`InvokeDumpCommandAsync`)**
25+
For each memory segment:
26+
- Leftover data is flushed and parser is reset to prevent state corruption.
27+
- The orchestrator frames and sends the dump hook payload.
28+
- It awaits the segment completion via `WaitForSegmentAsync`.
29+
- The `DS` parses bytes, dispatching `MemoryBlock` elements to the orchestrator via a `Channel<MemoryBlock>`.
30+
4. **Graceful Teardown and Cancellation (`StopAsync`)**
31+
When the task completes or is canceled:
32+
- `BootloaderService` triggers `MemoryDumpOrchestrator.StopAsync()`.
33+
- The orchestrator commands `DumperService.StopAsync()` which writes the cancellation byte `0x03` to the stream to pause PLC transmission.
34+
- Pending reads are aborted.
35+
- `SocatProcessManager.StopAll()` executes complete process tree termination to avoid orphan `socat` processes.
36+
37+
## Sequence Diagram
38+
39+
```mermaid
40+
sequenceDiagram
41+
participant UI as Job Wizard (UI)
42+
participant BO as BootloaderService
43+
participant SP as SocatProcessManager
44+
participant MO as MemoryDumpOrchestrator
45+
participant DS as DumperService
46+
participant PLC as PLC Device
47+
48+
UI->>BO: StartDumpSession(token)
49+
activate BO
50+
BO->>SP: StartSocatProxy()
51+
activate SP
52+
SP-->>BO: Proxy Ready
53+
BO->>MO: StartSessionAsync(token)
54+
activate MO
55+
MO->>DS: StartDumpingAsync(host, port, token)
56+
activate DS
57+
DS->>PLC: Connect Socket
58+
59+
loop Per Memory Segment
60+
MO->>MO: FlushRemainingDataAsync()
61+
MO->>DS: ResetForNewSegment()
62+
MO->>PLC: InvokeDumpCommandAsync(args)
63+
PLC-->>DS: Data Stream (Segments)
64+
DS-->>MO: ChannelReader<MemoryBlock>
65+
MO-->>UI: DispatchBatchToUIAsync()
66+
end
67+
68+
UI->>BO: Cancel or Finish
69+
BO->>MO: StopAsync()
70+
MO->>DS: StopAsync() (Send 0x03 byte)
71+
DS->>PLC: 0x03 Cancel Byte
72+
deactivate DS
73+
deactivate MO
74+
BO->>SP: StopAll() (Kill Process Tree)
75+
deactivate SP
76+
deactivate BO
77+
```

src/S7Tools.Core/Interfaces/Services/IPlcClient.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ Task InvokeDumperStreamAsync(
8080
/// <param name="port">The port number.</param>
8181
void Configure(string host, int port);
8282

83+
/// <summary>
84+
/// Sets an optional session-specific logger (e.g., for task-specific protocol logging).
85+
/// </summary>
86+
/// <param name="logger">The logger to use for this session.</param>
87+
void SetLogger(Microsoft.Extensions.Logging.ILogger? logger);
88+
8389
/// <summary>
8490
/// Establishes the connection to the PLC (via Socat).
8591
/// </summary>

src/S7Tools.Core/Interfaces/Services/IPlcProtocol.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ public interface IPlcProtocol
5454
/// <param name="port">The port number.</param>
5555
void Configure(string host, int port);
5656

57+
/// <summary>
58+
/// Sets an optional session-specific logger (e.g., for task-specific protocol logging).
59+
/// </summary>
60+
/// <param name="logger">The logger to use for this session.</param>
61+
void SetLogger(Microsoft.Extensions.Logging.ILogger? logger);
62+
5763
/// <summary>
5864
/// Establishes the connection to the PLC.
5965
/// </summary>

src/S7Tools/Services/Adapters/PlcClientAdapter.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ public void Configure(string host, int port)
6262
_protocol.Configure(host, port);
6363
}
6464

65+
/// <summary>
66+
/// Sets an optional session-specific logger (e.g., for task-specific protocol logging).
67+
/// </summary>
68+
public void SetLogger(ILogger? logger)
69+
{
70+
_protocol.SetLogger(logger);
71+
}
72+
6573
/// <summary>
6674
/// Executes the DisposeAsync operation.
6775
/// </summary>

src/S7Tools/Services/Adapters/PlcProtocolAdapter.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public sealed class PlcProtocolAdapter : IPlcProtocol
2020
{
2121
private readonly ILogger<PlcProtocolAdapter> _logger;
2222
private readonly IPlcTransport _transport;
23+
private ILogger _effectiveLogger;
2324

2425
/// <summary>
2526
/// Initializes a new instance of the <see cref="PlcProtocolAdapter"/> class.
@@ -28,6 +29,18 @@ public PlcProtocolAdapter(IPlcTransport transport, ILogger<PlcProtocolAdapter> l
2829
{
2930
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
3031
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
32+
_effectiveLogger = _logger;
33+
}
34+
35+
/// <summary>
36+
/// Sets an optional session-specific logger (e.g., for task-specific protocol logging).
37+
/// </summary>
38+
public void SetLogger(ILogger? logger)
39+
{
40+
if (logger != null)
41+
{
42+
_effectiveLogger = logger;
43+
}
3144
}
3245

3346
/// <summary>
@@ -125,9 +138,9 @@ public async Task SendPacketAsync(byte[] payload, int? maxChunk = 2, Cancellatio
125138
await Task.Delay(10, cancellationToken).ConfigureAwait(false);
126139

127140
var packet = EncodePacket(payload);
128-
if (_logger.IsEnabled(LogLevel.Trace))
141+
if (_effectiveLogger.IsEnabled(LogLevel.Trace))
129142
{
130-
_logger.LogTrace("-> SEND: {Hex}", BitConverter.ToString(packet).Replace("-", ""));
143+
_effectiveLogger.LogTrace("-> SEND: {Hex}", BitConverter.ToString(packet).Replace("-", ""));
131144
}
132145

133146
int step = maxChunk ?? 2;
@@ -182,9 +195,9 @@ public async Task<byte[]> ReceivePacketAsync(CancellationToken cancellationToken
182195
bytesRead += currentBytesRead;
183196
}
184197

185-
if (_logger.IsEnabled(LogLevel.Trace))
198+
if (_effectiveLogger.IsEnabled(LogLevel.Trace))
186199
{
187-
_logger.LogTrace("<- RECV: {Hex}", BitConverter.ToString(fullPacket).Replace("-", ""));
200+
_effectiveLogger.LogTrace("<- RECV: {Hex}", BitConverter.ToString(fullPacket).Replace("-", ""));
188201
}
189202
return DecodePacket(fullPacket);
190203
}

src/S7Tools/Services/Bootloader/BootloaderService.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,7 @@ private async Task<BootloaderResult> PerformDumpProcessStreamingAsync(
871871
JobProfileSet profiles,
872872
IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress,
873873
ILogger logger,
874+
ILogger? processLogger,
874875
double startPercent,
875876
double weight,
876877
Guid? taskId,
@@ -918,7 +919,7 @@ private async Task<BootloaderResult> PerformDumpProcessStreamingAsync(
918919

919920
// Start the dumper session ONCE for all iterations
920921
logger.LogInformation("Starting persistent dumper session for all iterations.");
921-
await client.StartDumperSessionAsync(cancellationToken, logger).ConfigureAwait(false);
922+
await client.StartDumperSessionAsync(cancellationToken, processLogger).ConfigureAwait(false);
922923

923924
for (int iter = 0; iter < iterationCount; iter++)
924925
{
@@ -941,6 +942,7 @@ private async Task<BootloaderResult> PerformDumpProcessStreamingAsync(
941942
client,
942943
progress,
943944
logger,
945+
processLogger,
944946
startPercent,
945947
weight,
946948
iterationCount,
@@ -990,12 +992,14 @@ private record StreamingContext(
990992
IPlcClient Client,
991993
IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> Progress,
992994
ILogger Logger,
995+
ILogger? ProcessLogger,
993996
double StartPercent,
994997
double Weight,
995998
int IterationCount,
996999
int CurrentIteration,
9971000
long TotalExpectedBytes,
998-
CancellationToken CancellationToken);
1001+
CancellationToken CancellationToken
1002+
);
9991003

10001004
private static uint ParseSegmentAddress(MemorySegment segment)
10011005
{
@@ -1108,7 +1112,7 @@ await ctx.Client.InvokeDumperStreamAsync(
11081112
async data => await fileStream.WriteAsync(data, ctx.CancellationToken),
11091113
segProgress,
11101114
ctx.CancellationToken,
1111-
logger: ctx.Logger).ConfigureAwait(false);
1115+
logger: ctx.ProcessLogger).ConfigureAwait(false);
11121116

11131117
// Use the local variable that was updated by the progress callback
11141118
// or fall back to segLength if the callback didn't fire for some reason
@@ -1181,7 +1185,7 @@ await ctx.Client.InvokeDumperStreamAsync(
11811185
async data => await fileStream.WriteAsync(data, ctx.CancellationToken),
11821186
regionProgress,
11831187
ctx.CancellationToken,
1184-
logger: ctx.Logger).ConfigureAwait(false);
1188+
logger: ctx.ProcessLogger).ConfigureAwait(false);
11851189

11861190
await fileStream.FlushAsync(ctx.CancellationToken).ConfigureAwait(false);
11871191

@@ -1360,6 +1364,9 @@ await WaitWithProgressAsync(
13601364
progress.Report(("plc_connect", 12.0, null, null));
13611365
await using IPlcClient client = clientFactory(profiles);
13621366

1367+
// Inject processlogger for protocol-level tracing during handshake and bootloader setup
1368+
client.SetLogger(processLogger);
1369+
13631370
effectiveTaskLogger.LogDebug("Connecting PLC client to localhost:{Port}", profiles.Socat.Port);
13641371
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
13651372

@@ -1455,7 +1462,7 @@ await WaitWithProgressAsync(
14551462

14561463
var dumpResult = await PerformDumpProcessStreamingAsync(
14571464
client, profiles,
1458-
progress, effectiveTaskLogger,
1465+
progress, effectiveTaskLogger, processLogger,
14591466
startPercent: 20.0, weight: 75.0,
14601467
taskId,
14611468
cancellationToken).ConfigureAwait(false);

src/S7Tools/Services/Plc/Adapters/PlcProtocolAdapter.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public sealed class PlcProtocolAdapter : IPlcProtocol
2020
{
2121
private readonly ILogger<PlcProtocolAdapter> _logger;
2222
private readonly IPlcTransport _transport;
23+
private ILogger _effectiveLogger;
2324

2425
/// <summary>
2526
/// Initializes a new instance of the <see cref="PlcProtocolAdapter"/> class.
@@ -28,6 +29,18 @@ public PlcProtocolAdapter(IPlcTransport transport, ILogger<PlcProtocolAdapter> l
2829
{
2930
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
3031
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
32+
_effectiveLogger = _logger;
33+
}
34+
35+
/// <summary>
36+
/// Sets an optional session-specific logger (e.g., for task-specific protocol logging).
37+
/// </summary>
38+
public void SetLogger(ILogger? logger)
39+
{
40+
if (logger != null)
41+
{
42+
_effectiveLogger = logger;
43+
}
3144
}
3245

3346
/// <summary>
@@ -125,9 +138,9 @@ public async Task SendPacketAsync(byte[] payload, int? maxChunk = 2, Cancellatio
125138
await Task.Delay(10, cancellationToken).ConfigureAwait(false);
126139

127140
var packet = EncodePacket(payload);
128-
if (_logger.IsEnabled(LogLevel.Trace))
141+
if (_effectiveLogger.IsEnabled(LogLevel.Trace))
129142
{
130-
_logger.LogTrace("-> SEND: {Hex}", BitConverter.ToString(packet).Replace("-", ""));
143+
_effectiveLogger.LogTrace("-> SEND: {Hex}", BitConverter.ToString(packet).Replace("-", ""));
131144
}
132145

133146
int step = maxChunk ?? 2;
@@ -182,9 +195,9 @@ public async Task<byte[]> ReceivePacketAsync(CancellationToken cancellationToken
182195
bytesRead += currentBytesRead;
183196
}
184197

185-
if (_logger.IsEnabled(LogLevel.Trace))
198+
if (_effectiveLogger.IsEnabled(LogLevel.Trace))
186199
{
187-
_logger.LogTrace("<- RECV: {Hex}", BitConverter.ToString(fullPacket).Replace("-", ""));
200+
_effectiveLogger.LogTrace("<- RECV: {Hex}", BitConverter.ToString(fullPacket).Replace("-", ""));
188201
}
189202
return DecodePacket(fullPacket);
190203
}

src/S7Tools/Services/Tasking/TaskScheduler.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -951,11 +951,7 @@ private async Task ExecuteTaskAsync(Guid taskId)
951951

952952
try
953953
{
954-
task.UpdateState(TaskState.Running, "Starting task execution");
955-
TaskStateChanged?.Invoke(task);
956-
_ = Task.Run(() => SaveTasksAsync(), CancellationToken.None); // Persist running state
957-
958-
// Create task-specific logger
954+
// Create task-specific logger FIRST so UI auto-open has access to it when state changes to Running
959955
taskLogger = await _taskLoggerFactory.CreateTaskLoggerAsync(
960956
taskId,
961957
task.JobName,
@@ -964,6 +960,10 @@ private async Task ExecuteTaskAsync(Guid taskId)
964960

965961
task.Logger = taskLogger;
966962

963+
task.UpdateState(TaskState.Running, "Starting task execution");
964+
TaskStateChanged?.Invoke(task);
965+
_ = Task.Run(() => SaveTasksAsync(), CancellationToken.None); // Persist running state
966+
967967
// Log task start
968968
taskLogger.MainLogger?.LogInformation(
969969
"Task execution started: {JobName} (ID: {TaskId}) at {StartTime}",

src/S7Tools/ViewModels/Layout/MainWindowViewModel.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,16 @@ public MainWindowViewModel(
173173

174174
// Wire navigation to open content in dock tabs
175175
Navigation.OpenDocumentAction = vm => OpenDocumentTab(vm);
176+
Navigation.OpenToolAction = vm => OpenToolTab(vm);
177+
178+
// Pre-wire TaskManagerViewModel so it can open task logs automatically
179+
// even if the user hasn't visited the Tasks sidebar view yet
180+
var taskManagerVm = _serviceProvider.GetService<ViewModels.Tasks.TaskManagerViewModel>();
181+
if (taskManagerVm != null)
182+
{
183+
taskManagerVm.OpenDocumentAction = vm => OpenDocumentTab(vm);
184+
taskManagerVm.OpenToolAction = vm => OpenToolTab(vm);
185+
}
176186

177187
_logger.LogDebug("MainWindowViewModel initialized with specialized ViewModels and docking system");
178188
}

src/S7Tools/ViewModels/Layout/NavigationViewModel.cs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ public NavigationViewModel(
101101
/// </summary>
102102
public Action<IDockableViewModel>? OpenDocumentAction { get; set; }
103103

104+
/// <summary>
105+
/// Action to open a tool window in the bottom dock panel.
106+
/// </summary>
107+
public Action<IDockableViewModel>? OpenToolAction { get; set; }
108+
104109
/// <summary>
105110
/// Creates the welcome/initial ViewModel for the dock's default document.
106111
/// </summary>
@@ -418,10 +423,20 @@ private void NavigateToActivityBarItemContent(string itemId)
418423
case "taskmanager":
419424
SidebarTitle = UIStrings.Navigation_TaskManager;
420425
TaskManagerShellViewModel? taskManagerShell = CreateViewModel<TaskManagerShellViewModel>();
421-
CurrentContent = taskManagerShell; // Sidebar categories
422-
ShowLogStats = false;
423-
// Open task manager as a dock tab
424-
OpenDockableContent(taskManagerShell);
426+
if (taskManagerShell != null)
427+
{
428+
taskManagerShell.OpenDocumentAction = OpenDocumentAction;
429+
taskManagerShell.OpenToolAction = OpenToolAction;
430+
CurrentContent = taskManagerShell; // Sidebar categories
431+
ShowLogStats = false;
432+
// Open task manager as a dock tab
433+
OpenDockableContent(taskManagerShell);
434+
}
435+
else
436+
{
437+
CurrentContent = null;
438+
ShowLogStats = false;
439+
}
425440
_logger.LogDebug("Navigated to Task Manager");
426441
break;
427442

0 commit comments

Comments
 (0)