Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: .NET WPF CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:
runs-on: windows-latest

steps:
- uses: actions/checkout@v4

- name: Setup .NET 10.0
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Restore dependencies
run: dotnet restore

- name: Build
run: dotnet build --no-restore --configuration Release
13 changes: 12 additions & 1 deletion MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1309,7 +1309,18 @@
<TextBlock Text="{Binding GoodPartsCount, Mode=OneWay, StringFormat='Good Parts: {0}'}" Style="{StaticResource StatusTextStyle}"/>
<TextBlock Text="{Binding BadPartsCount, Mode=OneWay, StringFormat='Bad Parts: {0}'}" Style="{StaticResource StatusTextStyle}"/>
<TextBlock Text="{Binding YieldPercentage, Mode=OneWay, StringFormat='Yield: {0:F1}%'}" Style="{StaticResource StatusTextStyle}"/>
<TextBlock Text="{Binding GatewayModeStatus, Mode=OneWay}" Foreground="#9CDCFE" FontSize="11" FontWeight="SemiBold" Margin="5,4,5,0" TextWrapping="Wrap"/>

<StackPanel Orientation="Horizontal" Margin="5,4,5,0">
<TextBlock Text="{Binding GatewayModeStatus, Mode=OneWay}" Foreground="#9CDCFE" FontSize="11" FontWeight="SemiBold" TextWrapping="Wrap" Margin="0,0,5,0" />
<Ellipse Width="8" Height="8" Fill="{Binding GatewayModeIndicatorColor}" VerticalAlignment="Center" />
</StackPanel>

<StackPanel Orientation="Horizontal" Margin="5,4,5,0">
<TextBlock Text="Connection: " Foreground="#AAAAAA" FontSize="11" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Text="{Binding ConnectionState, Mode=OneWay}" Foreground="{Binding ConnectionStateColor, Mode=OneWay}" FontSize="11" FontWeight="SemiBold" TextWrapping="Wrap" Margin="0,0,5,0" />
<Ellipse Width="8" Height="8" Fill="{Binding ConnectionStateColor}" VerticalAlignment="Center" />
</StackPanel>

<TextBlock Text="{Binding DatabasePath, Mode=OneWay, StringFormat='DB: {0}'}" Foreground="#AAAAAA" FontSize="10" Margin="5,4,5,0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
Expand Down
50 changes: 50 additions & 0 deletions Services/ErrorHandlingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,56 @@ public bool ExecuteWithRetry(
return fallback;
}

public async System.Threading.Tasks.Task<T?> ExecuteWithRetryAsync<T>(
Func<System.Threading.Tasks.Task<T>> func,
string operationName,
ErrorCategory category,
T? fallback = default,
int maxRetries = 3,
int baseDelayMs = 50)
{
var breaker = GetOrCreateCircuitBreaker(operationName);
if (!breaker.AllowAttempt())
{
ReportError(
ErrorSeverity.Warning,
category,
operationName,
$"Circuit breaker open for '{operationName}'. Returning fallback.",
wasRecovered: false);
return fallback;
}

for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
T result = await func().ConfigureAwait(false);
breaker.RecordSuccess();
return result;
}
catch (Exception ex) when (attempt < maxRetries && IsTransient(ex))
{
int delay = baseDelayMs * (int)Math.Pow(2, attempt);
await System.Threading.Tasks.Task.Delay(delay).ConfigureAwait(false);
ReportError(
ErrorSeverity.Warning,
category,
operationName,
$"Retry {attempt + 1}/{maxRetries}: {ex.Message}",
wasRecovered: true);
}
catch (Exception ex)
{
breaker.RecordFailure();
ReportException(category, operationName, ex, wasRecovered: false);
return fallback;
}
}

return fallback;
}

