diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9364a27 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: Build and test + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore .\ModbusLab.slnx + + - name: Build + run: dotnet build .\ModbusLab.slnx -c Release --no-restore /warnaserror + + - name: Test + run: dotnet test .\ModbusLab.slnx -c Release --no-build --no-restore diff --git a/ModbusLab.Tests/DomainValidatorTests.cs b/ModbusLab.Tests/DomainValidatorTests.cs new file mode 100644 index 0000000..f67e88d --- /dev/null +++ b/ModbusLab.Tests/DomainValidatorTests.cs @@ -0,0 +1,233 @@ +using ModbusLab.Models; +using ModbusLab.Validation; +using ModbusLab.ViewModels; + +namespace ModbusLab.Tests; + +public sealed class DomainValidatorTests +{ + [Fact] + public void WatchNamesMustBeUniqueIgnoringCase() + { + var existing = new WatchItem { Name = "Temperature" }; + var candidate = new WatchItem { Name = "temperature" }; + + var result = DomainValidator.ValidateWatch(candidate, [existing]); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Field == nameof(WatchItem.Name)); + } + + [Fact] + public void EditingOneLegacyDuplicateRequiresResolvingTheDuplicateName() + { + var first = new WatchItem { Name = "Duplicate", Address = 1 }; + var second = new WatchItem { Name = "duplicate", Address = 2 }; + var candidate = first.Clone(); + candidate.Address = 3; + + var result = DomainValidator.ValidateWatch(candidate, [first, second], first); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Field == nameof(WatchItem.Name)); + Assert.Equal("Duplicate", first.Name); + Assert.Equal("duplicate", second.Name); + } + + [Fact] + public void NonFiniteWatchAndReactionValuesAreRejected() + { + var floatWatch = new WatchItem + { + Name = "Float", + Area = MemoryArea.HoldingRegisters, + DataType = WatchDataTypes.Float32, + Count = 2 + }; + var watchValue = DomainValidator.ValidateWatchValue( + floatWatch, + "NaN", + ByteOrderMode.BigEndian, + WordOrderMode.HighWordFirst); + var reaction = DomainValidator.ValidateReaction(new WriteReactionRule + { + TriggerArea = MemoryArea.HoldingRegisters, + MatchValue = "1", + Action = WriteReactionAction.WriteValue, + TargetArea = MemoryArea.HoldingRegisters, + TargetAddress = 1, + Value = "70000" + }); + + Assert.False(watchValue.IsValid); + Assert.False(reaction.IsValid); + Assert.Contains(reaction.Issues, issue => issue.Field == nameof(WriteReactionRule.Value)); + } + + [Fact] + public void OrderingOperatorsAreRejectedForStringTriggers() + { + var watch = new WatchItem + { + Name = "Text", + Area = MemoryArea.HoldingRegisters, + DataType = WatchDataTypes.String, + Count = 2 + }; + var reaction = new WriteReactionRule + { + TriggerKind = WriteReactionEndpointKind.Watch, + TriggerWatchName = watch.Name, + MatchOperator = WriteReactionMatchOperator.GreaterThan, + MatchValue = "abc", + Action = WriteReactionAction.ResetTrigger + }; + + var result = DomainValidator.ValidateReaction(reaction, [watch]); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Field == nameof(WriteReactionRule.MatchOperator)); + } + + [Fact] + public void InvalidLegacyWatchFieldsAreReportedWithoutChangingTheSource() + { + var watch = new WatchItem + { + Name = "", + Area = MemoryArea.Coils, + DataType = WatchDataTypes.UInt32, + Count = 4, + StringFormat = "unsupported" + }; + + var result = DomainValidator.ValidateWatch(watch); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Field == nameof(WatchItem.Name)); + Assert.Contains(result.Issues, issue => issue.Field == nameof(WatchItem.DataType)); + Assert.Contains(result.Issues, issue => issue.Field == nameof(WatchItem.Count)); + Assert.Equal("", watch.Name); + Assert.Equal(WatchDataTypes.UInt32, watch.DataType); + Assert.Equal(4, watch.Count); + Assert.Equal(WatchDataTypes.Bool, result.NormalizedValue.DataType); + Assert.Equal(1, result.NormalizedValue.Count); + } + + [Fact] + public void InvalidImportedServerIsRejectedBeforeItCanBeAdded() + { + var imported = ServerProfile.CreateDefault("Imported", "127.0.0.1", 1502); + imported.WatchItems = + [ + new WatchItem { Name = "Duplicate" }, + new WatchItem { Name = "duplicate", Address = 1 } + ]; + var viewModel = new MainWindowViewModel(ProjectModel.CreateDefault()); + viewModel.InitializeRuntimes(); + + Assert.Throws(() => viewModel.PrepareImportedServer(imported)); + Assert.Single(viewModel.Project.Servers); + } + + [Fact] + public void MissingWatchTargetMakesSimulationInvalid() + { + var rule = new SimulationRule + { + TargetKind = SimulationTargetKind.Watch, + TargetWatchName = "Missing", + Mode = SimulationMode.Counter, + PeriodMs = 1000 + }; + + var result = DomainValidator.ValidateSimulationRule(rule, []); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Field == nameof(SimulationRule.TargetWatchName)); + } + + [Fact] + public void NumericSimulationModeIsRejectedForBitTargets() + { + var rule = new SimulationRule + { + Area = MemoryArea.Coils, + Mode = SimulationMode.Sine, + PeriodMs = 1000 + }; + + var result = DomainValidator.ValidateSimulationRule(rule); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Field == nameof(SimulationRule.Mode)); + } + + [Fact] + public void MaskWriteRegisterIsAValidFaultFunction() + { + var rule = new FaultRule + { + FunctionCode = 22, + StartAddress = 0, + EndAddress = 10 + }; + + Assert.True(DomainValidator.ValidateFaultRule(rule).IsValid); + } + + [Fact] + public void StringReactionCannotIncrementItsTarget() + { + var watch = new WatchItem + { + Name = "Status", + Area = MemoryArea.HoldingRegisters, + Address = 0, + DataType = WatchDataTypes.String, + Count = 4 + }; + var reaction = new WriteReactionRule + { + TriggerArea = MemoryArea.HoldingRegisters, + TriggerAddress = 0, + MatchValue = "", + Action = WriteReactionAction.IncrementRegister, + TargetKind = WriteReactionEndpointKind.Watch, + TargetWatchName = watch.Name, + Value = "1" + }; + + var result = DomainValidator.ValidateReaction(reaction, [watch]); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Field == nameof(WriteReactionRule.Action)); + } + + [Fact] + public void ProjectValidationReportsLegacyDuplicateWatchesWithoutChangingThem() + { + var profile = ServerProfile.CreateDefault("Server", "127.0.0.1", 1502); + profile.WatchItems.Add(new WatchItem { Name = "Duplicate" }); + profile.WatchItems.Add(new WatchItem { Name = "duplicate" }); + var project = new ProjectModel { Servers = [profile] }; + + var issues = DomainValidator.ValidateProject(project); + + Assert.Contains(issues, issue => issue.Message.Contains("Duplicate watch name", StringComparison.Ordinal)); + Assert.Equal(new[] { "Duplicate", "duplicate" }, profile.WatchItems.Select(watch => watch.Name)); + } + + [Fact] + public void ServerSettingsRequireAValidBindAddressAndSnapshot() + { + var profile = ServerProfile.CreateDefault("Server", "not-an-ip", 1502); + profile.StartupMemoryMode = StartupMemoryMode.RestoreSnapshot; + + var result = DomainValidator.ValidateServerSettings(profile); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Field == nameof(ServerProfile.BindAddress)); + Assert.Contains(result.Issues, issue => issue.Field == nameof(ServerProfile.StartupSnapshot)); + } +} diff --git a/ModbusLab.Tests/McpValidationTests.cs b/ModbusLab.Tests/McpValidationTests.cs new file mode 100644 index 0000000..4ddfc59 --- /dev/null +++ b/ModbusLab.Tests/McpValidationTests.cs @@ -0,0 +1,139 @@ +using ModbusLab.Mcp; +using ModbusLab.Models; +using ModbusLab.ViewModels; +using System.Text.Json; +using System.Windows.Threading; + +namespace ModbusLab.Tests; + +[Collection(ModbusTcpCollection.Name)] +public sealed class McpValidationTests +{ + [Fact] + public void McpDefaultsAreDisabledTokenProtectedAndReadOnly() + { + var defaults = new McpSettings(); + var missingProperties = JsonSerializer.Deserialize("{}", ProjectStore.JsonOptions); + + Assert.False(defaults.Enabled); + Assert.True(defaults.RequireToken); + Assert.False(defaults.AllowMutations); + Assert.NotNull(missingProperties); + Assert.False(missingProperties.Enabled); + Assert.True(missingProperties.RequireToken); + Assert.False(missingProperties.AllowMutations); + } + + [Fact] + public async Task DedicatedValidationReturnsStructuredErrorsWhileDryRunMutationFails() + { + await RunWithDispatcherAsync(async dispatcher => + { + var profile = ServerProfile.CreateDefault("Server", "127.0.0.1", 1502); + var viewModel = new MainWindowViewModel(new ProjectModel { Servers = [profile] }); + viewModel.InitializeRuntimes(); + viewModel.SelectedRuntime = viewModel.Runtimes[profile.Id]; + var facade = new ModbusLabMcpFacade(viewModel, dispatcher); + const string payload = """ + { + "TargetKind": "Watch", + "TargetWatchName": "Missing", + "Mode": "Counter", + "PeriodMs": 1000 + } + """; + + var validationJson = await facade.ValidateRulePayloadAsync("simulation", payload, CancellationToken.None); + using var validation = JsonDocument.Parse(validationJson); + Assert.True(validation.RootElement.GetProperty("ok").GetBoolean()); + Assert.False(validation.RootElement.GetProperty("result").GetProperty("valid").GetBoolean()); + Assert.NotEmpty(validation.RootElement.GetProperty("result").GetProperty("errors").EnumerateArray()); + + var mutationJson = await facade.UpsertSimulationRuleAsync(null, payload, -1, dryRun: true, CancellationToken.None); + using var mutation = JsonDocument.Parse(mutationJson); + Assert.False(mutation.RootElement.GetProperty("ok").GetBoolean()); + Assert.True(mutation.RootElement.GetProperty("dryRun").GetBoolean()); + }); + } + + [Fact] + public async Task ReadOnlyMcpAllowsReadsAndDryRunsButCannotElevateItself() + { + ProjectStore.SuppressSave = true; + try + { + await RunWithDispatcherAsync(async dispatcher => + { + var profile = ServerProfile.CreateDefault("Server", "127.0.0.1", 1502); + var project = new ProjectModel { Servers = [profile] }; + var viewModel = new MainWindowViewModel(project); + viewModel.InitializeRuntimes(); + var runtime = viewModel.Runtimes[profile.Id]; + viewModel.SelectedRuntime = runtime; + var facade = new ModbusLabMcpFacade(viewModel, dispatcher); + + using var read = JsonDocument.Parse(await facade.GetProjectSummaryAsync(CancellationToken.None)); + Assert.True(read.RootElement.GetProperty("ok").GetBoolean()); + + using var dryRun = JsonDocument.Parse(await facade.CreateServerAsync( + "Dry-run server", "127.0.0.1", 1503, dryRun: true, CancellationToken.None)); + Assert.True(dryRun.RootElement.GetProperty("ok").GetBoolean()); + Assert.Single(project.Servers); + + using var invalidServerSettings = JsonDocument.Parse(await facade.UpdateServerSettingsAsync( + null, "{ \"Port\": 70000 }", dryRun: true, CancellationToken.None)); + Assert.False(invalidServerSettings.RootElement.GetProperty("ok").GetBoolean()); + Assert.Equal(1502, profile.Port); + + using var invalidMcpSettings = JsonDocument.Parse(await facade.UpdateMcpSettingsAsync( + "{ \"Port\": 80 }", restartHost: false, dryRun: true, CancellationToken.None)); + Assert.False(invalidMcpSettings.RootElement.GetProperty("ok").GetBoolean()); + Assert.Equal(8765, project.Mcp.Port); + + using var elevation = JsonDocument.Parse(await facade.UpdateMcpSettingsAsync( + "{ \"AllowMutations\": true }", restartHost: false, dryRun: false, CancellationToken.None)); + Assert.False(elevation.RootElement.GetProperty("ok").GetBoolean()); + Assert.False(project.Mcp.AllowMutations); + + project.Mcp.AllowMutations = true; // Models an explicit grant from the local UI. + using var armSimulation = JsonDocument.Parse(await facade.SetSimulationAsync( + null, running: true, dryRun: false, CancellationToken.None)); + Assert.True(armSimulation.RootElement.GetProperty("ok").GetBoolean()); + Assert.True(runtime.SimulationRequested); + Assert.False(runtime.Simulator.IsRunning); + viewModel.SetSimulationRequested(runtime, requested: false); + + using var disable = JsonDocument.Parse(await facade.UpdateMcpSettingsAsync( + "{ \"AllowMutations\": false }", restartHost: false, dryRun: false, CancellationToken.None)); + Assert.True(disable.RootElement.GetProperty("ok").GetBoolean()); + Assert.False(project.Mcp.AllowMutations); + }); + } + finally + { + ProjectStore.SuppressSave = false; + } + } + + private static async Task RunWithDispatcherAsync(Func action) + { + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + ready.SetResult(Dispatcher.CurrentDispatcher); + Dispatcher.Run(); + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + var dispatcher = await ready.Task.WaitAsync(TimeSpan.FromSeconds(2)); + try + { + await action(dispatcher); + } + finally + { + dispatcher.BeginInvokeShutdown(DispatcherPriority.Send); + thread.Join(TimeSpan.FromSeconds(2)); + } + } +} diff --git a/ModbusLab.Tests/ProjectStoreTests.cs b/ModbusLab.Tests/ProjectStoreTests.cs new file mode 100644 index 0000000..fb4c3df --- /dev/null +++ b/ModbusLab.Tests/ProjectStoreTests.cs @@ -0,0 +1,263 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ModbusLab.Models; +using ModbusLab.ViewModels; + +namespace ModbusLab.Tests; + +[Collection(ModbusTcpCollection.Name)] +public sealed class ProjectStoreTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"ModbusLab.Tests.{Guid.NewGuid():N}"); + private readonly string _projectPath; + + public ProjectStoreTests() + { + Directory.CreateDirectory(_root); + _projectPath = Path.Combine(_root, "ModbusLab.modbuslab.json"); + ProjectStore.ConfigurePath(_projectPath); + ProjectStore.SuppressSave = false; + } + + [Fact] + public void MissingProjectCreatesAnUnsavedVersionTwoModel() + { + var result = ProjectStore.Load(); + + Assert.Equal(ProjectLoadSource.NewProject, result.Source); + Assert.Equal(ProjectRecoveryState.None, result.RecoveryState); + Assert.Equal(ProjectStore.CurrentFormatVersion, result.Project.FormatVersion); + Assert.True(result.Project.IsFirstRun); + Assert.False(File.Exists(_projectPath)); + } + + [Fact] + public void VersionOneWithoutFormatVersionIsMigratedDeterministically() + { + File.WriteAllText(_projectPath, """ + { + "Servers": [], + "ThemeMode": "Dark", + "Mcp": { "AllowMutations": true } + } + """); + + var result = ProjectStore.Load(); + + Assert.Equal(ProjectLoadSource.ActiveFile, result.Source); + Assert.Equal(1, result.SourceFormatVersion); + Assert.Equal(["v1 -> v2"], result.AppliedMigrations); + Assert.Equal(ProjectStore.CurrentFormatVersion, result.Project.FormatVersion); + Assert.True(result.Project.Mcp.AllowMutations); + Assert.Single(result.Project.Servers); + } + + [Fact] + public void InvalidLegacyRulesAndDuplicateWatchesArePreservedAndReported() + { + var model = ProjectModel.CreateDefault(); + model.Servers[0].WatchItems = + [ + new WatchItem { Name = "Duplicate", Area = MemoryArea.HoldingRegisters, Address = 1 }, + new WatchItem { Name = "duplicate", Area = MemoryArea.HoldingRegisters, Address = 2 } + ]; + model.Servers[0].SimulationRules = + [ + new SimulationRule + { + TargetKind = SimulationTargetKind.Watch, + TargetWatchName = "Missing watch", + Mode = SimulationMode.Counter + } + ]; + var root = JsonNode.Parse(JsonSerializer.Serialize(model, ProjectStore.JsonOptions))!.AsObject(); + root.Remove(nameof(ProjectModel.FormatVersion)); + File.WriteAllText(_projectPath, root.ToJsonString(ProjectStore.JsonOptions)); + + var result = ProjectStore.Load(); + + Assert.Equal(2, result.Project.Servers[0].WatchItems.Count); + Assert.Equal("Duplicate", result.Project.Servers[0].WatchItems[0].Name); + Assert.Equal("duplicate", result.Project.Servers[0].WatchItems[1].Name); + Assert.Equal("Missing watch", result.Project.Servers[0].SimulationRules[0].TargetWatchName); + Assert.Contains(result.Warnings, issue => issue.Message.Contains("Duplicate watch", StringComparison.Ordinal)); + Assert.Contains(result.Warnings, issue => issue.Message.Contains("was not found", StringComparison.Ordinal)); + } + + [Fact] + public void CorruptProjectRecoversNewestValidBackupWithoutOverwritingActiveFile() + { + const string corruptJson = "{ this is not json"; + File.WriteAllText(_projectPath, corruptJson); + var backupDirectory = Directory.CreateDirectory(Path.Combine(_root, "Backups")); + var backupPath = Path.Combine(backupDirectory.FullName, "ModbusLab.valid.modbuslab.json"); + var backupModel = ProjectModel.CreateDefault(); + backupModel.Servers[0].Name = "Recovered"; + File.WriteAllText(backupPath, JsonSerializer.Serialize(backupModel, ProjectStore.JsonOptions)); + + var result = ProjectStore.Load(); + + Assert.Equal(ProjectLoadSource.Backup, result.Source); + Assert.Equal(ProjectRecoveryState.RecoveredFromBackup, result.RecoveryState); + Assert.Equal(Path.GetFullPath(backupPath), result.BackupPath); + Assert.Equal("Recovered", result.Project.Servers[0].Name); + Assert.NotNull(result.CorruptFilePath); + Assert.True(File.Exists(result.CorruptFilePath)); + Assert.Equal(corruptJson, File.ReadAllText(_projectPath)); + + var save = ProjectStore.Save(result.Project, createBackup: false); + Assert.True(save.Success, save.ErrorMessage); + Assert.Equal(ProjectLoadSource.ActiveFile, ProjectStore.Load().Source); + } + + [Fact] + public void RecoverySkipsCorruptNewestBackup() + { + File.WriteAllText(_projectPath, "not-json"); + var backupDirectory = Directory.CreateDirectory(Path.Combine(_root, "Backups")); + var olderPath = Path.Combine(backupDirectory.FullName, "older.modbuslab.json"); + var newerPath = Path.Combine(backupDirectory.FullName, "newer.modbuslab.json"); + var olderModel = ProjectModel.CreateDefault(); + olderModel.Servers[0].Name = "Older valid"; + File.WriteAllText(olderPath, JsonSerializer.Serialize(olderModel, ProjectStore.JsonOptions)); + File.WriteAllText(newerPath, "broken"); + File.SetLastWriteTimeUtc(olderPath, DateTime.UtcNow.AddMinutes(-2)); + File.SetLastWriteTimeUtc(newerPath, DateTime.UtcNow.AddMinutes(-1)); + + var result = ProjectStore.Load(); + + Assert.Equal(Path.GetFullPath(olderPath), result.BackupPath); + Assert.Equal("Older valid", result.Project.Servers[0].Name); + } + + [Fact] + public void CorruptProjectWithoutBackupRequiresConfirmationAndRemainsUntouched() + { + const string corruptJson = "[ definitely broken"; + File.WriteAllText(_projectPath, corruptJson); + + var result = ProjectStore.Load(); + + Assert.Equal(ProjectLoadSource.NewProject, result.Source); + Assert.Equal(ProjectRecoveryState.NeedsNewProjectConfirmation, result.RecoveryState); + Assert.True(result.Project.IsFirstRun); + Assert.NotNull(result.CorruptFilePath); + Assert.Equal(corruptJson, File.ReadAllText(_projectPath)); + Assert.Equal(corruptJson, File.ReadAllText(result.CorruptFilePath!)); + } + + [Fact] + public void RecoveryBlocksWhenTheCorruptOriginalCannotBePreserved() + { + const string corruptJson = "not-json"; + File.WriteAllText(_projectPath, corruptJson); + + var result = ProjectStore.Load(() => throw new IOException("preservation denied")); + + Assert.False(result.CanContinue); + Assert.Equal(ProjectLoadSource.Blocked, result.Source); + Assert.Equal(ProjectRecoveryState.Blocked, result.RecoveryState); + Assert.Contains("preservation denied", result.ErrorMessage, StringComparison.Ordinal); + Assert.Equal(corruptJson, File.ReadAllText(_projectPath)); + } + + [Fact] + public void FutureVersionBlocksStartupWithoutRecoveryOrPreservation() + { + const string futureJson = "{ \"FormatVersion\": 999, \"Servers\": [] }"; + File.WriteAllText(_projectPath, futureJson); + var backupDirectory = Directory.CreateDirectory(Path.Combine(_root, "Backups")); + File.WriteAllText( + Path.Combine(backupDirectory.FullName, "valid.modbuslab.json"), + JsonSerializer.Serialize(ProjectModel.CreateDefault(), ProjectStore.JsonOptions)); + + var result = ProjectStore.Load(); + + Assert.False(result.CanContinue); + Assert.Equal(ProjectLoadSource.UnsupportedVersion, result.Source); + Assert.Equal(ProjectRecoveryState.UnsupportedVersion, result.RecoveryState); + Assert.Equal(999, result.SourceFormatVersion); + Assert.Equal(futureJson, File.ReadAllText(_projectPath)); + Assert.Empty(Directory.GetFiles(_root, "*.corrupt.*")); + } + + [Fact] + public void SaveIsAtomicVerifiedAndCreatesARecoverableBackup() + { + var model = ProjectModel.CreateDefault(); + model.Servers[0].Name = "Before"; + var firstSave = ProjectStore.Save(model, createBackup: false); + Assert.True(firstSave.Success, firstSave.ErrorMessage); + + model.Servers[0].Name = "After"; + var secondSave = ProjectStore.Save(model); + + Assert.True(secondSave.Success, secondSave.ErrorMessage); + Assert.NotNull(secondSave.SavedAt); + Assert.Equal("After", ProjectStore.Load().Project.Servers[0].Name); + var backup = Assert.Single(Directory.GetFiles(Path.Combine(_root, "Backups"), "*.modbuslab.json")); + var backupJson = File.ReadAllText(backup); + var backupModel = JsonSerializer.Deserialize(backupJson, ProjectStore.JsonOptions); + Assert.Equal("Before", backupModel!.Servers[0].Name); + Assert.Empty(Directory.GetFiles(_root, "*.tmp")); + } + + [Fact] + public void FailedSaveReturnsAnErrorAndDoesNotThrow() + { + var blockingFile = Path.Combine(_root, "not-a-directory"); + File.WriteAllText(blockingFile, "block"); + ProjectStore.ConfigurePath(Path.Combine(blockingFile, "ModbusLab.modbuslab.json")); + + var result = ProjectStore.Save(ProjectModel.CreateDefault()); + + Assert.False(result.Success); + Assert.False(string.IsNullOrWhiteSpace(result.ErrorMessage)); + } + + [Fact] + public void ViewModelKeepsLastSuccessfulSaveTimeWhenALaterSaveFails() + { + var viewModel = new MainWindowViewModel(ProjectModel.CreateDefault()); + var success = viewModel.SaveNow(createBackup: false); + var successfulTimestamp = viewModel.LastSavedAt; + Assert.True(success.Success, success.ErrorMessage); + Assert.NotNull(successfulTimestamp); + + var blockingFile = Path.Combine(_root, "blocked-parent"); + File.WriteAllText(blockingFile, "block"); + ProjectStore.ConfigurePath(Path.Combine(blockingFile, "ModbusLab.modbuslab.json")); + var failure = viewModel.SaveNow(createBackup: false); + + Assert.False(failure.Success); + Assert.Equal(successfulTimestamp, viewModel.LastSavedAt); + Assert.False(string.IsNullOrWhiteSpace(viewModel.LastSaveError)); + } + + [Fact] + public void SuppressedSaveReportsSuccessWithoutClaimingAPersistedTimestamp() + { + ProjectStore.SuppressSave = true; + + var result = ProjectStore.Save(ProjectModel.CreateDefault()); + + Assert.True(result.Success); + Assert.True(result.Suppressed); + Assert.Null(result.SavedAt); + Assert.False(File.Exists(_projectPath)); + } + + public void Dispose() + { + ProjectStore.SuppressSave = false; + ProjectStore.ConfigurePath(null); + try + { + Directory.Delete(_root, recursive: true); + } + catch + { + // Test cleanup is best-effort on Windows when an antivirus briefly holds a file. + } + } +} diff --git a/ModbusLab.Tests/RuntimeAcceptanceTests.cs b/ModbusLab.Tests/RuntimeAcceptanceTests.cs new file mode 100644 index 0000000..8d742f1 --- /dev/null +++ b/ModbusLab.Tests/RuntimeAcceptanceTests.cs @@ -0,0 +1,334 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using ModbusLab.Models; +using ModbusLab.Runtime; +using ModbusLab.ViewModels; + +namespace ModbusLab.Tests; + +public sealed class StartupMemoryAcceptanceTests +{ + [Fact] + public void KeepLastStatePreservesWritesAcrossRepeatedStartupApplications() + { + var profile = ServerProfile.CreateDefault("Keep", "127.0.0.1", 1502); + profile.StartupMemoryMode = StartupMemoryMode.KeepLastState; + var memory = profile.Memory; + var runtime = new ServerRuntime(profile); + + runtime.Store.WriteRegister(MemoryArea.HoldingRegisters, 4, 1234, markAccess: false); + MainWindowViewModel.ApplyStartupMemory(runtime); + MainWindowViewModel.ApplyStartupMemory(runtime); + + Assert.Same(memory, profile.Memory); + Assert.Equal((ushort)1234, runtime.Store.ReadRegisters(MemoryArea.HoldingRegisters, 4, 1, markAccess: false)[0]); + Assert.Equal((ushort)1234, profile.Memory.HoldingRegisters[4]); + } + + [Fact] + public async Task SnapshotIsConsistentWithConcurrentBlockWrites() + { + var store = new ModbusDataStore(MemoryImage.CreateDefault()); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var writer = Task.Run(() => + { + started.SetResult(); + for (var index = 0; index < 20_000; index++) + { + var value = index % 2 == 0 ? (ushort)0xAAAA : (ushort)0x5555; + store.WriteRegisters(MemoryArea.HoldingRegisters, 0, [value, value], markAccess: false); + } + }); + + await started.Task; + do + { + var snapshot = store.CreateMemorySnapshot(); + Assert.Equal(snapshot.HoldingRegisters[0], snapshot.HoldingRegisters[1]); + } + while (!writer.IsCompleted); + + await writer; + } +} + +[Collection(ModbusTcpCollection.Name)] +public sealed class ResponseDelayAcceptanceTests +{ + [Theory] + [InlineData(0, 0)] + [InlineData(80, 55)] + public async Task SuccessfulResponsesIncludeTheConfiguredGlobalDelay(int delayMs, int minimumElapsedMs) + { + var profile = ServerProfile.CreateDefault("Delay", "127.0.0.1", GetFreePort()); + profile.ResponseDelayMs = delayMs; + var runtime = new ServerRuntime(profile); + await runtime.Server.StartAsync(); + try + { + var stopwatch = Stopwatch.StartNew(); + var response = await SendRequestAsync(profile.Port, [3, 0, 0, 0, 1]); + stopwatch.Stop(); + + Assert.Equal(new byte[] { 3, 2, 0, 0 }, response); + Assert.InRange(stopwatch.ElapsedMilliseconds, minimumElapsedMs, 2_000); + if (delayMs > 0) + { + Assert.True(runtime.Server.GetMetricsSnapshot().LastLatencyMs >= minimumElapsedMs); + } + } + finally + { + runtime.Server.Stop(); + } + } + + [Fact] + public async Task StopCancelsARequestWaitingInGlobalDelay() + { + var profile = ServerProfile.CreateDefault("Cancellation", "127.0.0.1", GetFreePort()); + profile.ResponseDelayMs = 5_000; + var runtime = new ServerRuntime(profile); + await runtime.Server.StartAsync(); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, profile.Port).WaitAsync(TimeSpan.FromSeconds(2)); + await using var stream = client.GetStream(); + await stream.WriteAsync(BuildRequest([3, 0, 0, 0, 1])); + var readTask = stream.ReadAsync(new byte[1]).AsTask(); + await Task.Delay(100); + + var stopwatch = Stopwatch.StartNew(); + runtime.Server.Stop(); + try + { + _ = await readTask.WaitAsync(TimeSpan.FromSeconds(2)); + } + catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException) + { + // Closing a delayed connection may surface as EOF or a platform-specific socket error. + } + + stopwatch.Stop(); + Assert.InRange(stopwatch.ElapsedMilliseconds, 0, 1_900); + } + + private static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task SendRequestAsync(int port, byte[] pdu) + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port).WaitAsync(TimeSpan.FromSeconds(2)); + await using var stream = client.GetStream(); + await stream.WriteAsync(BuildRequest(pdu)); + var header = new byte[7]; + await ReadExactAsync(stream, header); + var length = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2)); + var response = new byte[length - 1]; + await ReadExactAsync(stream, response); + return response; + } + + private static byte[] BuildRequest(byte[] pdu) + { + var request = new byte[7 + pdu.Length]; + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(0, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(4, 2), (ushort)(pdu.Length + 1)); + request[6] = 1; + pdu.CopyTo(request, 7); + return request; + } + + private static async Task ReadExactAsync(NetworkStream stream, byte[] buffer) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset)).AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + Assert.NotEqual(0, read); + offset += read; + } + } +} + +[Collection(ModbusTcpCollection.Name)] +public sealed class SimulationAndReactionAcceptanceTests +{ + [Fact] + public async Task AddressSimulationWritesTheWholeRangeAndInvokesLocalReactionsOnce() + { + var profile = ServerProfile.CreateDefault("Block", "127.0.0.1", 1502); + profile.ReactionTriggerScope = WriteReactionTriggerScope.ClientAndLocalWrites; + profile.WatchItems.Add(new WatchItem + { + Name = "Range", + Area = MemoryArea.HoldingRegisters, + Address = 0, + DataType = WatchDataTypes.String, + Count = 3 + }); + var simulation = new SimulationRule + { + Area = MemoryArea.HoldingRegisters, + StartAddress = 0, + Count = 3, + Mode = SimulationMode.Constant, + Minimum = 7, + Maximum = 7, + PeriodMs = 50, + MaxHits = 1 + }; + var reaction = new WriteReactionRule + { + TriggerKind = WriteReactionEndpointKind.Watch, + TriggerWatchName = "Range", + MatchValue = "", + Action = WriteReactionAction.WriteValue, + TargetArea = MemoryArea.HoldingRegisters, + TargetAddress = 10, + Value = "9" + }; + profile.SimulationRules.Add(simulation); + profile.WriteReactionRules.Add(reaction); + var runtime = new ServerRuntime(profile); + + runtime.Simulator.Start(); + try + { + await WaitUntilAsync(() => simulation.HitCount == 1 && reaction.HitCount > 0); + } + finally + { + runtime.Simulator.Stop(); + } + + Assert.Equal(new ushort[] { 7, 7, 7 }, runtime.Store.ReadRegisters(MemoryArea.HoldingRegisters, 0, 3, markAccess: false)); + Assert.Equal(1, reaction.HitCount); + Assert.Equal((ushort)9, runtime.Store.ReadRegisters(MemoryArea.HoldingRegisters, 10, 1, markAccess: false)[0]); + } + + [Fact] + public async Task WatchSimulationUsesTypedWatchSemantics() + { + var profile = ServerProfile.CreateDefault("Watch", "127.0.0.1", 1502); + profile.WatchItems.Add(new WatchItem + { + Name = "Typed counter", + Area = MemoryArea.HoldingRegisters, + Address = 20, + DataType = WatchDataTypes.UInt32, + Count = 2 + }); + var simulation = new SimulationRule + { + TargetKind = SimulationTargetKind.Watch, + TargetWatchName = "Typed counter", + Mode = SimulationMode.Constant, + Minimum = 65_537, + Maximum = 65_537, + PeriodMs = 50, + MaxHits = 1 + }; + profile.SimulationRules.Add(simulation); + var runtime = new ServerRuntime(profile); + + runtime.Simulator.Start(); + try + { + await WaitUntilAsync(() => runtime.Store.ReadFormattedWatchValue( + MemoryArea.HoldingRegisters, + 20, + WatchDataTypes.UInt32, + 2, + WatchStringFormats.AsciiTwoCharsPerRegister, + profile.ByteOrder, + profile.WordOrder, + markAccess: false) == "65537"); + } + finally + { + runtime.Simulator.Stop(); + } + + Assert.Equal( + "65537", + runtime.Store.ReadFormattedWatchValue( + MemoryArea.HoldingRegisters, + 20, + WatchDataTypes.UInt32, + 2, + WatchStringFormats.AsciiTwoCharsPerRegister, + profile.ByteOrder, + profile.WordOrder, + markAccess: false)); + } + + [Theory] + [InlineData(WriteReactionTriggerScope.ClientWritesOnly, false, false)] + [InlineData(WriteReactionTriggerScope.ClientAndLocalWrites, true, true)] + [InlineData(WriteReactionTriggerScope.ClientAndLocalWritesWhenRunning, false, true)] + public async Task LocalReactionScopesHonorStoppedAndRunningState( + WriteReactionTriggerScope scope, + bool expectedWhileStopped, + bool expectedWhileRunning) + { + Assert.Equal(expectedWhileStopped, await ApplyLocalReactionAsync(scope, running: false)); + Assert.Equal(expectedWhileRunning, await ApplyLocalReactionAsync(scope, running: true)); + } + + private static async Task ApplyLocalReactionAsync(WriteReactionTriggerScope scope, bool running) + { + var profile = ServerProfile.CreateDefault("Scope", "127.0.0.1", GetFreePort()); + profile.ReactionTriggerScope = scope; + profile.WriteReactionRules.Add(new WriteReactionRule + { + TriggerArea = MemoryArea.HoldingRegisters, + TriggerAddress = 0, + MatchValue = "1", + Action = WriteReactionAction.WriteValue, + TargetArea = MemoryArea.HoldingRegisters, + TargetAddress = 1, + Value = "1" + }); + var runtime = new ServerRuntime(profile); + if (running) + { + await runtime.Server.StartAsync(); + } + + try + { + runtime.Store.WriteRegister(MemoryArea.HoldingRegisters, 0, 1, markAccess: false); + runtime.Server.ApplyLocalWriteReactions(MemoryArea.HoldingRegisters, 0, 1); + return runtime.Store.ReadRegisters(MemoryArea.HoldingRegisters, 1, 1, markAccess: false)[0] == 1; + } + finally + { + runtime.Server.Stop(); + } + } + + private static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task WaitUntilAsync(Func condition) + { + var stopwatch = Stopwatch.StartNew(); + while (!condition() && stopwatch.Elapsed < TimeSpan.FromSeconds(2)) + { + await Task.Delay(20); + } + + Assert.True(condition(), "Timed out waiting for simulation activity."); + } +} diff --git a/ModbusLab.Tests/RuntimeRemediationTests.cs b/ModbusLab.Tests/RuntimeRemediationTests.cs new file mode 100644 index 0000000..cd17780 --- /dev/null +++ b/ModbusLab.Tests/RuntimeRemediationTests.cs @@ -0,0 +1,342 @@ +using ModbusLab.Models; +using ModbusLab.Runtime; +using ModbusLab.ViewModels; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace ModbusLab.Tests; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ModbusTcpCollection +{ + public const string Name = "Modbus TCP integration"; +} + +public sealed class StartupMemoryTests +{ + [Fact] + public void ClearOnStartupMutatesTheRuntimeMemoryInPlace() + { + var profile = ServerProfile.CreateDefault("Test", "127.0.0.1", 1502); + profile.StartupMemoryMode = StartupMemoryMode.ClearOnStartup; + profile.Memory.HoldingRegisters[0] = 123; + var runtime = new ServerRuntime(profile); + + MainWindowViewModel.ApplyStartupMemory(runtime); + + Assert.Equal((ushort)0, runtime.Store.ReadRegisters(MemoryArea.HoldingRegisters, 0, 1, markAccess: false)[0]); + runtime.Store.WriteRegister(MemoryArea.HoldingRegisters, 0, 456, markAccess: false); + Assert.Equal((ushort)456, profile.Memory.HoldingRegisters[0]); + } + + [Fact] + public void RestoreSnapshotCanBeAppliedRepeatedlyWithoutBreakingMemoryIdentity() + { + var profile = ServerProfile.CreateDefault("Test", "127.0.0.1", 1502); + profile.StartupMemoryMode = StartupMemoryMode.RestoreSnapshot; + profile.StartupSnapshot = MemoryImage.CreateDefault(); + profile.StartupSnapshot.HoldingRegisters[3] = 900; + var runtime = new ServerRuntime(profile); + + MainWindowViewModel.ApplyStartupMemory(runtime); + runtime.Store.WriteRegister(MemoryArea.HoldingRegisters, 3, 1, markAccess: false); + MainWindowViewModel.ApplyStartupMemory(runtime); + + Assert.Equal((ushort)900, runtime.Store.ReadRegisters(MemoryArea.HoldingRegisters, 3, 1, markAccess: false)[0]); + Assert.Equal((ushort)900, profile.Memory.HoldingRegisters[3]); + } + + [Fact] + public async Task FailedBindDoesNotApplyStartupMemoryPolicy() + { + using var occupied = new TcpListener(IPAddress.Loopback, 0); + occupied.Start(); + var port = ((IPEndPoint)occupied.LocalEndpoint).Port; + var profile = ServerProfile.CreateDefault("Test", "127.0.0.1", port); + profile.StartupMemoryMode = StartupMemoryMode.ClearOnStartup; + profile.Memory.HoldingRegisters[0] = 77; + var project = new ProjectModel { Servers = [profile] }; + var viewModel = new MainWindowViewModel(project); + viewModel.InitializeRuntimes(); + var runtime = viewModel.Runtimes[profile.Id]; + + var error = await viewModel.StartRuntimeAsync(runtime); + + Assert.NotNull(error); + Assert.Equal((ushort)77, profile.Memory.HoldingRegisters[0]); + Assert.Equal((ushort)77, runtime.Store.ReadRegisters(MemoryArea.HoldingRegisters, 0, 1, markAccess: false)[0]); + } +} + +[Collection(ModbusTcpCollection.Name)] +public sealed class ModbusTcpDelayTests +{ + [Fact] + public async Task GlobalAndFaultDelaysAreAdditiveForExceptionResponses() + { + var profile = ServerProfile.CreateDefault("Delay", "127.0.0.1", GetFreePort()); + profile.ResponseDelayMs = 80; + profile.FaultRules.Add(new FaultRule + { + FunctionCode = 3, + StartAddress = 0, + EndAddress = 0, + DelayMs = 80, + ExceptionCode = 2 + }); + var runtime = new ServerRuntime(profile); + await runtime.Server.StartAsync(); + try + { + var stopwatch = Stopwatch.StartNew(); + var response = await SendRequestAsync(profile.Port, [3, 0, 0, 0, 1]); + stopwatch.Stop(); + + Assert.Equal(new byte[] { 0x83, 0x02 }, response); + Assert.InRange(stopwatch.ElapsedMilliseconds, 130, 2_000); + } + finally + { + runtime.Server.Stop(); + } + } + + [Fact] + public async Task GlobalDelayAlsoPrecedesFaultConnectionDrops() + { + var profile = ServerProfile.CreateDefault("Drop", "127.0.0.1", GetFreePort()); + profile.ResponseDelayMs = 70; + profile.FaultRules.Add(new FaultRule + { + FunctionCode = 3, + StartAddress = 0, + EndAddress = 0, + DelayMs = 70, + DropConnection = true + }); + var runtime = new ServerRuntime(profile); + await runtime.Server.StartAsync(); + try + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, profile.Port).WaitAsync(TimeSpan.FromSeconds(2)); + await using var stream = client.GetStream(); + var request = BuildRequest([3, 0, 0, 0, 1]); + var stopwatch = Stopwatch.StartNew(); + await stream.WriteAsync(request); + var buffer = new byte[1]; + var read = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + stopwatch.Stop(); + + Assert.Equal(0, read); + Assert.InRange(stopwatch.ElapsedMilliseconds, 110, 2_000); + } + finally + { + runtime.Server.Stop(); + } + } + + private static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task SendRequestAsync(int port, byte[] pdu) + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port).WaitAsync(TimeSpan.FromSeconds(2)); + await using var stream = client.GetStream(); + await stream.WriteAsync(BuildRequest(pdu)); + + var header = new byte[7]; + await ReadExactAsync(stream, header); + var length = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2)); + var responsePdu = new byte[length - 1]; + await ReadExactAsync(stream, responsePdu); + return responsePdu; + } + + private static byte[] BuildRequest(byte[] pdu) + { + var request = new byte[7 + pdu.Length]; + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(0, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(2, 2), 0); + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(4, 2), (ushort)(pdu.Length + 1)); + request[6] = 1; + pdu.CopyTo(request, 7); + return request; + } + + private static async Task ReadExactAsync(NetworkStream stream, byte[] buffer) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset)).AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + Assert.NotEqual(0, read); + offset += read; + } + } +} + +[Collection(ModbusTcpCollection.Name)] +public sealed class SimulationRuntimeTests +{ + [Fact] + public async Task AddressSimulationAppliesLocalWriteReactions() + { + var profile = ServerProfile.CreateDefault("Simulation", "127.0.0.1", 1502); + profile.ReactionTriggerScope = WriteReactionTriggerScope.ClientAndLocalWrites; + profile.SimulationRules.Add(new SimulationRule + { + TargetKind = SimulationTargetKind.Address, + Area = MemoryArea.HoldingRegisters, + StartAddress = 0, + Count = 1, + Mode = SimulationMode.Constant, + Minimum = 5, + Maximum = 5, + PeriodMs = 50 + }); + profile.WriteReactionRules.Add(new WriteReactionRule + { + TriggerArea = MemoryArea.HoldingRegisters, + TriggerAddress = 0, + MatchValue = "5", + Action = WriteReactionAction.WriteValue, + TargetArea = MemoryArea.HoldingRegisters, + TargetAddress = 1, + Value = "9" + }); + var runtime = new ServerRuntime(profile); + + runtime.Simulator.Start(); + try + { + await WaitUntilAsync(() => runtime.Store.ReadRegisters(MemoryArea.HoldingRegisters, 1, 1, markAccess: false)[0] == 9); + } + finally + { + runtime.Simulator.Stop(); + } + + Assert.Equal((ushort)9, runtime.Store.ReadRegisters(MemoryArea.HoldingRegisters, 1, 1, markAccess: false)[0]); + } + + [Fact] + public async Task SimulationRequestArmsWhileStoppedAndRestartsWithTheServer() + { + var profile = ServerProfile.CreateDefault("Armed", "127.0.0.1", GetFreePort()); + profile.SimulationRules.Add(new SimulationRule + { + Area = MemoryArea.HoldingRegisters, + Mode = SimulationMode.Constant, + Minimum = 1, + Maximum = 1, + PeriodMs = 50 + }); + var project = new ProjectModel { Servers = [profile] }; + var viewModel = new MainWindowViewModel(project); + viewModel.InitializeRuntimes(); + var runtime = viewModel.Runtimes[profile.Id]; + + viewModel.SetSimulationRequested(runtime, requested: true); + Assert.True(runtime.SimulationRequested); + Assert.False(runtime.Simulator.IsRunning); + + try + { + Assert.Null(await viewModel.StartRuntimeAsync(runtime)); + Assert.True(runtime.Simulator.IsRunning); + + viewModel.StopRuntime(runtime); + Assert.True(runtime.SimulationRequested); + Assert.False(runtime.Simulator.IsRunning); + + Assert.Null(await viewModel.StartRuntimeAsync(runtime)); + Assert.True(runtime.Simulator.IsRunning); + } + finally + { + viewModel.SetSimulationRequested(runtime, requested: false); + viewModel.StopRuntime(runtime); + } + } + + private static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task WaitUntilAsync(Func condition) + { + var timeout = Stopwatch.StartNew(); + while (!condition() && timeout.Elapsed < TimeSpan.FromSeconds(2)) + { + await Task.Delay(25); + } + + Assert.True(condition(), "Timed out waiting for simulation activity."); + } +} + +public sealed class ReactionRuntimeTests +{ + [Fact] + public void IncrementReactionUsesConfiguredStepForTypedWatches() + { + var profile = ServerProfile.CreateDefault("Reaction", "127.0.0.1", 1502); + profile.ReactionTriggerScope = WriteReactionTriggerScope.ClientAndLocalWrites; + profile.WatchItems.Add(new WatchItem + { + Name = "Counter", + Area = MemoryArea.HoldingRegisters, + Address = 10, + DataType = WatchDataTypes.UInt32, + Count = 2 + }); + profile.WriteReactionRules.Add(new WriteReactionRule + { + TriggerArea = MemoryArea.HoldingRegisters, + TriggerAddress = 0, + MatchValue = "1", + Action = WriteReactionAction.IncrementRegister, + TargetKind = WriteReactionEndpointKind.Watch, + TargetWatchName = "Counter", + Value = "5" + }); + var runtime = new ServerRuntime(profile); + runtime.Store.WriteFormattedWatchValue( + MemoryArea.HoldingRegisters, + 10, + WatchDataTypes.UInt32, + 2, + WatchStringFormats.AsciiTwoCharsPerRegister, + "65536", + profile.ByteOrder, + profile.WordOrder, + markAccess: false); + + runtime.Store.WriteRegister(MemoryArea.HoldingRegisters, 0, 1, markAccess: false); + runtime.Server.ApplyLocalWriteReactions(MemoryArea.HoldingRegisters, 0, 1); + + Assert.Equal( + "65541", + runtime.Store.ReadFormattedWatchValue( + MemoryArea.HoldingRegisters, + 10, + WatchDataTypes.UInt32, + 2, + WatchStringFormats.AsciiTwoCharsPerRegister, + profile.ByteOrder, + profile.WordOrder, + markAccess: false)); + } +} diff --git a/ModbusLab/App.xaml.cs b/ModbusLab/App.xaml.cs index d20ba0e..e8e8ff2 100644 --- a/ModbusLab/App.xaml.cs +++ b/ModbusLab/App.xaml.cs @@ -26,12 +26,25 @@ protected override void OnStartup(StartupEventArgs e) ProjectStore.ConfigurePath(AppRuntimeOptions.Current.SettingsPath); ProjectStore.SuppressSave = AppRuntimeOptions.Current.IsAutomationRun; - var project = ProjectStore.Load(); - WpfThemeService.Apply(AppRuntimeOptions.Current.ResolveTheme(project.EffectiveThemeMode)); + var loadResult = ProjectStore.Load(); + WpfThemeService.Apply(AppRuntimeOptions.Current.ResolveTheme(loadResult.Project.EffectiveThemeMode)); base.OnStartup(e); - MainWindow = new MainWindow(); + if (!loadResult.CanContinue) + { + MessageBox.Show( + $"{loadResult.ErrorMessage}\r\n\r\nThe active project was not changed.\r\n{ProjectStore.CurrentPath}", + loadResult.RecoveryState == ProjectRecoveryState.UnsupportedVersion + ? "Unsupported project version" + : "Project could not be opened", + MessageBoxButton.OK, + MessageBoxImage.Error); + Shutdown(); + return; + } + + MainWindow = new MainWindow(loadResult); MainWindow.Show(); } diff --git a/ModbusLab/Mcp/ModbusLabMcpFacade.cs b/ModbusLab/Mcp/ModbusLabMcpFacade.cs index 6bfc453..b08cf6b 100644 --- a/ModbusLab/Mcp/ModbusLabMcpFacade.cs +++ b/ModbusLab/Mcp/ModbusLabMcpFacade.cs @@ -1,6 +1,7 @@ using ModbusLab.Models; using ModbusLab.Runtime; using ModbusLab.ViewModels; +using ModbusLab.Validation; using System.Globalization; using System.IO; using System.Text.Json; @@ -37,6 +38,8 @@ public Task GetProjectSummaryAsync(CancellationToken cancellationToken) _viewModel.Project.Mcp.AllowMutations }, host = _mcpHostStatusFactory?.Invoke(), + projectWarnings = _viewModel.ProjectWarnings, + lastSaveError = _viewModel.LastSaveError, diagnostics = McpDiagnosticLog.Snapshot().Take(25) }, cancellationToken); @@ -51,6 +54,7 @@ public Task ListServersAsync(CancellationToken cancellationToken) => x.Profile.UnitId, x.Server.IsRunning, simulationRunning = x.Simulator.IsRunning, + simulationRequested = x.SimulationRequested, watches = x.Profile.WatchItems.Count, simulationRules = x.Profile.SimulationRules.Count, faultRules = x.Profile.FaultRules.Count, @@ -166,6 +170,8 @@ public Task GetRuntimeDiagnosticsAsync(string? server, CancellationToken clients = runtime.Server.GetClientsSnapshot(), blocks = runtime.Server.GetBlocksSnapshot().Take(500), logs = runtime.Server.GetLogsSnapshot().Take(500), + projectWarnings = _viewModel.ProjectWarnings, + lastSaveError = _viewModel.LastSaveError, mcp = McpDiagnosticLog.Snapshot().Take(100) }; }, cancellationToken); @@ -188,8 +194,9 @@ public Task CreateServerAsync(string? name, string? bindAddress, int por { var serverName = NextUniqueServerName(string.IsNullOrWhiteSpace(name) ? $"Server {_viewModel.Project.Servers.Count + 1}" : name.Trim()); var serverBindAddress = string.IsNullOrWhiteSpace(bindAddress) ? "0.0.0.0" : bindAddress.Trim(); - var serverPort = port <= 0 ? _viewModel.NextAvailablePort() : Math.Clamp(port, 1, 65535); + var serverPort = port == 0 ? _viewModel.NextAvailablePort() : port; var profile = ServerProfile.CreateDefault(serverName, serverBindAddress, serverPort); + DomainValidator.ThrowIfInvalid(DomainValidator.ValidateServerSettings(profile)); if (!dryRun) { _viewModel.Project.Servers.Add(profile); @@ -210,6 +217,8 @@ public Task CloneServerAsync(string server, string? name, bool dryRun, C var source = ResolveRuntime(server); var cloneName = NextUniqueServerName(string.IsNullOrWhiteSpace(name) ? $"{source.Profile.Name} copy" : name.Trim()); var clone = source.Profile.CloneAs(cloneName, _viewModel.NextAvailablePort()); + DomainValidator.ThrowIfInvalid(new ValidationResult(clone, DomainValidator.ValidateProject( + new ProjectModel { Servers = [clone] }))); if (!dryRun) { _viewModel.Project.Servers.Add(clone); @@ -286,13 +295,14 @@ public Task UpdateMcpSettingsAsync(string settingsJson, bool restartHost var next = new McpSettings { Enabled = update.Enabled ?? current.Enabled, - Port = Math.Clamp(update.Port ?? current.Port, 1024, 65535), + Port = update.Port ?? current.Port, AccessToken = update.RegenerateToken == true ? McpSettings.GenerateAccessToken() : string.IsNullOrWhiteSpace(update.AccessToken) ? current.AccessToken : update.AccessToken, RequireToken = update.RequireToken ?? current.RequireToken, AllowMutations = update.AllowMutations ?? current.AllowMutations }; + DomainValidator.ThrowIfInvalid(DomainValidator.ValidateMcpSettings(next)); if (!dryRun) { @@ -382,17 +392,16 @@ public Task SetSimulationAsync(string? server, bool running, bool dryRun var runtime = ResolveRuntime(server); if (!dryRun) { - if (running) - { - runtime.Simulator.Start(); - } - else - { - runtime.Simulator.Stop(); - } + _viewModel.SetSimulationRequested(runtime, running); } - return new { server = runtime.Profile.Name, simulationRunning = running, dryRun }; + return new + { + server = runtime.Profile.Name, + simulationRequested = running, + simulationRunning = !dryRun && runtime.Simulator.IsRunning, + dryRun + }; }, cancellationToken); public Task WriteMemoryAsync(string? server, string area, int start, string valuesJson, bool dryRun, CancellationToken cancellationToken) => @@ -449,7 +458,7 @@ public Task CaptureStartupSnapshotAsync(string? server, bool dryRun, Can var runtime = ResolveRuntime(server); if (!dryRun) { - runtime.Profile.StartupSnapshot = runtime.Profile.Memory.Clone(); + runtime.Profile.StartupSnapshot = runtime.Store.CreateMemorySnapshot(); runtime.Profile.StartupMemoryMode = StartupMemoryMode.RestoreSnapshot; _viewModel.SaveNow(); } @@ -462,11 +471,14 @@ public Task WriteWatchAsync(string? server, string watchName, string val { var runtime = ResolveRuntime(server); var watch = ResolveWatch(runtime, watchName); - if (dryRun) - { - ValidateWatchValue(runtime, watch, value); - } - else + var watchValidation = DomainValidator.ValidateWatch(watch, runtime.Profile.WatchItems, watch); + DomainValidator.ThrowIfInvalid(watchValidation); + DomainValidator.ThrowIfInvalid(DomainValidator.ValidateWatchValue( + watchValidation.NormalizedValue, + value, + runtime.Profile.ByteOrder, + runtime.Profile.WordOrder)); + if (!dryRun) { _viewModel.WriteWatchValue(runtime, watch, value); } @@ -479,10 +491,12 @@ public Task UpsertWatchAsync(string? server, string watchJson, string? e { var runtime = ResolveRuntime(server); var watch = Deserialize(watchJson, "watch"); - MainWindowViewModel.NormalizeWatchItem(watch); + var existing = string.IsNullOrWhiteSpace(existingName) ? null : ResolveWatch(runtime, existingName); + var validation = DomainValidator.ValidateWatch(watch, runtime.Profile.WatchItems, existing); + DomainValidator.ThrowIfInvalid(validation); + watch = validation.NormalizedValue; if (!dryRun) { - var existing = string.IsNullOrWhiteSpace(existingName) ? null : FindWatch(runtime, existingName); if (existing is null) { runtime.Profile.WatchItems.Add(watch); @@ -643,9 +657,10 @@ public Task ImportServerAsync(string path, bool dryRun, CancellationToke RunMutation("import_server", dryRun, () => { var profile = ServerProfileTransfer.Import(path); + var preview = _viewModel.PrepareImportedServer(profile); if (dryRun) { - return new { source = Path.GetFullPath(path), importedName = profile.Name, dryRun }; + return new { source = Path.GetFullPath(path), importedName = preview.Name, dryRun }; } var runtime = _viewModel.ImportServer(profile); @@ -677,7 +692,7 @@ public Task SetStartupMemoryModeAsync(string? server, string mode, bool RunMutation("set_startup_memory_mode", dryRun, () => { var runtime = ResolveRuntime(server); - if (!Enum.TryParse(mode, ignoreCase: true, out var startupMode)) + if (!Enum.TryParse(mode, ignoreCase: true, out var startupMode) || !Enum.IsDefined(startupMode)) { throw new ArgumentException("mode must be KeepLastState, ClearOnStartup, or RestoreSnapshot."); } @@ -697,24 +712,64 @@ public Task SetStartupMemoryModeAsync(string? server, string mode, bool }, cancellationToken); public Task ValidateMemoryWriteAsync(string? server, string area, int start, string valuesJson, CancellationToken cancellationToken) => - WriteMemoryAsync(server, area, start, valuesJson, dryRun: true, cancellationToken); + RunRead("validate_memory_write", () => + { + try + { + var runtime = ResolveRuntime(server); + var memoryArea = ParseArea(area); + Array values = IsBitArea(memoryArea) ? ParseBoolArray(valuesJson) : ParseUShortArray(valuesJson); + ValidateRange(runtime, memoryArea, start, values.Length); + return ValidationResponse(values); + } + catch (Exception ex) when (ex is JsonException or FormatException or OverflowException or ArgumentException) + { + return InvalidValidationResponse(ex.Message); + } + }, cancellationToken); public Task ValidateWatchWriteAsync(string? server, string watchName, string value, CancellationToken cancellationToken) => - WriteWatchAsync(server, watchName, value, dryRun: true, cancellationToken); + RunRead("validate_watch_write", () => + { + try + { + var runtime = ResolveRuntime(server); + var watch = ResolveWatch(runtime, watchName); + var result = DomainValidator.ValidateWatchValue(watch, value, runtime.Profile.ByteOrder, runtime.Profile.WordOrder); + return ValidationResponse(result); + } + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) + { + return InvalidValidationResponse(ex.Message); + } + }, cancellationToken); public Task ValidateRulePayloadAsync(string ruleKind, string payloadJson, CancellationToken cancellationToken) => RunRead("validate_rule_payload", () => { var normalized = ruleKind.Trim().ToLowerInvariant(); - object rule = normalized switch + try { - "simulation" or "simulationrule" => Deserialize(payloadJson, "simulation rule"), - "fault" or "faultrule" => Deserialize(payloadJson, "fault rule"), - "reaction" or "writereaction" or "writereactionrule" => Deserialize(payloadJson, "write reaction"), - _ => throw new ArgumentException("ruleKind must be simulation, fault, or reaction.") - }; - - return new { valid = true, ruleKind = normalized, rule }; + var runtime = _viewModel.SelectedRuntime; + return normalized switch + { + "simulation" or "simulationrule" => ValidationResponse( + DomainValidator.ValidateSimulationRule( + Deserialize(payloadJson, "simulation rule"), + runtime?.Profile.WatchItems)), + "fault" or "faultrule" => ValidationResponse( + DomainValidator.ValidateFaultRule(Deserialize(payloadJson, "fault rule"))), + "reaction" or "writereaction" or "writereactionrule" => ValidationResponse( + DomainValidator.ValidateReaction( + Deserialize(payloadJson, "write reaction"), + runtime?.Profile.WatchItems)), + _ => InvalidValidationResponse("ruleKind must be simulation, fault, or reaction.", nameof(ruleKind)) + }; + } + catch (Exception ex) when (ex is InvalidDataException or JsonException or ArgumentException) + { + return InvalidValidationResponse(ex.Message); + } }, cancellationToken); private Task UpsertRule( @@ -730,6 +785,9 @@ private Task UpsertRule( { var runtime = ResolveRuntime(server); var rule = Deserialize(payloadJson, typeof(T).Name); + var validation = ValidateRule(runtime, rule); + DomainValidator.ThrowIfInvalid(validation); + rule = validation.NormalizedValue; var list = selector(runtime.Profile); var action = index < 0 ? "add" : "update"; if (index >= list.Count) @@ -863,11 +921,18 @@ private ServerRuntime ResolveRuntime(string? server) ?? throw new InvalidOperationException($"Server '{server}' was not found."); } - private static WatchItem ResolveWatch(ServerRuntime runtime, string watchName) => - FindWatch(runtime, watchName) ?? throw new InvalidOperationException($"Watch '{watchName}' was not found."); - - private static WatchItem? FindWatch(ServerRuntime runtime, string watchName) => - runtime.Profile.WatchItems.FirstOrDefault(x => string.Equals(x.Name, watchName, StringComparison.OrdinalIgnoreCase)); + private static WatchItem ResolveWatch(ServerRuntime runtime, string watchName) + { + var matches = runtime.Profile.WatchItems + .Where(x => string.Equals(x.Name, watchName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + return matches.Count switch + { + 1 => matches[0], + 0 => throw new InvalidOperationException($"Watch '{watchName}' was not found."), + _ => throw new InvalidOperationException($"Watch '{watchName}' is ambiguous because the name is duplicated.") + }; + } private string NextUniqueServerName(string baseName) { @@ -887,26 +952,6 @@ private string NextUniqueServerName(string baseName) } } - private static void ValidateWatchValue(ServerRuntime runtime, WatchItem watch, string value) - { - MainWindowViewModel.NormalizeWatchItem(watch); - if (watch.Area is MemoryArea.Coils or MemoryArea.DiscreteInputs) - { - runtime.Store.ReadBits(watch.Area, watch.Address, 1, markAccess: false); - } - else - { - var count = watch.DataType == WatchDataTypes.String - ? Math.Clamp(watch.Count, 1, 125) - : WatchDataTypes.RegisterCount(watch.DataType); - runtime.Store.ReadRegisters(watch.Area, watch.Address, count, markAccess: false); - } - - var clone = runtime.Profile.Memory.Clone(); - var store = new ModbusDataStore(clone); - store.WriteFormattedWatchValue(watch.Area, watch.Address, watch.DataType, watch.Count, watch.StringFormat, value, runtime.Profile.ByteOrder, runtime.Profile.WordOrder, markAccess: false); - } - private static void CopyWatch(WatchItem source, WatchItem target) { target.Enabled = source.Enabled; @@ -918,8 +963,52 @@ private static void CopyWatch(WatchItem source, WatchItem target) target.StringFormat = source.StringFormat; } + private static ValidationResult ValidateRule(ServerRuntime runtime, T rule) + { + if (rule is SimulationRule simulationRule) + { + var result = DomainValidator.ValidateSimulationRule(simulationRule, runtime.Profile.WatchItems); + return new ValidationResult((T)(object)result.NormalizedValue, result.Issues); + } + + if (rule is FaultRule faultRule) + { + var result = DomainValidator.ValidateFaultRule(faultRule); + return new ValidationResult((T)(object)result.NormalizedValue, result.Issues); + } + + if (rule is WriteReactionRule reactionRule) + { + var result = DomainValidator.ValidateReaction(reactionRule, runtime.Profile.WatchItems); + return new ValidationResult((T)(object)result.NormalizedValue, result.Issues); + } + + return new ValidationResult(rule, []); + } + + private static object ValidationResponse(ValidationResult result) => new + { + valid = result.IsValid, + normalized = result.NormalizedValue, + errors = result.Issues + }; + + private static object ValidationResponse(object normalized) => new + { + valid = true, + normalized, + errors = Array.Empty() + }; + + private static object InvalidValidationResponse(string message, string field = "$") => new + { + valid = false, + normalized = (object?)null, + errors = new[] { new ValidationIssue(field, message) } + }; + private static MemoryArea ParseArea(string area) => - Enum.TryParse(area, ignoreCase: true, out var result) + Enum.TryParse(area, ignoreCase: true, out var result) && Enum.IsDefined(result) ? result : throw new ArgumentException($"Unknown memory area '{area}'."); @@ -1009,14 +1098,15 @@ private static T Deserialize(string json, string label) runtime.Profile.Name, runtime.Profile.Endpoint, runtime.Server.IsRunning, - simulationRunning = runtime.Simulator.IsRunning + simulationRunning = runtime.Simulator.IsRunning, + simulationRequested = runtime.SimulationRequested }; private static object ApplyServerSettings(ServerProfile profile, ServerSettingsUpdate update, bool apply) { var nextName = string.IsNullOrWhiteSpace(update.Name) ? profile.Name : update.Name.Trim(); var nextBindAddress = string.IsNullOrWhiteSpace(update.BindAddress) ? profile.BindAddress : update.BindAddress.Trim(); - var nextPort = Math.Clamp(update.Port ?? profile.Port, 1, 65535); + var nextPort = update.Port ?? profile.Port; var nextUnitId = update.ClearUnitId == true ? null : update.UnitId ?? profile.UnitId; if (nextUnitId is < 0 or > 255) { @@ -1028,12 +1118,32 @@ private static object ApplyServerSettings(ServerProfile profile, ServerSettingsU var nextWriteMode = ParseEnumOrDefault(update.WriteMode, profile.WriteMode); var nextReactionScope = ParseEnumOrDefault(update.ReactionTriggerScope, profile.ReactionTriggerScope); var nextStartupMode = ParseEnumOrDefault(update.StartupMemoryMode, profile.StartupMemoryMode); - var nextResponseDelay = Math.Clamp(update.ResponseDelayMs ?? profile.ResponseDelayMs, 0, 60_000); - var nextMaxRps = Math.Clamp(update.MaxRequestsPerSecond ?? profile.MaxRequestsPerSecond, 0, 100_000); - var nextLogCapacity = Math.Clamp(update.LogCapacity ?? profile.LogCapacity, 100, 500); - var nextAddressBase = Math.Clamp(update.AddressBase ?? profile.AddressBase, 0, 1_000_000); + var nextResponseDelay = update.ResponseDelayMs ?? profile.ResponseDelayMs; + var nextMaxRps = update.MaxRequestsPerSecond ?? profile.MaxRequestsPerSecond; + var nextLogCapacity = update.LogCapacity ?? profile.LogCapacity; + var nextAddressBase = update.AddressBase ?? profile.AddressBase; var nextShowClassic = update.ShowClassicAddresses ?? profile.ShowClassicAddresses; + var candidate = new ServerProfile + { + Name = nextName, + BindAddress = nextBindAddress, + Port = nextPort, + UnitId = nextUnitId, + ByteOrder = nextByteOrder, + WordOrder = nextWordOrder, + WriteMode = nextWriteMode, + ReactionTriggerScope = nextReactionScope, + StartupMemoryMode = nextStartupMode, + StartupSnapshot = profile.StartupSnapshot, + ResponseDelayMs = nextResponseDelay, + MaxRequestsPerSecond = nextMaxRps, + LogCapacity = nextLogCapacity, + AddressBase = nextAddressBase, + ShowClassicAddresses = nextShowClassic + }; + DomainValidator.ThrowIfInvalid(DomainValidator.ValidateServerSettings(candidate)); + if (apply) { profile.Name = nextName; diff --git a/ModbusLab/Mcp/ModbusLabMcpTools.cs b/ModbusLab/Mcp/ModbusLabMcpTools.cs index 659c2ff..4fd68c5 100644 --- a/ModbusLab/Mcp/ModbusLabMcpTools.cs +++ b/ModbusLab/Mcp/ModbusLabMcpTools.cs @@ -69,7 +69,7 @@ public Task UpdateServerSettings( [Description("When true, validates the operation without changing state.")] bool dryRun, CancellationToken cancellationToken) => facade.UpdateServerSettingsAsync(server, settingsJson, dryRun, cancellationToken); - [McpServerTool(Name = "modbuslab_update_mcp_settings", Title = "Update MCP settings", Destructive = false), Description("Updates MCP settings from a JSON payload. Supported fields: Enabled, Port, AccessToken, RegenerateToken, RequireToken, AllowMutations. restartHost=true applies host changes after returning the response and may disconnect this MCP client.")] + [McpServerTool(Name = "modbuslab_update_mcp_settings", Title = "Update MCP settings", Destructive = true), Description("Updates MCP settings from a JSON payload. Supported fields: Enabled, Port, AccessToken, RegenerateToken, RequireToken, AllowMutations. A remote client cannot enable mutations while they are disabled; use the local UI to grant that permission. restartHost=true applies host changes after returning the response and may disconnect this MCP client.")] public Task UpdateMcpSettings( [Description("MCP settings update JSON payload. Omitted fields keep current values.")] string settingsJson, [Description("When true, restarts the MCP host after returning the response. This may disconnect the current client.")] bool restartHost, diff --git a/ModbusLab/Models/McpSettings.cs b/ModbusLab/Models/McpSettings.cs index ca403e3..3a08328 100644 --- a/ModbusLab/Models/McpSettings.cs +++ b/ModbusLab/Models/McpSettings.cs @@ -8,7 +8,7 @@ public sealed class McpSettings public int Port { get; set; } = 8765; public string AccessToken { get; set; } = GenerateAccessToken(); public bool RequireToken { get; set; } = true; - public bool AllowMutations { get; set; } = true; + public bool AllowMutations { get; set; } public static string GenerateAccessToken() { diff --git a/ModbusLab/Models/ProjectModel.cs b/ModbusLab/Models/ProjectModel.cs index e6b0ef1..0e24c69 100644 --- a/ModbusLab/Models/ProjectModel.cs +++ b/ModbusLab/Models/ProjectModel.cs @@ -3,6 +3,7 @@ namespace ModbusLab.Models; public sealed class ProjectModel { + public int FormatVersion { get; set; } = ProjectStore.CurrentFormatVersion; public List Servers { get; set; } = []; public AppThemeMode? ThemeMode { get; set; } public int AddressBase { get; set; } diff --git a/ModbusLab/Models/ProjectStore.cs b/ModbusLab/Models/ProjectStore.cs index de4ef9a..2e731d6 100644 --- a/ModbusLab/Models/ProjectStore.cs +++ b/ModbusLab/Models/ProjectStore.cs @@ -1,13 +1,16 @@ using System.IO; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using ModbusLab.Validation; namespace ModbusLab.Models; + public static class ProjectStore { + public const int CurrentFormatVersion = 2; + private const int MaxBackupCount = 30; - private const int MinLogCapacity = 100; - private const int MaxLogCapacity = 500; private static string? _configuredPath; public static JsonSerializerOptions JsonOptions { get; } = new() @@ -30,55 +33,61 @@ public static string DefaultPath get { var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ModbusLab"); - Directory.CreateDirectory(dir); return Path.Combine(dir, "ModbusLab.modbuslab.json"); } } public static string CurrentPath => _configuredPath ?? DefaultPath; - public static ProjectModel Load() + public static ProjectLoadResult Load() => Load(PreserveCorruptProject); + + internal static ProjectLoadResult Load(Func corruptFilePreserver) { - try + ArgumentNullException.ThrowIfNull(corruptFilePreserver); + if (!File.Exists(CurrentPath)) { - if (!File.Exists(CurrentPath)) - { - var defaultModel = ProjectModel.CreateDefault(); - defaultModel.IsFirstRun = true; - return defaultModel; - } - - var json = File.ReadAllText(CurrentPath); - var model = JsonSerializer.Deserialize(json, JsonOptions) ?? ProjectModel.CreateDefault(); - NormalizeProject(model); - if (model.Servers.Count == 0) - { - model.Servers.Add(ServerProfile.CreateDefault("Server 1", "0.0.0.0", 502)); - } - - foreach (var server in model.Servers) - { - NormalizeServer(server); - server.Memory.EnsureDefaultSizes(); - server.StartupSnapshot?.EnsureDefaultSizes(); - ApplyStartupMemoryPolicy(server); - } + var defaultModel = ProjectModel.CreateDefault(); + defaultModel.IsFirstRun = true; + return CreateLoadResult(defaultModel, ProjectLoadSource.NewProject, ProjectRecoveryState.None, + CurrentFormatVersion, []); + } - return model; + try + { + var loaded = LoadProjectFile(CurrentPath); + return CreateLoadResult(loaded.Model, ProjectLoadSource.ActiveFile, ProjectRecoveryState.None, + loaded.SourceFormatVersion, loaded.AppliedMigrations); } - catch + catch (UnsupportedProjectVersionException ex) { - return ProjectModel.CreateDefault(); + return new ProjectLoadResult( + ProjectModel.CreateDefault(), + ProjectLoadSource.UnsupportedVersion, + ProjectRecoveryState.UnsupportedVersion, + ex.FormatVersion, + [], + [], + ErrorMessage: ex.Message); + } + catch (Exception ex) when (IsCorruptProjectException(ex)) + { + return RecoverCorruptProject(ex, corruptFilePreserver); + } + catch (Exception ex) + { + return CreateBlockedResult($"The project could not be read: {ex.Message}"); } } public static void NormalizeProject(ProjectModel model) { + model.FormatVersion = CurrentFormatVersion; + model.Servers ??= []; model.SkippedUpdateVersion = string.IsNullOrWhiteSpace(model.SkippedUpdateVersion) ? null : model.SkippedUpdateVersion.Trim(); model.Mcp ??= new McpSettings(); - model.Mcp.Port = Math.Clamp(model.Mcp.Port, 1024, 65535); + model.UiState ??= new UiState(); if (string.IsNullOrWhiteSpace(model.Mcp.AccessToken)) { model.Mcp.AccessToken = McpSettings.GenerateAccessToken(); @@ -87,7 +96,6 @@ public static void NormalizeProject(ProjectModel model) public static void NormalizeServer(ServerProfile server) { - server.LogCapacity = Math.Clamp(server.LogCapacity, MinLogCapacity, MaxLogCapacity); server.SimulationRules ??= []; server.FaultRules ??= []; server.WriteReactionRules ??= []; @@ -97,76 +105,317 @@ public static void NormalizeServer(ServerProfile server) server.StartupSnapshot?.EnsureDefaultSizes(); } - public static void Save(ProjectModel model, bool createBackup = true) + public static ProjectSaveResult Save(ProjectModel model, bool createBackup = true) { if (SuppressSave) { - return; + return new ProjectSaveResult(true, true, CurrentPath); } - Directory.CreateDirectory(Path.GetDirectoryName(CurrentPath)!); + var temporaryPath = string.Empty; + try + { + var directory = Path.GetDirectoryName(CurrentPath) + ?? throw new InvalidOperationException("The project path has no parent directory."); + Directory.CreateDirectory(directory); + + model.FormatVersion = CurrentFormatVersion; + var json = JsonSerializer.Serialize(model, JsonOptions); + temporaryPath = Path.Combine(directory, $".{Path.GetFileName(CurrentPath)}.{Guid.NewGuid():N}.tmp"); + WriteDurableFile(temporaryPath, json); - if (createBackup) + // Verify the exact bytes that will replace the active file before touching it. + var verified = LoadProjectFile(temporaryPath); + if (verified.SourceFormatVersion != CurrentFormatVersion) + { + throw new InvalidDataException("The serialized project failed format verification."); + } + + if (createBackup) + { + BackupExistingConfig(); + } + + if (File.Exists(CurrentPath)) + { + File.Replace(temporaryPath, CurrentPath, null, ignoreMetadataErrors: true); + } + else + { + File.Move(temporaryPath, CurrentPath); + } + + temporaryPath = string.Empty; + PruneBackups(); + return new ProjectSaveResult(true, false, CurrentPath, DateTimeOffset.Now); + } + catch (Exception ex) { - BackupExistingConfig(); + return new ProjectSaveResult(false, false, CurrentPath, ErrorMessage: ex.Message); } - else + finally { - PruneBackups(); + if (!string.IsNullOrEmpty(temporaryPath)) + { + try + { + File.Delete(temporaryPath); + } + catch + { + // Cleanup is best-effort; the active project was never replaced. + } + } } - - var json = JsonSerializer.Serialize(model, JsonOptions); - File.WriteAllText(CurrentPath, json); } - private static void ApplyStartupMemoryPolicy(ServerProfile server) + private static ProjectLoadResult RecoverCorruptProject(Exception activeFileError, Func corruptFilePreserver) { - switch (server.StartupMemoryMode) + LoadedProject? recovered = null; + string? backupPath = null; + + try + { + foreach (var candidate in EnumerateBackupsNewestFirst()) + { + try + { + recovered = LoadProjectFile(candidate.FullName); + backupPath = candidate.FullName; + break; + } + catch (Exception ex) when (IsCorruptProjectException(ex) || + ex is UnsupportedProjectVersionException or IOException or UnauthorizedAccessException) + { + // Keep searching: a newer backup may itself be corrupt or unsupported. + } + } + } + catch (Exception ex) + { + return CreateBlockedResult($"Backups could not be inspected: {ex.Message}"); + } + + string corruptFilePath; + try + { + corruptFilePath = corruptFilePreserver(); + } + catch (Exception ex) { - case StartupMemoryMode.ClearOnStartup: - server.Memory = MemoryImage.CreateDefault(); - break; - case StartupMemoryMode.RestoreSnapshot when server.StartupSnapshot is not null: - server.Memory = server.StartupSnapshot.Clone(); - break; + return CreateBlockedResult( + $"The corrupt project could not be preserved, so recovery was stopped: {ex.Message}"); } + + if (recovered is not null) + { + recovered.Model.IsFirstRun = false; + var result = CreateLoadResult( + recovered.Model, + ProjectLoadSource.Backup, + ProjectRecoveryState.RecoveredFromBackup, + recovered.SourceFormatVersion, + recovered.AppliedMigrations, + backupPath, + corruptFilePath, + activeFileError.Message); + return result; + } + + var newProject = ProjectModel.CreateDefault(); + newProject.IsFirstRun = true; + return CreateLoadResult( + newProject, + ProjectLoadSource.NewProject, + ProjectRecoveryState.NeedsNewProjectConfirmation, + CurrentFormatVersion, + [], + corruptFilePath: corruptFilePath, + errorMessage: activeFileError.Message); } - private static void BackupExistingConfig() + private static ProjectLoadResult CreateLoadResult( + ProjectModel model, + ProjectLoadSource source, + ProjectRecoveryState recoveryState, + int? sourceFormatVersion, + IReadOnlyList appliedMigrations, + string? backupPath = null, + string? corruptFilePath = null, + string? errorMessage = null) { - try + var warnings = DomainValidator.ValidateProject(model); + return new ProjectLoadResult( + model, + source, + recoveryState, + sourceFormatVersion, + appliedMigrations, + warnings, + backupPath, + corruptFilePath, + errorMessage); + } + + private static ProjectLoadResult CreateBlockedResult(string errorMessage) + { + return new ProjectLoadResult( + ProjectModel.CreateDefault(), + ProjectLoadSource.Blocked, + ProjectRecoveryState.Blocked, + null, + [], + [], + ErrorMessage: errorMessage); + } + + private static LoadedProject LoadProjectFile(string path) + { + var json = File.ReadAllText(path); + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) { - if (!File.Exists(CurrentPath)) + throw new InvalidDataException("The project root must be a JSON object."); + } + + var sourceFormatVersion = 1; + if (document.RootElement.TryGetProperty(nameof(ProjectModel.FormatVersion), out var formatElement)) + { + if (formatElement.ValueKind != JsonValueKind.Number || !formatElement.TryGetInt32(out sourceFormatVersion)) { - return; + throw new InvalidDataException("FormatVersion must be an integer."); } + } - var backupDir = Path.Combine(Path.GetDirectoryName(CurrentPath)!, "Backups"); - Directory.CreateDirectory(backupDir); - var backupPath = Path.Combine(backupDir, $"ModbusLab.{DateTime.Now:yyyyMMdd.HHmmss}.modbuslab.json"); - File.Copy(CurrentPath, backupPath, overwrite: false); + if (sourceFormatVersion > CurrentFormatVersion) + { + throw new UnsupportedProjectVersionException(sourceFormatVersion); + } - PruneBackups(); + if (sourceFormatVersion < 1) + { + throw new InvalidDataException($"FormatVersion {sourceFormatVersion} is invalid."); } - catch + + var model = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidDataException("The project did not contain a model."); + var migrations = new List(); + + if (sourceFormatVersion == 1) { - // Backups are best-effort and must not prevent saving the active project. + MigrateV1ToV2(model); + migrations.Add("v1 -> v2"); } + + NormalizeProject(model); + if (model.Servers.Count == 0) + { + model.Servers.Add(ServerProfile.CreateDefault("Server 1", "0.0.0.0", 502)); + } + + foreach (var server in model.Servers) + { + NormalizeServer(server); + } + + return new LoadedProject(model, sourceFormatVersion, migrations); + } + + private static void MigrateV1ToV2(ProjectModel model) + { + // v2 makes the format version explicit. Existing values are intentionally preserved. + model.FormatVersion = CurrentFormatVersion; + } + + private static bool IsCorruptProjectException(Exception exception) + { + return exception is JsonException or InvalidDataException or NotSupportedException; + } + + private static IEnumerable EnumerateBackupsNewestFirst() + { + var backupDirectory = Path.Combine(Path.GetDirectoryName(CurrentPath)!, "Backups"); + if (!Directory.Exists(backupDirectory)) + { + return []; + } + + return Directory.GetFiles(backupDirectory, "*.modbuslab.json") + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .ToArray(); + } + + private static string PreserveCorruptProject() + { + var directory = Path.GetDirectoryName(CurrentPath)!; + var fileName = Path.GetFileName(CurrentPath); + var stem = fileName.EndsWith(".modbuslab.json", StringComparison.OrdinalIgnoreCase) + ? fileName[..^".modbuslab.json".Length] + : Path.GetFileNameWithoutExtension(fileName); + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd.HHmmss.fff"); + var destination = Path.Combine(directory, $"{stem}.corrupt.{timestamp}.modbuslab.json"); + var suffix = 0; + while (File.Exists(destination)) + { + suffix++; + destination = Path.Combine(directory, $"{stem}.corrupt.{timestamp}.{suffix}.modbuslab.json"); + } + + File.Copy(CurrentPath, destination, overwrite: false); + return destination; + } + + private static void WriteDurableFile(string path, string content) + { + using var stream = new FileStream(path, new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.WriteThrough + }); + using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + bufferSize: 4096, leaveOpen: true); + writer.Write(content); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + private static void BackupExistingConfig() + { + if (!File.Exists(CurrentPath)) + { + return; + } + + var backupDirectory = Path.Combine(Path.GetDirectoryName(CurrentPath)!, "Backups"); + Directory.CreateDirectory(backupDirectory); + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd.HHmmss.fff"); + var backupPath = Path.Combine(backupDirectory, $"ModbusLab.{timestamp}.modbuslab.json"); + var suffix = 0; + while (File.Exists(backupPath)) + { + suffix++; + backupPath = Path.Combine(backupDirectory, $"ModbusLab.{timestamp}.{suffix}.modbuslab.json"); + } + + File.Copy(CurrentPath, backupPath, overwrite: false); } private static void PruneBackups() { try { - var backupDir = Path.Combine(Path.GetDirectoryName(CurrentPath)!, "Backups"); - if (!Directory.Exists(backupDir)) + var backupDirectory = Path.Combine(Path.GetDirectoryName(CurrentPath)!, "Backups"); + if (!Directory.Exists(backupDirectory)) { return; } - foreach (var file in Directory.GetFiles(backupDir, "*.modbuslab.json") - .Select(x => new FileInfo(x)) - .OrderByDescending(x => x.CreationTimeUtc) + foreach (var file in Directory.GetFiles(backupDirectory, "*.modbuslab.json") + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTimeUtc) .Skip(MaxBackupCount)) { file.Delete(); @@ -174,8 +423,18 @@ private static void PruneBackups() } catch { - // Backup cleanup is best-effort. + // Cleanup is best-effort and never changes the success of an atomic save. } } -} + private sealed record LoadedProject( + ProjectModel Model, + int SourceFormatVersion, + IReadOnlyList AppliedMigrations); + + private sealed class UnsupportedProjectVersionException(int formatVersion) : Exception( + $"Project format version {formatVersion} is newer than the supported version {CurrentFormatVersion}.") + { + public int FormatVersion { get; } = formatVersion; + } +} diff --git a/ModbusLab/Models/ProjectStoreResults.cs b/ModbusLab/Models/ProjectStoreResults.cs new file mode 100644 index 0000000..5b0d221 --- /dev/null +++ b/ModbusLab/Models/ProjectStoreResults.cs @@ -0,0 +1,43 @@ +using ModbusLab.Validation; + +namespace ModbusLab.Models; + +public enum ProjectLoadSource +{ + ActiveFile, + Backup, + NewProject, + UnsupportedVersion, + Blocked +} + +public enum ProjectRecoveryState +{ + None, + RecoveredFromBackup, + NeedsNewProjectConfirmation, + UnsupportedVersion, + Blocked +} + +public sealed record ProjectLoadResult( + ProjectModel Project, + ProjectLoadSource Source, + ProjectRecoveryState RecoveryState, + int? SourceFormatVersion, + IReadOnlyList AppliedMigrations, + IReadOnlyList Warnings, + string? BackupPath = null, + string? CorruptFilePath = null, + string? ErrorMessage = null) +{ + public bool CanContinue => RecoveryState is not ProjectRecoveryState.UnsupportedVersion + and not ProjectRecoveryState.Blocked; +} + +public sealed record ProjectSaveResult( + bool Success, + bool Suppressed, + string Path, + DateTimeOffset? SavedAt = null, + string? ErrorMessage = null); diff --git a/ModbusLab/Models/WatchItem.cs b/ModbusLab/Models/WatchItem.cs index e4ad940..f24dfed 100644 --- a/ModbusLab/Models/WatchItem.cs +++ b/ModbusLab/Models/WatchItem.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; namespace ModbusLab.Models; -public sealed class WatchItem : INotifyPropertyChanged +public sealed class WatchItem : INotifyPropertyChanged, IEditableObject { private bool _enabled = true; private string _name = "Watch"; @@ -13,6 +13,7 @@ public sealed class WatchItem : INotifyPropertyChanged private int _count = 1; private string _stringFormat = WatchStringFormats.AsciiTwoCharsPerRegister; private string _displayValue = ""; + private WatchItem? _editBackup; public event PropertyChangedEventHandler? PropertyChanged; @@ -25,7 +26,7 @@ public bool Enabled public string Name { get => _name; - set => SetField(ref _name, string.IsNullOrWhiteSpace(value) ? "Watch" : value); + set => SetField(ref _name, value ?? ""); } public MemoryArea Area @@ -38,7 +39,6 @@ public MemoryArea Area return; } - DataType = WatchDataTypes.NormalizeForArea(_area, _dataType); OnPropertyChanged(nameof(AvailableDataTypes)); } } @@ -54,12 +54,11 @@ public string DataType get => _dataType; set { - if (!SetField(ref _dataType, WatchDataTypes.NormalizeForArea(_area, value))) + if (!SetField(ref _dataType, value ?? "")) { return; } - NormalizeCountForType(); OnPropertyChanged(nameof(StringFormatDisplay)); OnPropertyChanged(nameof(IsStringType)); OnPropertyChanged(nameof(IsBoolType)); @@ -78,7 +77,7 @@ public string StringFormat get => _stringFormat; set { - if (SetField(ref _stringFormat, WatchStringFormats.Normalize(value))) + if (SetField(ref _stringFormat, value ?? "")) { OnPropertyChanged(nameof(StringFormatDisplay)); } @@ -140,11 +139,31 @@ public WatchItem Clone() }; } - private void NormalizeCountForType() + public void BeginEdit() { - Count = WatchDataTypes.IsVariableCount(DataType) - ? Math.Clamp(Count, 1, 125) - : WatchDataTypes.RegisterCount(DataType); + _editBackup ??= Clone(); + } + + public void CancelEdit() + { + if (_editBackup is not { } backup) + { + return; + } + + _editBackup = null; + Enabled = backup.Enabled; + Name = backup.Name; + Area = backup.Area; + Address = backup.Address; + DataType = backup.DataType; + Count = backup.Count; + StringFormat = backup.StringFormat; + } + + public void EndEdit() + { + _editBackup = null; } private bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null) diff --git a/ModbusLab/Runtime/ModbusDataStore.cs b/ModbusLab/Runtime/ModbusDataStore.cs index 57c63e7..c2d1f18 100644 --- a/ModbusLab/Runtime/ModbusDataStore.cs +++ b/ModbusLab/Runtime/ModbusDataStore.cs @@ -67,6 +67,55 @@ public int GetSize(MemoryArea area) }; } + public MemoryImage CreateMemorySnapshot() + { + lock (_sync) + { + return _memory.Clone(); + } + } + + public void ClearAllMemory(bool markAccess = false) + { + lock (_sync) + { + Array.Clear(_memory.Coils); + Array.Clear(_memory.DiscreteInputs); + Array.Clear(_memory.HoldingRegisters); + Array.Clear(_memory.InputRegisters); + + if (markAccess) + { + Mark(MemoryArea.Coils, 0, _memory.Coils.Length, true); + Mark(MemoryArea.DiscreteInputs, 0, _memory.DiscreteInputs.Length, true); + Mark(MemoryArea.HoldingRegisters, 0, _memory.HoldingRegisters.Length, true); + Mark(MemoryArea.InputRegisters, 0, _memory.InputRegisters.Length, true); + } + } + } + + public void ApplyMemoryImage(MemoryImage source, bool markAccess = false) + { + ArgumentNullException.ThrowIfNull(source); + source.EnsureDefaultSizes(); + + lock (_sync) + { + CopyArea(source.Coils, _memory.Coils); + CopyArea(source.DiscreteInputs, _memory.DiscreteInputs); + CopyArea(source.HoldingRegisters, _memory.HoldingRegisters); + CopyArea(source.InputRegisters, _memory.InputRegisters); + + if (markAccess) + { + Mark(MemoryArea.Coils, 0, _memory.Coils.Length, true); + Mark(MemoryArea.DiscreteInputs, 0, _memory.DiscreteInputs.Length, true); + Mark(MemoryArea.HoldingRegisters, 0, _memory.HoldingRegisters.Length, true); + Mark(MemoryArea.InputRegisters, 0, _memory.InputRegisters.Length, true); + } + } + } + public bool[] ReadBits(MemoryArea area, int start, int count, bool markAccess = true) { var source = area switch @@ -800,6 +849,12 @@ private static CultureInfo IntegerCulture(string value) EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); + private static void CopyArea(T[] source, T[] target) + { + Array.Clear(target); + Array.Copy(source, target, Math.Min(source.Length, target.Length)); + } + private static void ValidateRange(int size, int start, int count) { if (start < 0 || count < 1 || start + count > size) diff --git a/ModbusLab/Runtime/ModbusTcpServer.ClientLoop.cs b/ModbusLab/Runtime/ModbusTcpServer.ClientLoop.cs index fd735bb..f1c216e 100644 --- a/ModbusLab/Runtime/ModbusTcpServer.ClientLoop.cs +++ b/ModbusLab/Runtime/ModbusTcpServer.ClientLoop.cs @@ -75,6 +75,11 @@ private async Task HandleClientAsync(ClientRuntime client, CancellationToken ser var isError = false; try { + if (_profile.ResponseDelayMs > 0) + { + await Task.Delay(_profile.ResponseDelayMs, token); + } + var request = ModbusRequestInfo.Parse(unitId, pdu); TrackBlocks(request, client.Key, requestBytes.Length); if (_profile.UnitId.HasValue && _profile.UnitId.Value != unitId) diff --git a/ModbusLab/Runtime/ModbusTcpServer.Reactions.cs b/ModbusLab/Runtime/ModbusTcpServer.Reactions.cs index 4bf265c..b444222 100644 --- a/ModbusLab/Runtime/ModbusTcpServer.Reactions.cs +++ b/ModbusLab/Runtime/ModbusTcpServer.Reactions.cs @@ -189,7 +189,26 @@ private void IncrementReactionTarget(WriteReactionRule rule) { if (FindWatch(rule.TargetWatchName) is { } watch && IsNumericWatch(watch)) { - WriteWatchValue(watch, (TryReadRawWatchValue(watch) + 1).ToString(CultureInfo.InvariantCulture)); + var current = ReadWatchValue(watch); + var step = GetReactionIncrementStep(rule.Value); + if (watch.DataType is WatchDataTypes.Float32 or WatchDataTypes.Double) + { + if (!TryParseFlexibleDouble(current, out var currentValue)) + { + throw new FormatException($"Watch '{watch.Name}' does not contain a numeric value."); + } + + WriteWatchValue(watch, (currentValue + step).ToString("G17", CultureInfo.InvariantCulture)); + } + else + { + if (!TryParseFlexibleDecimal(current, out var currentValue)) + { + throw new FormatException($"Watch '{watch.Name}' does not contain an integral value."); + } + + WriteWatchValue(watch, (currentValue + (decimal)step).ToString(CultureInfo.InvariantCulture)); + } } return; @@ -201,7 +220,8 @@ private void IncrementReactionTarget(WriteReactionRule rule) } var value = _store.ReadRegisters(rule.TargetArea, rule.TargetAddress, 1, markAccess: false)[0]; - WriteReactionValue(rule.TargetArea, rule.TargetAddress, unchecked((ushort)(value + 1))); + var rawStep = (ushort)Math.Clamp(Math.Round(GetReactionIncrementStep(rule.Value)), ushort.MinValue, ushort.MaxValue); + WriteReactionValue(rule.TargetArea, rule.TargetAddress, unchecked((ushort)(value + rawStep))); } private void WriteReactionValue(MemoryArea area, int address, ushort value) @@ -320,6 +340,17 @@ private static bool TryParseFlexibleDouble(string value, out double result) double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result); } + private static bool TryParseFlexibleDecimal(string value, out decimal result) + { + return decimal.TryParse(value, NumberStyles.Float, CultureInfo.CurrentCulture, out result) || + decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result); + } + + private static double GetReactionIncrementStep(string value) + { + return TryParseFlexibleDouble(value, out var step) && step > 0 ? step : 1d; + } + private static bool NearlyEqual(double left, double right) { return Math.Abs(left - right) < 0.000001d; @@ -327,7 +358,7 @@ private static bool NearlyEqual(double left, double right) private static ushort ParseReactionValue(string value) { - if (TryParseBoolLike(value, out var bit)) + if (bool.TryParse(value, out var bit)) { return bit ? (ushort)1 : (ushort)0; } diff --git a/ModbusLab/Runtime/ModbusTcpServer.cs b/ModbusLab/Runtime/ModbusTcpServer.cs index 42ccbeb..b0f0906 100644 --- a/ModbusLab/Runtime/ModbusTcpServer.cs +++ b/ModbusLab/Runtime/ModbusTcpServer.cs @@ -32,7 +32,7 @@ public sealed partial class ModbusTcpServer(ServerProfile profile, ModbusDataSto public bool IsRunning { get; private set; } public string? LastError { get; private set; } - public async Task StartAsync() + public async Task StartAsync(Action? initializeBeforeAcceptingClients = null) { if (IsRunning) { @@ -41,24 +41,43 @@ public async Task StartAsync() LastError = null; var ip = IPAddress.Parse(_profile.BindAddress); - _listener = new TcpListener(ip, _profile.Port); - _listener.Server.ExclusiveAddressUse = true; - _listener.Start(); - _cts = new CancellationTokenSource(); - IsRunning = true; - _ = Task.Run(() => AcceptLoopAsync(_cts.Token)); - await Task.CompletedTask; + var listener = new TcpListener(ip, _profile.Port); + listener.Server.ExclusiveAddressUse = true; + try + { + listener.Start(); + initializeBeforeAcceptingClients?.Invoke(); + _listener = listener; + _cts = new CancellationTokenSource(); + IsRunning = true; + _ = Task.Run(() => AcceptLoopAsync(_cts.Token)); + await Task.CompletedTask; + } + catch + { + listener.Stop(); + _listener = null; + _cts?.Dispose(); + _cts = null; + IsRunning = false; + throw; + } } public void Stop() { IsRunning = false; - _cts?.Cancel(); - _listener?.Stop(); + var cts = _cts; + var listener = _listener; + _cts = null; + cts?.Cancel(); + listener?.Stop(); foreach (var client in _clients.Values) { client.Close(); } + + cts?.Dispose(); } public void DisconnectClient(string key) diff --git a/ModbusLab/Runtime/ServerRuntime.cs b/ModbusLab/Runtime/ServerRuntime.cs index 169cac1..3450691 100644 --- a/ModbusLab/Runtime/ServerRuntime.cs +++ b/ModbusLab/Runtime/ServerRuntime.cs @@ -15,5 +15,6 @@ public ServerRuntime(ServerProfile profile) public ModbusDataStore Store { get; } public ModbusTcpServer Server { get; } public SimulationEngine Simulator { get; } + public bool SimulationRequested { get; set; } } diff --git a/ModbusLab/Runtime/SimulationEngine.cs b/ModbusLab/Runtime/SimulationEngine.cs index 993cf9b..01114f4 100644 --- a/ModbusLab/Runtime/SimulationEngine.cs +++ b/ModbusLab/Runtime/SimulationEngine.cs @@ -112,16 +112,30 @@ private void ApplyAddressRule(SimulationRule rule, long tick) { var count = Math.Max(1, rule.Count); var elapsed = GetElapsed(rule, tick); + var values = new double[count]; for (var i = 0; i < count; i++) { - var address = rule.StartAddress + i; var advanceState = rule.Mode == SimulationMode.Counter || i == 0; - var value = SimulationSignalEvaluator.Evaluate(rule, elapsed, rule.HitCount, i, _random, advanceState); - WriteAddressValue(rule.Area, address, value); - rule.LastValueDisplay = SimulationSignalEvaluator.FormatValue(value); + values[i] = SimulationSignalEvaluator.Evaluate(rule, elapsed, rule.HitCount, i, _random, advanceState); } + if (rule.Area is MemoryArea.Coils or MemoryArea.DiscreteInputs) + { + _store.WriteBits(rule.Area, rule.StartAddress, [.. values.Select(value => Math.Abs(value) > 0.00001)], markAccess: false); + } + else + { + _store.WriteRegisters( + rule.Area, + rule.StartAddress, + [.. values.Select(value => (ushort)Math.Clamp((int)Math.Round(value), ushort.MinValue, ushort.MaxValue))], + markAccess: false); + } + + _server.ApplyLocalWriteReactions(rule.Area, rule.StartAddress, count); + rule.LastValueDisplay = SimulationSignalEvaluator.FormatValue(values[^1]); + rule.LastError = ""; } @@ -150,19 +164,6 @@ private void ApplyWatchRule(SimulationRule rule, long tick) rule.LastError = ""; } - private void WriteAddressValue(MemoryArea area, int address, double value) - { - if (area is MemoryArea.Coils or MemoryArea.DiscreteInputs) - { - _store.WriteBit(area, address, Math.Abs(value) > 0.00001, markAccess: false); - return; - } - - var rounded = (int)Math.Round(value); - var register = (ushort)Math.Clamp(rounded, ushort.MinValue, ushort.MaxValue); - _store.WriteRegister(area, address, register, markAccess: false); - } - private int NextPeriod(int period, int jitter) { if (jitter <= 0) diff --git a/ModbusLab/Validation/DomainValidator.cs b/ModbusLab/Validation/DomainValidator.cs new file mode 100644 index 0000000..9b8bdda --- /dev/null +++ b/ModbusLab/Validation/DomainValidator.cs @@ -0,0 +1,635 @@ +using ModbusLab.Models; +using ModbusLab.Runtime; +using System.Globalization; +using System.Net; + +namespace ModbusLab.Validation; + +public static class DomainValidator +{ + private const int MemorySize = 10_000; + private static readonly HashSet SupportedFunctions = [0, 1, 2, 3, 4, 5, 6, 15, 16, 22, 23]; + private static readonly HashSet SupportedExceptions = [0, 1, 2, 3, 4, 6, 10, 11]; + private static readonly HashSet BitSimulationModes = + [ + SimulationMode.Disabled, + SimulationMode.Constant, + SimulationMode.Toggle, + SimulationMode.SquareWave, + SimulationMode.Pulse + ]; + + public static ValidationResult ValidateMcpSettings(McpSettings settings) + { + var issues = new List(); + AddRangeIssue(issues, nameof(settings.Port), settings.Port, 1024, 65_535); + if (settings.RequireToken && string.IsNullOrWhiteSpace(settings.AccessToken)) + { + issues.Add(new(nameof(settings.AccessToken), "An access token is required while token protection is enabled.")); + } + + return new(settings, issues); + } + + public static ValidationResult ValidateServerSettings(ServerProfile profile) + { + var issues = new List(); + if (string.IsNullOrWhiteSpace(profile.Name)) + { + issues.Add(new(nameof(profile.Name), "Server name is required.")); + } + + if (!IPAddress.TryParse(profile.BindAddress, out _)) + { + issues.Add(new(nameof(profile.BindAddress), "Bind address must be a valid IP address.")); + } + + AddRangeIssue(issues, nameof(profile.Port), profile.Port, 1, 65_535); + if (profile.UnitId is { } unitId) + { + AddRangeIssue(issues, nameof(profile.UnitId), unitId, 0, 255); + } + + AddRangeIssue(issues, nameof(profile.ResponseDelayMs), profile.ResponseDelayMs, 0, 60_000); + AddRangeIssue(issues, nameof(profile.MaxRequestsPerSecond), profile.MaxRequestsPerSecond, 0, 100_000); + AddRangeIssue(issues, nameof(profile.LogCapacity), profile.LogCapacity, 100, 500); + AddRangeIssue(issues, nameof(profile.AddressBase), profile.AddressBase, 0, 1_000_000); + ValidateEnum(issues, nameof(profile.ByteOrder), profile.ByteOrder); + ValidateEnum(issues, nameof(profile.WordOrder), profile.WordOrder); + ValidateEnum(issues, nameof(profile.WriteMode), profile.WriteMode); + ValidateEnum(issues, nameof(profile.ReactionTriggerScope), profile.ReactionTriggerScope); + ValidateEnum(issues, nameof(profile.StartupMemoryMode), profile.StartupMemoryMode); + if (profile.StartupMemoryMode == StartupMemoryMode.RestoreSnapshot && profile.StartupSnapshot is null) + { + issues.Add(new(nameof(profile.StartupSnapshot), "RestoreSnapshot requires a captured startup snapshot.")); + } + + return new(profile, issues); + } + + public static ValidationResult ValidateWatch( + WatchItem source, + IEnumerable? existingWatches = null, + WatchItem? replacingWatch = null) + { + var watch = source.Clone(); + var issues = new List(); + if (string.IsNullOrWhiteSpace(source.Name)) + { + issues.Add(new(nameof(watch.Name), "Watch name is required.")); + } + + watch.Name = string.IsNullOrWhiteSpace(source.Name) ? "Watch" : source.Name.Trim(); + if (!Enum.IsDefined(source.Area)) + { + issues.Add(new(nameof(watch.Area), "Memory area is not supported.")); + } + + var normalizedDataType = WatchDataTypes.NormalizeForArea(source.Area, source.DataType); + if (!string.Equals(source.DataType, normalizedDataType, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(new(nameof(watch.DataType), "Data type is not compatible with the selected memory area.")); + } + + watch.DataType = normalizedDataType; + var normalizedStringFormat = WatchStringFormats.Normalize(source.StringFormat); + if (watch.DataType == WatchDataTypes.String && + !string.Equals(source.StringFormat, normalizedStringFormat, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(new(nameof(watch.StringFormat), "String format is not supported.")); + } + + watch.StringFormat = normalizedStringFormat; + + if (watch.Address is < 0 or >= MemorySize) + { + issues.Add(new(nameof(watch.Address), "Address must be between 0 and 9999.")); + } + + var requiredCount = GetWatchRangeLength(watch); + if (WatchDataTypes.IsVariableCount(watch.DataType)) + { + if (watch.Count is < 1 or > 125) + { + issues.Add(new(nameof(watch.Count), "String watch count must be between 1 and 125.")); + } + + watch.Count = Math.Clamp(watch.Count, 1, 125); + requiredCount = watch.Count; + } + else + { + if (watch.Count != requiredCount) + { + issues.Add(new(nameof(watch.Count), $"{watch.DataType} requires a count of {requiredCount}.")); + } + + watch.Count = requiredCount; + } + + if (watch.Address >= 0 && watch.Address + requiredCount > MemorySize) + { + issues.Add(new(nameof(watch.Count), "Watch range exceeds the available 0..9999 address space.")); + } + + if (existingWatches is not null) + { + var duplicate = existingWatches.Any(existing => + !ReferenceEquals(existing, replacingWatch) && + string.Equals(existing.Name, watch.Name, StringComparison.OrdinalIgnoreCase)); + if (duplicate) + { + issues.Add(new(nameof(watch.Name), "Watch names must be unique, ignoring case.")); + } + } + + return new(watch, issues); + } + + public static ValidationResult ValidateSimulationRule( + SimulationRule source, + IReadOnlyList? watches = null) + { + var rule = source.Clone(); + var issues = new List(); + WatchItem? targetWatch = null; + if (rule.TargetKind == SimulationTargetKind.Watch) + { + targetWatch = ResolveUniqueWatch(watches, rule.TargetWatchName, nameof(rule.TargetWatchName), issues); + } + else + { + ValidateAddressRange(rule.StartAddress, rule.Count, nameof(rule.StartAddress), nameof(rule.Count), issues); + } + + var bitTarget = targetWatch is not null + ? targetWatch.Area is MemoryArea.Coils or MemoryArea.DiscreteInputs || targetWatch.DataType == WatchDataTypes.Bool + : rule.Area is MemoryArea.Coils or MemoryArea.DiscreteInputs; + if (bitTarget && !BitSimulationModes.Contains(rule.Mode)) + { + issues.Add(new(nameof(rule.Mode), "The selected simulation mode is not compatible with a bit target.")); + } + + if (rule.Mode != SimulationMode.Disabled) + { + AddRangeIssue(issues, nameof(rule.PeriodMs), rule.PeriodMs, 50, 600_000); + } + + ValidateFinite(rule.Minimum, nameof(rule.Minimum), issues); + ValidateFinite(rule.Maximum, nameof(rule.Maximum), issues); + ValidateFinite(rule.Step, nameof(rule.Step), issues); + ValidateFinite(rule.BaseValue, nameof(rule.BaseValue), issues); + ValidateFinite(rule.NoiseAmplitude, nameof(rule.NoiseAmplitude), issues); + ValidateFinite(rule.DutyCyclePercent, nameof(rule.DutyCyclePercent), issues); + + if (!bitTarget && UsesBounds(rule.Mode) && rule.Minimum > rule.Maximum) + { + issues.Add(new(nameof(rule.Minimum), "Minimum must be less than or equal to maximum.")); + } + + if (rule.Mode is SimulationMode.Counter or SimulationMode.Ramp && rule.Step <= 0) + { + issues.Add(new(nameof(rule.Step), "Step must be greater than zero.")); + } + + if (rule.Mode == SimulationMode.Noise && rule.NoiseAmplitude < 0) + { + issues.Add(new(nameof(rule.NoiseAmplitude), "Noise amplitude must be zero or greater.")); + } + + if (rule.Mode == SimulationMode.SquareWave && rule.DutyCyclePercent is < 0 or > 100) + { + issues.Add(new(nameof(rule.DutyCyclePercent), "Duty cycle must be between 0 and 100.")); + } + + AddRangeIssue(issues, nameof(rule.PulseWidthMs), rule.PulseWidthMs, 0, 600_000); + AddRangeIssue(issues, nameof(rule.InitialDelayMs), rule.InitialDelayMs, 0, 600_000); + AddRangeIssue(issues, nameof(rule.DurationMs), rule.DurationMs, 0, 600_000); + AddRangeIssue(issues, nameof(rule.MaxHits), rule.MaxHits, 0, 1_000_000); + AddRangeIssue(issues, nameof(rule.PhaseOffsetMs), rule.PhaseOffsetMs, 0, 600_000); + AddRangeIssue(issues, nameof(rule.JitterMs), rule.JitterMs, 0, 600_000); + return new(rule, issues); + } + + public static ValidationResult ValidateFaultRule(FaultRule source) + { + var rule = source.Clone(); + var issues = new List(); + rule.Name = string.IsNullOrWhiteSpace(rule.Name) ? "Rule" : rule.Name.Trim(); + if (!SupportedFunctions.Contains(rule.FunctionCode)) + { + issues.Add(new(nameof(rule.FunctionCode), "Function code is not supported by ModbusLab.")); + } + + if (rule.StartAddress is < 0 or >= MemorySize || rule.EndAddress is < 0 or >= MemorySize || rule.StartAddress > rule.EndAddress) + { + issues.Add(new(nameof(rule.StartAddress), "Fault range must be ordered and contained in 0..9999.")); + } + + AddRangeIssue(issues, nameof(rule.DelayMs), rule.DelayMs, 0, 600_000); + if (!SupportedExceptions.Contains(rule.ExceptionCode)) + { + issues.Add(new(nameof(rule.ExceptionCode), "Exception code is not supported by ModbusLab.")); + } + + return new(rule, issues); + } + + public static ValidationResult ValidateReaction( + WriteReactionRule source, + IReadOnlyList? watches = null) + { + var rule = source.Clone(); + var issues = new List(); + rule.Name = string.IsNullOrWhiteSpace(rule.Name) ? "Reaction" : rule.Name.Trim(); + var triggerType = ResolveEndpointType( + rule.TriggerKind, + rule.TriggerArea, + rule.TriggerAddress, + rule.TriggerWatchName, + watches, + "Trigger", + issues, + out _); + + if (triggerType is WatchDataTypes.Bool or WatchDataTypes.String && + rule.MatchOperator is not WriteReactionMatchOperator.Equals and not WriteReactionMatchOperator.NotEquals) + { + issues.Add(new(nameof(rule.MatchOperator), "Boolean and string triggers support only Equals and NotEquals.")); + } + + if (string.IsNullOrWhiteSpace(rule.MatchValue)) + { + if (rule.MatchOperator != WriteReactionMatchOperator.Equals) + { + issues.Add(new(nameof(rule.MatchValue), "An empty match value is valid only for 'Any written value'.")); + } + } + else + { + ValidateReactionValue(triggerType, rule.MatchValue, nameof(rule.MatchValue), allowString: true, issues); + } + + WatchItem? targetWatch = null; + var targetType = WatchDataTypes.UInt16; + if (rule.Action != WriteReactionAction.ResetTrigger) + { + targetType = ResolveEndpointType( + rule.TargetKind, + rule.TargetArea, + rule.TargetAddress, + rule.TargetWatchName, + watches, + "Target", + issues, + out targetWatch); + } + + if (targetType == WatchDataTypes.String && rule.Action is not WriteReactionAction.WriteValue and not WriteReactionAction.ResetTrigger) + { + issues.Add(new(nameof(rule.Action), "String targets support only WriteValue and ResetTrigger.")); + } + + if (targetType == WatchDataTypes.Bool && rule.Action == WriteReactionAction.IncrementRegister) + { + issues.Add(new(nameof(rule.Action), "IncrementRegister requires a numeric target.")); + } + + if (rule.Action == WriteReactionAction.WriteValue) + { + if (targetWatch is not null) + { + issues.AddRange(ValidateWatchValue(targetWatch, rule.Value, ByteOrderMode.BigEndian, WordOrderMode.HighWordFirst).Issues); + } + else + { + ValidateRawReactionWriteValue(targetType, rule.Value, nameof(rule.Value), issues); + } + } + + if (rule.Action == WriteReactionAction.IncrementRegister && + (!TryParseFiniteDouble(rule.Value, out var incrementStep) || incrementStep <= 0)) + { + issues.Add(new(nameof(rule.Value), "Increment step must be greater than zero.")); + } + + AddRangeIssue(issues, nameof(rule.DelayMs), rule.DelayMs, 0, 600_000); + return new(rule, issues); + } + + public static ValidationResult ValidateWatchValue( + WatchItem watch, + string value, + ByteOrderMode byteOrder, + WordOrderMode wordOrder) + { + var issues = new List(); + var watchValidation = ValidateWatch(watch); + issues.AddRange(watchValidation.Issues); + if (watchValidation.NormalizedValue.DataType is WatchDataTypes.Float32 or WatchDataTypes.Double && + !TryParseFiniteDouble(value, out _)) + { + issues.Add(new("Value", "Value must be finite.")); + } + + if (issues.Count == 0) + { + try + { + var store = new ModbusDataStore(MemoryImage.CreateDefault()); + var normalized = watchValidation.NormalizedValue; + store.WriteFormattedWatchValue( + normalized.Area, + normalized.Address, + normalized.DataType, + normalized.Count, + normalized.StringFormat, + value, + byteOrder, + wordOrder, + markAccess: false); + } + catch (Exception ex) when (ex is ModbusException or InvalidOperationException or FormatException or OverflowException or ArgumentException) + { + issues.Add(new("Value", ex.Message)); + } + } + + return new(value, issues); + } + + public static IReadOnlyList ValidateProject(ProjectModel project) + { + var issues = new List(); + AddPrefixed(issues, "Mcp", ValidateMcpSettings(project.Mcp).Issues); + + for (var serverIndex = 0; serverIndex < project.Servers.Count; serverIndex++) + { + var server = project.Servers[serverIndex]; + AddPrefixed(issues, $"Servers[{serverIndex}]", ValidateServerSettings(server).Issues); + for (var watchIndex = 0; watchIndex < server.WatchItems.Count; watchIndex++) + { + AddPrefixed( + issues, + $"Servers[{serverIndex}].WatchItems[{watchIndex}]", + ValidateWatch(server.WatchItems[watchIndex]).Issues); + } + + var duplicateNames = server.WatchItems + .GroupBy(watch => watch.Name, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key); + foreach (var duplicateName in duplicateNames) + { + issues.Add(new($"Servers[{serverIndex}].WatchItems", $"Duplicate watch name '{duplicateName}'.")); + } + + for (var index = 0; index < server.SimulationRules.Count; index++) + { + AddPrefixed(issues, $"Servers[{serverIndex}].SimulationRules[{index}]", ValidateSimulationRule(server.SimulationRules[index], server.WatchItems).Issues); + } + + for (var index = 0; index < server.FaultRules.Count; index++) + { + AddPrefixed(issues, $"Servers[{serverIndex}].FaultRules[{index}]", ValidateFaultRule(server.FaultRules[index]).Issues); + } + + for (var index = 0; index < server.WriteReactionRules.Count; index++) + { + AddPrefixed(issues, $"Servers[{serverIndex}].WriteReactionRules[{index}]", ValidateReaction(server.WriteReactionRules[index], server.WatchItems).Issues); + } + } + + return issues; + } + + public static void ThrowIfInvalid(ValidationResult result) + { + if (!result.IsValid) + { + throw new DomainValidationException(result.Issues); + } + } + + private static string ResolveEndpointType( + WriteReactionEndpointKind kind, + MemoryArea area, + int address, + string watchName, + IReadOnlyList? watches, + string prefix, + ICollection issues, + out WatchItem? watch) + { + watch = null; + if (kind == WriteReactionEndpointKind.Watch) + { + watch = ResolveUniqueWatch(watches, watchName, $"{prefix}WatchName", issues); + return watch?.DataType ?? WatchDataTypes.UInt16; + } + + if (address is < 0 or >= MemorySize) + { + issues.Add(new($"{prefix}Address", "Address must be between 0 and 9999.")); + } + + return area is MemoryArea.Coils or MemoryArea.DiscreteInputs ? WatchDataTypes.Bool : WatchDataTypes.UInt16; + } + + private static WatchItem? ResolveUniqueWatch( + IReadOnlyList? watches, + string name, + string field, + ICollection issues) + { + if (string.IsNullOrWhiteSpace(name)) + { + issues.Add(new(field, "A watch target is required.")); + return null; + } + + var matches = watches?.Where(watch => string.Equals(watch.Name, name, StringComparison.OrdinalIgnoreCase)).ToList() ?? []; + if (matches.Count == 0) + { + issues.Add(new(field, $"Watch '{name}' was not found.")); + return null; + } + + if (matches.Count > 1) + { + issues.Add(new(field, $"Watch '{name}' is ambiguous because the name is duplicated.")); + return null; + } + + return matches[0]; + } + + private static void ValidateReactionValue( + string dataType, + string value, + string field, + bool allowString, + ICollection issues) + { + if (dataType == WatchDataTypes.String) + { + if (!allowString && value is null) + { + issues.Add(new(field, "A string value is required.")); + } + + return; + } + + if (dataType == WatchDataTypes.Bool) + { + if (!TryParseBoolLike(value, out _)) + { + issues.Add(new(field, "Value must be true/false or 1/0.")); + } + + return; + } + + if (!TryParseFiniteDouble(value, out _)) + { + issues.Add(new(field, "Value must be a finite number.")); + } + } + + private static void ValidateRawReactionWriteValue( + string dataType, + string value, + string field, + ICollection issues) + { + if (dataType == WatchDataTypes.Bool) + { + if (!TryParseBoolLike(value, out _)) + { + issues.Add(new(field, "Value must be true/false or 1/0.")); + } + + return; + } + + if (!ushort.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + { + issues.Add(new(field, "Raw register writes require an unsigned 16-bit integer between 0 and 65535.")); + } + } + + private static bool TryParseBoolLike(string value, out bool result) + { + if (bool.TryParse(value, out result)) + { + return true; + } + + if (value.Trim() == "1") + { + result = true; + return true; + } + + if (value.Trim() == "0") + { + result = false; + return true; + } + + result = false; + return false; + } + + private static bool TryParseFiniteDouble(string value, out double result) + { + var parsed = double.TryParse(value, NumberStyles.Float, CultureInfo.CurrentCulture, out result) || + double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result); + return parsed && !double.IsNaN(result) && !double.IsInfinity(result); + } + + private static int GetWatchRangeLength(WatchItem watch) + { + if (watch.Area is MemoryArea.Coils or MemoryArea.DiscreteInputs) + { + return 1; + } + + return WatchDataTypes.IsVariableCount(watch.DataType) + ? Math.Clamp(watch.Count, 1, 125) + : WatchDataTypes.RegisterCount(watch.DataType); + } + + private static bool UsesBounds(SimulationMode mode) + { + return mode is SimulationMode.Counter or SimulationMode.Sine or SimulationMode.Random or SimulationMode.Ramp or + SimulationMode.SquareWave or SimulationMode.Pulse or SimulationMode.Sawtooth; + } + + private static void ValidateAddressRange( + int start, + int count, + string startField, + string countField, + ICollection issues) + { + if (start is < 0 or >= MemorySize) + { + issues.Add(new(startField, "Start address must be between 0 and 9999.")); + } + + if (count < 1) + { + issues.Add(new(countField, "Count must be at least 1.")); + } + else if (start >= 0 && start + count > MemorySize) + { + issues.Add(new(countField, "Range exceeds the available 0..9999 address space.")); + } + } + + private static void ValidateFinite(double value, string field, ICollection issues) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + issues.Add(new(field, "Value must be finite.")); + } + } + + private static void AddRangeIssue( + ICollection issues, + string field, + long value, + long minimum, + long maximum) + { + if (value < minimum || value > maximum) + { + issues.Add(new(field, $"Value must be between {minimum} and {maximum}.")); + } + } + + private static void ValidateEnum( + ICollection issues, + string field, + TEnum value) + where TEnum : struct, Enum + { + if (!Enum.IsDefined(value)) + { + issues.Add(new(field, $"{value} is not a supported value.")); + } + } + + private static void AddPrefixed( + ICollection target, + string prefix, + IEnumerable issues) + { + foreach (var issue in issues) + { + target.Add(new($"{prefix}.{issue.Field}", issue.Message)); + } + } +} diff --git a/ModbusLab/Validation/ValidationResult.cs b/ModbusLab/Validation/ValidationResult.cs new file mode 100644 index 0000000..fa1ecc4 --- /dev/null +++ b/ModbusLab/Validation/ValidationResult.cs @@ -0,0 +1,16 @@ +namespace ModbusLab.Validation; + +public sealed record ValidationIssue(string Field, string Message); + +public sealed class ValidationResult(T normalizedValue, IReadOnlyList issues) +{ + public T NormalizedValue { get; } = normalizedValue; + public IReadOnlyList Issues { get; } = issues; + public bool IsValid => Issues.Count == 0; +} + +public sealed class DomainValidationException(IReadOnlyList issues) + : Exception(string.Join("; ", issues.Select(issue => $"{issue.Field}: {issue.Message}"))) +{ + public IReadOnlyList Issues { get; } = issues; +} diff --git a/ModbusLab/ViewModels/MainWindowViewModel.cs b/ModbusLab/ViewModels/MainWindowViewModel.cs index 1d149c7..9b447c3 100644 --- a/ModbusLab/ViewModels/MainWindowViewModel.cs +++ b/ModbusLab/ViewModels/MainWindowViewModel.cs @@ -1,12 +1,13 @@ using ModbusLab.Models; using ModbusLab.Runtime; +using ModbusLab.Validation; using System.Collections.ObjectModel; using System.Net; using System.Net.Sockets; namespace ModbusLab.ViewModels; -public sealed class MainWindowViewModel(ProjectModel project) +public sealed class MainWindowViewModel(ProjectModel project, IReadOnlyList? projectWarnings = null) { private LastMetricSample? _lastSample; private readonly Dictionary _clientSamples = []; @@ -25,6 +26,8 @@ public sealed class MainWindowViewModel(ProjectModel project) public ObservableCollection LogRows { get; } = []; public ObservableCollection FunctionRows { get; } = []; public DateTimeOffset? LastSavedAt { get; private set; } + public string? LastSaveError { get; private set; } + public IReadOnlyList ProjectWarnings { get; private set; } = projectWarnings ?? []; public ServerRuntime? SelectedRuntime { get; set; } @@ -94,6 +97,8 @@ public ServerRuntime AddServer() } var clone = runtime.Profile.CloneAs($"{runtime.Profile.Name} copy", NextAvailablePort()); + DomainValidator.ThrowIfInvalid(new ValidationResult(clone, DomainValidator.ValidateProject( + new ProjectModel { Servers = [clone] }))); Project.Servers.Add(clone); var cloneRuntime = new ServerRuntime(clone); Runtimes[clone.Id] = cloneRuntime; @@ -105,13 +110,7 @@ public ServerRuntime AddServer() public ServerRuntime ImportServer(ServerProfile importedProfile) { - ProjectStore.NormalizeServer(importedProfile); - - var name = NextImportedServerName(importedProfile.Name); - var imported = importedProfile.CloneAs(name, NextAvailablePort()); - imported.Id = Guid.NewGuid(); - imported.WasRunningOnShutdown = false; - ProjectStore.NormalizeServer(imported); + var imported = PrepareImportedServer(importedProfile); Project.Servers.Add(imported); var runtime = new ServerRuntime(imported); @@ -122,6 +121,19 @@ public ServerRuntime ImportServer(ServerProfile importedProfile) return runtime; } + public ServerProfile PrepareImportedServer(ServerProfile importedProfile) + { + ProjectStore.NormalizeServer(importedProfile); + var name = NextImportedServerName(importedProfile.Name); + var imported = importedProfile.CloneAs(name, NextAvailablePort()); + imported.Id = Guid.NewGuid(); + imported.WasRunningOnShutdown = false; + ProjectStore.NormalizeServer(imported); + DomainValidator.ThrowIfInvalid(new ValidationResult(imported, DomainValidator.ValidateProject( + new ProjectModel { Servers = [imported] }))); + return imported; + } + public ServerRuntime? DeleteSelectedServer() { if (SelectedRuntime is not { } runtime) @@ -160,8 +172,8 @@ public ServerRuntime ImportServer(ServerProfile importedProfile) try { - ApplyStartupMemory(runtime.Profile); - await runtime.Server.StartAsync(); + await runtime.Server.StartAsync(() => ApplyStartupMemory(runtime)); + ApplyRequestedSimulationState(runtime); return null; } catch (SocketException ex) @@ -240,6 +252,24 @@ public void StopRuntime(ServerRuntime runtime) RefreshInstanceList(runtime.Profile.Id); } + public void SetSimulationRequested(ServerRuntime runtime, bool requested) + { + runtime.SimulationRequested = requested; + ApplyRequestedSimulationState(runtime); + } + + public static void ApplyRequestedSimulationState(ServerRuntime runtime) + { + if (runtime.SimulationRequested && runtime.Server.IsRunning) + { + runtime.Simulator.Start(); + } + else + { + runtime.Simulator.Stop(); + } + } + public void StopAll() { foreach (var runtime in Runtimes.Values) @@ -356,7 +386,7 @@ public void CaptureStartupSnapshot() return; } - runtime.Profile.StartupSnapshot = runtime.Profile.Memory.Clone(); + runtime.Profile.StartupSnapshot = runtime.Store.CreateMemorySnapshot(); runtime.Profile.StartupMemoryMode = StartupMemoryMode.RestoreSnapshot; SaveNow(); } @@ -661,15 +691,22 @@ public static void RefreshWatchValues(ServerRuntime runtime) { foreach (var item in runtime.Profile.WatchItems) { - NormalizeWatchItem(item); + var validation = DomainValidator.ValidateWatch(item, runtime.Profile.WatchItems, item); + if (!validation.IsValid) + { + item.DisplayValue = "n/a"; + continue; + } + + var normalized = validation.NormalizedValue; try { item.DisplayValue = runtime.Store.ReadFormattedWatchValue( - item.Area, - item.Address, - item.DataType, - item.Count, - item.StringFormat, + normalized.Area, + normalized.Address, + normalized.DataType, + normalized.Count, + normalized.StringFormat, runtime.Profile.ByteOrder, runtime.Profile.WordOrder, markAccess: false); @@ -683,7 +720,10 @@ public static void RefreshWatchValues(ServerRuntime runtime) public void WriteWatchValue(ServerRuntime runtime, WatchItem item, string value) { - NormalizeWatchItem(item); + var validation = DomainValidator.ValidateWatch(item, runtime.Profile.WatchItems, item); + DomainValidator.ThrowIfInvalid(validation); + DomainValidator.ThrowIfInvalid(DomainValidator.ValidateWatchValue(validation.NormalizedValue, value, runtime.Profile.ByteOrder, runtime.Profile.WordOrder)); + CopyNormalizedWatch(validation.NormalizedValue, item); runtime.Store.WriteFormattedWatchValue( item.Area, item.Address, @@ -701,13 +741,18 @@ public void WriteWatchValue(ServerRuntime runtime, WatchItem item, string value) public static void NormalizeWatchItem(WatchItem item) { - item.Enabled = true; - item.DataType = WatchDataTypes.NormalizeForArea(item.Area, item.DataType); - item.StringFormat = WatchStringFormats.Normalize(item.StringFormat); - item.Address = Math.Clamp(item.Address, 0, 9999); - item.Count = WatchDataTypes.IsVariableCount(item.DataType) - ? Math.Clamp(item.Count, 1, 125) - : WatchDataTypes.RegisterCount(item.DataType); + CopyNormalizedWatch(DomainValidator.ValidateWatch(item).NormalizedValue, item); + } + + private static void CopyNormalizedWatch(WatchItem source, WatchItem target) + { + target.Enabled = source.Enabled; + target.Name = source.Name; + target.Area = source.Area; + target.Address = source.Address; + target.DataType = source.DataType; + target.Count = source.Count; + target.StringFormat = source.StringFormat; } private static int GetWatchRangeLength(WatchItem item) @@ -781,10 +826,17 @@ private void RemoveSmoothers(string keyPrefix) } } - public void SaveNow(bool createBackup = true) + public ProjectSaveResult SaveNow(bool createBackup = true) { - ProjectStore.Save(Project, createBackup); - LastSavedAt = DateTimeOffset.Now; + ProjectWarnings = DomainValidator.ValidateProject(Project); + var result = ProjectStore.Save(Project, createBackup); + LastSaveError = result.Success ? null : result.ErrorMessage ?? "The project could not be saved."; + if (result.SavedAt.HasValue) + { + LastSavedAt = result.SavedAt; + } + + return result; } public int NextAvailablePort() @@ -826,15 +878,15 @@ private string NextImportedServerName(string? sourceName) } } - public static void ApplyStartupMemory(ServerProfile profile) + public static void ApplyStartupMemory(ServerRuntime runtime) { - switch (profile.StartupMemoryMode) + switch (runtime.Profile.StartupMemoryMode) { case StartupMemoryMode.ClearOnStartup: - profile.Memory = MemoryImage.CreateDefault(); + runtime.Store.ClearAllMemory(markAccess: false); break; - case StartupMemoryMode.RestoreSnapshot when profile.StartupSnapshot is not null: - profile.Memory = profile.StartupSnapshot.Clone(); + case StartupMemoryMode.RestoreSnapshot when runtime.Profile.StartupSnapshot is not null: + runtime.Store.ApplyMemoryImage(runtime.Profile.StartupSnapshot, markAccess: false); break; } } diff --git a/ModbusLab/Views/MainWindow.Diagnostics.xaml.cs b/ModbusLab/Views/MainWindow.Diagnostics.xaml.cs index 28541d1..ee2582d 100644 --- a/ModbusLab/Views/MainWindow.Diagnostics.xaml.cs +++ b/ModbusLab/Views/MainWindow.Diagnostics.xaml.cs @@ -12,7 +12,9 @@ public partial class MainWindow { private void RefreshDiagnostics(ServerRuntime runtime, ServerMetricsSnapshot metrics, LiveViewSnapshot snapshot) { - var issueCount = metrics.Errors + metrics.Exceptions; + var configurationIssues = _viewModel.ProjectWarnings.Count; + var saveIssues = string.IsNullOrWhiteSpace(_viewModel.LastSaveError) ? 0 : 1; + var issueCount = metrics.Errors + metrics.Exceptions + configurationIssues + saveIssues; DiagnosticsHeaderDot.Fill = issueCount > 0 || !string.IsNullOrWhiteSpace(runtime.Server.LastError) ? (Brush)FindResource("SystemFillColorCautionBrush") : runtime.Server.IsRunning @@ -20,7 +22,7 @@ private void RefreshDiagnostics(ServerRuntime runtime, ServerMetricsSnapshot met : Brushes.Gray; DiagServerTile.Value = runtime.Server.IsRunning ? "Running" : "Stopped"; - DiagSimulationTile.Value = runtime.Simulator.IsRunning ? "Running" : _simulationRequested.Contains(runtime.Profile.Id) ? "Armed" : "Stopped"; + DiagSimulationTile.Value = runtime.Simulator.IsRunning ? "Running" : runtime.SimulationRequested ? "Armed" : "Stopped"; DiagTrafficTile.Value = $"{snapshot.Rps:N1} rps"; DiagRequestsTile.Value = metrics.Requests.ToString("N0"); DiagIssuesTile.Value = issueCount.ToString("N0"); @@ -32,7 +34,13 @@ private void RefreshDiagnostics(ServerRuntime runtime, ServerMetricsSnapshot met DiagnosticsClientsText.Text = $"{metrics.ActiveClients:N0} active / {metrics.TotalConnections:N0} total"; DiagnosticsTrafficText.Text = $"{metrics.Requests:N0} requests, in {FormatBytes(snapshot.InRate)}/s, out {FormatBytes(snapshot.OutRate)}/s"; DiagnosticsLatencyText.Text = $"last {FormatLatency(metrics.LastLatencyMs)}, avg {FormatLatency(metrics.AverageLatencyMs)}"; - DiagnosticsErrorText.Text = DisplayOrFallback(runtime.Server.LastError); + var diagnosticErrors = new[] + { + runtime.Server.LastError, + string.IsNullOrWhiteSpace(_viewModel.LastSaveError) ? null : $"Save: {_viewModel.LastSaveError}", + configurationIssues == 0 ? null : $"Configuration: {configurationIssues} warning(s)" + }.Where(value => !string.IsNullOrWhiteSpace(value)); + DiagnosticsErrorText.Text = DisplayOrFallback(string.Join(" | ", diagnosticErrors)); } private void RunSelfTest_Click(object sender, RoutedEventArgs e) @@ -53,7 +61,13 @@ private void RunSelfTest_Click(object sender, RoutedEventArgs e) ("Endpoint", runtime.Profile.Port is > 0 and <= 65535 && !string.IsNullOrWhiteSpace(runtime.Profile.BindAddress), $"{runtime.Profile.BindAddress}:{runtime.Profile.Port}"), ("Memory", memoryOk, "Memory areas report valid sizes."), ("Logging", runtime.Profile.LogCapacity >= 100, $"Log capacity {runtime.Profile.LogCapacity}."), - ("Automation", runtime.Profile.SimulationRules.All(x => x.Count > 0 && x.PeriodMs > 0), "Simulation rules have positive count and period.") + ("Automation", runtime.Profile.SimulationRules.All(x => x.Count > 0 && x.PeriodMs > 0), "Simulation rules have positive count and period."), + ("Configuration", _viewModel.ProjectWarnings.Count == 0, _viewModel.ProjectWarnings.Count == 0 + ? "Shared domain validation passed." + : string.Join("; ", _viewModel.ProjectWarnings.Take(5).Select(issue => $"{issue.Field}: {issue.Message}"))), + ("Persistence", string.IsNullOrWhiteSpace(_viewModel.LastSaveError), string.IsNullOrWhiteSpace(_viewModel.LastSaveError) + ? "No save errors are active." + : _viewModel.LastSaveError) }; var failed = checks.Where(x => !x.Ok).ToList(); diff --git a/ModbusLab/Views/MainWindow.Live.xaml.cs b/ModbusLab/Views/MainWindow.Live.xaml.cs index decd55c..3bea037 100644 --- a/ModbusLab/Views/MainWindow.Live.xaml.cs +++ b/ModbusLab/Views/MainWindow.Live.xaml.cs @@ -37,20 +37,27 @@ private void RefreshLiveViews() SettingsEditor.IsEnabled = !runtime.Server.IsRunning; SelectedStatusDot.Fill = runtime.Server.IsRunning ? Brushes.LimeGreen : Brushes.Gray; StatusEndpointText.Text = runtime.Profile.Endpoint; - if (!string.IsNullOrWhiteSpace(runtime.Server.LastError)) + var statusError = !string.IsNullOrWhiteSpace(runtime.Server.LastError) + ? runtime.Server.LastError + : !string.IsNullOrWhiteSpace(_viewModel.LastSaveError) + ? $"Save failed: {_viewModel.LastSaveError}" + : null; + if (!string.IsNullOrWhiteSpace(statusError)) { - StatusErrorText.Text = runtime.Server.LastError; + StatusErrorText.Text = statusError; + StatusErrorText.ToolTip = statusError; StatusErrorText.Visibility = Visibility.Visible; StatusErrorSeparator.Visibility = Visibility.Visible; } else { StatusErrorText.Text = ""; + StatusErrorText.ToolTip = null; StatusErrorText.Visibility = Visibility.Collapsed; StatusErrorSeparator.Visibility = Visibility.Collapsed; } - if (_viewModel.LastSavedAt.HasValue) + if (_viewModel.LastSavedAt.HasValue && string.IsNullOrWhiteSpace(_viewModel.LastSaveError)) { StatusSavedText.Text = $"saved {_viewModel.LastSavedAt.Value:HH:mm:ss}"; StatusSavedText.Visibility = Visibility.Visible; diff --git a/ModbusLab/Views/MainWindow.Servers.xaml.cs b/ModbusLab/Views/MainWindow.Servers.xaml.cs index 20254a4..d01c5da 100644 --- a/ModbusLab/Views/MainWindow.Servers.xaml.cs +++ b/ModbusLab/Views/MainWindow.Servers.xaml.cs @@ -1,6 +1,7 @@ using ModbusLab.Models; using ModbusLab.Services; using ModbusLab.ViewModels; +using ModbusLab.Validation; using Microsoft.Win32; using System.ComponentModel; using System.Globalization; @@ -60,6 +61,16 @@ private async void Window_Loaded(object sender, RoutedEventArgs e) { var options = AppRuntimeOptions.Current; WpfThemeService.Apply(options.ResolveTheme(_viewModel.Project.EffectiveThemeMode), this); + + if (!await ConfirmProjectRecoveryAsync()) + { + Close(); + return; + } + + _runtimeServicesStarted = true; + _saveTimer.Start(); + await RestartMcpHostAsync(); var updateCheckTask = BeginUpdateCheckOnStartup(); if (options.StartSelectedServer && SelectedRuntime is { } selectedRuntime) @@ -70,7 +81,6 @@ private async void Window_Loaded(object sender, RoutedEventArgs e) await ModernDialog.ErrorAsync(this, "Start failed", error); } - ApplyRequestedSimulationState(selectedRuntime); RefreshInstanceList(); RefreshLiveViews(); if (options.SelectDemoRow) @@ -105,7 +115,6 @@ private async void Window_Loaded(object sender, RoutedEventArgs e) await ModernDialog.ErrorAsync(this, "Start failed", error); } - ApplyRequestedSimulationState(runtime); } RefreshInstanceList(); @@ -117,20 +126,97 @@ private async void Window_Loaded(object sender, RoutedEventArgs e) await CompleteUpdateCheckOnStartupAsync(updateCheckTask); } + private async Task ConfirmProjectRecoveryAsync() + { + if (_projectLoadResult.RecoveryState == ProjectRecoveryState.RecoveredFromBackup) + { + var confirmed = await ModernDialog.ConfirmAsync( + this, + "Project recovered from backup", + "The active project was corrupt. ModbusLab loaded the newest valid backup and preserved the corrupt file.\r\n\r\n" + + $"Backup: {_projectLoadResult.BackupPath}\r\n" + + $"Preserved file: {_projectLoadResult.CorruptFilePath}\r\n\r\n" + + "Repair the active project with the recovered data?", + "Repair project", + "Exit"); + if (!confirmed) + { + return false; + } + + return await SaveRecoveredProjectAsync(); + } + + if (_projectLoadResult.RecoveryState == ProjectRecoveryState.NeedsNewProjectConfirmation) + { + var confirmed = await ModernDialog.ConfirmAsync( + this, + "No valid project backup", + "The active project was corrupt and no valid backup could be loaded. The corrupt file was preserved.\r\n\r\n" + + $"Preserved file: {_projectLoadResult.CorruptFilePath}\r\n\r\n" + + "Create and save a new default project?", + "Create new project", + "Exit"); + if (!confirmed) + { + return false; + } + + return await SaveRecoveredProjectAsync(); + } + + _allowProjectSaveOnClose = true; + return true; + } + + private async Task SaveRecoveredProjectAsync() + { + var saveResult = _viewModel.SaveNow(createBackup: false); + if (!saveResult.Success || saveResult.Suppressed) + { + await ModernDialog.ErrorAsync( + this, + "Recovery could not be completed", + "The active project was not replaced.\r\n\r\n" + + (saveResult.Suppressed ? "Saving is disabled for this run." : saveResult.ErrorMessage)); + return false; + } + + _allowProjectSaveOnClose = true; + return true; + } + private void Window_Closing(object? sender, CancelEventArgs e) { + _saveTimer.Stop(); + _uiTimer.Stop(); CancelUpdateCheck(); - SaveUiState(); + if (_allowProjectSaveOnClose) + { + SaveUiState(save: false); + } + WpfThemeService.Unwatch(this); - _mcpHost.StopForShutdown(); + if (_runtimeServicesStarted) + { + _mcpHost.StopForShutdown(); + } + foreach (var runtime in _viewModel.Runtimes.Values) { - runtime.Profile.WasRunningOnShutdown = runtime.Server.IsRunning; + if (_allowProjectSaveOnClose) + { + runtime.Profile.WasRunningOnShutdown = runtime.Server.IsRunning; + } + runtime.Server.Stop(); runtime.Simulator.Stop(); } - _viewModel.SaveNow(); + if (_allowProjectSaveOnClose) + { + _viewModel.SaveNow(); + } } private void RefreshInstanceList() @@ -199,23 +285,64 @@ private async void ApplySettings_Click(object sender, RoutedEventArgs e) try { var profile = runtime.Profile; - profile.Name = string.IsNullOrWhiteSpace(NameText.Text) ? "Server" : NameText.Text.Trim(); - profile.BindAddress = string.IsNullOrWhiteSpace(BindText.Text) ? "0.0.0.0" : BindText.Text.Trim(); - profile.Port = ReadNumberBoxValue(PortNumber, 1, 65535, "Port"); - profile.UnitId = string.IsNullOrWhiteSpace(UnitIdText.Text) ? null : ParseInt(UnitIdText.Text, 0, 255, "Unit ID"); - profile.ByteOrder = Enum.Parse(ByteOrderCombo.SelectedItem?.ToString() ?? ByteOrderMode.BigEndian.ToString()); - profile.WordOrder = Enum.Parse(WordOrderCombo.SelectedItem?.ToString() ?? WordOrderMode.HighWordFirst.ToString()); - profile.WriteMode = Enum.Parse(WriteModeCombo.SelectedItem?.ToString() ?? ModbusWriteMode.Normal.ToString()); - profile.StartupMemoryMode = Enum.Parse(StartupMemoryCombo.SelectedItem?.ToString() ?? StartupMemoryMode.KeepLastState.ToString()); - profile.ResponseDelayMs = ReadNumberBoxValue(DelayNumber, 0, 60_000, "Global delay ms"); - profile.MaxRequestsPerSecond = ReadNumberBoxValue(MaxRpsNumber, 0, 100_000, "Max RPS"); + var candidate = new ServerProfile + { + Name = string.IsNullOrWhiteSpace(NameText.Text) ? "Server" : NameText.Text.Trim(), + BindAddress = string.IsNullOrWhiteSpace(BindText.Text) ? "0.0.0.0" : BindText.Text.Trim(), + Port = ReadNumberBoxValue(PortNumber, 1, 65535, "Port"), + UnitId = string.IsNullOrWhiteSpace(UnitIdText.Text) ? null : ParseInt(UnitIdText.Text, 0, 255, "Unit ID"), + ByteOrder = Enum.Parse(ByteOrderCombo.SelectedItem?.ToString() ?? ByteOrderMode.BigEndian.ToString()), + WordOrder = Enum.Parse(WordOrderCombo.SelectedItem?.ToString() ?? WordOrderMode.HighWordFirst.ToString()), + WriteMode = Enum.Parse(WriteModeCombo.SelectedItem?.ToString() ?? ModbusWriteMode.Normal.ToString()), + ReactionTriggerScope = profile.ReactionTriggerScope, + StartupMemoryMode = Enum.Parse(StartupMemoryCombo.SelectedItem?.ToString() ?? StartupMemoryMode.KeepLastState.ToString()), + StartupSnapshot = profile.StartupSnapshot, + ResponseDelayMs = ReadNumberBoxValue(DelayNumber, 0, 60_000, "Global delay ms"), + MaxRequestsPerSecond = ReadNumberBoxValue(MaxRpsNumber, 0, 100_000, "Max RPS"), + LogCapacity = profile.LogCapacity, + AddressBase = profile.AddressBase, + ShowClassicAddresses = profile.ShowClassicAddresses + }; + var validation = DomainValidator.ValidateServerSettings(candidate); + if (!validation.IsValid) + { + var issue = validation.Issues[0]; + Control focusTarget = issue.Field switch + { + nameof(ServerProfile.Name) => NameText, + nameof(ServerProfile.BindAddress) => BindText, + nameof(ServerProfile.Port) => PortNumber, + nameof(ServerProfile.UnitId) => UnitIdText, + nameof(ServerProfile.ByteOrder) => ByteOrderCombo, + nameof(ServerProfile.WordOrder) => WordOrderCombo, + nameof(ServerProfile.WriteMode) => WriteModeCombo, + nameof(ServerProfile.StartupMemoryMode) or nameof(ServerProfile.StartupSnapshot) => StartupMemoryCombo, + nameof(ServerProfile.ResponseDelayMs) => DelayNumber, + nameof(ServerProfile.MaxRequestsPerSecond) => MaxRpsNumber, + _ => NameText + }; + await ModernDialog.WarningAsync(this, "Invalid settings", issue.Message); + focusTarget.Focus(); + return; + } + + profile.Name = candidate.Name; + profile.BindAddress = candidate.BindAddress; + profile.Port = candidate.Port; + profile.UnitId = candidate.UnitId; + profile.ByteOrder = candidate.ByteOrder; + profile.WordOrder = candidate.WordOrder; + profile.WriteMode = candidate.WriteMode; + profile.StartupMemoryMode = candidate.StartupMemoryMode; + profile.ResponseDelayMs = candidate.ResponseDelayMs; + profile.MaxRequestsPerSecond = candidate.MaxRequestsPerSecond; RefreshInstanceList(); _viewModel.SaveNow(); await Dispatcher.InvokeAsync(LoadSelectedSettings); UpdateSettingsPendingBadge(); } - catch (Exception ex) + catch (Exception ex) when (ex is InvalidOperationException or FormatException or OverflowException or ArgumentException) { await ModernDialog.WarningAsync(this, "Invalid settings", ex.Message); } @@ -253,7 +380,6 @@ private async void StartSelected_Click(object sender, RoutedEventArgs e) RefreshInstanceList(); RefreshLiveViews(); - ApplyRequestedSimulationState(runtime); } } @@ -275,11 +401,6 @@ private async void StartAll_Click(object sender, RoutedEventArgs e) await ModernDialog.ErrorAsync(this, "Start failed", error); } - foreach (var runtime in _viewModel.Runtimes.Values) - { - ApplyRequestedSimulationState(runtime); - } - RefreshInstanceList(); RefreshLiveViews(); } @@ -298,15 +419,22 @@ private void AddServer_Click(object sender, RoutedEventArgs e) SelectPage("Settings"); } - private void CloneServer_Click(object sender, RoutedEventArgs e) + private async void CloneServer_Click(object sender, RoutedEventArgs e) { - var clone = _viewModel.CloneSelectedServer(); - if (clone is null) + try { - return; - } + var clone = _viewModel.CloneSelectedServer(); + if (clone is null) + { + return; + } - InstancesList.SelectedItem = _viewModel.Instances.FirstOrDefault(x => x.Runtime.Profile.Id == clone.Profile.Id); + InstancesList.SelectedItem = _viewModel.Instances.FirstOrDefault(x => x.Runtime.Profile.Id == clone.Profile.Id); + } + catch (DomainValidationException ex) + { + await ModernDialog.WarningAsync(this, "Cannot clone server", ex.Message); + } } private async void ExportServer_Click(object sender, RoutedEventArgs e) @@ -363,7 +491,7 @@ private async void ImportServer_Click(object sender, RoutedEventArgs e) InstancesList.SelectedItem = _viewModel.Instances.FirstOrDefault(x => x.Runtime.Profile.Id == runtime.Profile.Id); SelectPage("Settings"); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or InvalidDataException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or InvalidDataException or DomainValidationException) { await ModernDialog.ErrorAsync(this, "Import failed", ex.Message); } diff --git a/ModbusLab/Views/MainWindow.Simulation.xaml.cs b/ModbusLab/Views/MainWindow.Simulation.xaml.cs index fb5825c..23d832f 100644 --- a/ModbusLab/Views/MainWindow.Simulation.xaml.cs +++ b/ModbusLab/Views/MainWindow.Simulation.xaml.cs @@ -19,8 +19,7 @@ private void StartSimulation_Click(object sender, RoutedEventArgs e) return; } - _simulationRequested.Add(runtime.Profile.Id); - ApplyRequestedSimulationState(runtime); + _viewModel.SetSimulationRequested(runtime, requested: true); RefreshLiveViews(); } @@ -31,8 +30,7 @@ private void StopSimulation_Click(object sender, RoutedEventArgs e) return; } - _simulationRequested.Remove(runtime.Profile.Id); - runtime.Simulator.Stop(); + _viewModel.SetSimulationRequested(runtime, requested: false); RefreshLiveViews(); } @@ -413,8 +411,7 @@ private static string FormatNumber(double value) private void RefreshSimulationStatus(ServerRuntime runtime) { - ApplyRequestedSimulationState(runtime); - var requested = _simulationRequested.Contains(runtime.Profile.Id); + var requested = runtime.SimulationRequested; var running = runtime.Simulator.IsRunning; SimulationStatusDot.Fill = running ? Brushes.LimeGreen : Brushes.Gray; SimulationStatusText.Text = running @@ -426,23 +423,6 @@ private void RefreshSimulationStatus(ServerRuntime runtime) StopSimulationButton.IsEnabled = requested; } - private void ApplyRequestedSimulationState(ServerRuntime runtime) - { - if (!_simulationRequested.Contains(runtime.Profile.Id)) - { - runtime.Simulator.Stop(); - return; - } - - if (runtime.Server.IsRunning) - { - runtime.Simulator.Start(); - } - else - { - runtime.Simulator.Stop(); - } - } } diff --git a/ModbusLab/Views/MainWindow.UiState.xaml.cs b/ModbusLab/Views/MainWindow.UiState.xaml.cs index 4d404b3..8fe3265 100644 --- a/ModbusLab/Views/MainWindow.UiState.xaml.cs +++ b/ModbusLab/Views/MainWindow.UiState.xaml.cs @@ -30,7 +30,7 @@ private void ApplyUiState() } } - private void SaveUiState() + private void SaveUiState(bool save = true) { if (_loadingUiState) { @@ -63,7 +63,10 @@ private void SaveUiState() _viewModel.Project.UiState.GridColumnWidths.Remove(gridKey); } - _viewModel.SaveNow(createBackup: false); + if (save) + { + _viewModel.SaveNow(createBackup: false); + } } private void ApplyWindowPlacement() diff --git a/ModbusLab/Views/MainWindow.Watch.xaml.cs b/ModbusLab/Views/MainWindow.Watch.xaml.cs index c7f5686..923409d 100644 --- a/ModbusLab/Views/MainWindow.Watch.xaml.cs +++ b/ModbusLab/Views/MainWindow.Watch.xaml.cs @@ -2,6 +2,7 @@ using ModbusLab.Runtime; using ModbusLab.Services; using ModbusLab.ViewModels; +using ModbusLab.Validation; using System.Windows; using System.Windows.Controls; using System.Windows.Media; @@ -19,7 +20,14 @@ private void AddWatch_Click(object sender, RoutedEventArgs e) } var watch = CreateWatchItem(runtime); - MainWindowViewModel.NormalizeWatchItem(watch); + var validation = DomainValidator.ValidateWatch(watch, runtime.Profile.WatchItems); + if (!validation.IsValid) + { + _ = ModernDialog.WarningAsync(this, "Invalid watch", validation.Issues[0].Message); + return; + } + + watch = validation.NormalizedValue; runtime.Profile.WatchItems.Add(watch); _viewModel.SaveNow(); RefreshGridAfterCollectionMutation(WatchGrid); @@ -31,7 +39,14 @@ private WatchItem CreateWatchItem(ServerRuntime runtime) var item = _viewModel.Project.UseLastRecordValuesForNewRows && GetNewRecordTemplate(WatchGrid, runtime.Profile.WatchItems) is { } source ? source.Clone() : new WatchItem(); - item.Name = $"Watch {runtime.Profile.WatchItems.Count + 1}"; + var usedNames = runtime.Profile.WatchItems.Select(watch => watch.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + var index = runtime.Profile.WatchItems.Count + 1; + while (usedNames.Contains($"Watch {index}")) + { + index++; + } + + item.Name = $"Watch {index}"; return item; } @@ -79,16 +94,87 @@ e.Row.Item is WatchItem writeItem && return; } + if (GetColumnKey(e.Column) == nameof(WatchItem.Name) && + e.Row.Item is WatchItem namedItem && + FindDescendant(e.EditingElement) is { } nameTextBox && + SelectedRuntime is { } namedRuntime) + { + var candidate = namedItem.Clone(); + candidate.Name = nameTextBox.Text; + var validation = DomainValidator.ValidateWatch(candidate, namedRuntime.Profile.WatchItems, namedItem); + if (!validation.IsValid) + { + e.Cancel = true; + _ = ModernDialog.WarningAsync(this, "Invalid watch", validation.Issues[0].Message); + return; + } + } + + if (GetColumnKey(e.Column) is nameof(WatchItem.Address) or nameof(WatchItem.Count) && + e.Row.Item is WatchItem numericItem && + FindDescendant(e.EditingElement) is { } numericTextBox && + SelectedRuntime is { } numericRuntime) + { + if (!int.TryParse(numericTextBox.Text, out var number)) + { + e.Cancel = true; + _ = ModernDialog.WarningAsync(this, "Invalid watch", "Enter a valid whole number."); + return; + } + + var candidate = numericItem.Clone(); + if (GetColumnKey(e.Column) == nameof(WatchItem.Address)) + { + candidate.Address = number; + } + else + { + candidate.Count = number; + } + + var validation = DomainValidator.ValidateWatch(candidate, numericRuntime.Profile.WatchItems, numericItem); + if (!validation.IsValid) + { + e.Cancel = true; + _ = ModernDialog.WarningAsync(this, "Invalid watch", validation.Issues[0].Message); + return; + } + } + QueueWatchValueRefresh(e.Row.Item as WatchItem); } + private void WatchGrid_RowEditEnding(object? sender, DataGridRowEditEndingEventArgs e) + { + if (e.EditAction != DataGridEditAction.Commit || + e.Row.Item is not WatchItem item || + SelectedRuntime is not { } runtime) + { + return; + } + + var validation = DomainValidator.ValidateWatch(item, runtime.Profile.WatchItems, item); + if (!validation.IsValid) + { + e.Cancel = true; + item.CancelEdit(); + _ = ModernDialog.WarningAsync(this, "Invalid watch", validation.Issues[0].Message); + return; + } + + MainWindowViewModel.NormalizeWatchItem(item); + } + private void WatchCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (e.AddedItems.Count == 0 || sender is not FrameworkElement { DataContext: WatchItem item }) + if (e.AddedItems.Count == 0 || + sender is not ComboBox { DataContext: WatchItem item } comboBox || + (!comboBox.IsKeyboardFocusWithin && !comboBox.IsDropDownOpen)) { return; } + MainWindowViewModel.NormalizeWatchItem(item); QueueWatchValueRefresh(item); } diff --git a/ModbusLab/Views/MainWindow.xaml b/ModbusLab/Views/MainWindow.xaml index 1951346..0109ca5 100644 --- a/ModbusLab/Views/MainWindow.xaml +++ b/ModbusLab/Views/MainWindow.xaml @@ -1610,7 +1610,7 @@ - + diff --git a/ModbusLab/Views/MainWindow.xaml.cs b/ModbusLab/Views/MainWindow.xaml.cs index e3a972c..cbc0c80 100644 --- a/ModbusLab/Views/MainWindow.xaml.cs +++ b/ModbusLab/Views/MainWindow.xaml.cs @@ -16,11 +16,11 @@ namespace ModbusLab; public partial class MainWindow : Wpf.Ui.Controls.FluentWindow { private readonly MainWindowViewModel _viewModel; + private readonly ProjectLoadResult _projectLoadResult; private readonly ModbusLabMcpFacade _mcpFacade; private readonly McpHostService _mcpHost; private readonly DispatcherTimer _uiTimer = new() { Interval = TimeSpan.FromMilliseconds(500) }; private readonly DispatcherTimer _saveTimer = new() { Interval = TimeSpan.FromSeconds(5) }; - private readonly HashSet _simulationRequested = []; private static readonly string[] ReactionTriggerScopeLabels = [ "Client writes only", @@ -35,10 +35,14 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow private ContextMenu? _automationContextMenu; private Window? _automationMenuPreviewWindow; private readonly Dictionary> _defaultGridColumnLayouts = []; + private bool _allowProjectSaveOnClose; + private bool _runtimeServicesStarted; - public MainWindow() + public MainWindow(ProjectLoadResult projectLoadResult) { - _viewModel = new MainWindowViewModel(ProjectStore.Load()); + _projectLoadResult = projectLoadResult; + _allowProjectSaveOnClose = projectLoadResult.RecoveryState == ProjectRecoveryState.None; + _viewModel = new MainWindowViewModel(projectLoadResult.Project, projectLoadResult.Warnings); _mcpFacade = new ModbusLabMcpFacade(_viewModel, Dispatcher); _mcpHost = new McpHostService(_mcpFacade); _mcpFacade.ConfigureMcpHost( @@ -60,11 +64,8 @@ public MainWindow() _saveTimer.Tick += (_, _) => { SaveUiState(); - _viewModel.SaveNow(createBackup: false); }; _uiTimer.Start(); - _saveTimer.Start(); - _ = RestartMcpHostAsync(); } private ServerRuntime? SelectedRuntime => _viewModel.SelectedRuntime; diff --git a/ModbusLab/Windows/FaultRuleEditorWindow.xaml.cs b/ModbusLab/Windows/FaultRuleEditorWindow.xaml.cs index 3b87b24..cf10ac4 100644 --- a/ModbusLab/Windows/FaultRuleEditorWindow.xaml.cs +++ b/ModbusLab/Windows/FaultRuleEditorWindow.xaml.cs @@ -1,5 +1,6 @@ using ModbusLab.Models; using ModbusLab.Services; +using ModbusLab.Validation; using System.Windows; using System.Windows.Controls; @@ -18,6 +19,7 @@ public partial class FaultRuleEditorWindow new(6, "06 Write Single Register"), new(15, "15 Write Multiple Coils"), new(16, "16 Write Multiple Registers"), + new(22, "22 Mask Write Register"), new(23, "23 Read/Write Multiple Registers") ]; @@ -78,6 +80,24 @@ private async void Save_Click(object sender, RoutedEventArgs e) Rule.DelayMs = ReadNumber(DelayNumber, 0, 600_000); Rule.ExceptionCode = ExceptionCombo.SelectedItem is ComboOption exception ? exception.Value : 0; Rule.DropConnection = DropConnectionSwitch.IsChecked == true; + var domainValidation = DomainValidator.ValidateFaultRule(Rule); + if (!domainValidation.IsValid) + { + var issue = domainValidation.Issues[0]; + await ModernDialog.WarningAsync(this, "Invalid rule", issue.Message); + Control domainFocusTarget = issue.Field switch + { + nameof(FaultRule.FunctionCode) => FunctionCombo, + nameof(FaultRule.StartAddress) or nameof(FaultRule.EndAddress) => StartAddressNumber, + nameof(FaultRule.DelayMs) => DelayNumber, + nameof(FaultRule.ExceptionCode) => ExceptionCombo, + _ => NameText + }; + domainFocusTarget.Focus(); + return; + } + + Rule.Name = domainValidation.NormalizedValue.Name; DialogResult = true; } diff --git a/ModbusLab/Windows/ReactionEditorWindow.xaml.cs b/ModbusLab/Windows/ReactionEditorWindow.xaml.cs index 2deb5dd..c02548f 100644 --- a/ModbusLab/Windows/ReactionEditorWindow.xaml.cs +++ b/ModbusLab/Windows/ReactionEditorWindow.xaml.cs @@ -1,5 +1,6 @@ using ModbusLab.Models; using ModbusLab.Services; +using ModbusLab.Validation; using System.Globalization; using System.Windows; using System.Windows.Controls; @@ -130,6 +131,29 @@ private async void Save_Click(object sender, RoutedEventArgs e) Rule.TargetAddress = ReadNumber(TargetAddressNumber, 0, 9999); Rule.Value = BuildEffectValue(); Rule.DelayMs = ReadNumber(DelayNumber, 0, 600_000); + var domainValidation = DomainValidator.ValidateReaction(Rule, _watches); + if (!domainValidation.IsValid) + { + var issue = domainValidation.Issues[0]; + await ModernDialog.WarningAsync(this, "Invalid reaction", issue.Message); + Control domainFocusTarget = issue.Field switch + { + nameof(WriteReactionRule.TriggerWatchName) => TriggerWatchCombo, + nameof(WriteReactionRule.TriggerAddress) => TriggerAddressNumber, + nameof(WriteReactionRule.MatchValue) => MatchOperatorCombo, + nameof(WriteReactionRule.MatchOperator) => MatchOperatorCombo, + nameof(WriteReactionRule.TargetWatchName) => TargetWatchCombo, + nameof(WriteReactionRule.TargetAddress) => TargetAddressNumber, + nameof(WriteReactionRule.Action) => ActionCombo, + nameof(WriteReactionRule.Value) => ValueNumber, + nameof(WriteReactionRule.DelayMs) => DelayNumber, + _ => NameText + }; + domainFocusTarget.Focus(); + return; + } + + Rule.Name = domainValidation.NormalizedValue.Name; DialogResult = true; } diff --git a/ModbusLab/Windows/SimulationEditorWindow.xaml.cs b/ModbusLab/Windows/SimulationEditorWindow.xaml.cs index 8940c98..2fa10c7 100644 --- a/ModbusLab/Windows/SimulationEditorWindow.xaml.cs +++ b/ModbusLab/Windows/SimulationEditorWindow.xaml.cs @@ -1,6 +1,7 @@ using ModbusLab.Models; using ModbusLab.Runtime; using ModbusLab.Services; +using ModbusLab.Validation; using System.Globalization; using System.Windows; using System.Windows.Controls; @@ -107,6 +108,36 @@ private async void Save_Click(object sender, RoutedEventArgs e) Rule.MaxHits = ReadLong(MaxHitsNumber, 0, 1_000_000); Rule.PhaseOffsetMs = ReadNumber(PhaseOffsetNumber, 0, 600_000); Rule.JitterMs = ReadNumber(JitterNumber, 0, 600_000); + var domainValidation = DomainValidator.ValidateSimulationRule(Rule, _watches); + if (!domainValidation.IsValid) + { + var issue = domainValidation.Issues[0]; + await ModernDialog.WarningAsync(this, "Invalid simulation rule", issue.Message); + Control domainFocusTarget = issue.Field switch + { + nameof(SimulationRule.TargetWatchName) => TargetWatchCombo, + nameof(SimulationRule.StartAddress) => StartAddressNumber, + nameof(SimulationRule.Count) => CountNumber, + nameof(SimulationRule.Mode) => ModeCombo, + nameof(SimulationRule.PeriodMs) => PeriodNumber, + nameof(SimulationRule.Minimum) => MinimumNumber, + nameof(SimulationRule.Maximum) => MaximumNumber, + nameof(SimulationRule.Step) => StepNumber, + nameof(SimulationRule.BaseValue) => BaseValueNumber, + nameof(SimulationRule.NoiseAmplitude) => NoiseAmplitudeNumber, + nameof(SimulationRule.DutyCyclePercent) => DutyCycleNumber, + nameof(SimulationRule.PulseWidthMs) => PulseWidthNumber, + nameof(SimulationRule.InitialDelayMs) => InitialDelayNumber, + nameof(SimulationRule.DurationMs) => DurationNumber, + nameof(SimulationRule.MaxHits) => MaxHitsNumber, + nameof(SimulationRule.PhaseOffsetMs) => PhaseOffsetNumber, + nameof(SimulationRule.JitterMs) => JitterNumber, + _ => ModeCombo + }; + domainFocusTarget.Focus(); + return; + } + DialogResult = true; } diff --git a/README.md b/README.md index e06c1ee..51f6133 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,17 @@ ModbusLab gives you a controllable Modbus TCP server workspace when the real dev - Analyze connected clients, request blocks, logs, raw bytes, rates, and latency. - Import and export complete server profiles as dedicated JSON files. - Expose the running workspace to local LLM agents through the optional loopback MCP server. -- Persist server configuration, memory, UI layout, grid layout, and app settings. +- Persist server configuration, memory, UI layout, grid layout, and app settings with versioned, atomic project saves and guided recovery. +- Validate server settings, Watches, simulations, faults, and reactions through the same domain rules in the UI and MCP. ## Get Started Official builds may be published through TyKonLab release channels. You can also build ModbusLab from source: ```powershell -dotnet build .\ModbusLab.slnx +dotnet restore .\ModbusLab.slnx +dotnet build .\ModbusLab.slnx -c Release /warnaserror +dotnet test .\ModbusLab.slnx -c Release --no-build --no-restore ``` Run the app, start the default server on a test port such as `1502`, and point your Modbus TCP client to `127.0.0.1:1502`. @@ -57,13 +60,15 @@ The GitHub Pages site is built with MkDocs and the built-in `mkdocs` theme. Requirements: - Windows 10 or Windows 11. -- .NET SDK 11.0 preview or newer compatible SDK. +- The .NET SDK selected by [`global.json`](global.json), currently `11.0.100-preview.5.26302.115` with prerelease and latest-patch roll-forward enabled. - An editor is optional when building from the command line. The app targets `net11.0-windows`. ```powershell -dotnet build .\ModbusLab.slnx +dotnet restore .\ModbusLab.slnx +dotnet build .\ModbusLab.slnx -c Release /warnaserror +dotnet test .\ModbusLab.slnx -c Release --no-build --no-restore ``` Build outputs are written under `artifacts/bin` and `artifacts/obj`. @@ -92,7 +97,7 @@ artifacts/publish/ModbusLab/win-x86/ModbusLab_x86.exe ## Release Preparation -Use the release preparation script to update version metadata, rebuild, refresh documentation screenshots, validate the MkDocs site, and publish Windows artifacts: +Use the release preparation script to update version metadata, build with warnings as errors, run all tests, refresh documentation screenshots, validate the MkDocs site, and publish Windows artifacts: ```powershell .\scripts\prepare-release.ps1 -Version 1.1.0 @@ -101,7 +106,8 @@ Use the release preparation script to update version metadata, rebuild, refresh Equivalent manual checks: ```powershell -dotnet build .\ModbusLab.slnx -c Release +dotnet build .\ModbusLab.slnx -c Release /warnaserror +dotnet test .\ModbusLab.slnx -c Release --no-build --no-restore .\scripts\update-screenshots.ps1 .\scripts\build-docs.ps1 .\scripts\publish-windows.ps1 -Configuration Release -NoRestore diff --git a/docs/assets/screenshot-seed/ModbusLab.screenshots.modbuslab.json b/docs/assets/screenshot-seed/ModbusLab.screenshots.modbuslab.json index 8208770..eebfc7c 100644 --- a/docs/assets/screenshot-seed/ModbusLab.screenshots.modbuslab.json +++ b/docs/assets/screenshot-seed/ModbusLab.screenshots.modbuslab.json @@ -1,4 +1,5 @@ { + "FormatVersion": 2, "Servers": [ { "Id": "11111111-1111-1111-1111-111111111111", @@ -220,6 +221,15 @@ "AddressBase": 0, "ShowClassicAddresses": false, "UseLastRecordValuesForNewRows": true, + "AutomaticallyCheckForUpdates": false, + "SkippedUpdateVersion": null, + "Mcp": { + "Enabled": false, + "Port": 8765, + "AccessToken": "0000000000000000000000000000000000000000000000000000000000000000", + "RequireToken": true, + "AllowMutations": false + }, "UiState": { "MainSplitterDistance": 300, "MemorySplitterDistance": 520, diff --git a/docs/assets/screenshots/dark/app-settings.png b/docs/assets/screenshots/dark/app-settings.png index b0b991e..40600ed 100644 Binary files a/docs/assets/screenshots/dark/app-settings.png and b/docs/assets/screenshots/dark/app-settings.png differ diff --git a/docs/assets/screenshots/dark/diagnostics.png b/docs/assets/screenshots/dark/diagnostics.png index 0eacbc9..1b9aeaf 100644 Binary files a/docs/assets/screenshots/dark/diagnostics.png and b/docs/assets/screenshots/dark/diagnostics.png differ diff --git a/docs/assets/screenshots/dark/fault-rule-editor.png b/docs/assets/screenshots/dark/fault-rule-editor.png index c9ce289..f670d74 100644 Binary files a/docs/assets/screenshots/dark/fault-rule-editor.png and b/docs/assets/screenshots/dark/fault-rule-editor.png differ diff --git a/docs/assets/screenshots/dark/fault-rules.png b/docs/assets/screenshots/dark/fault-rules.png index 3e7995c..61efbd7 100644 Binary files a/docs/assets/screenshots/dark/fault-rules.png and b/docs/assets/screenshots/dark/fault-rules.png differ diff --git a/docs/assets/screenshots/dark/grid-context-menu.png b/docs/assets/screenshots/dark/grid-context-menu.png index 32ca932..3e78708 100644 Binary files a/docs/assets/screenshots/dark/grid-context-menu.png and b/docs/assets/screenshots/dark/grid-context-menu.png differ diff --git a/docs/assets/screenshots/dark/grid-header-menu.png b/docs/assets/screenshots/dark/grid-header-menu.png index e310b64..47026b5 100644 Binary files a/docs/assets/screenshots/dark/grid-header-menu.png and b/docs/assets/screenshots/dark/grid-header-menu.png differ diff --git a/docs/assets/screenshots/dark/memory.png b/docs/assets/screenshots/dark/memory.png index 80bc7f5..ebedf2d 100644 Binary files a/docs/assets/screenshots/dark/memory.png and b/docs/assets/screenshots/dark/memory.png differ diff --git a/docs/assets/screenshots/dark/reaction-editor.png b/docs/assets/screenshots/dark/reaction-editor.png index 104256f..1249192 100644 Binary files a/docs/assets/screenshots/dark/reaction-editor.png and b/docs/assets/screenshots/dark/reaction-editor.png differ diff --git a/docs/assets/screenshots/dark/reactions.png b/docs/assets/screenshots/dark/reactions.png index 4561a62..5e3e942 100644 Binary files a/docs/assets/screenshots/dark/reactions.png and b/docs/assets/screenshots/dark/reactions.png differ diff --git a/docs/assets/screenshots/dark/settings.png b/docs/assets/screenshots/dark/settings.png index c1d7359..80ff2a9 100644 Binary files a/docs/assets/screenshots/dark/settings.png and b/docs/assets/screenshots/dark/settings.png differ diff --git a/docs/assets/screenshots/dark/simulation-editor.png b/docs/assets/screenshots/dark/simulation-editor.png index a8d09b9..32f0867 100644 Binary files a/docs/assets/screenshots/dark/simulation-editor.png and b/docs/assets/screenshots/dark/simulation-editor.png differ diff --git a/docs/assets/screenshots/dark/simulation.png b/docs/assets/screenshots/dark/simulation.png index 5fef518..8207b5f 100644 Binary files a/docs/assets/screenshots/dark/simulation.png and b/docs/assets/screenshots/dark/simulation.png differ diff --git a/docs/assets/screenshots/dark/traffic-blocks.png b/docs/assets/screenshots/dark/traffic-blocks.png index 04bb801..4371f15 100644 Binary files a/docs/assets/screenshots/dark/traffic-blocks.png and b/docs/assets/screenshots/dark/traffic-blocks.png differ diff --git a/docs/assets/screenshots/dark/traffic-clients.png b/docs/assets/screenshots/dark/traffic-clients.png index 18a3c01..fbc72a8 100644 Binary files a/docs/assets/screenshots/dark/traffic-clients.png and b/docs/assets/screenshots/dark/traffic-clients.png differ diff --git a/docs/assets/screenshots/dark/traffic-log-frozen.png b/docs/assets/screenshots/dark/traffic-log-frozen.png index 0d16432..34ab531 100644 Binary files a/docs/assets/screenshots/dark/traffic-log-frozen.png and b/docs/assets/screenshots/dark/traffic-log-frozen.png differ diff --git a/docs/assets/screenshots/dark/traffic-log.png b/docs/assets/screenshots/dark/traffic-log.png index 166b457..fa61e22 100644 Binary files a/docs/assets/screenshots/dark/traffic-log.png and b/docs/assets/screenshots/dark/traffic-log.png differ diff --git a/docs/assets/screenshots/dark/watch.png b/docs/assets/screenshots/dark/watch.png index fa29e8f..a1c42f5 100644 Binary files a/docs/assets/screenshots/dark/watch.png and b/docs/assets/screenshots/dark/watch.png differ diff --git a/docs/assets/screenshots/light/app-settings.png b/docs/assets/screenshots/light/app-settings.png index e925b50..e23d9b9 100644 Binary files a/docs/assets/screenshots/light/app-settings.png and b/docs/assets/screenshots/light/app-settings.png differ diff --git a/docs/assets/screenshots/light/diagnostics.png b/docs/assets/screenshots/light/diagnostics.png index 218ca31..b33b938 100644 Binary files a/docs/assets/screenshots/light/diagnostics.png and b/docs/assets/screenshots/light/diagnostics.png differ diff --git a/docs/assets/screenshots/light/fault-rule-editor.png b/docs/assets/screenshots/light/fault-rule-editor.png index e34a7b1..4e8fb10 100644 Binary files a/docs/assets/screenshots/light/fault-rule-editor.png and b/docs/assets/screenshots/light/fault-rule-editor.png differ diff --git a/docs/assets/screenshots/light/fault-rules.png b/docs/assets/screenshots/light/fault-rules.png index 7fa5b8b..98299b9 100644 Binary files a/docs/assets/screenshots/light/fault-rules.png and b/docs/assets/screenshots/light/fault-rules.png differ diff --git a/docs/assets/screenshots/light/grid-context-menu.png b/docs/assets/screenshots/light/grid-context-menu.png index f19dc6e..ae9f9b2 100644 Binary files a/docs/assets/screenshots/light/grid-context-menu.png and b/docs/assets/screenshots/light/grid-context-menu.png differ diff --git a/docs/assets/screenshots/light/grid-header-menu.png b/docs/assets/screenshots/light/grid-header-menu.png index 1022976..f82cb7a 100644 Binary files a/docs/assets/screenshots/light/grid-header-menu.png and b/docs/assets/screenshots/light/grid-header-menu.png differ diff --git a/docs/assets/screenshots/light/memory.png b/docs/assets/screenshots/light/memory.png index 88eeed5..1e615c0 100644 Binary files a/docs/assets/screenshots/light/memory.png and b/docs/assets/screenshots/light/memory.png differ diff --git a/docs/assets/screenshots/light/reaction-editor.png b/docs/assets/screenshots/light/reaction-editor.png index 64e42ba..20d6b45 100644 Binary files a/docs/assets/screenshots/light/reaction-editor.png and b/docs/assets/screenshots/light/reaction-editor.png differ diff --git a/docs/assets/screenshots/light/reactions.png b/docs/assets/screenshots/light/reactions.png index f63ca5c..ec76e15 100644 Binary files a/docs/assets/screenshots/light/reactions.png and b/docs/assets/screenshots/light/reactions.png differ diff --git a/docs/assets/screenshots/light/settings.png b/docs/assets/screenshots/light/settings.png index bb93833..13f27d8 100644 Binary files a/docs/assets/screenshots/light/settings.png and b/docs/assets/screenshots/light/settings.png differ diff --git a/docs/assets/screenshots/light/simulation-editor.png b/docs/assets/screenshots/light/simulation-editor.png index 89fc68e..5306a07 100644 Binary files a/docs/assets/screenshots/light/simulation-editor.png and b/docs/assets/screenshots/light/simulation-editor.png differ diff --git a/docs/assets/screenshots/light/simulation.png b/docs/assets/screenshots/light/simulation.png index 118c702..222aa5d 100644 Binary files a/docs/assets/screenshots/light/simulation.png and b/docs/assets/screenshots/light/simulation.png differ diff --git a/docs/assets/screenshots/light/traffic-blocks.png b/docs/assets/screenshots/light/traffic-blocks.png index 6ccf00b..138fddc 100644 Binary files a/docs/assets/screenshots/light/traffic-blocks.png and b/docs/assets/screenshots/light/traffic-blocks.png differ diff --git a/docs/assets/screenshots/light/traffic-clients.png b/docs/assets/screenshots/light/traffic-clients.png index efd19c6..c9c7fce 100644 Binary files a/docs/assets/screenshots/light/traffic-clients.png and b/docs/assets/screenshots/light/traffic-clients.png differ diff --git a/docs/assets/screenshots/light/traffic-log-frozen.png b/docs/assets/screenshots/light/traffic-log-frozen.png index 770da6b..9bde19b 100644 Binary files a/docs/assets/screenshots/light/traffic-log-frozen.png and b/docs/assets/screenshots/light/traffic-log-frozen.png differ diff --git a/docs/assets/screenshots/light/traffic-log.png b/docs/assets/screenshots/light/traffic-log.png index dc7a9c6..5ec5a53 100644 Binary files a/docs/assets/screenshots/light/traffic-log.png and b/docs/assets/screenshots/light/traffic-log.png differ diff --git a/docs/assets/screenshots/light/watch.png b/docs/assets/screenshots/light/watch.png index 92b630c..ecdee67 100644 Binary files a/docs/assets/screenshots/light/watch.png and b/docs/assets/screenshots/light/watch.png differ diff --git a/docs/release-notes.md b/docs/release-notes.md index 69489bb..d33f27b 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -8,6 +8,38 @@ This page summarizes user-facing changes by release. It focuses on features, wor - Optional automatic update checks against the latest stable GitHub release when ModbusLab starts. - Update notification actions to open the release page, skip one version, or disable future checks. +- Versioned project format 2 with deterministic migration from unversioned version 1 projects. +- Guided corrupt-project recovery that preserves the active file, searches backups newest-first, and requires confirmation before repair or default-project creation. +- A shared domain validator used by WPF editors and MCP for server settings, Watches, simulation rules, fault rules, reactions, and Watch values. +- A Windows CI baseline and xUnit regression suite for runtime, validation, persistence, recovery, MCP permissions, and update checks. + +### Improved + +- Project saves now use a verified temporary file, backup, and atomic replacement. Save failures remain visible without advancing the last-successful-save time. +- Startup memory policies run only after a successful TCP bind and mutate the runtime memory in place. Failed binds no longer clear or restore memory. +- Global response delay is cancellable, applies to successful responses, exceptions, and connection drops, and is additive with the first matching fault-rule delay. +- Address simulations write their full range as one block and invoke local reactions once. UI and MCP now share the same runtime-only simulation arming state. +- Dedicated MCP validation tools return `valid`, a normalized model, and field errors inside a successful validation response. Invalid upserts fail consistently, including in dry-run mode. +- The release pipeline runs tests before screenshot generation and publishing, and builds with warnings treated as errors. + +### Security + +- MCP remains disabled and bearer-token protection remains enabled by default. +- MCP mutation permission is now disabled by default for new projects and files that omit the property. Existing explicit `true` values remain compatible. +- Read operations and dry runs remain available in read-only mode. MCP clients cannot enable mutation permission themselves, but an already-authorized client may disable it. + +### Fixed + +- Repeated startup-memory application no longer replaces the data-store memory reference or loses persistent writes. +- Typed Watch increment reactions now honor their configured step instead of reading only the first raw register. +- Multi-address simulations no longer trigger local reactions once per individual element. +- Legacy invalid configurations and duplicate Watch names are preserved and reported in diagnostics instead of being silently rewritten. + +### Compatibility Notes + +- Existing unversioned project files load as version 1 and migrate in memory to version 2; the next successful save writes the explicit version. +- `ModbusLab.ServerExport` remains at version 1 and its file format is unchanged. +- Future unsupported project versions block startup without automatic recovery or overwrite. ## 1.1.0 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 31334bd..511a7c1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -66,15 +66,37 @@ The MCP endpoint is loopback-only. Remote machines cannot connect to it directly Mutating MCP tools require **Allow mutations** to be enabled. Many tools also support `dryRun`; when `dryRun` is `true`, ModbusLab validates the request and reports the expected effect without changing the workspace. +Mutation permission is off by default and cannot be enabled through MCP. Enable it in the local Application Settings window. A remote client may disable its existing mutation permission, but it cannot subsequently re-enable it. + +## Project Recovery Appears At Startup + +If the active project JSON is corrupt, ModbusLab preserves it as `ModbusLab.corrupt..modbuslab.json` before offering any repair: + +- if a valid backup exists, the newest valid backup by modification time is loaded and the app asks before repairing the active file; +- corrupt or unsupported backups are skipped while searching for an older valid backup; +- if no valid backup exists, choose **Create new project** to save a default project or **Exit** to leave the active file untouched. + +Autosave, MCP, update checks, and automatic server restoration do not start until recovery is confirmed. If the original file cannot be preserved, recovery is blocked. + +If a project was written by a newer unsupported format version, install a compatible ModbusLab version. ModbusLab does not fall back to a backup or overwrite a future-version project. + +## Project Save Failed + +Save failures appear in the status area and Diagnostics. The last successful save time is retained so it is not confused with a failed attempt. + +Check that the project directory is writable and has free space. Saves are written to a temporary file in the same directory, deserialized for verification, backed up, and then atomically replace the active file. A failure before replacement leaves the active project unchanged. + ## Build From Source Fails -Confirm that you are on Windows and have a compatible .NET SDK: +Confirm that you are on Windows and have the SDK selected by the repository's `global.json`: ```powershell dotnet --info -dotnet build .\ModbusLab.slnx +dotnet --version +dotnet build .\ModbusLab.slnx -c Release /warnaserror +dotnet test .\ModbusLab.slnx -c Release --no-build --no-restore ``` -The app targets `net11.0-windows` and uses WPF, so non-Windows builds are not supported. +The app targets `net11.0-windows` and uses WPF, so non-Windows builds are not supported. The pinned SDK is a prerelease; install the version named in `global.json` or a compatible newer patch allowed by its roll-forward policy. If a build fails because `ModbusLab.exe` is locked, close the running app and build again. This can happen when rebuilding while testing the desktop app. diff --git a/docs/user-guide/mcp.md b/docs/user-guide/mcp.md index 8dd9a3a..58a892e 100644 --- a/docs/user-guide/mcp.md +++ b/docs/user-guide/mcp.md @@ -28,11 +28,14 @@ MCP is disabled by default. When enabled: - the HTTP host binds only to `127.0.0.1`; - token authentication is enabled by default; +- mutation permission is disabled by default, including for projects created from files that omit the setting; - tools run against the same write paths used by the UI; - mutating tools are blocked unless **Allow mutations** is enabled; - mutating tools support `dryRun` where validation without changes is useful. -Use `dryRun=true` before applying changes from an LLM client. Disable **Allow mutations** when you want read-only inspection plus validation tools. +Use `dryRun=true` before applying changes from an LLM client. Read tools, resources, validation tools, and dry runs remain available while mutations are disabled. + +Only the local Application Settings window can grant **Allow mutations**. An MCP client cannot grant that permission to itself. A client that already has mutation permission may turn the permission off remotely; after that change, the local UI is required to enable it again. ## Resources @@ -120,6 +123,23 @@ Validation tools are read-only and do not change state: Most mutating tools also include a `dryRun` parameter. A dry run validates the request and returns the expected result shape without changing memory, settings, rules, logs, or files. +When a dedicated validation tool successfully performs validation, the outer call has `ok=true` even when the payload is invalid. Inspect `result.valid`, the normalized model, and the field-level errors: + +```json +{ + "ok": true, + "result": { + "valid": false, + "normalized": {}, + "errors": [ + { "Field": "TargetWatchName", "Message": "Watch 'Missing' was not found." } + ] + } +} +``` + +Upsert and mutation tools use the same validator. An invalid mutation returns `ok=false`, including when `dryRun=true`, and does not change the workspace. + ## Common Workflows Read the project and server list first: @@ -137,6 +157,6 @@ For raw memory writes, pass a JSON array of booleans for bit areas or unsigned 1 ## Diagnostics -MCP calls and failures are recorded in the app's MCP diagnostics. Use `modbuslab_mcp_status`, `modbuslab_runtime_diagnostics`, or Application Settings to inspect recent status and errors. +MCP calls and failures are recorded in the app's MCP diagnostics. Project validation warnings and the latest save error are also exposed by project and runtime diagnostics. Use `modbuslab_mcp_status`, `modbuslab_runtime_diagnostics`, or Application Settings to inspect recent status and errors. If a client receives `401 Unauthorized`, check the bearer token. If it receives `404 Not Found`, verify that it is connecting to `/mcp` and not `/`. diff --git a/docs/user-guide/settings.md b/docs/user-guide/settings.md index aa8db4e..0d822be 100644 --- a/docs/user-guide/settings.md +++ b/docs/user-guide/settings.md @@ -11,6 +11,10 @@ Per-server settings control endpoint, identity, data representation, write behav Stop the server before changing endpoint or protocol behavior settings. +Startup memory is applied only after the TCP endpoint has bound successfully. **Keep last state** preserves current memory, **Clear on startup** clears every area, and **Restore snapshot** copies the captured snapshot into the existing runtime memory. A failed bind never changes memory. + +**Response delay** is applied once to every completed request path, including exception responses and connection drops. When a matching fault rule also has a delay, the two delays are additive. + Open per-server settings by selecting a server and choosing **Settings** in the main navigation. You can also right-click a server in the server list and choose **Server settings**. ## Application Settings @@ -79,6 +83,10 @@ Use layout reset actions if a grid or window becomes inconvenient after experime ## Project Storage -ModbusLab stores its active configuration in the current user's local application data folder and keeps best-effort backups when saving. +ModbusLab stores its active configuration in the current user's local application data folder. Project format 2 saves through a verified temporary file in the same directory, creates a backup, and atomically replaces the active file. + +Unversioned projects are treated as format 1 and migrated automatically. Invalid legacy rules and duplicate Watch names remain intact and are reported in Diagnostics; editing them requires satisfying the current validation rules. + +If the active JSON is corrupt, ModbusLab preserves it before loading the newest valid backup or offering to create a new project. No recovery repair is written until you confirm it. A project from a future unsupported format blocks startup without being overwritten. This keeps runtime configuration separate from the source repository and makes the published executable self-contained. diff --git a/global.json b/global.json new file mode 100644 index 0000000..b2a2e56 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "11.0.100-preview.5.26302.115", + "rollForward": "latestPatch", + "allowPrerelease": true + } +} diff --git a/scripts/prepare-release.ps1 b/scripts/prepare-release.ps1 index d41e5ce..d50e919 100644 --- a/scripts/prepare-release.ps1 +++ b/scripts/prepare-release.ps1 @@ -49,8 +49,12 @@ try { Write-Host "Preparing ModbusLab release $Version" Set-ProjectVersion -Path $projectPath - Invoke-Checked "Build" { - dotnet build .\ModbusLab.slnx -c $Configuration + Invoke-Checked "Build (warnings as errors)" { + dotnet build .\ModbusLab.slnx -c $Configuration /warnaserror + } + + Invoke-Checked "Tests" { + dotnet test .\ModbusLab.slnx -c $Configuration --no-build --no-restore } if (-not $SkipScreenshots) {