public IReadOnlyList<ErrorRecord> GetRecentErrors(int count = 50)
{
return _errorLog
Expand Down
35 changes: 23 additions & 12 deletions Services/HmiServiceContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,21 @@

namespace XTSPrimeMoverProject.Services
{
public enum GatewayConnectionState
{
Connected,
Degraded,
Reconnecting,
Offline
}

public interface IMachineGatewayService
{
event EventHandler? StateChanged;
event EventHandler<string>? LogGenerated;
event EventHandler<GatewayConnectionState>? ConnectionStateChanged;

GatewayConnectionState ConnectionState { get; }

IReadOnlyList<Mover> Movers { get; }
IReadOnlyList<Machine> Machines { get; }
Expand All @@ -29,24 +40,24 @@ public interface IMachineGatewayService

IReadOnlyList<WatchdogStatusEntry> GetWatchdogStatus();
IReadOnlyList<ProductionSequenceStep> GetOrchestrationSteps();
bool TryApplyOrchestration(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions, out string message);
IReadOnlyList<string> PreviewOrchestrationValidation(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions);
IReadOnlyList<SafetyGateStatus> GetOrchestrationSafetyGateStatuses();
Task<(bool Success, string Message)> TryApplyOrchestrationAsync(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions);
Task<IReadOnlyList<string>> PreviewOrchestrationValidationAsync(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions);
Task<IReadOnlyList<SafetyGateStatus>> GetOrchestrationSafetyGateStatusesAsync();
}

public interface IDataGatewayService
{
string DatabasePath { get; }

IReadOnlyList<PartHistoryEventRecord> GetPartHistory(string trackingNumber);
PartSummaryRecord? GetPartSummary(string trackingNumber);
Task<IReadOnlyList<PartHistoryEventRecord>> GetPartHistoryAsync(string trackingNumber);
Task<PartSummaryRecord?> GetPartSummaryAsync(string trackingNumber);

IReadOnlyList<string> GetExportableTables();
IReadOnlyList<string> GetAllTables();
IReadOnlyList<string> GetTableColumns(string tableName);
int GetTableRowCount(string tableName);
IReadOnlyList<Dictionary<string, string>> GetTableRows(string tableName, int maxRows = 500);
string ExportTableToCsv(string tableName, string? exportDirectory = null);
string GetDefaultExportDirectory();
Task<IReadOnlyList<string>> GetExportableTablesAsync();
Task<IReadOnlyList<string>> GetAllTablesAsync();
Task<IReadOnlyList<string>> GetTableColumnsAsync(string tableName);
Task<int> GetTableRowCountAsync(string tableName);
Task<IReadOnlyList<Dictionary<string, string>>> GetTableRowsAsync(string tableName, int maxRows = 500);
Task<string> ExportTableToCsvAsync(string tableName, string? exportDirectory = null);
Task<string> GetDefaultExportDirectoryAsync();
}
}
73 changes: 38 additions & 35 deletions Services/LocalSimulationServiceGateway.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public LocalSimulationServiceGateway(XTSSimulationEngine engine)

public event EventHandler? StateChanged;
public event EventHandler<string>? LogGenerated;
public event EventHandler<GatewayConnectionState>? ConnectionStateChanged { add { } remove { } }

public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;

public IReadOnlyList<Mover> Movers => _engine.Movers;
public IReadOnlyList<Machine> Machines => _engine.Machines;
Expand Down Expand Up @@ -106,115 +109,115 @@ public IReadOnlyList<ProductionSequenceStep> GetOrchestrationSteps()
fallback: Array.Empty<ProductionSequenceStep>())!;
}

public bool TryApplyOrchestration(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions, out string message)
public Task<(bool Success, string Message)> TryApplyOrchestrationAsync(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions)
{
try
{
return _engine.TryApplyOrchestration(stepDefinitions, out message);
bool success = _engine.TryApplyOrchestration(stepDefinitions, out string msg);
return Task.FromResult((success, msg));
}
catch (Exception ex)
{
_errorHandler.ReportException(ErrorCategory.Gateway, "LocalGateway.TryApplyOrchestration", ex);
message = $"Gateway error: {ex.Message}";
return false;
return Task.FromResult((false, $"Gateway error: {ex.Message}"));
}
}

public IReadOnlyList<string> PreviewOrchestrationValidation(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions)
public Task<IReadOnlyList<string>> PreviewOrchestrationValidationAsync(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions)
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.PreviewOrchestrationValidation(stepDefinitions),
"LocalGateway.PreviewOrchestrationValidation",
ErrorCategory.Gateway,
fallback: new List<string> { "Validation unavailable due to gateway error." })!;
fallback: new List<string> { "Validation unavailable due to gateway error." })!);
}

public IReadOnlyList<SafetyGateStatus> GetOrchestrationSafetyGateStatuses()
public Task<IReadOnlyList<SafetyGateStatus>> GetOrchestrationSafetyGateStatusesAsync()
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.GetOrchestrationSafetyGateStatuses(),
"LocalGateway.GetOrchestrationSafetyGateStatuses",
ErrorCategory.Gateway,
fallback: Array.Empty<SafetyGateStatus>())!;
fallback: Array.Empty<SafetyGateStatus>())!);
}

// --- IDataGatewayService ---

public string DatabasePath => _engine.DatabasePath;

public IReadOnlyList<PartHistoryEventRecord> GetPartHistory(string trackingNumber)
public Task<IReadOnlyList<PartHistoryEventRecord>> GetPartHistoryAsync(string trackingNumber)
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.GetPartHistory(trackingNumber),
"LocalGateway.GetPartHistory",
ErrorCategory.Gateway,
fallback: Array.Empty<PartHistoryEventRecord>())!;
fallback: Array.Empty<PartHistoryEventRecord>())!);
}

public PartSummaryRecord? GetPartSummary(string trackingNumber)
public Task<PartSummaryRecord?> GetPartSummaryAsync(string trackingNumber)
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.GetPartSummary(trackingNumber),
"LocalGateway.GetPartSummary",
ErrorCategory.Gateway,
fallback: null);
fallback: null));
}

public IReadOnlyList<string> GetExportableTables()
public Task<IReadOnlyList<string>> GetExportableTablesAsync()
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.GetExportableTables(),
"LocalGateway.GetExportableTables",
ErrorCategory.Gateway,
fallback: Array.Empty<string>())!;
fallback: Array.Empty<string>())!);
}

public IReadOnlyList<string> GetAllTables()
public Task<IReadOnlyList<string>> GetAllTablesAsync()
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.GetAllTables(),
"LocalGateway.GetAllTables",
ErrorCategory.Gateway,
fallback: Array.Empty<string>())!;
fallback: Array.Empty<string>())!);
}

public IReadOnlyList<string> GetTableColumns(string tableName)
public Task<IReadOnlyList<string>> GetTableColumnsAsync(string tableName)
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.GetTableColumns(tableName),
"LocalGateway.GetTableColumns",
ErrorCategory.Gateway,
fallback: Array.Empty<string>())!;
fallback: Array.Empty<string>())!);
}

public int GetTableRowCount(string tableName)
public Task<int> GetTableRowCountAsync(string tableName)
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.GetTableRowCount(tableName),
"LocalGateway.GetTableRowCount",
ErrorCategory.Gateway,
fallback: 0);
fallback: 0));
}

public IReadOnlyList<Dictionary<string, string>> GetTableRows(string tableName, int maxRows = 500)
public Task<IReadOnlyList<Dictionary<string, string>>> GetTableRowsAsync(string tableName, int maxRows = 500)
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.GetTableRows(tableName, maxRows),
"LocalGateway.GetTableRows",
ErrorCategory.Gateway,
fallback: Array.Empty<Dictionary<string, string>>())!;
fallback: Array.Empty<Dictionary<string, string>>())!);
}

public string ExportTableToCsv(string tableName, string? exportDirectory = null)
public Task<string> ExportTableToCsvAsync(string tableName, string? exportDirectory = null)
{
return _errorHandler.ExecuteWithRetry(
return Task.FromResult(_errorHandler.ExecuteWithRetry(
() => _engine.ExportTableToCsv(tableName, exportDirectory),
"LocalGateway.ExportTableToCsv",
ErrorCategory.Gateway,
fallback: string.Empty)!;
fallback: string.Empty)!);
}

public string GetDefaultExportDirectory() => _engine.GetDefaultExportDirectory();
public Task<string> GetDefaultExportDirectoryAsync() => Task.FromResult(_engine.GetDefaultExportDirectory());

// --- Event forwarding ---

Expand Down
43 changes: 30 additions & 13 deletions Services/RemoteTwinCatMock/RemoteTwinCatMachineGatewayMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ public RemoteTwinCatMachineGatewayMock(IMachineGatewayService inner, int command

public event EventHandler? StateChanged;
public event EventHandler<string>? LogGenerated;
public event EventHandler<GatewayConnectionState>? ConnectionStateChanged
{
add => _inner.ConnectionStateChanged += value;
remove => _inner.ConnectionStateChanged -= value;
}

public GatewayConnectionState ConnectionState => _inner.ConnectionState;

public IReadOnlyList<Mover> Movers => _inner.Movers;
public IReadOnlyList<Machine> Machines => _inner.Machines;
Expand Down Expand Up @@ -83,37 +90,47 @@ public IReadOnlyList<ProductionSequenceStep> GetOrchestrationSteps()
fallback: Array.Empty<ProductionSequenceStep>())!;
}

public bool TryApplyOrchestration(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions, out string message)
public async Task<(bool Success, string Message)> TryApplyOrchestrationAsync(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions)
{
try
{
SimulateNetworkLatencySync();
return _inner.TryApplyOrchestration(stepDefinitions, out message);
if (_commandLatencyMs > 0)
{
await Task.Delay(_commandLatencyMs).ConfigureAwait(false);
}
return await _inner.TryApplyOrchestrationAsync(stepDefinitions).ConfigureAwait(false);
}
catch (Exception ex)
{
_errorHandler.ReportException(ErrorCategory.Gateway, "RemoteMock.TryApplyOrchestration", ex);
message = $"Remote gateway error: {ex.Message}";
return false;
return (false, $"Remote gateway error: {ex.Message}");
}
}

public IReadOnlyList<string> PreviewOrchestrationValidation(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions)
public async Task<IReadOnlyList<string>> PreviewOrchestrationValidationAsync(IReadOnlyList<OrchestrationStepDefinition> stepDefinitions)
{
return _errorHandler.ExecuteWithRetry(
() => _inner.PreviewOrchestrationValidation(stepDefinitions),
if (_commandLatencyMs > 0)
{
await Task.Delay(_commandLatencyMs).ConfigureAwait(false);
}
return await _errorHandler.ExecuteWithRetryAsync(
async () => await _inner.PreviewOrchestrationValidationAsync(stepDefinitions),
"RemoteMock.PreviewOrchestrationValidation",
ErrorCategory.Gateway,
fallback: new List<string> { "Validation unavailable due to remote gateway error." })!;
fallback: new List<string> { "Validation unavailable due to remote gateway error." }).ConfigureAwait(false) ?? new List<string>();
}

public IReadOnlyList<SafetyGateStatus> GetOrchestrationSafetyGateStatuses()
public async Task<IReadOnlyList<SafetyGateStatus>> GetOrchestrationSafetyGateStatusesAsync()
{
return _errorHandler.ExecuteWithRetry(
() => _inner.GetOrchestrationSafetyGateStatuses(),
if (_commandLatencyMs > 0)
{
await Task.Delay(_commandLatencyMs).ConfigureAwait(false);
}
return await _errorHandler.ExecuteWithRetryAsync(
async () => await _inner.GetOrchestrationSafetyGateStatusesAsync(),
"RemoteMock.GetOrchestrationSafetyGateStatuses",
ErrorCategory.Gateway,
fallback: Array.Empty<SafetyGateStatus>())!;
fallback: (IReadOnlyList<SafetyGateStatus>)Array.Empty<SafetyGateStatus>()).ConfigureAwait(false) ?? Array.Empty<SafetyGateStatus>();
}

private void DispatchWithLatency(Action command)
Expand Down
Loading