diff --git a/.gitattributes b/.gitattributes index caec87e4..a5f66bd3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -42,4 +42,5 @@ *.ttf binary *.eot binary *.otf binary -*.svg binary \ No newline at end of file +*.svg binary +*.bin binary \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index fbcf7b16..d2434a11 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,5 +21,6 @@ }, "chat.instructionsFilesLocations": { ".github/instructions": false - } + }, + "dotnet.defaultSolution": "src/S7Tools.sln" } diff --git a/benchmarks/S7Tools.Benchmarks/ProfileCrudBenchmarks.cs b/benchmarks/S7Tools.Benchmarks/ProfileCrudBenchmarks.cs index 56100205..90000d13 100644 --- a/benchmarks/S7Tools.Benchmarks/ProfileCrudBenchmarks.cs +++ b/benchmarks/S7Tools.Benchmarks/ProfileCrudBenchmarks.cs @@ -135,9 +135,8 @@ private class MockPathService : IPathService public string SerialProfilesPath => Path.Combine(ProfilesDirectory, "Serial", "SerialProfiles.json"); public string SocatProfilesPath => Path.Combine(ProfilesDirectory, "Socat", "SocatProfiles.json"); public string PowerSupplyProfilesPath => Path.Combine(ProfilesDirectory, "PowerSupply", "PowerSupplyProfiles.json"); - public string MemoryRegionProfilesPath => Path.Combine(ProfilesDirectory, "MemoryRegions", "MemoryRegionProfiles.json"); + public string MemoryRegionProfilesPath => Path.Combine(ProfilesDirectory, "MemoryRegion", "MemoryRegionProfiles.json"); public string PayloadSetProfilesPath => Path.Combine(ProfilesDirectory, "PayloadSets", "PayloadSetProfiles.json"); - public string MemoryRegionsDirectory => Path.Combine(ResourcesDirectory, "MemoryRegions"); public string LogsDirectory => Path.Combine(ResourcesDirectory, "Logs"); public string MainLogsDirectory => Path.Combine(LogsDirectory, "Main"); public string ExportedLogsDirectory => Path.Combine(LogsDirectory, "Exported"); diff --git a/bootloader-payloads/payloads/lib/print.c b/bootloader-payloads/payloads/lib/print.c index 7f434f46..db235221 100644 --- a/bootloader-payloads/payloads/lib/print.c +++ b/bootloader-payloads/payloads/lib/print.c @@ -14,6 +14,7 @@ #include "print.h" #include "stdlib.h" +#include "read.h" #define BUF_LEN 16 @@ -194,6 +195,13 @@ int UART_protocol_send_many(const char *s, unsigned int len) { unsigned int transfer_size; while(i new FileTreeItemViewModel(d, true)); - -- var files = Directory.GetFiles(FullPath, "*.*") -+ var files = Directory.EnumerateFiles(FullPath, "*.*") - .Where(f => f.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) || - f.EndsWith(".dmp", StringComparison.OrdinalIgnoreCase)) - .Select(f => new FileTreeItemViewModel(f, false)); diff --git a/patch2.diff b/patch2.diff deleted file mode 100644 index f84102c0..00000000 --- a/patch2.diff +++ /dev/null @@ -1,14 +0,0 @@ ---- src/S7Tools/ViewModels/Pages/FileTreeItemViewModel.cs -+++ src/S7Tools/ViewModels/Pages/FileTreeItemViewModel.cs -@@ -86,9 +86,8 @@ - var directories = Directory.EnumerateDirectories(FullPath) - .Select(d => new FileTreeItemViewModel(d, true)); - -- var files = Directory.EnumerateFiles(FullPath, "*.*") -- .Where(f => f.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) || -- f.EndsWith(".dmp", StringComparison.OrdinalIgnoreCase)) -+ var files = Directory.EnumerateFiles(FullPath, "*.bin") -+ .Concat(Directory.EnumerateFiles(FullPath, "*.dmp")) - .Select(f => new FileTreeItemViewModel(f, false)); - - foreach (var dir in directories.OrderBy(d => d.Name)) diff --git a/src/S7Tools.Core/Constants/ColorPalette.cs b/src/S7Tools.Core/Constants/ColorPalette.cs index 422815f5..003a0b05 100644 --- a/src/S7Tools.Core/Constants/ColorPalette.cs +++ b/src/S7Tools.Core/Constants/ColorPalette.cs @@ -7,7 +7,7 @@ namespace S7Tools.Core.Constants; /// These RGB color values ensure consistent visual feedback across all UI components. /// Each color is defined with RGB byte values (0-255) for red, green, and blue channels. /// -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + public static class ColorPalette { #region Log Level Colors @@ -18,8 +18,11 @@ public static class ColorPalette /// public static class Trace { + /// Gets the red channel value. public const byte R = 128; + /// Gets the green channel value. public const byte G = 128; + /// Gets the blue channel value. public const byte B = 128; } @@ -29,8 +32,11 @@ public static class Trace /// public static class Debug { + /// Gets the red channel value. public const byte R = 0; + /// Gets the green channel value. public const byte G = 122; + /// Gets the blue channel value. public const byte B = 204; } @@ -40,8 +46,11 @@ public static class Debug /// public static class Information { + /// Gets the red channel value. public const byte R = 0; + /// Gets the green channel value. public const byte G = 150; + /// Gets the blue channel value. public const byte B = 0; } @@ -51,8 +60,11 @@ public static class Information /// public static class Warning { + /// Gets the red channel value. public const byte R = 255; + /// Gets the green channel value. public const byte G = 165; + /// Gets the blue channel value. public const byte B = 0; } @@ -62,8 +74,11 @@ public static class Warning /// public static class WarningLight { + /// Gets the red channel value. public const byte R = 255; + /// Gets the green channel value. public const byte G = 152; + /// Gets the blue channel value. public const byte B = 0; } @@ -73,8 +88,11 @@ public static class WarningLight /// public static class Error { + /// Gets the red channel value. public const byte R = 220; + /// Gets the green channel value. public const byte G = 20; + /// Gets the blue channel value. public const byte B = 60; } @@ -84,8 +102,11 @@ public static class Error /// public static class ErrorRed { + /// Gets the red channel value. public const byte R = 211; + /// Gets the green channel value. public const byte G = 47; + /// Gets the blue channel value. public const byte B = 47; } @@ -95,8 +116,11 @@ public static class ErrorRed /// public static class Critical { + /// Gets the red channel value. public const byte R = 139; + /// Gets the green channel value. public const byte G = 0; + /// Gets the blue channel value. public const byte B = 0; } @@ -106,8 +130,11 @@ public static class Critical /// public static class None { + /// Gets the red channel value. public const byte R = 64; + /// Gets the green channel value. public const byte G = 64; + /// Gets the blue channel value. public const byte B = 64; } @@ -117,11 +144,14 @@ public static class None /// public static class Default { + /// Gets the red channel value. public const byte R = 128; + /// Gets the green channel value. public const byte G = 128; + /// Gets the blue channel value. public const byte B = 128; } #endregion } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + diff --git a/src/S7Tools.Core/Constants/ResourcePathConstants.cs b/src/S7Tools.Core/Constants/ResourcePathConstants.cs index 62cfc6c5..08ba3196 100644 --- a/src/S7Tools.Core/Constants/ResourcePathConstants.cs +++ b/src/S7Tools.Core/Constants/ResourcePathConstants.cs @@ -58,7 +58,13 @@ public static class ResourcePaths /// /// Folder name for memory region profiles /// - public const string MemoryRegionsFolder = "MemoryRegions"; + public const string MemoryRegionFolder = "MemoryRegion"; + + /// + /// Legacy folder name for memory region profiles used by previous versions. + /// Keep for backward compatibility when probing or migrating existing profiles. + /// + public const string LegacyMemoryRegionsFolder = "MemoryRegions"; /// /// File name for memory region profiles JSON file diff --git a/src/S7Tools.Core/Logging/IStructuredLogger.cs b/src/S7Tools.Core/Interfaces/Logging/IStructuredLogger.cs similarity index 99% rename from src/S7Tools.Core/Logging/IStructuredLogger.cs rename to src/S7Tools.Core/Interfaces/Logging/IStructuredLogger.cs index 01f0c80d..c73ccba9 100644 --- a/src/S7Tools.Core/Logging/IStructuredLogger.cs +++ b/src/S7Tools.Core/Interfaces/Logging/IStructuredLogger.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using Microsoft.Extensions.Logging; -namespace S7Tools.Core.Logging; +namespace S7Tools.Core.Interfaces.Logging; /// /// Defines a structured logger with enhanced logging capabilities. diff --git a/src/S7Tools.Core/Interfaces/Services/IApplicationSettingsService.cs b/src/S7Tools.Core/Interfaces/Services/IApplicationSettingsService.cs index 037a2313..72781460 100644 --- a/src/S7Tools.Core/Interfaces/Services/IApplicationSettingsService.cs +++ b/src/S7Tools.Core/Interfaces/Services/IApplicationSettingsService.cs @@ -1,93 +1,77 @@ -using S7Tools.Core.Models.Configuration; +using S7Tools.Core.Models.Configuration.StrongSettings; -namespace S7Tools.Core.Interfaces.Services +namespace S7Tools.Core.Interfaces.Services; + +/// +/// Defines the contract for managing application settings with user override support. +/// +/// +/// Implementations load settings from a layered configuration (default + user overrides), +/// support in-process mutation via , and raise +/// when the configuration changes so that dependent +/// services can react without polling. +/// +public interface IApplicationSettingsService { /// - /// Service for managing application settings with user override support + /// Gets the current strongly-typed application settings snapshot. /// - public interface IApplicationSettingsService - { - /// - /// Loads settings from default and user configuration sources - /// - /// Merged application settings - Task LoadSettingsAsync(); - - /// - /// Saves user settings to the user configuration file - /// - /// Settings to save - Task SaveUserSettingsAsync(Dictionary userSettings); - - /// - /// Gets a specific setting value with type conversion - /// - /// Type to convert value to - /// Setting key - /// Setting value or default if not found - T GetSetting(string key); + AppSettings Current { get; } - /// - /// Gets a specific setting value with type conversion and fallback - /// - /// Type to convert value to - /// Setting key - /// Value to return if setting not found - /// Setting value or provided default - T GetSetting(string key, T defaultValue); - - /// - /// Sets a user setting value - /// - /// Setting key - /// Setting value - Task SetSettingAsync(string key, object value); - - /// - /// Resets a user setting to its default value - /// - /// Setting key to reset - Task ResetSettingAsync(string key); + /// + /// Loads settings from the default and user configuration sources asynchronously. + /// + /// A task representing the asynchronous load operation. + Task LoadSettingsAsync(); - /// - /// Resets all user settings to defaults - /// - Task ResetAllSettingsAsync(); + /// + /// Applies the specified mutation to the current settings and persists the result. + /// + /// An action that receives the mutable instance and applies the desired changes. + /// A task representing the asynchronous save operation. + Task UpdateSettingsAsync(Action updateAction); - /// - /// Restores all default values to user settings, preserving the default settings section - /// - Task RestoreDefaultsAsync(); + /// + /// Resets all user settings to their factory defaults and persists the change. + /// + /// A task representing the asynchronous reset operation. + Task ResetAllSettingsAsync(); - /// - /// Event fired when settings are reloaded - /// - event EventHandler SettingsChanged; - } + /// + /// Restores all user-overridden values to their defaults while preserving the base default settings section. + /// + /// A task representing the asynchronous restore operation. + Task RestoreDefaultsAsync(); /// - /// Event arguments for settings change notifications + /// Exports the current settings to a JSON string. /// - public class SettingsChangedEventArgs : EventArgs - { - /// - /// The setting key that changed - /// - public string Key { get; set; } = string.Empty; + /// A JSON string representation of the current . + string ExportSettingsToJson(); - /// - /// The previous value of the setting - /// - public object? OldValue { get; set; } + /// + /// Imports settings from the provided JSON string and applies them to the current configuration. + /// + /// The JSON string containing the settings to import. + /// if the import was successful; otherwise, . + Task ImportSettingsFromJsonAsync(string json); - /// - /// The new value of the setting - /// - public object? NewValue { get; set; } + /// + /// Occurs when settings are reloaded or updated via or . + /// + event EventHandler SettingsChanged; +} - /// - /// Whether this is a user setting (true) or default setting (false) - /// - public bool IsUserSetting { get; set; } - } +/// +/// Provides event data for the event. +/// +public class SettingsChangedEventArgs : EventArgs +{ + /// + /// Gets or sets a value indicating whether the change originated from a user override. + /// + /// + /// if a user setting was changed; if a default setting was changed. + /// + public bool IsUserSetting { get; set; } } diff --git a/src/S7Tools.Core/Services/Interfaces/IEnhancedBootloaderService.cs b/src/S7Tools.Core/Interfaces/Services/IBootloaderService.cs similarity index 84% rename from src/S7Tools.Core/Services/Interfaces/IEnhancedBootloaderService.cs rename to src/S7Tools.Core/Interfaces/Services/IBootloaderService.cs index d89f37ce..b5f97803 100644 --- a/src/S7Tools.Core/Services/Interfaces/IEnhancedBootloaderService.cs +++ b/src/S7Tools.Core/Interfaces/Services/IBootloaderService.cs @@ -1,15 +1,50 @@ using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Models.Validation; +using S7Tools.Core.Validation.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines the contract for enhanced bootloader orchestration operations with task integration. /// Extends the basic bootloader service with TaskExecution integration, retry mechanisms, and detailed progress tracking. /// -public interface IEnhancedBootloaderService : IBootloaderService +public interface IBootloaderService { +/// + /// Performs a complete memory dump operation on the PLC. + /// Orchestrates socat bridge setup, power cycling, handshake, stager installation, and memory dumping. + /// + /// Job profile set containing all configuration parameters. + /// Progress reporter providing stage name and completion percentage. + /// Optional logger for main task operations and workflow steps. + /// Optional logger for capturing socat process stdout/stderr output. + /// Cancellation token for the operation. + /// The result containing dump data and saved file paths. + Task DumpAsync( + JobProfileSet profiles, + IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, + Microsoft.Extensions.Logging.ILogger? taskLogger = null, + Microsoft.Extensions.Logging.ILogger? processLogger = null, + CancellationToken cancellationToken = default); + + /// + /// Validates profile set configuration before workflow execution. + /// Checks: serial port accessible, TCP port available, modbus reachable, payloads exist, memory region valid. + /// + /// Configuration to validate. + /// Cancellation token. + /// Validation result with errors if configuration invalid. + Task ValidateProfileSetAsync( + JobProfileSet profiles, + CancellationToken cancellationToken = default); + + /// + /// Estimates workflow duration based on memory region size and historical performance. + /// + /// Memory region to dump. + /// Estimated duration (range: 5-300s per SC-001). + TimeSpan EstimateDuration(MemoryRegionProfile memoryRegion); + /// /// Performs a complete memory dump operation with TaskExecution integration. /// Orchestrates socat bridge setup, power cycling, handshake, stager installation, and memory dumping diff --git a/src/S7Tools.Core/Services/Interfaces/ICentralizedTaskLogService.cs b/src/S7Tools.Core/Interfaces/Services/ICentralizedTaskLogService.cs similarity index 93% rename from src/S7Tools.Core/Services/Interfaces/ICentralizedTaskLogService.cs rename to src/S7Tools.Core/Interfaces/Services/ICentralizedTaskLogService.cs index 06892705..786e2d5b 100644 --- a/src/S7Tools.Core/Services/Interfaces/ICentralizedTaskLogService.cs +++ b/src/S7Tools.Core/Interfaces/Services/ICentralizedTaskLogService.cs @@ -1,6 +1,6 @@ using System; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Service that manages centralized logging stores for tasks. diff --git a/src/S7Tools.Core/Services/Interfaces/IJobManager.cs b/src/S7Tools.Core/Interfaces/Services/IJobManager.cs similarity index 98% rename from src/S7Tools.Core/Services/Interfaces/IJobManager.cs rename to src/S7Tools.Core/Interfaces/Services/IJobManager.cs index 9f7e9c15..94bfd232 100644 --- a/src/S7Tools.Core/Services/Interfaces/IJobManager.cs +++ b/src/S7Tools.Core/Interfaces/Services/IJobManager.cs @@ -1,7 +1,7 @@ using S7Tools.Core.Models.Jobs; using S7Tools.Core.Validation; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines the contract for job management operations. diff --git a/src/S7Tools.Core/Services/Interfaces/IJobScheduler.cs b/src/S7Tools.Core/Interfaces/Services/IJobScheduler.cs similarity index 98% rename from src/S7Tools.Core/Services/Interfaces/IJobScheduler.cs rename to src/S7Tools.Core/Interfaces/Services/IJobScheduler.cs index 92ca7794..68f5079a 100644 --- a/src/S7Tools.Core/Services/Interfaces/IJobScheduler.cs +++ b/src/S7Tools.Core/Interfaces/Services/IJobScheduler.cs @@ -1,6 +1,6 @@ using S7Tools.Core.Models.Jobs; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Manages job queue and execution for bootloader operations. diff --git a/src/S7Tools.Core/Services/Interfaces/IMemoryRegionProfileService.cs b/src/S7Tools.Core/Interfaces/Services/IMemoryRegionProfileService.cs similarity index 94% rename from src/S7Tools.Core/Services/Interfaces/IMemoryRegionProfileService.cs rename to src/S7Tools.Core/Interfaces/Services/IMemoryRegionProfileService.cs index 27d80d02..afffc0f0 100644 --- a/src/S7Tools.Core/Services/Interfaces/IMemoryRegionProfileService.cs +++ b/src/S7Tools.Core/Interfaces/Services/IMemoryRegionProfileService.cs @@ -1,6 +1,6 @@ using S7Tools.Core.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Standard contract for managing memory mapping profiles. diff --git a/src/S7Tools.Core/Services/Interfaces/IMemorySegmentValidator.cs b/src/S7Tools.Core/Interfaces/Services/IMemorySegmentValidator.cs similarity index 98% rename from src/S7Tools.Core/Services/Interfaces/IMemorySegmentValidator.cs rename to src/S7Tools.Core/Interfaces/Services/IMemorySegmentValidator.cs index 8c1bc799..3e73024c 100644 --- a/src/S7Tools.Core/Services/Interfaces/IMemorySegmentValidator.cs +++ b/src/S7Tools.Core/Interfaces/Services/IMemorySegmentValidator.cs @@ -1,7 +1,7 @@ using S7Tools.Core.Models; using S7Tools.Core.Validation; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Service for validating memory segments and detecting conflicts. diff --git a/src/S7Tools.Core/Interfaces/Services/IPathService.cs b/src/S7Tools.Core/Interfaces/Services/IPathService.cs index 9def3552..95c40dc6 100644 --- a/src/S7Tools.Core/Interfaces/Services/IPathService.cs +++ b/src/S7Tools.Core/Interfaces/Services/IPathService.cs @@ -47,11 +47,6 @@ public interface IPathService /// string MemoryRegionProfilesPath { get; } - /// - /// Gets the path to Resources/Profiles/MemoryRegions directory - /// - string MemoryRegionsDirectory { get; } - /// /// Gets the path to Resources/Profiles/PayloadSets/PayloadSetProfiles.json /// diff --git a/src/S7Tools.Core/Services/Interfaces/IPayloadProvider.cs b/src/S7Tools.Core/Interfaces/Services/IPayloadProvider.cs similarity index 96% rename from src/S7Tools.Core/Services/Interfaces/IPayloadProvider.cs rename to src/S7Tools.Core/Interfaces/Services/IPayloadProvider.cs index b82618a5..f71176fb 100644 --- a/src/S7Tools.Core/Services/Interfaces/IPayloadProvider.cs +++ b/src/S7Tools.Core/Interfaces/Services/IPayloadProvider.cs @@ -1,4 +1,4 @@ -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines the contract for providing bootloader payload files. diff --git a/src/S7Tools.Core/Services/Interfaces/IPlcClient.cs b/src/S7Tools.Core/Interfaces/Services/IPlcClient.cs similarity index 99% rename from src/S7Tools.Core/Services/Interfaces/IPlcClient.cs rename to src/S7Tools.Core/Interfaces/Services/IPlcClient.cs index 14289b81..8f16219e 100644 --- a/src/S7Tools.Core/Services/Interfaces/IPlcClient.cs +++ b/src/S7Tools.Core/Interfaces/Services/IPlcClient.cs @@ -1,4 +1,4 @@ -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines the contract for high-level PLC client operations. diff --git a/src/S7Tools.Core/Services/Interfaces/IPlcProtocol.cs b/src/S7Tools.Core/Interfaces/Services/IPlcProtocol.cs similarity index 98% rename from src/S7Tools.Core/Services/Interfaces/IPlcProtocol.cs rename to src/S7Tools.Core/Interfaces/Services/IPlcProtocol.cs index bf168137..833b67cf 100644 --- a/src/S7Tools.Core/Services/Interfaces/IPlcProtocol.cs +++ b/src/S7Tools.Core/Interfaces/Services/IPlcProtocol.cs @@ -1,4 +1,4 @@ -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines the contract for PLC protocol operations. diff --git a/src/S7Tools.Core/Services/Interfaces/IPlcTransport.cs b/src/S7Tools.Core/Interfaces/Services/IPlcTransport.cs similarity index 98% rename from src/S7Tools.Core/Services/Interfaces/IPlcTransport.cs rename to src/S7Tools.Core/Interfaces/Services/IPlcTransport.cs index b6775b72..538fbbc6 100644 --- a/src/S7Tools.Core/Services/Interfaces/IPlcTransport.cs +++ b/src/S7Tools.Core/Interfaces/Services/IPlcTransport.cs @@ -1,4 +1,4 @@ -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines the contract for PLC transport layer communication. diff --git a/src/S7Tools.Core/Services/Interfaces/IPowerSupplyProfileService.cs b/src/S7Tools.Core/Interfaces/Services/IPowerSupplyProfileService.cs similarity index 93% rename from src/S7Tools.Core/Services/Interfaces/IPowerSupplyProfileService.cs rename to src/S7Tools.Core/Interfaces/Services/IPowerSupplyProfileService.cs index 651cee6e..b47619f7 100644 --- a/src/S7Tools.Core/Services/Interfaces/IPowerSupplyProfileService.cs +++ b/src/S7Tools.Core/Interfaces/Services/IPowerSupplyProfileService.cs @@ -1,6 +1,6 @@ using S7Tools.Core.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Standard contract for managing power supply profiles. diff --git a/src/S7Tools.Core/Services/Interfaces/IPowerSupplyService.cs b/src/S7Tools.Core/Interfaces/Services/IPowerSupplyService.cs similarity index 99% rename from src/S7Tools.Core/Services/Interfaces/IPowerSupplyService.cs rename to src/S7Tools.Core/Interfaces/Services/IPowerSupplyService.cs index 1e715708..28448085 100644 --- a/src/S7Tools.Core/Services/Interfaces/IPowerSupplyService.cs +++ b/src/S7Tools.Core/Interfaces/Services/IPowerSupplyService.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using S7Tools.Core.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines the contract for power supply control operations using configuration-based approach. diff --git a/src/S7Tools.Core/Services/Interfaces/IProfileBase.cs b/src/S7Tools.Core/Interfaces/Services/IProfileBase.cs similarity index 99% rename from src/S7Tools.Core/Services/Interfaces/IProfileBase.cs rename to src/S7Tools.Core/Interfaces/Services/IProfileBase.cs index 757668cc..1b5adad8 100644 --- a/src/S7Tools.Core/Services/Interfaces/IProfileBase.cs +++ b/src/S7Tools.Core/Interfaces/Services/IProfileBase.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Base interface for all profile types in the S7Tools application. diff --git a/src/S7Tools.Core/Services/Interfaces/IProfileManager.cs b/src/S7Tools.Core/Interfaces/Services/IProfileManager.cs similarity index 99% rename from src/S7Tools.Core/Services/Interfaces/IProfileManager.cs rename to src/S7Tools.Core/Interfaces/Services/IProfileManager.cs index 27a16cdc..ad4511a9 100644 --- a/src/S7Tools.Core/Services/Interfaces/IProfileManager.cs +++ b/src/S7Tools.Core/Interfaces/Services/IProfileManager.cs @@ -3,7 +3,7 @@ using System.Threading; using System.Threading.Tasks; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Unified interface for profile management operations across all profile types. diff --git a/src/S7Tools.Core/Services/Interfaces/IProfileValidator.cs b/src/S7Tools.Core/Interfaces/Services/IProfileValidator.cs similarity index 99% rename from src/S7Tools.Core/Services/Interfaces/IProfileValidator.cs rename to src/S7Tools.Core/Interfaces/Services/IProfileValidator.cs index 63c0c2d4..373a5a56 100644 --- a/src/S7Tools.Core/Services/Interfaces/IProfileValidator.cs +++ b/src/S7Tools.Core/Interfaces/Services/IProfileValidator.cs @@ -3,7 +3,7 @@ using System.Threading; using System.Threading.Tasks; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Validation result containing detailed validation information. diff --git a/src/S7Tools.Core/Services/Interfaces/IResourceCoordinator.cs b/src/S7Tools.Core/Interfaces/Services/IResourceCoordinator.cs similarity index 98% rename from src/S7Tools.Core/Services/Interfaces/IResourceCoordinator.cs rename to src/S7Tools.Core/Interfaces/Services/IResourceCoordinator.cs index fdf3e18a..d2754701 100644 --- a/src/S7Tools.Core/Services/Interfaces/IResourceCoordinator.cs +++ b/src/S7Tools.Core/Interfaces/Services/IResourceCoordinator.cs @@ -1,6 +1,6 @@ using S7Tools.Core.Models.Jobs; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Coordinates resource access across concurrent jobs to prevent conflicts. diff --git a/src/S7Tools.Core/Services/Interfaces/IS7ConnectionProvider.cs b/src/S7Tools.Core/Interfaces/Services/IS7ConnectionProvider.cs similarity index 99% rename from src/S7Tools.Core/Services/Interfaces/IS7ConnectionProvider.cs rename to src/S7Tools.Core/Interfaces/Services/IS7ConnectionProvider.cs index c7b21b6f..957b8547 100644 --- a/src/S7Tools.Core/Services/Interfaces/IS7ConnectionProvider.cs +++ b/src/S7Tools.Core/Interfaces/Services/IS7ConnectionProvider.cs @@ -1,6 +1,6 @@ using S7Tools.Core.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines a contract for managing connections to an S7 PLC with modern async patterns and connection state management. diff --git a/src/S7Tools.Core/Services/Interfaces/ISerialPortProfileService.cs b/src/S7Tools.Core/Interfaces/Services/ISerialPortProfileService.cs similarity index 93% rename from src/S7Tools.Core/Services/Interfaces/ISerialPortProfileService.cs rename to src/S7Tools.Core/Interfaces/Services/ISerialPortProfileService.cs index 783c619e..98479a95 100644 --- a/src/S7Tools.Core/Services/Interfaces/ISerialPortProfileService.cs +++ b/src/S7Tools.Core/Interfaces/Services/ISerialPortProfileService.cs @@ -1,6 +1,6 @@ using S7Tools.Core.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Standard contract for managing serial port profiles. diff --git a/src/S7Tools.Core/Services/Interfaces/ISerialPortService.cs b/src/S7Tools.Core/Interfaces/Services/ISerialPortService.cs similarity index 97% rename from src/S7Tools.Core/Services/Interfaces/ISerialPortService.cs rename to src/S7Tools.Core/Interfaces/Services/ISerialPortService.cs index 80c043be..8f4b4fc2 100644 --- a/src/S7Tools.Core/Services/Interfaces/ISerialPortService.cs +++ b/src/S7Tools.Core/Interfaces/Services/ISerialPortService.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using S7Tools.Core.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines the contract for serial port operations including port discovery, configuration management, and Linux stty command integration. @@ -220,12 +220,12 @@ public class SerialPortInfo public SerialPortType PortType { get; set; } /// - /// Gets or sets whether the port is currently accessible. + /// Gets or sets a value indicating whether the port is currently accessible. /// public bool IsAccessible { get; set; } /// - /// Gets or sets whether the port is currently in use. + /// Gets or sets a value indicating whether the port is currently in use. /// public bool IsInUse { get; set; } @@ -318,7 +318,7 @@ public class UsbDeviceInfo public class SttyCommandResult { /// - /// Gets or sets whether the command executed successfully. + /// Gets or sets a value indicating whether the command executed successfully. /// public bool Success { get; set; } @@ -354,7 +354,7 @@ public class SttyCommandResult public class SttyCommandValidationResult { /// - /// Gets or sets whether the command is valid and safe to execute. + /// Gets or sets a value indicating whether the command is valid and safe to execute. /// public bool IsValid { get; set; } diff --git a/src/S7Tools.Core/Services/Interfaces/ISocatProfileService.cs b/src/S7Tools.Core/Interfaces/Services/ISocatProfileService.cs similarity index 93% rename from src/S7Tools.Core/Services/Interfaces/ISocatProfileService.cs rename to src/S7Tools.Core/Interfaces/Services/ISocatProfileService.cs index 811ca80f..d7a0f0bf 100644 --- a/src/S7Tools.Core/Services/Interfaces/ISocatProfileService.cs +++ b/src/S7Tools.Core/Interfaces/Services/ISocatProfileService.cs @@ -1,6 +1,6 @@ using S7Tools.Core.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Standard contract for managing socat profiles. diff --git a/src/S7Tools.Core/Services/Interfaces/ISocatService.cs b/src/S7Tools.Core/Interfaces/Services/ISocatService.cs similarity index 97% rename from src/S7Tools.Core/Services/Interfaces/ISocatService.cs rename to src/S7Tools.Core/Interfaces/Services/ISocatService.cs index b5fe0189..8c2acf95 100644 --- a/src/S7Tools.Core/Services/Interfaces/ISocatService.cs +++ b/src/S7Tools.Core/Interfaces/Services/ISocatService.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using S7Tools.Core.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines the contract for socat (Serial-to-TCP Proxy) operations including process management, command generation, and status monitoring. @@ -297,7 +297,7 @@ public class SocatProcessInfo public DateTime StartTime { get; set; } = DateTime.UtcNow; /// - /// Gets or sets whether the process is currently running. + /// Gets or sets a value indicating whether the process is currently running. /// public bool IsRunning { get; set; } = true; @@ -446,7 +446,7 @@ public class SocatTransferStats public class SocatCommandValidationResult { /// - /// Gets or sets whether the command is valid and safe to execute. + /// Gets or sets a value indicating whether the command is valid and safe to execute. /// public bool IsValid { get; set; } @@ -466,7 +466,7 @@ public class SocatCommandValidationResult public string ValidatedCommand { get; set; } = string.Empty; /// - /// Gets or sets whether the command requires root privileges. + /// Gets or sets a value indicating whether the command requires root privileges. /// public bool RequiresRoot { get; set; } @@ -487,22 +487,22 @@ public class SocatCommandValidationResult public class SerialDeviceValidationResult { /// - /// Gets or sets whether the device is valid and accessible. + /// Gets or sets a value indicating whether the device is valid and accessible. /// public bool IsValid { get; set; } /// - /// Gets or sets whether the device exists. + /// Gets or sets a value indicating whether the device exists. /// public bool Exists { get; set; } /// - /// Gets or sets whether the device is accessible for reading/writing. + /// Gets or sets a value indicating whether the device is accessible for reading/writing. /// public bool IsAccessible { get; set; } /// - /// Gets or sets whether the device is currently in use by another process. + /// Gets or sets a value indicating whether the device is currently in use by another process. /// public bool IsInUse { get; set; } diff --git a/src/S7Tools.Core/Services/Interfaces/ITagRepository.cs b/src/S7Tools.Core/Interfaces/Services/ITagRepository.cs similarity index 99% rename from src/S7Tools.Core/Services/Interfaces/ITagRepository.cs rename to src/S7Tools.Core/Interfaces/Services/ITagRepository.cs index 5e8fa9e1..1854b6b7 100644 --- a/src/S7Tools.Core/Services/Interfaces/ITagRepository.cs +++ b/src/S7Tools.Core/Interfaces/Services/ITagRepository.cs @@ -1,7 +1,7 @@ using S7Tools.Core.Models; using S7Tools.Core.Models.ValueObjects; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines a contract for reading and writing tags from a data source with modern async patterns. diff --git a/src/S7Tools.Core/Services/Interfaces/ITaskLogDataStore.cs b/src/S7Tools.Core/Interfaces/Services/ITaskLogDataStore.cs similarity index 93% rename from src/S7Tools.Core/Services/Interfaces/ITaskLogDataStore.cs rename to src/S7Tools.Core/Interfaces/Services/ITaskLogDataStore.cs index 3378b5aa..865831dc 100644 --- a/src/S7Tools.Core/Services/Interfaces/ITaskLogDataStore.cs +++ b/src/S7Tools.Core/Interfaces/Services/ITaskLogDataStore.cs @@ -3,7 +3,7 @@ using System.Collections.Specialized; using S7Tools.Core.Models; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Defines a data store for task logs, supporting collection change notifications and log entry management. diff --git a/src/S7Tools.Core/Services/Interfaces/ITaskScheduler.cs b/src/S7Tools.Core/Interfaces/Services/ITaskScheduler.cs similarity index 99% rename from src/S7Tools.Core/Services/Interfaces/ITaskScheduler.cs rename to src/S7Tools.Core/Interfaces/Services/ITaskScheduler.cs index 5d7f1a06..0f99cecb 100644 --- a/src/S7Tools.Core/Services/Interfaces/ITaskScheduler.cs +++ b/src/S7Tools.Core/Interfaces/Services/ITaskScheduler.cs @@ -1,7 +1,7 @@ using S7Tools.Core.Models.Jobs; using S7Tools.Core.Validation; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Delegate for task state change notifications. diff --git a/src/S7Tools.Core/Services/Interfaces/ITimeProvider.cs b/src/S7Tools.Core/Interfaces/Services/ITimeProvider.cs similarity index 91% rename from src/S7Tools.Core/Services/Interfaces/ITimeProvider.cs rename to src/S7Tools.Core/Interfaces/Services/ITimeProvider.cs index 8908854e..807e3c42 100644 --- a/src/S7Tools.Core/Services/Interfaces/ITimeProvider.cs +++ b/src/S7Tools.Core/Interfaces/Services/ITimeProvider.cs @@ -1,4 +1,4 @@ -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Provides an abstraction for retrieving the current date and time. diff --git a/src/S7Tools.Core/Services/Interfaces/IUnifiedProfileDialogService.cs b/src/S7Tools.Core/Interfaces/Services/IUnifiedProfileDialogService.cs similarity index 98% rename from src/S7Tools.Core/Services/Interfaces/IUnifiedProfileDialogService.cs rename to src/S7Tools.Core/Interfaces/Services/IUnifiedProfileDialogService.cs index 411828e6..d57c3e3c 100644 --- a/src/S7Tools.Core/Services/Interfaces/IUnifiedProfileDialogService.cs +++ b/src/S7Tools.Core/Interfaces/Services/IUnifiedProfileDialogService.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace S7Tools.Core.Services.Interfaces; +namespace S7Tools.Core.Interfaces.Services; /// /// Request model for profile creation dialog operations. @@ -30,7 +30,7 @@ public class ProfileCreateRequest public string DefaultDescription { get; set; } = string.Empty; /// - /// Gets or sets whether to auto-generate a unique name if the default name is taken. + /// Gets or sets a value indicating whether to auto-generate a unique name if the default name is taken. /// public bool AutoGenerateUniqueName { get; set; } = true; } diff --git a/src/S7Tools.Core/Services/Shell/IShellCommandExecutor.cs b/src/S7Tools.Core/Interfaces/Shell/IShellCommandExecutor.cs similarity index 98% rename from src/S7Tools.Core/Services/Shell/IShellCommandExecutor.cs rename to src/S7Tools.Core/Interfaces/Shell/IShellCommandExecutor.cs index c8e4be92..a090bd6b 100644 --- a/src/S7Tools.Core/Services/Shell/IShellCommandExecutor.cs +++ b/src/S7Tools.Core/Interfaces/Shell/IShellCommandExecutor.cs @@ -2,7 +2,7 @@ using System.Threading; using System.Threading.Tasks; -namespace S7Tools.Core.Services.Shell; +namespace S7Tools.Core.Interfaces.Shell; /// /// Result of a shell command execution. diff --git a/src/S7Tools.Core/Models/Configuration/ApplicationSettings.cs b/src/S7Tools.Core/Models/Configuration/ApplicationSettings.cs deleted file mode 100644 index 5e55778d..00000000 --- a/src/S7Tools.Core/Models/Configuration/ApplicationSettings.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System.Text.Json; - -namespace S7Tools.Core.Models.Configuration -{ - /// - /// Represents the hierarchical settings structure with default and user overrides - /// - public sealed class ApplicationSettings - { - /// - /// Built-in default values that ship with the application - /// - public Dictionary DefaultSettings { get; init; } = new(); - - /// - /// User-customized values that override defaults - /// - public Dictionary UserSettings { get; set; } = new(); - - /// - /// Computed merged settings (defaults + user overrides) - /// - public Dictionary EffectiveSettings { get; private set; } = new(); - - /// - /// Path to user settings file - /// - public string SettingsFilePath { get; init; } = string.Empty; - - /// - /// When settings were last updated - /// - public DateTime LastModified { get; set; } = DateTime.UtcNow; - - /// - /// Merges default and user settings to compute effective settings - /// - public void ComputeEffectiveSettings() - { - EffectiveSettings.Clear(); - - // Start with defaults - foreach (KeyValuePair kvp in DefaultSettings) - { - EffectiveSettings[kvp.Key] = kvp.Value; - } - - // Override with user settings - foreach (KeyValuePair kvp in UserSettings) - { - EffectiveSettings[kvp.Key] = kvp.Value; - } - - LastModified = DateTime.UtcNow; - } - - /// - /// Gets a setting value by key from effective settings - /// - /// The expected type of the setting value - /// Setting key using dot notation (e.g., "logging.level") - /// Default value if setting is not found - /// The setting value or default - public T GetSetting(string key, T defaultValue = default!) - { - if (!EffectiveSettings.TryGetValue(key, out object? value)) - { - return defaultValue; - } - - try - { - if (value is JsonElement jsonElement) - { - return jsonElement.Deserialize() ?? defaultValue; - } - - if (value is T directValue) - { - return directValue; - } - - // Handle enums from string - if (typeof(T).IsEnum && value is string stringValue) - { - return (T)Enum.Parse(typeof(T), stringValue, ignoreCase: true); - } - - // Try to convert for other types - return (T)Convert.ChangeType(value, typeof(T)) ?? defaultValue; - } - catch (Exception ex) - { - // Log the exception to make configuration errors visible - System.Diagnostics.Debug.WriteLine($"Failed to convert setting '{key}' to type {typeof(T).Name}. Falling back to default. Error: {ex.Message}"); - return defaultValue; - } - } - - /// - /// Sets a user setting value, overriding any default - /// - /// Setting key using dot notation - /// Setting value - public void SetUserSetting(string key, object value) - { - UserSettings[key] = value; - ComputeEffectiveSettings(); - } - - /// - /// Removes a user setting, reverting to default if available - /// - /// Setting key to remove - /// True if setting was removed, false if it didn't exist - public bool RemoveUserSetting(string key) - { - if (UserSettings.Remove(key)) - { - ComputeEffectiveSettings(); - return true; - } - return false; - } - - /// - /// Checks if a setting key exists in effective settings - /// - /// Setting key to check - /// True if setting exists, false otherwise - public bool HasSetting(string key) - { - return EffectiveSettings.ContainsKey(key); - } - - /// - /// Gets all setting keys that start with a prefix - /// - /// Key prefix to search for - /// List of matching keys - public List GetSettingKeys(string prefix) - { - return EffectiveSettings.Keys - .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - - /// - /// Creates default application settings with proper resource paths - /// - /// Path to the settings file - /// ApplicationSettings with default values - public static ApplicationSettings CreateDefault(string settingsFilePath = "") - { - var settings = new ApplicationSettings - { - SettingsFilePath = settingsFilePath - }; - - // Logging configuration - settings.DefaultSettings.Add("logging.level", "Information"); - settings.DefaultSettings.Add("logging.enableFileLogging", true); - settings.DefaultSettings.Add("logging.maxFileSize", 10485760); // 10MB - settings.DefaultSettings.Add("logging.maxFiles", 5); - settings.DefaultSettings.Add("logging.logDirectory", "Resources/Logs/Main"); - settings.DefaultSettings.Add("logging.exportDirectory", "Resources/Logs/Exported"); - - // UI settings - settings.DefaultSettings.Add("ui.theme", "System"); - settings.DefaultSettings.Add("ui.startMinimized", false); - settings.DefaultSettings.Add("ui.autoRefreshInterval", 2000); - - // Path management settings - settings.DefaultSettings.Add("paths.autoCreateDirectories", true); - settings.DefaultSettings.Add("paths.resourcesDirectory", "Resources"); - settings.DefaultSettings.Add("paths.profilesDirectory", "Resources/Profiles"); - settings.DefaultSettings.Add("paths.logsDirectory", "Resources/Logs"); - settings.DefaultSettings.Add("paths.jobsDirectory", "Resources/Jobs"); - settings.DefaultSettings.Add("paths.tasksDirectory", "Resources/Tasks"); - settings.DefaultSettings.Add("paths.payloadsDirectory", "Resources/Payloads"); - settings.DefaultSettings.Add("paths.dumpsDirectory", "Resources/Dumps"); - - // Profile management settings - settings.DefaultSettings.Add("profiles.autoSave", true); - settings.DefaultSettings.Add("profiles.backupOnSave", true); - settings.DefaultSettings.Add("profiles.serialPath", "Resources/Profiles/Serial/SerialProfiles.json"); - settings.DefaultSettings.Add("profiles.socatPath", "Resources/Profiles/Socat/SocatProfiles.json"); - settings.DefaultSettings.Add("profiles.powerSupplyPath", "Resources/Profiles/PowerSupply/PowerSupplyProfiles.json"); - settings.DefaultSettings.Add("profiles.memoryRegionPath", "Resources/Profiles/MemoryRegions/profiles.json"); - - // Memory region specific settings - settings.DefaultSettings.Add("memoryRegion.maxProfiles", 100); - settings.DefaultSettings.Add("memoryRegion.autoLoadDefaultProfile", true); - settings.DefaultSettings.Add("memoryRegion.autoSaveProfiles", true); - settings.DefaultSettings.Add("memoryRegion.validationEnabled", true); - settings.DefaultSettings.Add("memoryRegion.autoSelectBssSegment", true); - settings.DefaultSettings.Add("memoryRegion.maxProfilesInDropdown", 50); - settings.DefaultSettings.Add("memoryRegion.enableTemplateImport", true); - settings.DefaultSettings.Add("memoryRegion.exportFormats", "JSON"); - settings.DefaultSettings.Add("memoryRegion.maxSegmentsPerProfile", 50); - settings.DefaultSettings.Add("memoryRegion.enableOverlapDetection", true); - settings.DefaultSettings.Add("memoryRegion.logProfileOperations", true); - - // Export settings - settings.DefaultSettings.Add("export.defaultFormat", "JSON"); - settings.DefaultSettings.Add("export.csvDirectory", "Resources/Logs/Exported/CSV"); - settings.DefaultSettings.Add("export.txtDirectory", "Resources/Logs/Exported/TXT"); - settings.DefaultSettings.Add("export.jsonDirectory", "Resources/Logs/Exported/JSON"); - - // PLC connection settings - settings.DefaultSettings.Add("plc.connectionTimeout", 5000); - settings.DefaultSettings.Add("plc.readTimeout", 2000); - settings.DefaultSettings.Add("plc.retryAttempts", 3); - - // Job and task settings - settings.DefaultSettings.Add("jobs.profilesPath", "Resources/Jobs/Jobs.json"); - settings.DefaultSettings.Add("tasks.profilesPath", "Resources/Tasks/Tasks.json"); - settings.DefaultSettings.Add("tasks.autoSaveInterval", 10000); - - // Serial port settings - settings.DefaultSettings.Add("serial.defaultBaudRate", 9600); - settings.DefaultSettings.Add("serial.defaultDataBits", 8); - settings.DefaultSettings.Add("serial.defaultParity", "None"); - settings.DefaultSettings.Add("serial.defaultStopBits", "One"); - settings.DefaultSettings.Add("serial.includeUsbPorts", true); - settings.DefaultSettings.Add("serial.includeAcmPorts", true); - settings.DefaultSettings.Add("serial.includeStandardPorts", true); - settings.DefaultSettings.Add("serial.maxScanPorts", 32); - settings.DefaultSettings.Add("serial.scanIntervalSeconds", 5); - settings.DefaultSettings.Add("serial.portTestTimeoutMs", 1000); - - // Network settings - settings.DefaultSettings.Add("network.defaultSocatPort", 2023); - settings.DefaultSettings.Add("network.powerSupplyPort", 502); - settings.DefaultSettings.Add("network.connectionRetries", 3); - - // Socat settings - settings.DefaultSettings.Add("socat.maxConcurrentInstances", 5); - settings.DefaultSettings.Add("socat.autoConfigureSerialDevice", true); - settings.DefaultSettings.Add("socat.processShutdownTimeoutSeconds", 5); - settings.DefaultSettings.Add("socat.statusRefreshIntervalSeconds", 2); - settings.DefaultSettings.Add("socat.captureProcessOutput", true); - - // Power supply settings - settings.DefaultSettings.Add("powerSupply.maxProfiles", 100); - settings.DefaultSettings.Add("powerSupply.autoLoadDefaultProfile", true); - settings.DefaultSettings.Add("powerSupply.autoSaveProfiles", true); - settings.DefaultSettings.Add("powerSupply.defaultConnectionTimeoutMs", 5000); - settings.DefaultSettings.Add("powerSupply.enableConnectionPooling", true); - settings.DefaultSettings.Add("powerSupply.enableAutoReconnect", true); - settings.DefaultSettings.Add("powerSupply.reconnectDelayMs", 2000); - settings.DefaultSettings.Add("powerSupply.maxReconnectAttempts", 5); - settings.DefaultSettings.Add("powerSupply.confirmPowerOff", true); - settings.DefaultSettings.Add("powerSupply.confirmPowerOn", false); - settings.DefaultSettings.Add("powerSupply.powerStateChangeDelayMs", 1000); - settings.DefaultSettings.Add("powerSupply.autoReadStateAfterConnect", true); - settings.DefaultSettings.Add("powerSupply.statusRefreshIntervalMs", 5000); - settings.DefaultSettings.Add("powerSupply.showPowerStateNotifications", true); - settings.DefaultSettings.Add("powerSupply.showConnectionNotifications", true); - settings.DefaultSettings.Add("powerSupply.logModbusOperations", false); - settings.DefaultSettings.Add("powerSupply.logConnectionStateChanges", true); - settings.DefaultSettings.Add("powerSupply.logPowerStateChanges", true); - - settings.ComputeEffectiveSettings(); - return settings; - } - } -} diff --git a/src/S7Tools.Core/Models/Configuration/PathConfiguration.cs b/src/S7Tools.Core/Models/Configuration/PathConfiguration.cs index e2bbf46c..9d979a54 100644 --- a/src/S7Tools.Core/Models/Configuration/PathConfiguration.cs +++ b/src/S7Tools.Core/Models/Configuration/PathConfiguration.cs @@ -52,11 +52,6 @@ public sealed class PathConfiguration /// public string DumpsDirectory => Path.Combine(ResourcesDirectory, ResourcePaths.DumpsFolder); - /// - /// Path to Resources/Profiles/MemoryRegions/ - /// - public string MemoryRegionsDirectory => Path.Combine(ProfilesDirectory, ResourcePaths.MemoryRegionsFolder); - /// /// Whether paths have been resolved and validated /// @@ -74,7 +69,7 @@ public string GetProfileDirectory(string profileType) "Serial" => Path.Combine(ProfilesDirectory, ResourcePaths.SerialFolder), "Socat" => Path.Combine(ProfilesDirectory, ResourcePaths.SocatFolder), "PowerSupply" => Path.Combine(ProfilesDirectory, ResourcePaths.PowerSupplyFolder), - "MemoryRegions" => MemoryRegionsDirectory, + "MemoryRegion" => Path.Combine(ProfilesDirectory, ResourcePaths.MemoryRegionFolder), _ => throw new ArgumentException($"Unknown profile type: {profileType}", nameof(profileType)) }; } @@ -91,7 +86,7 @@ public string GetProfileFilePath(string profileType) "Serial" => Path.Combine(GetProfileDirectory(profileType), ResourcePaths.SerialProfilesFile), "Socat" => Path.Combine(GetProfileDirectory(profileType), ResourcePaths.SocatProfilesFile), "PowerSupply" => Path.Combine(GetProfileDirectory(profileType), ResourcePaths.PowerSupplyProfilesFile), - "MemoryRegion" => Path.Combine(GetProfileDirectory("MemoryRegions"), ResourcePaths.MemoryRegionProfilesFile), + "MemoryRegion" => Path.Combine(GetProfileDirectory(profileType), ResourcePaths.MemoryRegionProfilesFile), _ => throw new ArgumentException($"Profile type {profileType} does not have a single file", nameof(profileType)) }; } @@ -145,8 +140,7 @@ public bool Validate() JobsPath, TasksPath, PayloadsDirectory, - DumpsDirectory, - MemoryRegionsDirectory + DumpsDirectory }; foreach (string? path in paths) diff --git a/src/S7Tools.Core/Models/Configuration/ResourceManifest.cs b/src/S7Tools.Core/Models/Configuration/ResourceManifest.cs index 5c66145b..35f1e5f6 100644 --- a/src/S7Tools.Core/Models/Configuration/ResourceManifest.cs +++ b/src/S7Tools.Core/Models/Configuration/ResourceManifest.cs @@ -75,8 +75,8 @@ public static ResourceManifest CreateDefault() }, new DirectoryInfo { - Name = ResourcePaths.MemoryRegionsFolder, - RelativePath = Path.Combine(ResourcePaths.ResourcesFolder, ResourcePaths.ProfilesFolder, ResourcePaths.MemoryRegionsFolder), + Name = ResourcePaths.MemoryRegionFolder, + RelativePath = Path.Combine(ResourcePaths.ResourcesFolder, ResourcePaths.ProfilesFolder, ResourcePaths.MemoryRegionFolder), Purpose = "Memory region profiles and definitions" }, new DirectoryInfo @@ -175,7 +175,7 @@ public static ResourceManifest CreateDefault() new FileInfo { Name = ResourcePaths.MemoryRegionProfilesFile, - RelativePath = Path.Combine(ResourcePaths.ResourcesFolder, ResourcePaths.ProfilesFolder, ResourcePaths.MemoryRegionsFolder, ResourcePaths.MemoryRegionProfilesFile), + RelativePath = Path.Combine(ResourcePaths.ResourcesFolder, ResourcePaths.ProfilesFolder, ResourcePaths.MemoryRegionFolder, ResourcePaths.MemoryRegionProfilesFile), DefaultContent = "[]", Purpose = "Memory region profile configurations" }, @@ -199,31 +199,24 @@ public static ResourceManifest CreateDefault() } /// - /// Generates default content for AppSettings.json file with proper structure + /// Generates default content for the AppSettings.json file using the StrongSettings.AppSettings schema. /// - /// JSON content with both default and user settings sections + /// Formatted JSON representing the default StrongSettings.AppSettings configuration. private static string GetDefaultAppSettingsContent() { - // Create a default ApplicationSettings instance to get ALL the default values - var defaultSettings = ApplicationSettings.CreateDefault(); - - // Create the proper file structure with both sections - var appSettingsFileContent = new - { - DefaultSettings = defaultSettings.DefaultSettings, - UserSettings = new Dictionary(defaultSettings.DefaultSettings), // Copy defaults to user settings initially - SettingsFilePath = "Resources/AppSettings/AppSettings.json", - LastModified = DateTime.UtcNow - }; - - // Serialize to JSON with proper formatting + var defaultSettings = new StrongSettings.AppSettings(); var options = new System.Text.Json.JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; - - return System.Text.Json.JsonSerializer.Serialize(appSettingsFileContent, options); + // Use Dictionary to preserve the exact "App" key casing. + // An anonymous type would be camelCased to "app" by the naming policy. + var rootObject = new System.Collections.Generic.Dictionary + { + ["App"] = defaultSettings + }; + return System.Text.Json.JsonSerializer.Serialize(rootObject, options); } } } diff --git a/src/S7Tools.Core/Models/Configuration/StrongSettings/AppSettings.cs b/src/S7Tools.Core/Models/Configuration/StrongSettings/AppSettings.cs index 83b338f0..d6119a43 100644 --- a/src/S7Tools.Core/Models/Configuration/StrongSettings/AppSettings.cs +++ b/src/S7Tools.Core/Models/Configuration/StrongSettings/AppSettings.cs @@ -1,203 +1,537 @@ -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member using System.ComponentModel.DataAnnotations; -namespace S7Tools.Core.Models.Configuration.StrongSettings +namespace S7Tools.Core.Models.Configuration.StrongSettings; + +/// +/// Represents the root application settings, loaded from appsettings.json. +/// Provides strongly-typed configuration for all subsystems within S7Tools. +/// +public class AppSettings +{ + /// Gets or sets the logging configuration. + public LoggingSettings Logging { get; set; } = new(); + + /// Gets or sets the UI configuration. + public UiSettings Ui { get; set; } = new(); + + /// Gets or sets the directory path configuration. + public PathSettings Paths { get; set; } = new(); + + /// Gets or sets the profile persistence configuration. + public ProfileSettings Profiles { get; set; } = new(); + + /// Gets or sets the memory region profile configuration. + public MemoryRegionSettings MemoryRegion { get; set; } = new(); + + /// Gets or sets the log export configuration. + public ExportSettings Export { get; set; } = new(); + + /// Gets or sets the PLC connection configuration. + public PlcSettings Plc { get; set; } = new(); + + /// Gets or sets the job persistence configuration. + public JobSettings Jobs { get; set; } = new(); + + /// Gets or sets the task persistence configuration. + public TaskSettings Tasks { get; set; } = new(); + + /// Gets or sets the serial port configuration. + public SerialSettings Serial { get; set; } = new(); + + /// Gets or sets the network configuration. + public NetworkSettings Network { get; set; } = new(); + + /// Gets or sets the socat process configuration. + public SocatSettings Socat { get; set; } = new(); + + /// Gets or sets the power supply configuration. + public PowerSupplySettings PowerSupply { get; set; } = new(); + + /// Gets or sets the memory dump operation configuration. + public MemoryDumpSettings MemoryDump { get; set; } = new(); +} + +/// +/// Configures the memory dump operation behaviour, including delays and the default output folder. +/// +public class MemoryDumpSettings { - public class AppSettings - { - public LoggingSettings Logging { get; set; } = new(); - public UiSettings Ui { get; set; } = new(); - public PathSettings Paths { get; set; } = new(); - public ProfileSettings Profiles { get; set; } = new(); - public MemoryRegionSettings MemoryRegion { get; set; } = new(); - public ExportSettings Export { get; set; } = new(); - public PlcSettings Plc { get; set; } = new(); - public JobSettings Jobs { get; set; } = new(); - public TaskSettings Tasks { get; set; } = new(); - public SerialSettings Serial { get; set; } = new(); - public NetworkSettings Network { get; set; } = new(); - public SocatSettings Socat { get; set; } = new(); - public PowerSupplySettings PowerSupply { get; set; } = new(); - public MemoryDumpSettings MemoryDump { get; set; } = new(); - } - - public class MemoryDumpSettings - { - public string DefaultFolder { get; set; } = ""; - } - - public class LoggingSettings - { - [Required] - public string Level { get; set; } = "Information"; - public bool EnableFileLogging { get; set; } = true; - [Range(1024, 1073741824)] - public long MaxFileSize { get; set; } = 10485760; // 10MB default - [Range(1, 100)] - public int MaxFiles { get; set; } = 5; - [Required] - public string LogDirectory { get; set; } = "Resources/Logs/Main"; - [Required] - public string ExportDirectory { get; set; } = "Resources/Logs/Exported"; - } - - public class UiSettings - { - [Required] - public string Theme { get; set; } = "System"; - public bool StartMinimized { get; set; } - [Range(100, 60000)] - public int AutoRefreshInterval { get; set; } = 2000; - public bool AutoScrollLogs { get; set; } = true; - public bool ShowTimestampInLogs { get; set; } = true; - public bool ShowCategoryInLogs { get; set; } = true; - public bool ShowLogLevelInLogs { get; set; } = true; - } - - public class PathSettings - { - public bool AutoCreateDirectories { get; set; } = true; - [Required] - public string ResourcesDirectory { get; set; } = "Resources"; - [Required] - public string ProfilesDirectory { get; set; } = "Resources/Profiles"; - [Required] - public string LogsDirectory { get; set; } = "Resources/Logs"; - [Required] - public string JobsDirectory { get; set; } = "Resources/Jobs"; - [Required] - public string TasksDirectory { get; set; } = "Resources/Tasks"; - [Required] - public string PayloadsDirectory { get; set; } = "Resources/Payloads"; - [Required] - public string DumpsDirectory { get; set; } = "Resources/Dumps"; - } - - public class ProfileSettings - { - public bool AutoSave { get; set; } = true; - public bool BackupOnSave { get; set; } = true; - public string SerialPath { get; set; } = "Resources/Profiles/Serial/SerialProfiles.json"; - public string SocatPath { get; set; } = "Resources/Profiles/Socat/SocatProfiles.json"; - public string PowerSupplyPath { get; set; } = "Resources/Profiles/PowerSupply/PowerSupplyProfiles.json"; - public string MemoryRegionPath { get; set; } = "Resources/Profiles/MemoryRegions/profiles.json"; - } - - public class MemoryRegionSettings - { - [Range(1, 1000)] - public int MaxProfiles { get; set; } = 100; - public bool AutoLoadDefaultProfile { get; set; } = true; - public bool AutoSaveProfiles { get; set; } = true; - public bool ValidationEnabled { get; set; } = true; - public bool AutoSelectBssSegment { get; set; } = true; - [Range(1, 500)] - public int MaxProfilesInDropdown { get; set; } = 50; - public bool EnableTemplateImport { get; set; } = true; - public string ExportFormats { get; set; } = "JSON"; - [Range(1, 1000)] - public int MaxSegmentsPerProfile { get; set; } = 50; - public bool EnableOverlapDetection { get; set; } = true; - public bool LogProfileOperations { get; set; } = true; - } - - public class ExportSettings - { - [Required] - public string DefaultFormat { get; set; } = "JSON"; - public string CsvDirectory { get; set; } = "Resources/Logs/Exported/CSV"; - public string TxtDirectory { get; set; } = "Resources/Logs/Exported/TXT"; - public string JsonDirectory { get; set; } = "Resources/Logs/Exported/JSON"; - } - - public class PlcSettings - { - [Range(100, 60000)] - public int ConnectionTimeout { get; set; } = 5000; - [Range(100, 60000)] - public int ReadTimeout { get; set; } = 2000; - [Range(0, 10)] - public int RetryAttempts { get; set; } = 3; - } - - public class JobSettings - { - public string ProfilesPath { get; set; } = "Resources/Jobs/Jobs.json"; - } - - public class TaskSettings - { - public string ProfilesPath { get; set; } = "Resources/Tasks/Tasks.json"; - [Range(1000, 3600000)] - public int AutoSaveInterval { get; set; } = 10000; - } - - public class SerialSettings - { - [Range(300, 4000000)] - public int DefaultBaudRate { get; set; } = 9600; - [Range(5, 8)] - public int DefaultDataBits { get; set; } = 8; - public string DefaultParity { get; set; } = "None"; - public string DefaultStopBits { get; set; } = "One"; - public bool IncludeUsbPorts { get; set; } = true; - public bool IncludeAcmPorts { get; set; } = true; - public bool IncludeStandardPorts { get; set; } = true; - [Range(1, 256)] - public int MaxScanPorts { get; set; } = 32; - [Range(1, 60)] - public int ScanIntervalSeconds { get; set; } = 5; - [Range(100, 30000)] - public int PortTestTimeoutMs { get; set; } = 1000; - } - - public class NetworkSettings - { - [Range(1, 65535)] - public int DefaultSocatPort { get; set; } = 2023; - [Range(1, 65535)] - public int PowerSupplyPort { get; set; } = 502; - [Range(0, 10)] - public int ConnectionRetries { get; set; } = 3; - } - - public class SocatSettings - { - [Range(1, 100)] - public int MaxConcurrentInstances { get; set; } = 5; - public bool AutoConfigureSerialDevice { get; set; } = true; - [Range(1, 60)] - public int ProcessShutdownTimeoutSeconds { get; set; } = 5; - [Range(1, 60)] - public int StatusRefreshIntervalSeconds { get; set; } = 2; - public bool CaptureProcessOutput { get; set; } = true; - } - - public class PowerSupplySettings - { - [Range(1, 1000)] - public int MaxProfiles { get; set; } = 100; - public bool AutoLoadDefaultProfile { get; set; } = true; - public bool AutoSaveProfiles { get; set; } = true; - [Range(100, 60000)] - public int DefaultConnectionTimeoutMs { get; set; } = 5000; - public bool EnableConnectionPooling { get; set; } = true; - public bool EnableAutoReconnect { get; set; } = true; - [Range(100, 60000)] - public int ReconnectDelayMs { get; set; } = 2000; - [Range(0, 20)] - public int MaxReconnectAttempts { get; set; } = 5; - public bool ConfirmPowerOff { get; set; } = true; - public bool ConfirmPowerOn { get; set; } - [Range(100, 10000)] - public int PowerStateChangeDelayMs { get; set; } = 1000; - public bool AutoReadStateAfterConnect { get; set; } = true; - [Range(100, 60000)] - public int StatusRefreshIntervalMs { get; set; } = 5000; - public bool ShowPowerStateNotifications { get; set; } = true; - public bool ShowConnectionNotifications { get; set; } = true; - public bool LogModbusOperations { get; set; } - public bool LogConnectionStateChanges { get; set; } = true; - public bool LogPowerStateChanges { get; set; } = true; - } + /// Gets or sets the default output folder for memory dump files. + public string DefaultFolder { get; set; } = ""; + + /// + /// Gets or sets the delay in milliseconds between individual segment dumps. + /// + /// A value between 0 and 60000 ms. The default is 5000 ms. + [Range(0, 60000)] + public int SegmentDumpDelayMilliseconds { get; set; } = 5000; + + /// + /// Gets or sets the delay in milliseconds between multi-dump iterations. + /// + /// A value between 0 and 60000 ms. The default is 5000 ms. + [Range(0, 60000)] + public int IterationDumpDelayMilliseconds { get; set; } = 5000; +} + +/// +/// Configures the application logging subsystem, including level, file rotation, and output directories. +/// +public class LoggingSettings +{ + /// Gets or sets the minimum log level (e.g., Information, Debug, Warning). + [Required] + public string Level { get; set; } = "Information"; + + /// + /// Gets or sets a value indicating whether log output should be written to files on disk. + /// + /// if file logging is enabled; otherwise, . The default is . + public bool EnableFileLogging { get; set; } = true; + + /// + /// Gets or sets the maximum size in bytes of a single log file before it is rotated. + /// + /// A value between 1 KB and 1 GB. The default is 10485760 (10 MB). + [Range(1024, 1073741824)] + public long MaxFileSize { get; set; } = 10485760; + + /// + /// Gets or sets the maximum number of rotated log files to retain on disk. + /// + /// A value between 1 and 100. The default is 5. + [Range(1, 100)] + public int MaxFiles { get; set; } = 5; + + /// Gets or sets the directory where application log files are written. + [Required] + public string LogDirectory { get; set; } = "Resources/Logs/Main"; + + /// Gets or sets the directory where exported log files are saved. + [Required] + public string ExportDirectory { get; set; } = "Resources/Logs/Exported"; +} + +/// +/// Configures the application user-interface behaviour, including theme, window state, and log display options. +/// +public class UiSettings +{ + /// + /// Gets or sets the UI theme name (e.g., Light, Dark, System). + /// + [Required] + public string Theme { get; set; } = "System"; + + /// + /// Gets or sets a value indicating whether the application window starts minimized. + /// + /// to start minimized; otherwise, . + public bool StartMinimized { get; set; } + + /// + /// Gets or sets the interval in milliseconds at which UI data is refreshed automatically. + /// + /// A value between 100 and 60000 ms. The default is 2000 ms. + [Range(100, 60000)] + public int AutoRefreshInterval { get; set; } = 2000; + + /// + /// Gets or sets a value indicating whether the log view automatically scrolls to the latest entry. + /// + public bool AutoScrollLogs { get; set; } = true; + + /// Gets or sets a value indicating whether timestamps are shown in log entries. + public bool ShowTimestampInLogs { get; set; } = true; + + /// Gets or sets a value indicating whether the category column is shown in log entries. + public bool ShowCategoryInLogs { get; set; } = true; + + /// Gets or sets a value indicating whether the log level column is shown in log entries. + public bool ShowLogLevelInLogs { get; set; } = true; +} + +/// +/// Configures the directory paths used by S7Tools for resources, profiles, logs, and output. +/// +public class PathSettings +{ + /// + /// Gets or sets a value indicating whether missing directories are created automatically at startup. + /// + public bool AutoCreateDirectories { get; set; } = true; + + /// Gets or sets the root resources directory path. + [Required] + public string ResourcesDirectory { get; set; } = "Resources"; + + /// Gets or sets the directory path for stored profile files. + [Required] + public string ProfilesDirectory { get; set; } = "Resources/Profiles"; + + /// Gets or sets the directory path for log files. + [Required] + public string LogsDirectory { get; set; } = "Resources/Logs"; + + /// Gets or sets the directory path for job definition files. + [Required] + public string JobsDirectory { get; set; } = "Resources/Jobs"; + + /// Gets or sets the directory path for task definition files. + [Required] + public string TasksDirectory { get; set; } = "Resources/Tasks"; + + /// Gets or sets the directory path for payload binary files. + [Required] + public string PayloadsDirectory { get; set; } = "Resources/Payloads"; + + /// Gets or sets the directory path for memory dump output files. + [Required] + public string DumpsDirectory { get; set; } = "Resources/Dumps"; +} + +/// +/// Configures profile file paths and auto-save behaviour for all profile types. +/// +public class ProfileSettings +{ + /// Gets or sets a value indicating whether profiles are saved automatically after each change. + public bool AutoSave { get; set; } = true; + + /// Gets or sets a value indicating whether a backup is created before each save operation. + public bool BackupOnSave { get; set; } = true; + + /// Gets or sets the file path for the serial port profiles JSON store. + public string SerialPath { get; set; } = "Resources/Profiles/Serial/SerialProfiles.json"; + + /// Gets or sets the file path for the socat profiles JSON store. + public string SocatPath { get; set; } = "Resources/Profiles/Socat/SocatProfiles.json"; + + /// Gets or sets the file path for the power supply profiles JSON store. + public string PowerSupplyPath { get; set; } = "Resources/Profiles/PowerSupply/PowerSupplyProfiles.json"; + + /// Gets or sets the file path for the memory region profiles JSON store. + public string MemoryRegionPath { get; set; } = "Resources/Profiles/MemoryRegion/MemoryRegionProfiles.json"; +} + +/// +/// Configures the memory region profile management subsystem. +/// +public class MemoryRegionSettings +{ + /// + /// Gets or sets the maximum number of memory region profiles that can be stored. + /// + /// A value between 1 and 1000. The default is 100. + [Range(1, 1000)] + public int MaxProfiles { get; set; } = 100; + + /// Gets or sets a value indicating whether the default memory region profile is loaded on startup. + public bool AutoLoadDefaultProfile { get; set; } = true; + + /// Gets or sets a value indicating whether profiles are saved automatically after each change. + public bool AutoSaveProfiles { get; set; } = true; + + /// Gets or sets a value indicating whether profile validation rules are enforced. + public bool ValidationEnabled { get; set; } = true; + + /// Gets or sets a value indicating whether the .bss segment is pre-selected when a profile is loaded. + public bool AutoSelectBssSegment { get; set; } = true; + + /// + /// Gets or sets the maximum number of profiles shown in the selection dropdown. + /// + /// A value between 1 and 500. The default is 50. + [Range(1, 500)] + public int MaxProfilesInDropdown { get; set; } = 50; + + /// Gets or sets a value indicating whether template importing is enabled. + public bool EnableTemplateImport { get; set; } = true; + + /// Gets or sets the comma-separated list of supported export formats (e.g., JSON). + public string ExportFormats { get; set; } = "JSON"; + + /// + /// Gets or sets the maximum number of memory segments allowed per profile. + /// + /// A value between 1 and 1000. The default is 50. + [Range(1, 1000)] + public int MaxSegmentsPerProfile { get; set; } = 50; + + /// Gets or sets a value indicating whether overlapping segment detection is enabled. + public bool EnableOverlapDetection { get; set; } = true; + + /// Gets or sets a value indicating whether profile CRUD operations are logged. + public bool LogProfileOperations { get; set; } = true; +} + +/// +/// Configures log file export paths for different output formats. +/// +public class ExportSettings +{ + /// Gets or sets the default export format (e.g., JSON, CSV, TXT). + [Required] + public string DefaultFormat { get; set; } = "JSON"; + + /// Gets or sets the output directory for CSV-formatted log exports. + public string CsvDirectory { get; set; } = "Resources/Logs/Exported/CSV"; + + /// Gets or sets the output directory for plain-text log exports. + public string TxtDirectory { get; set; } = "Resources/Logs/Exported/TXT"; + + /// Gets or sets the output directory for JSON-formatted log exports. + public string JsonDirectory { get; set; } = "Resources/Logs/Exported/JSON"; +} + +/// +/// Configures the PLC connection parameters such as timeouts and retry behaviour. +/// +public class PlcSettings +{ + /// + /// Gets or sets the connection timeout in milliseconds. + /// + /// A value between 100 and 60000 ms. The default is 5000 ms. + [Range(100, 60000)] + public int ConnectionTimeout { get; set; } = 5000; + + /// + /// Gets or sets the read operation timeout in milliseconds. + /// + /// A value between 100 and 60000 ms. The default is 2000 ms. + [Range(100, 60000)] + public int ReadTimeout { get; set; } = 2000; + + /// + /// Gets or sets the number of retry attempts for failed PLC operations. + /// + /// A value between 0 and 10. The default is 3. + [Range(0, 10)] + public int RetryAttempts { get; set; } = 3; +} + +/// +/// Configures persistence settings for job definitions. +/// +public class JobSettings +{ + /// Gets or sets the file path for the job profiles JSON store. + public string ProfilesPath { get; set; } = "Resources/Jobs/Jobs.json"; +} + +/// +/// Configures persistence settings for task executions. +/// +public class TaskSettings +{ + /// Gets or sets the file path for the task definitions JSON store. + public string ProfilesPath { get; set; } = "Resources/Tasks/Tasks.json"; + + /// + /// Gets or sets the interval in milliseconds at which task state is auto-saved. + /// + /// A value between 1000 and 3600000 ms. The default is 10000 ms. + [Range(1000, 3600000)] + public int AutoSaveInterval { get; set; } = 10000; +} + +/// +/// Configures serial port discovery and default communication parameters. +/// +public class SerialSettings +{ + /// + /// Gets or sets the default baud rate for new serial port connections. + /// + /// A value between 300 and 4000000. The default is 9600. + [Range(300, 4000000)] + public int DefaultBaudRate { get; set; } = 9600; + + /// + /// Gets or sets the default number of data bits per character. + /// + /// A value between 5 and 8. The default is 8. + [Range(5, 8)] + public int DefaultDataBits { get; set; } = 8; + + /// Gets or sets the default parity mode (e.g., None, Even, Odd). + public string DefaultParity { get; set; } = "None"; + + /// Gets or sets the default stop bits setting (e.g., One, Two). + public string DefaultStopBits { get; set; } = "One"; + + /// Gets or sets a value indicating whether USB serial ports (/dev/ttyUSB*) are included during port discovery. + public bool IncludeUsbPorts { get; set; } = true; + + /// Gets or sets a value indicating whether ACM serial ports (/dev/ttyACM*) are included during port discovery. + public bool IncludeAcmPorts { get; set; } = true; + + /// Gets or sets a value indicating whether standard serial ports (/dev/ttyS*) are included during port discovery. + public bool IncludeStandardPorts { get; set; } = true; + + /// + /// Gets or sets the maximum number of serial ports to scan during discovery. + /// + /// A value between 1 and 256. The default is 32. + [Range(1, 256)] + public int MaxScanPorts { get; set; } = 32; + + /// + /// Gets or sets the interval in seconds between automatic port discovery scans. + /// + /// A value between 1 and 60 seconds. The default is 5. + [Range(1, 60)] + public int ScanIntervalSeconds { get; set; } = 5; + + /// + /// Gets or sets the timeout in milliseconds for testing a single port during discovery. + /// + /// A value between 100 and 30000 ms. The default is 1000 ms. + [Range(100, 30000)] + public int PortTestTimeoutMs { get; set; } = 1000; +} + +/// +/// Configures network defaults for socat TCP bridging and Modbus power supply connectivity. +/// +public class NetworkSettings +{ + /// + /// Gets or sets the default TCP port used by socat for serial-to-TCP bridging. + /// + /// A value between 1 and 65535. The default is 2023. + [Range(1, 65535)] + public int DefaultSocatPort { get; set; } = 2023; + + /// + /// Gets or sets the default Modbus TCP port for power supply communication. + /// + /// A value between 1 and 65535. The default is 502. + [Range(1, 65535)] + public int PowerSupplyPort { get; set; } = 502; + + /// + /// Gets or sets the number of retry attempts for failed TCP connection attempts. + /// + /// A value between 0 and 10. The default is 3. + [Range(0, 10)] + public int ConnectionRetries { get; set; } = 3; +} + +/// +/// Configures the socat process lifecycle management, including concurrency limits and output capture. +/// +public class SocatSettings +{ + /// + /// Gets or sets the maximum number of socat instances that can run concurrently. + /// + /// A value between 1 and 100. The default is 5. + [Range(1, 100)] + public int MaxConcurrentInstances { get; set; } = 5; + + /// Gets or sets a value indicating whether the serial device is auto-configured when launching socat. + public bool AutoConfigureSerialDevice { get; set; } = true; + + /// + /// Gets or sets the graceful shutdown timeout in seconds when stopping a socat process. + /// + /// A value between 1 and 60 seconds. The default is 5. + [Range(1, 60)] + public int ProcessShutdownTimeoutSeconds { get; set; } = 5; + + /// + /// Gets or sets the interval in seconds at which socat process status is refreshed. + /// + /// A value between 1 and 60 seconds. The default is 2. + [Range(1, 60)] + public int StatusRefreshIntervalSeconds { get; set; } = 2; + + /// Gets or sets a value indicating whether stdout/stderr output from socat is captured and logged. + public bool CaptureProcessOutput { get; set; } = true; +} + +/// +/// Configures the power supply subsystem, including connection handling, power state notifications, and logging. +/// +public class PowerSupplySettings +{ + /// + /// Gets or sets the maximum number of power supply profiles that can be stored. + /// + /// A value between 1 and 1000. The default is 100. + [Range(1, 1000)] + public int MaxProfiles { get; set; } = 100; + + /// Gets or sets a value indicating whether the default power supply profile is loaded on startup. + public bool AutoLoadDefaultProfile { get; set; } = true; + + /// Gets or sets a value indicating whether profiles are saved automatically after each change. + public bool AutoSaveProfiles { get; set; } = true; + + /// + /// Gets or sets the Modbus TCP connection timeout in milliseconds. + /// + /// A value between 100 and 60000 ms. The default is 5000 ms. + [Range(100, 60000)] + public int DefaultConnectionTimeoutMs { get; set; } = 5000; + + /// Gets or sets a value indicating whether connection pooling is enabled for Modbus TCP sessions. + public bool EnableConnectionPooling { get; set; } = true; + + /// Gets or sets a value indicating whether automatic reconnection is attempted on connection loss. + public bool EnableAutoReconnect { get; set; } = true; + + /// + /// Gets or sets the delay in milliseconds between automatic reconnection attempts. + /// + /// A value between 100 and 60000 ms. The default is 2000 ms. + [Range(100, 60000)] + public int ReconnectDelayMs { get; set; } = 2000; + + /// + /// Gets or sets the maximum number of automatic reconnection attempts before giving up. + /// + /// A value between 0 and 20. The default is 5. + [Range(0, 20)] + public int MaxReconnectAttempts { get; set; } = 5; + + /// Gets or sets a value indicating whether a confirmation prompt is displayed before powering off the supply. + public bool ConfirmPowerOff { get; set; } = true; + + /// Gets or sets a value indicating whether a confirmation prompt is displayed before powering on the supply. + public bool ConfirmPowerOn { get; set; } + + /// + /// Gets or sets the delay in milliseconds after a power state change before the next operation proceeds. + /// + /// A value between 100 and 10000 ms. The default is 1000 ms. + [Range(100, 10000)] + public int PowerStateChangeDelayMs { get; set; } = 1000; + + /// Gets or sets a value indicating whether the power state is read automatically after connecting. + public bool AutoReadStateAfterConnect { get; set; } = true; + + /// + /// Gets or sets the interval in milliseconds between automatic power supply status refresh polls. + /// + /// A value between 100 and 60000 ms. The default is 5000 ms. + [Range(100, 60000)] + public int StatusRefreshIntervalMs { get; set; } = 5000; + + /// Gets or sets a value indicating whether UI notifications are shown on power state changes. + public bool ShowPowerStateNotifications { get; set; } = true; + + /// Gets or sets a value indicating whether UI notifications are shown on connection state changes. + public bool ShowConnectionNotifications { get; set; } = true; + + /// Gets or sets a value indicating whether individual Modbus TCP operations are logged at Debug level. + public bool LogModbusOperations { get; set; } + + /// Gets or sets a value indicating whether connection state transitions are logged. + public bool LogConnectionStateChanges { get; set; } = true; + + /// Gets or sets a value indicating whether power state transitions are logged. + public bool LogPowerStateChanges { get; set; } = true; } diff --git a/src/S7Tools.Core/Models/Jobs/JobProfile.cs b/src/S7Tools.Core/Models/Jobs/JobProfile.cs index f883d146..3b6c8488 100644 --- a/src/S7Tools.Core/Models/Jobs/JobProfile.cs +++ b/src/S7Tools.Core/Models/Jobs/JobProfile.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using S7Tools.Core.Constants; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Core.Models.Jobs; @@ -232,7 +232,7 @@ public static JobProfile CreateDefaultProfile() SocatProfileId = 1, // Default socat profile PowerSupplyProfileId = 1, // Default power supply profile MemoryRegionProfileId = 1, // Default memory region profile - SelectedMemorySegment = "BSS", // Standard segment + SelectedMemorySegment = ".bss", // Standard segment DumpCount = 1, Payloads = PayloadSetProfile.CreateDefault(), // Default payload configuration OutputPath = "./dumps", diff --git a/src/S7Tools.Core/Models/Jobs/PayloadSetProfile.cs b/src/S7Tools.Core/Models/Jobs/PayloadSetProfile.cs index 1e216931..6b724db0 100644 --- a/src/S7Tools.Core/Models/Jobs/PayloadSetProfile.cs +++ b/src/S7Tools.Core/Models/Jobs/PayloadSetProfile.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Core.Models.Jobs; diff --git a/src/S7Tools.Core/Models/Jobs/TaskExecution.cs b/src/S7Tools.Core/Models/Jobs/TaskExecution.cs index a469ef77..12f8fecb 100644 --- a/src/S7Tools.Core/Models/Jobs/TaskExecution.cs +++ b/src/S7Tools.Core/Models/Jobs/TaskExecution.cs @@ -5,7 +5,7 @@ using System.Text.Json.Serialization; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Core.Models.Jobs; diff --git a/src/S7Tools.Core/Models/Jobs/TaskLogger.cs b/src/S7Tools.Core/Models/Jobs/TaskLogger.cs index 77f01cc4..1640bbce 100644 --- a/src/S7Tools.Core/Models/Jobs/TaskLogger.cs +++ b/src/S7Tools.Core/Models/Jobs/TaskLogger.cs @@ -50,14 +50,14 @@ public class TaskLogger public string? ProcessLogFilePath { get; set; } /// - /// Gets or sets whether logging is enabled for this task. + /// Gets or sets a value indicating whether logging is enabled for this task. /// public bool IsEnabled { get; set; } = true; /// - /// Gets or sets whether process output logging is enabled. + /// Gets or sets a value indicating whether process output logging is enabled. /// public bool CaptureProcessOutput { get; set; } = true; diff --git a/src/S7Tools.Core/Models/MemoryMappingProfile.cs b/src/S7Tools.Core/Models/MemoryMappingProfile.cs index 3047aca4..6cadece1 100644 --- a/src/S7Tools.Core/Models/MemoryMappingProfile.cs +++ b/src/S7Tools.Core/Models/MemoryMappingProfile.cs @@ -5,7 +5,7 @@ using System.Linq; using System.Text.Json.Serialization; using S7Tools.Core.Constants; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Core.Models; @@ -86,7 +86,7 @@ public class MemoryMappingProfile : IProfileBase public List Segments { get; set; } = new(); /// - /// Gets or sets whether this profile is currently active for job operations. + /// Gets or sets a value indicating whether this profile is currently active for job operations. /// /// True if the profile is active and selected for operations, false otherwise. [Display(Name = "Active Profile")] diff --git a/src/S7Tools.Core/Models/MemorySegment.cs b/src/S7Tools.Core/Models/MemorySegment.cs index d70a0e03..0bea5708 100644 --- a/src/S7Tools.Core/Models/MemorySegment.cs +++ b/src/S7Tools.Core/Models/MemorySegment.cs @@ -85,7 +85,7 @@ public string Name public MemorySegmentType Type { get; set; } = MemorySegmentType.Flash; /// - /// Gets or sets whether this segment is selected for operations. + /// Gets or sets a value indicating whether this segment is selected for operations. /// /// True if the segment is selected for memory operations, false otherwise. [Display(Name = "Selected")] diff --git a/src/S7Tools.Core/Models/ModbusTcpConfiguration.cs b/src/S7Tools.Core/Models/ModbusTcpConfiguration.cs index 689ce434..1878623a 100644 --- a/src/S7Tools.Core/Models/ModbusTcpConfiguration.cs +++ b/src/S7Tools.Core/Models/ModbusTcpConfiguration.cs @@ -106,7 +106,7 @@ public class ModbusTcpConfiguration : PowerSupplyConfiguration public int WriteTimeoutMs { get; set; } = 3000; /// - /// Gets or sets whether to enable automatic reconnection on connection loss. + /// Gets or sets a value indicating whether to enable automatic reconnection on connection loss. /// /// True to enable auto-reconnect, false otherwise. Default is true. [Display(Name = "Enable Auto-Reconnect", Order = 9)] diff --git a/src/S7Tools.Core/Models/PowerSupplyProfile.cs b/src/S7Tools.Core/Models/PowerSupplyProfile.cs index 0ab1f5af..fd5283e6 100644 --- a/src/S7Tools.Core/Models/PowerSupplyProfile.cs +++ b/src/S7Tools.Core/Models/PowerSupplyProfile.cs @@ -3,7 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Core.Models; diff --git a/src/S7Tools.Core/Models/PowerSupplySettings.cs b/src/S7Tools.Core/Models/PowerSupplySettings.cs index b293856f..4e61ce71 100644 --- a/src/S7Tools.Core/Models/PowerSupplySettings.cs +++ b/src/S7Tools.Core/Models/PowerSupplySettings.cs @@ -38,13 +38,13 @@ public class PowerSupplySettings public int MaxProfiles { get; set; } = 100; /// - /// Gets or sets whether to automatically load the default profile on application startup. + /// Gets or sets a value indicating whether to automatically load the default profile on application startup. /// /// True to auto-load default profile, false otherwise. Default is true. public bool AutoLoadDefaultProfile { get; set; } = true; /// - /// Gets or sets whether to automatically save profile changes. + /// Gets or sets a value indicating whether to automatically save profile changes. /// /// True to auto-save changes, false otherwise. Default is true. /// @@ -68,7 +68,7 @@ public class PowerSupplySettings public int DefaultConnectionTimeoutMs { get; set; } = 5000; /// - /// Gets or sets whether to enable connection pooling. + /// Gets or sets a value indicating whether to enable connection pooling. /// /// True to enable connection pooling, false otherwise. Default is true. /// @@ -78,7 +78,7 @@ public class PowerSupplySettings public bool EnableConnectionPooling { get; set; } = true; /// - /// Gets or sets whether to automatically reconnect on connection loss. + /// Gets or sets a value indicating whether to automatically reconnect on connection loss. /// /// True to enable auto-reconnect, false otherwise. Default is true. public bool EnableAutoReconnect { get; set; } = true; @@ -100,7 +100,7 @@ public class PowerSupplySettings #region Power Control Settings /// - /// Gets or sets whether to require confirmation before turning power off. + /// Gets or sets a value indicating whether to require confirmation before turning power off. /// /// True to require confirmation, false otherwise. Default is true. /// @@ -109,7 +109,7 @@ public class PowerSupplySettings public bool ConfirmPowerOff { get; set; } = true; /// - /// Gets or sets whether to require confirmation before turning power on. + /// Gets or sets a value indicating whether to require confirmation before turning power on. /// /// True to require confirmation, false otherwise. Default is false. public bool ConfirmPowerOn { get; set; } @@ -125,7 +125,7 @@ public class PowerSupplySettings public int PowerStateChangeDelayMs { get; set; } = 1000; /// - /// Gets or sets whether to automatically read power state after connection. + /// Gets or sets a value indicating whether to automatically read power state after connection. /// /// True to auto-read state, false otherwise. Default is true. public bool AutoReadStateAfterConnect { get; set; } = true; @@ -144,13 +144,13 @@ public class PowerSupplySettings public int StatusRefreshIntervalMs { get; set; } = 5000; /// - /// Gets or sets whether to show notifications for power state changes. + /// Gets or sets a value indicating whether to show notifications for power state changes. /// /// True to show notifications, false otherwise. Default is true. public bool ShowPowerStateNotifications { get; set; } = true; /// - /// Gets or sets whether to show notifications for connection state changes. + /// Gets or sets a value indicating whether to show notifications for connection state changes. /// /// True to show notifications, false otherwise. Default is true. public bool ShowConnectionNotifications { get; set; } = true; @@ -160,7 +160,7 @@ public class PowerSupplySettings #region Logging Settings /// - /// Gets or sets whether to log all Modbus operations. + /// Gets or sets a value indicating whether to log all Modbus operations. /// /// True to enable operation logging, false otherwise. Default is false. /// @@ -170,13 +170,13 @@ public class PowerSupplySettings public bool LogModbusOperations { get; set; } /// - /// Gets or sets whether to log connection state changes. + /// Gets or sets a value indicating whether to log connection state changes. /// /// True to log connection changes, false otherwise. Default is true. public bool LogConnectionStateChanges { get; set; } = true; /// - /// Gets or sets whether to log power state changes. + /// Gets or sets a value indicating whether to log power state changes. /// /// True to log power changes, false otherwise. Default is true. public bool LogPowerStateChanges { get; set; } = true; diff --git a/src/S7Tools.Core/Models/SerialPortConfiguration.cs b/src/S7Tools.Core/Models/SerialPortConfiguration.cs index 78731469..196b86ac 100644 --- a/src/S7Tools.Core/Models/SerialPortConfiguration.cs +++ b/src/S7Tools.Core/Models/SerialPortConfiguration.cs @@ -49,28 +49,28 @@ public class SerialPortConfiguration #region Control Flags (c_cflag) /// - /// Gets or sets whether to enable receiver (CREAD flag). + /// Gets or sets a value indicating whether to enable receiver (CREAD flag). /// /// True to enable receiver, false otherwise. Default is true. [Display(Name = "Enable Receiver", Order = 5)] public bool EnableReceiver { get; set; } = true; /// - /// Gets or sets whether to use hardware flow control (CRTSCTS flag). + /// Gets or sets a value indicating whether to use hardware flow control (CRTSCTS flag). /// /// True to disable hardware flow control (-crtscts), false to enable. Default is true (disabled). [Display(Name = "Disable Hardware Flow Control", Order = 6)] public bool DisableHardwareFlowControl { get; set; } = true; /// - /// Gets or sets whether parity is enabled (PARENB flag). + /// Gets or sets a value indicating whether parity is enabled (PARENB flag). /// /// True to enable parity checking, false otherwise. Default is true. [Browsable(false)] // Combined with Parity property public bool ParityEnabled { get; set; } = true; /// - /// Gets or sets whether to use odd parity (PARODD flag). + /// Gets or sets a value indicating whether to use odd parity (PARODD flag). /// /// True for odd parity, false for even parity. Default is false (even parity, -parodd). [Browsable(false)] // Combined with Parity property @@ -81,35 +81,35 @@ public class SerialPortConfiguration #region Input Flags (c_iflag) /// - /// Gets or sets whether to ignore break conditions (IGNBRK flag). + /// Gets or sets a value indicating whether to ignore break conditions (IGNBRK flag). /// /// True to ignore break conditions, false otherwise. Default is true. [Browsable(false)] public bool IgnoreBreak { get; set; } = true; /// - /// Gets or sets whether to signal interrupt on break (BRKINT flag). + /// Gets or sets a value indicating whether to signal interrupt on break (BRKINT flag). /// /// True to disable break interrupt (-brkint), false to enable. Default is true (disabled). [Browsable(false)] public bool DisableBreakInterrupt { get; set; } = true; /// - /// Gets or sets whether to map CR to NL on input (ICRNL flag). + /// Gets or sets a value indicating whether to map CR to NL on input (ICRNL flag). /// /// True to disable CR to NL mapping (-icrnl), false to enable. Default is true (disabled). [Browsable(false)] public bool DisableMapCRtoNL { get; set; } = true; /// - /// Gets or sets whether to ring bell on input queue full (IMAXBEL flag). + /// Gets or sets a value indicating whether to ring bell on input queue full (IMAXBEL flag). /// /// True to disable bell on queue full (-imaxbel), false to enable. Default is true (disabled). [Browsable(false)] public bool DisableBellOnQueueFull { get; set; } = true; /// - /// Gets or sets whether to enable XON/XOFF flow control (IXON flag). + /// Gets or sets a value indicating whether to enable XON/XOFF flow control (IXON flag). /// /// True to disable XON/XOFF flow control (-ixon), false to enable. Default is true (disabled). [Display(Name = "Disable XON/XOFF Flow Control", Order = 7)] @@ -120,14 +120,14 @@ public class SerialPortConfiguration #region Output Flags (c_oflag) /// - /// Gets or sets whether to enable output processing (OPOST flag). + /// Gets or sets a value indicating whether to enable output processing (OPOST flag). /// /// True to disable output processing (-opost), false to enable. Default is true (disabled). [Browsable(false)] public bool DisableOutputProcessing { get; set; } = true; /// - /// Gets or sets whether to map NL to CR-NL on output (ONLCR flag). + /// Gets or sets a value indicating whether to map NL to CR-NL on output (ONLCR flag). /// /// True to disable NL to CR-NL mapping (-onlcr), false to enable. Default is true (disabled). [Browsable(false)] @@ -138,56 +138,56 @@ public class SerialPortConfiguration #region Local Flags (c_lflag) /// - /// Gets or sets whether to enable canonical input processing (ICANON flag). + /// Gets or sets a value indicating whether to enable canonical input processing (ICANON flag). /// /// True to disable canonical mode (-icanon), false to enable. Default is true (disabled for raw mode). [Browsable(false)] public bool DisableCanonicalMode { get; set; } = true; /// - /// Gets or sets whether to enable signal generation (ISIG flag). + /// Gets or sets a value indicating whether to enable signal generation (ISIG flag). /// /// True to disable signal generation (-isig), false to enable. Default is true (disabled). [Browsable(false)] public bool DisableSignalGeneration { get; set; } = true; /// - /// Gets or sets whether to enable extended input processing (IEXTEN flag). + /// Gets or sets a value indicating whether to enable extended input processing (IEXTEN flag). /// /// True to disable extended processing (-iexten), false to enable. Default is true (disabled). [Browsable(false)] public bool DisableExtendedProcessing { get; set; } = true; /// - /// Gets or sets whether to echo input characters (ECHO flag). + /// Gets or sets a value indicating whether to echo input characters (ECHO flag). /// /// True to disable echo (-echo), false to enable. Default is true (disabled). [Display(Name = "Disable Echo", Order = 8)] public bool DisableEcho { get; set; } = true; /// - /// Gets or sets whether to echo erase characters (ECHOE flag). + /// Gets or sets a value indicating whether to echo erase characters (ECHOE flag). /// /// True to disable echo erase (-echoe), false to enable. Default is true (disabled). [Browsable(false)] public bool DisableEchoErase { get; set; } = true; /// - /// Gets or sets whether to echo kill characters (ECHOK flag). + /// Gets or sets a value indicating whether to echo kill characters (ECHOK flag). /// /// True to disable echo kill (-echok), false to enable. Default is true (disabled). [Browsable(false)] public bool DisableEchoKill { get; set; } = true; /// - /// Gets or sets whether to echo control characters (ECHOCTL flag). + /// Gets or sets a value indicating whether to echo control characters (ECHOCTL flag). /// /// True to disable echo control (-echoctl), false to enable. Default is true (disabled). [Browsable(false)] public bool DisableEchoControl { get; set; } = true; /// - /// Gets or sets whether to echo kill with erase (ECHOKE flag). + /// Gets or sets a value indicating whether to echo kill with erase (ECHOKE flag). /// /// True to disable echo kill erase (-echoke), false to enable. Default is true (disabled). [Browsable(false)] @@ -198,7 +198,7 @@ public class SerialPortConfiguration #region Special Modes /// - /// Gets or sets whether to enable raw mode. + /// Gets or sets a value indicating whether to enable raw mode. /// /// True to enable raw mode, false otherwise. Default is true. /// diff --git a/src/S7Tools.Core/Models/SerialPortProfile.cs b/src/S7Tools.Core/Models/SerialPortProfile.cs index 013977c6..e648d607 100644 --- a/src/S7Tools.Core/Models/SerialPortProfile.cs +++ b/src/S7Tools.Core/Models/SerialPortProfile.cs @@ -3,7 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Core.Models; diff --git a/src/S7Tools.Core/Models/SocatConfiguration.cs b/src/S7Tools.Core/Models/SocatConfiguration.cs index 71ec67c3..ef3d0b4e 100644 --- a/src/S7Tools.Core/Models/SocatConfiguration.cs +++ b/src/S7Tools.Core/Models/SocatConfiguration.cs @@ -32,14 +32,14 @@ public class SocatConfiguration public string TcpHost { get; set; } = string.Empty; /// - /// Gets or sets whether to enable fork mode for multiple concurrent connections. + /// Gets or sets a value indicating whether to enable fork mode for multiple concurrent connections. /// /// True to enable fork mode (allows multiple connections), false otherwise. Default is true. [Display(Name = "Enable Fork Mode", Order = 3)] public bool EnableFork { get; set; } = true; /// - /// Gets or sets whether to enable address reuse. + /// Gets or sets a value indicating whether to enable address reuse. /// /// True to enable reuseaddr option, false otherwise. Default is true. [Display(Name = "Enable Address Reuse", Order = 4)] @@ -50,14 +50,14 @@ public class SocatConfiguration #region socat Flags /// - /// Gets or sets whether to enable verbose logging. + /// Gets or sets a value indicating whether to enable verbose logging. /// /// True to enable verbose mode (-v flag), false otherwise. Default is true. [Display(Name = "Verbose Logging", Order = 5)] public bool Verbose { get; set; } = true; /// - /// Gets or sets whether to enable hex dump of transferred data. + /// Gets or sets a value indicating whether to enable hex dump of transferred data. /// /// True to enable hex dump (-x flag), false otherwise. Default is true. [Display(Name = "Hex Dump", Order = 6)] @@ -92,7 +92,7 @@ public class SocatConfiguration public int BaudRate { get; set; } = 38400; /// - /// Gets or sets whether to enable raw mode for the serial device. + /// Gets or sets a value indicating whether to enable raw mode for the serial device. /// /// True to enable raw mode, false otherwise. Default is true. /// @@ -103,7 +103,7 @@ public class SocatConfiguration public bool SerialRawMode { get; set; } = true; /// - /// Gets or sets whether to disable echo on the serial device. + /// Gets or sets a value indicating whether to disable echo on the serial device. /// /// True to disable echo (echo=0), false to enable. Default is true. [Display(Name = "Disable Serial Echo", Order = 11)] @@ -114,7 +114,7 @@ public class SocatConfiguration #region Process Management /// - /// Gets or sets whether to automatically configure the serial port with stty before starting socat. + /// Gets or sets a value indicating whether to automatically configure the serial port with stty before starting socat. /// /// True to run stty configuration first, false to skip. Default is true. [Display(Name = "Auto-Configure Serial", Order = 11)] @@ -129,7 +129,7 @@ public class SocatConfiguration public int ConnectionTimeout { get; set; } = 0; /// - /// Gets or sets whether to restart socat automatically if it terminates unexpectedly. + /// Gets or sets a value indicating whether to restart socat automatically if it terminates unexpectedly. /// /// True to enable auto-restart, false otherwise. Default is false. [Display(Name = "Auto-Restart", Order = 13)] diff --git a/src/S7Tools.Core/Models/SocatProfile.cs b/src/S7Tools.Core/Models/SocatProfile.cs index 716ef027..b7a6c1bb 100644 --- a/src/S7Tools.Core/Models/SocatProfile.cs +++ b/src/S7Tools.Core/Models/SocatProfile.cs @@ -3,7 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Core.Models; diff --git a/src/S7Tools.Core/Services/Interfaces/IBootloaderService.cs b/src/S7Tools.Core/Services/Interfaces/IBootloaderService.cs deleted file mode 100644 index b0c9d24f..00000000 --- a/src/S7Tools.Core/Services/Interfaces/IBootloaderService.cs +++ /dev/null @@ -1,47 +0,0 @@ -using S7Tools.Core.Models; -using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Models.Validation; - -namespace S7Tools.Core.Services.Interfaces; - -/// -/// Defines the contract for bootloader orchestration operations. -/// Coordinates the complete memory dump workflow including socat bridge, power cycling, and PLC communication. -/// -public interface IBootloaderService -{ - /// - /// Performs a complete memory dump operation on the PLC. - /// Orchestrates socat bridge setup, power cycling, handshake, stager installation, and memory dumping. - /// - /// Job profile set containing all configuration parameters. - /// Progress reporter providing stage name and completion percentage. - /// Optional logger for main task operations and workflow steps. - /// Optional logger for capturing socat process stdout/stderr output. - /// Cancellation token for the operation. - /// The result containing dump data and saved file paths. - Task DumpAsync( - JobProfileSet profiles, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, - Microsoft.Extensions.Logging.ILogger? taskLogger = null, - Microsoft.Extensions.Logging.ILogger? processLogger = null, - CancellationToken cancellationToken = default); - - /// - /// Validates profile set configuration before workflow execution. - /// Checks: serial port accessible, TCP port available, modbus reachable, payloads exist, memory region valid. - /// - /// Configuration to validate. - /// Cancellation token. - /// Validation result with errors if configuration invalid. - Task ValidateProfileSetAsync( - JobProfileSet profiles, - CancellationToken cancellationToken = default); - - /// - /// Estimates workflow duration based on memory region size and historical performance. - /// - /// Memory region to dump. - /// Estimated duration (range: 5-300s per SC-001). - TimeSpan EstimateDuration(MemoryRegionProfile memoryRegion); -} diff --git a/src/S7Tools.Core/Models/Validation/ValidationError.cs b/src/S7Tools.Core/Validation/Models/ValidationError.cs similarity index 87% rename from src/S7Tools.Core/Models/Validation/ValidationError.cs rename to src/S7Tools.Core/Validation/Models/ValidationError.cs index 3c478e14..d6e41889 100644 --- a/src/S7Tools.Core/Models/Validation/ValidationError.cs +++ b/src/S7Tools.Core/Validation/Models/ValidationError.cs @@ -1,4 +1,4 @@ -namespace S7Tools.Core.Models.Validation; +namespace S7Tools.Core.Validation.Models; /// /// Represents a validation error for a specific field. diff --git a/src/S7Tools.Core/Models/Validation/ValidationResult.cs b/src/S7Tools.Core/Validation/Models/ValidationResult.cs similarity index 97% rename from src/S7Tools.Core/Models/Validation/ValidationResult.cs rename to src/S7Tools.Core/Validation/Models/ValidationResult.cs index 5b8f4185..886284cf 100644 --- a/src/S7Tools.Core/Models/Validation/ValidationResult.cs +++ b/src/S7Tools.Core/Validation/Models/ValidationResult.cs @@ -1,4 +1,4 @@ -namespace S7Tools.Core.Models.Validation; +namespace S7Tools.Core.Validation.Models; /// /// Represents the result of a validation operation. diff --git a/src/S7Tools.Core/Models/Validators/PlcAddressValidator.cs b/src/S7Tools.Core/Validation/Validators/PlcAddressValidator.cs similarity index 96% rename from src/S7Tools.Core/Models/Validators/PlcAddressValidator.cs rename to src/S7Tools.Core/Validation/Validators/PlcAddressValidator.cs index 35ceb58a..69adfd82 100644 --- a/src/S7Tools.Core/Models/Validators/PlcAddressValidator.cs +++ b/src/S7Tools.Core/Validation/Validators/PlcAddressValidator.cs @@ -2,7 +2,7 @@ using S7Tools.Core.Models.ValueObjects; using S7Tools.Core.Validation; -namespace S7Tools.Core.Models.Validators; +namespace S7Tools.Core.Validation.Validators; /// /// Concrete validator for PLC addresses (PlcAddress). diff --git a/src/S7Tools.Diagnostics/Program.cs b/src/S7Tools.Diagnostics/Program.cs index 4616e991..532cbda1 100644 --- a/src/S7Tools.Diagnostics/Program.cs +++ b/src/S7Tools.Diagnostics/Program.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using S7Tools.Extensions; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Diagnostics; diff --git a/src/S7Tools.Infrastructure.Logging/Core/Storage/LogDataStore.cs b/src/S7Tools.Infrastructure.Logging/Core/Storage/LogDataStore.cs index 398ecfc3..9146ebe7 100644 --- a/src/S7Tools.Infrastructure.Logging/Core/Storage/LogDataStore.cs +++ b/src/S7Tools.Infrastructure.Logging/Core/Storage/LogDataStore.cs @@ -4,7 +4,7 @@ using System.Text; using System.Text.Json; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Infrastructure.Logging.Core.Models; namespace S7Tools.Infrastructure.Logging.Core.Storage; diff --git a/src/S7Tools.Infrastructure.Logging/Providers/Extensions/LoggingServiceCollectionExtensions.cs b/src/S7Tools.Infrastructure.Logging/Providers/Extensions/LoggingServiceCollectionExtensions.cs index b51072a1..374f54e6 100644 --- a/src/S7Tools.Infrastructure.Logging/Providers/Extensions/LoggingServiceCollectionExtensions.cs +++ b/src/S7Tools.Infrastructure.Logging/Providers/Extensions/LoggingServiceCollectionExtensions.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Infrastructure.Logging.Core.Configuration; using S7Tools.Infrastructure.Logging.Core.Models; using S7Tools.Infrastructure.Logging.Core.Storage; diff --git a/src/S7Tools.Infrastructure.Logging/Providers/Microsoft/DataStoreLogger.cs b/src/S7Tools.Infrastructure.Logging/Providers/Microsoft/DataStoreLogger.cs index 3812b710..bfe2dc9b 100644 --- a/src/S7Tools.Infrastructure.Logging/Providers/Microsoft/DataStoreLogger.cs +++ b/src/S7Tools.Infrastructure.Logging/Providers/Microsoft/DataStoreLogger.cs @@ -1,7 +1,7 @@ using System.Text; using Microsoft.Extensions.Logging; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Infrastructure.Logging.Core.Configuration; using S7Tools.Infrastructure.Logging.Core.Models; using S7Tools.Infrastructure.Logging.Core.Storage; diff --git a/src/S7Tools.Infrastructure.Logging/Providers/Microsoft/DataStoreLoggerProvider.cs b/src/S7Tools.Infrastructure.Logging/Providers/Microsoft/DataStoreLoggerProvider.cs index 89622b70..8ce37aea 100644 --- a/src/S7Tools.Infrastructure.Logging/Providers/Microsoft/DataStoreLoggerProvider.cs +++ b/src/S7Tools.Infrastructure.Logging/Providers/Microsoft/DataStoreLoggerProvider.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Infrastructure.Logging.Core.Configuration; using S7Tools.Infrastructure.Logging.Core.Storage; @@ -11,13 +11,13 @@ namespace S7Tools.Infrastructure.Logging.Providers.Microsoft; /// Logger provider that creates DataStore loggers for capturing log entries in memory. /// [ProviderAlias("DataStore")] -public sealed class DataStoreLoggerProvider : ILoggerProvider, ISupportExternalScope +public sealed class DataStoreLoggerProvider : ILoggerProvider { private readonly ILogDataStore _dataStore; private readonly DataStoreLoggerConfiguration _configuration; private readonly ITimeProvider _timeProvider; private readonly ConcurrentDictionary _loggers = new(); - private IExternalScopeProvider? _scopeProvider; + private bool _disposed; /// @@ -68,12 +68,6 @@ public ILogger CreateLogger(string categoryName) return _loggers.GetOrAdd(categoryName, name => new DataStoreLogger(name, _dataStore, _configuration, _timeProvider)); } - /// - public void SetScopeProvider(IExternalScopeProvider scopeProvider) - { - _scopeProvider = scopeProvider; - } - /// /// Gets the data store used by this provider. /// @@ -140,7 +134,7 @@ public void Dispose() } _loggers.Clear(); - _scopeProvider = null; + _disposed = true; } } diff --git a/src/S7Tools.Infrastructure.Logging/Sinks/FileLogSink.cs b/src/S7Tools.Infrastructure.Logging/Sinks/FileLogSink.cs index 959d3e0c..cf932f88 100644 --- a/src/S7Tools.Infrastructure.Logging/Sinks/FileLogSink.cs +++ b/src/S7Tools.Infrastructure.Logging/Sinks/FileLogSink.cs @@ -78,7 +78,7 @@ private async Task ProcessQueueAsync() try { - while (await _logChannel.Reader.WaitToReadAsync(_cts.Token)) + while (await _logChannel.Reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false)) { while (_logChannel.Reader.TryRead(out var entry)) { @@ -108,12 +108,12 @@ private async Task ProcessQueueAsync() line += Environment.NewLine + entry.Exception; } - await writer.WriteLineAsync(line); + await writer.WriteLineAsync(line).ConfigureAwait(false); // Force flush on errors to ensure they are persisted immediately if (entry.LogLevel >= LogLevel.Error) { - await writer.FlushAsync(); + await writer.FlushAsync().ConfigureAwait(false); } } catch (Exception ex) @@ -129,7 +129,7 @@ private async Task ProcessQueueAsync() foreach (var writer in writers.Values) { try - { await writer.FlushAsync(); } + { await writer.FlushAsync().ConfigureAwait(false); } catch { } } lastFlush = DateTime.UtcNow; @@ -147,7 +147,7 @@ private async Task ProcessQueueAsync() { try { - await writer.FlushAsync(); + await writer.FlushAsync().ConfigureAwait(false); writer.Dispose(); } catch { } @@ -179,7 +179,7 @@ public async ValueTask DisposeAsync() try { - await _processTask; + await _processTask.ConfigureAwait(false); } catch { @@ -210,7 +210,7 @@ protected virtual void Dispose(bool disposing) // This is critical for preventing log loss during synchronous shutdown. try { - _processTask.GetAwaiter().GetResult(); + Task.Run(() => _processTask).GetAwaiter().GetResult(); } catch { diff --git a/src/S7Tools.Infrastructure.Logging/Sinks/InMemoryTaskLogSink.cs b/src/S7Tools.Infrastructure.Logging/Sinks/InMemoryTaskLogSink.cs index 0bcaba0c..96a8b0bc 100644 --- a/src/S7Tools.Infrastructure.Logging/Sinks/InMemoryTaskLogSink.cs +++ b/src/S7Tools.Infrastructure.Logging/Sinks/InMemoryTaskLogSink.cs @@ -1,7 +1,7 @@ using System; using System.Linq; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Infrastructure.Logging.Providers.Microsoft; namespace S7Tools.Infrastructure.Logging.Sinks; diff --git a/src/S7Tools/App.axaml.cs b/src/S7Tools/App.axaml.cs index b55a4f6f..9d452719 100644 --- a/src/S7Tools/App.axaml.cs +++ b/src/S7Tools/App.axaml.cs @@ -1,17 +1,18 @@ using System.Reactive; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Styling; using Microsoft.Extensions.DependencyInjection; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models.Configuration; using S7Tools.Core.Resources; using S7Tools.Extensions; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; using S7Tools.Resources; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Dialogs; using S7Tools.Views.Dialogs; using S7Tools.Views.Layout; -using Avalonia.Styling; namespace S7Tools; @@ -99,14 +100,19 @@ public override void OnFrameworkInitializationCompleted() // 4. Set Initial Theme and Subscribe to Changes var settingsService = _serviceProvider.GetRequiredService(); - Avalonia.Threading.Dispatcher.UIThread.Post(() => ApplyThemeVariant(settingsService.GetSetting("ui.theme", "System"))); - + var lastAppliedTheme = settingsService.Current.Ui.Theme; + Avalonia.Threading.Dispatcher.UIThread.Post(() => ApplyThemeVariant(lastAppliedTheme)); + settingsService.SettingsChanged += (s, e) => { - if (e.Key.Equals("ui.theme", StringComparison.OrdinalIgnoreCase)) + var newTheme = settingsService.Current.Ui.Theme; + if (Equals(newTheme, lastAppliedTheme)) { - Avalonia.Threading.Dispatcher.UIThread.Post(() => ApplyThemeVariant(e.NewValue?.ToString() ?? "System")); + return; } + + lastAppliedTheme = newTheme; + Avalonia.Threading.Dispatcher.UIThread.Post(() => ApplyThemeVariant(newTheme)); }; // 5. Switch to Main Window on UI Thread @@ -343,7 +349,7 @@ await ShowCriticalErrorNotificationAsync( logger.LogDebug("Showing job selection dialog"); // Get job manager to fetch available jobs - var jobManager = _serviceProvider.GetService(); + var jobManager = _serviceProvider.GetService(); if (jobManager == null) { logger.LogError("IJobManager service not available for job selection dialog"); @@ -491,7 +497,7 @@ private async Task StartSchedulersAsync(ILogger logger) try { logger.LogInformation("🚀 Starting JobScheduler..."); - Core.Services.Interfaces.IJobScheduler? jobScheduler = _serviceProvider.GetService(); + Core.Interfaces.Services.IJobScheduler? jobScheduler = _serviceProvider.GetService(); if (jobScheduler != null) { await jobScheduler.StartAsync(System.Threading.CancellationToken.None).ConfigureAwait(false); @@ -511,7 +517,7 @@ private async Task StartSchedulersAsync(ILogger logger) try { logger.LogInformation("🚀 Starting TaskScheduler..."); - Core.Services.Interfaces.ITaskScheduler? taskScheduler = _serviceProvider.GetService(); + Core.Interfaces.Services.ITaskScheduler? taskScheduler = _serviceProvider.GetService(); if (taskScheduler != null) { await taskScheduler.StartAsync(System.Threading.CancellationToken.None).ConfigureAwait(false); diff --git a/src/S7Tools/Extensions/ServiceCollectionExtensions.cs b/src/S7Tools/Extensions/ServiceCollectionExtensions.cs index bb0db448..e6395a08 100644 --- a/src/S7Tools/Extensions/ServiceCollectionExtensions.cs +++ b/src/S7Tools/Extensions/ServiceCollectionExtensions.cs @@ -6,16 +6,16 @@ using S7Tools.Core.Commands; using S7Tools.Core.Factories; using S7Tools.Core.Interfaces.Services; -using S7Tools.Core.Logging; +using S7Tools.Core.Interfaces.Logging; using S7Tools.Core.Models.Jobs; using S7Tools.Core.Resources; -using S7Tools.Core.Services.Interfaces; using S7Tools.Core.Validation; using S7Tools.Infrastructure.Logging.Core.Models; using S7Tools.Infrastructure.Logging.Core.Storage; using S7Tools.Infrastructure.Logging.Providers.Extensions; using S7Tools.Infrastructure.Logging.Sinks; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; using S7Tools.Resources; using S7Tools.Services; using S7Tools.Services.Bootloader; @@ -52,7 +52,7 @@ public static IServiceCollection AddS7ToolsFoundationServices(this IServiceColle services.TryAddSingleton(); // Add Shell Command Executor - services.TryAddSingleton(); + services.TryAddSingleton(); // Add UI Thread Service services.TryAddSingleton(); @@ -78,9 +78,6 @@ public static IServiceCollection AddS7ToolsFoundationServices(this IServiceColle // Add Dialog Service services.TryAddTransient(); - // Add Profile Edit Dialog Service - services.TryAddTransient(); - // Add Unified Profile Dialog Service (delegates to ProfileEditDialogService) services.TryAddTransient(); @@ -347,10 +344,7 @@ public static IServiceCollection AddS7ToolsTaskManagerServices(this IServiceColl // Add Bootloader Services // Add Bootloader Services - // Use EnhancedBootloaderService as the implementation for IBootloaderService - services.TryAddSingleton(); - services.TryAddSingleton(provider => - provider.GetRequiredService()); + services.TryAddSingleton(); // Add Payload Services services.TryAddSingleton(); @@ -389,9 +383,6 @@ public static IServiceCollection AddS7ToolsViewModels(this IServiceCollection se { ArgumentNullException.ThrowIfNull(services); - // Add ViewModel Factory - services.TryAddSingleton(); - // Add Main ViewModels services.TryAddSingleton(provider => new MainWindowViewModel( provider.GetRequiredService(), @@ -447,7 +438,7 @@ public static IServiceCollection AddS7ToolsViewModels(this IServiceCollection se // Add Power Supply ViewModels (Power Supply Control - Modbus TCP) services.TryAddTransient(); services.TryAddTransient(); - + // Add Memory Region Profiles ViewModels services.TryAddTransient(); @@ -669,10 +660,10 @@ public static async Task InitializeS7ToolsServicesAsync(this IServiceProvider se /// The startup logger for overall initialization tracking. /// A task representing the asynchronous initialization operation. private static async Task InitializeProfileServiceAsync( - Core.Services.Interfaces.IProfileManager profileService, + Core.Interfaces.Services.IProfileManager profileService, string serviceName, ILogger? serviceLogger, - ILogger? startupLogger) where T : class, Core.Services.Interfaces.IProfileBase + ILogger? startupLogger) where T : class, Core.Interfaces.Services.IProfileBase { var serviceStartTime = System.Diagnostics.Stopwatch.StartNew(); @@ -773,7 +764,7 @@ private static async Task DisposeServicesAsync(IServiceProvider serviceProvider, // UI and logging services typeof(IUIRefreshService), - typeof(S7Tools.Infrastructure.Logging.Core.Storage.ILogDataStore) + typeof(global::S7Tools.Infrastructure.Logging.Core.Storage.ILogDataStore) ]; foreach (Type serviceType in serviceTypes) diff --git a/src/S7Tools/Factories/MainDockFactory.cs b/src/S7Tools/Factories/MainDockFactory.cs index 2d0706b1..b6d0988a 100644 --- a/src/S7Tools/Factories/MainDockFactory.cs +++ b/src/S7Tools/Factories/MainDockFactory.cs @@ -1,10 +1,10 @@ +using System.ComponentModel; +using System.Linq; +using Dock.Avalonia.Controls; using Dock.Model.Controls; using Dock.Model.Core; using Dock.Model.Mvvm; using Dock.Model.Mvvm.Controls; -using Dock.Avalonia.Controls; -using System.ComponentModel; -using System.Linq; using S7Tools.Core.Interfaces.ViewModels; using S7Tools.ViewModels.Layout; @@ -44,7 +44,10 @@ public MainDockFactory(object context) /// private bool EnsureDocumentDock() { - if (_mainDocumentDock == null) return false; + if (_mainDocumentDock == null) + { + return false; + } if (_mainDocumentDock.VisibleDockables == null) { @@ -59,7 +62,10 @@ private bool EnsureDocumentDock() /// private bool EnsureToolDock() { - if (_bottomToolDock == null) return false; + if (_bottomToolDock == null) + { + return false; + } if (_bottomToolDock.VisibleDockables == null) { @@ -75,7 +81,10 @@ private bool EnsureToolDock() /// public void OpenDocument(IDockableViewModel vm) { - if (!EnsureDocumentDock()) return; + if (!EnsureDocumentDock()) + { + return; + } // Check for existing open document by DockId if (_openDocuments.TryGetValue(vm.DockId, out var existingDoc)) @@ -128,7 +137,10 @@ public void OpenDocument(IDockableViewModel vm) /// public void OpenTool(IDockableViewModel vm) { - if (!EnsureToolDock()) return; + if (!EnsureToolDock()) + { + return; + } if (_openTools.TryGetValue(vm.DockId, out var existingTool)) { @@ -185,7 +197,10 @@ public void CloseTool(string dockId) /// public void RestoreSettings() { - if (!EnsureDocumentDock() || SettingsContent == null) return; + if (!EnsureDocumentDock() || SettingsContent == null) + { + return; + } var existingSettings = _mainDocumentDock!.VisibleDockables? .OfType() @@ -247,7 +262,7 @@ public override void OnDockableRemoved(IDockable? dockable) public override void CloseDockable(IDockable dockable) { base.CloseDockable(dockable); - + // When actually closing a dockable tab, dispose its resources if (dockable is IDocument doc && doc.Context is IDisposable disposableVm) { diff --git a/src/S7Tools/Models/ApplicationSettings.cs b/src/S7Tools/Models/ApplicationSettings.cs deleted file mode 100644 index 76f80e7f..00000000 --- a/src/S7Tools/Models/ApplicationSettings.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using System.IO; -using S7Tools.Core.Models; - -namespace S7Tools.Models; - -/// -/// Application-wide settings. -/// -public class ApplicationSettings -{ - /// - /// Gets or sets the logging settings. - /// - public LoggingSettings Logging { get; set; } = new(); - - /// - /// Gets or sets the serial port settings. - /// - public SerialPortSettings SerialPorts { get; set; } = new(); - - /// - /// Gets or sets the socat (Serial-to-TCP Proxy) settings. - /// - public SocatSettings Socat { get; set; } = new(); - - /// - /// Gets or sets the power supply control settings. - /// - public PowerSupplySettings PowerSupply { get; set; } = new(); - - /// - /// Gets or sets the theme settings. - /// - public string Theme { get; set; } = "Dark"; - - /// - /// Gets or sets the language/culture setting. - /// - public string Language { get; set; } = "en-US"; - - /// - /// Gets or sets whether the sidebar is visible by default. - /// - public bool SidebarVisible { get; set; } = true; - - /// - /// Gets or sets the default sidebar width. - /// - public double SidebarWidth { get; set; } = 300; - - /// - /// Gets or sets the default bottom panel height. - /// - public double BottomPanelHeight { get; set; } = 200; - - /// - /// Gets or sets whether the bottom panel is visible by default. - /// - public bool BottomPanelVisible { get; set; } = true; - - // Resources folder paths - populated dynamically by PathService at runtime - - /// - /// Root resources directory. - /// This will be populated by the PathService at runtime to use dynamic paths. - /// - public string ResourcesRoot { get; set; } = string.Empty; - - /// - /// Default path for payload files. - /// This will be populated by the PathService at runtime to use dynamic paths. - /// - public string PayloadsPath { get; set; } = string.Empty; - - /// - /// Default path for firmware files. - /// This will be populated by the PathService at runtime to use dynamic paths. - /// - public string FirmwarePath { get; set; } = string.Empty; - - /// - /// Default path for extractions. - /// This will be populated by the PathService at runtime to use dynamic paths. - /// - public string ExtractionsPath { get; set; } = string.Empty; - - /// - /// Default path for memory dumps. - /// This will be populated by the PathService at runtime to use dynamic paths. - /// - public string DumpsPath { get; set; } = string.Empty; - - /// - /// Creates a copy of the current settings. - /// - /// A new ApplicationSettings instance with the same values. - public ApplicationSettings Clone() - { - return new ApplicationSettings - { - Logging = Logging.Clone(), - SerialPorts = SerialPorts.Clone(), - Socat = Socat.Clone(), - PowerSupply = PowerSupply.Clone(), - Theme = Theme, - Language = Language, - SidebarVisible = SidebarVisible, - SidebarWidth = SidebarWidth, - BottomPanelHeight = BottomPanelHeight, - BottomPanelVisible = BottomPanelVisible, - ResourcesRoot = ResourcesRoot, - PayloadsPath = PayloadsPath, - FirmwarePath = FirmwarePath, - ExtractionsPath = ExtractionsPath, - DumpsPath = DumpsPath - }; - } -} diff --git a/src/S7Tools/Models/LoggingSettings.cs b/src/S7Tools/Models/LoggingSettings.cs deleted file mode 100644 index 3012075a..00000000 --- a/src/S7Tools/Models/LoggingSettings.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Microsoft.Extensions.Logging; - -namespace S7Tools.Models; - -/// -/// Settings for logging configuration. -/// -public class LoggingSettings -{ - /// - /// Gets or sets the default path for log files. - /// This will be populated by the PathService at runtime to use dynamic paths. - /// - public string DefaultLogPath { get; set; } = string.Empty; - - /// - /// Gets or sets the export path for log files. - /// This will be populated by the PathService at runtime to use dynamic paths. - /// - public string ExportPath { get; set; } = string.Empty; - - /// - /// Gets or sets the minimum log level to display. - /// - public LogLevel MinimumLogLevel { get; set; } = LogLevel.Trace; - - /// - /// Gets or sets whether auto-scroll is enabled. - /// - public bool AutoScroll { get; set; } = true; - - /// - /// Gets or sets the maximum number of log entries to keep in memory. - /// - public int MaxLogEntries { get; set; } = 10000; - - /// - /// Gets or sets whether to enable file logging. - /// - public bool EnableFileLogging { get; set; } = true; - - /// - /// Gets or sets the log file name pattern. - /// - public string LogFileNamePattern { get; set; } = "MainLog_{timestamp}.log"; - - /// - /// Gets or sets the maximum log file size in MB before rolling. - /// - public int MaxLogFileSizeMB { get; set; } = 10; - - /// - /// Gets or sets the number of log files to retain. - /// - public int RetainedLogFiles { get; set; } = 5; - - /// - /// Gets or sets the color settings for different log levels. - /// - public Dictionary LogLevelColors { get; set; } = new() - { - { LogLevel.Trace, "#808080" }, // Gray - { LogLevel.Debug, "#007ACC" }, // Blue - { LogLevel.Information, "#00AA00" }, // Green - { LogLevel.Warning, "#FFA500" }, // Orange - { LogLevel.Error, "#DC143C" }, // Crimson - { LogLevel.Critical, "#8B0000" } // Dark Red - }; - - /// - /// Gets or sets whether to show timestamps in the log viewer. - /// - public bool ShowTimestamp { get; set; } = true; - - /// - /// Gets or sets whether to show categories in the log viewer. - /// - public bool ShowCategory { get; set; } = true; - - /// - /// Gets or sets whether to show log levels in the log viewer. - /// - public bool ShowLevel { get; set; } = true; - - /// - /// Creates a copy of the current settings. - /// - /// A new LoggingSettings instance with the same values. - public LoggingSettings Clone() - { - return new LoggingSettings - { - DefaultLogPath = DefaultLogPath, - ExportPath = ExportPath, - MinimumLogLevel = MinimumLogLevel, - AutoScroll = AutoScroll, - MaxLogEntries = MaxLogEntries, - EnableFileLogging = EnableFileLogging, - LogFileNamePattern = LogFileNamePattern, - MaxLogFileSizeMB = MaxLogFileSizeMB, - RetainedLogFiles = RetainedLogFiles, - LogLevelColors = new Dictionary(LogLevelColors), - ShowTimestamp = ShowTimestamp, - ShowCategory = ShowCategory, - ShowLevel = ShowLevel - }; - } -} diff --git a/src/S7Tools/Models/ProfileEditRequest.cs b/src/S7Tools/Models/ProfileEditRequest.cs deleted file mode 100644 index 749ad288..00000000 --- a/src/S7Tools/Models/ProfileEditRequest.cs +++ /dev/null @@ -1,63 +0,0 @@ -using S7Tools.ViewModels; - -namespace S7Tools.Models; - -/// -/// Represents a request for profile editing through a dialog. -/// -/// The dialog title. -/// The profile ViewModel containing the profile data and validation logic. -/// The type of profile being edited (Serial or Socat). -public record ProfileEditRequest( - string Title, - ViewModelBase ProfileViewModel, - ProfileType ProfileType); - -/// -/// Represents the result of a profile editing dialog. -/// -/// True if the user saved the changes, false if cancelled. -/// The updated profile ViewModel, or null if cancelled. -public record ProfileEditResult(bool IsSuccess, ViewModelBase? ProfileViewModel) -{ - /// - /// Creates a successful profile edit result. - /// - /// The updated profile ViewModel. - /// A ProfileEditResult representing success. - public static ProfileEditResult Success(ViewModelBase profileViewModel) => new(true, profileViewModel); - - /// - /// Creates a cancelled profile edit result. - /// - /// A ProfileEditResult representing cancellation. - public static ProfileEditResult Cancelled() => new(false, null); - - /// - /// Creates a failed profile edit result. - /// - /// The error message. - /// A ProfileEditResult representing failure. - public static ProfileEditResult Failed(string error) => new(false, null); -} - -/// -/// Enumeration of profile types for dialog selection. -/// -public enum ProfileType -{ - /// - /// Serial port profile type. - /// - Serial, - - /// - /// Socat profile type. - /// - Socat, - - /// - /// Power supply profile type. - /// - PowerSupply -} diff --git a/src/S7Tools/Program.cs b/src/S7Tools/Program.cs index 5db386db..2bf94d3e 100644 --- a/src/S7Tools/Program.cs +++ b/src/S7Tools/Program.cs @@ -10,7 +10,7 @@ using Projektanker.Icons.Avalonia.FontAwesome; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Extensions; using S7Tools.Infrastructure.Logging.Core.Models; using S7Tools.Infrastructure.Logging.Providers.Extensions; @@ -62,7 +62,7 @@ public static async Task Main(string[] args) // Run initialization asynchronously for diagnostics await serviceProvider.InitializeS7ToolsServicesAsync().ConfigureAwait(false); - ISerialPortProfileService? profileService = serviceProvider.GetService(); + ISerialPortProfileService? profileService = serviceProvider.GetService(); if (profileService != null) { @@ -80,7 +80,7 @@ public static async Task Main(string[] args) } // Initialize SocatProfileService and ensure default profile exists - ISocatProfileService? socatProfileService = serviceProvider.GetService(); + ISocatProfileService? socatProfileService = serviceProvider.GetService(); if (socatProfileService != null) { @@ -98,7 +98,7 @@ public static async Task Main(string[] args) } // Initialize PowerSupplyProfileService and ensure default profile exists - IPowerSupplyProfileService? powerSupplyProfileService = serviceProvider.GetService(); + IPowerSupplyProfileService? powerSupplyProfileService = serviceProvider.GetService(); if (powerSupplyProfileService != null) { @@ -116,7 +116,7 @@ public static async Task Main(string[] args) } // Initialize JobManager and ensure default job profiles exist - IJobManager? jobManager = serviceProvider.GetService(); + IJobManager? jobManager = serviceProvider.GetService(); if (jobManager != null) { @@ -181,7 +181,7 @@ private static void ConfigureServices(IServiceCollection services) .ValidateOnStart(); // Register WritableOptions factory - services.AddTransient>(provider => + services.AddTransient>(provider => new S7Tools.Services.WritableOptions( basePath, provider.GetRequiredService>(), diff --git a/src/S7Tools/Services/Adapters/FilePayloadProvider.cs b/src/S7Tools/Services/Adapters/FilePayloadProvider.cs index af15ec46..9ee29f5a 100644 --- a/src/S7Tools/Services/Adapters/FilePayloadProvider.cs +++ b/src/S7Tools/Services/Adapters/FilePayloadProvider.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Extensions; namespace S7Tools.Services.Adapters; diff --git a/src/S7Tools/Services/Adapters/Plc/DumperService.cs b/src/S7Tools/Services/Adapters/Plc/DumperService.cs index 017cd9f6..53f10910 100644 --- a/src/S7Tools/Services/Adapters/Plc/DumperService.cs +++ b/src/S7Tools/Services/Adapters/Plc/DumperService.cs @@ -30,6 +30,9 @@ public sealed class DumperService : IDisposable private ILogger Logger => _sessionLogger ?? _logger; private bool _expectGreeting; + /// + /// Initializes a new instance of the class. + /// public DumperService(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -39,14 +42,20 @@ public DumperService(ILogger logger) } // Expose the reader for the consumer + /// + /// Gets or sets the DataReader. + /// public ChannelReader DataReader => _outputChannel?.Reader ?? throw new InvalidOperationException("Dumper session not started"); + /// + /// Executes the StartDumpingAsync operation. + /// public async Task StartDumpingAsync(string host, int port, CancellationToken token, Stream? existingStream = null, ILogger? logger = null) { _sessionLogger = logger; // Always stop previous session to cancel old tasks (e.g. FillPipeAsync) // protecting the stream from concurrent reads. - await StopAsync(); + await StopAsync().ConfigureAwait(false); if (existingStream != null) { @@ -77,7 +86,7 @@ public async Task StartDumpingAsync(string host, int port, CancellationToken tok { _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); Logger.LogInformation("Connecting to socat at {Host}:{Port}...", host, port); - await _socket.ConnectAsync(new IPEndPoint(IPAddress.Parse(host), port), _cts.Token); + await _socket.ConnectAsync(new IPEndPoint(IPAddress.Parse(host), port), _cts.Token).ConfigureAwait(false); _stream = new NetworkStream(_socket, ownsSocket: true); } @@ -86,7 +95,7 @@ public async Task StartDumpingAsync(string host, int port, CancellationToken tok var fillTask = FillPipeAsync(_stream, _pipe.Writer, _cts.Token); var readTask = ProcessPipeAsync(_pipe.Reader, _outputChannel.Writer, _cts.Token); - await Task.WhenAll(fillTask, readTask); + await Task.WhenAll(fillTask, readTask).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -166,7 +175,7 @@ private async Task FillPipeAsync(Stream stream, PipeWriter writer, CancellationT try { - int bytesRead = await stream.ReadAsync(memory, token); + int bytesRead = await stream.ReadAsync(memory, token).ConfigureAwait(false); if (bytesRead == 0) { @@ -180,7 +189,7 @@ private async Task FillPipeAsync(Stream stream, PipeWriter writer, CancellationT } Logger.LogDebug("EOF detected ({Count}/{Max}), waiting for more data...", _consecutiveEofCount, MaxEofRetries); - await Task.Delay(50, token); + await Task.Delay(50, token).ConfigureAwait(false); continue; } @@ -218,7 +227,7 @@ private async Task ProcessPipeAsync(PipeReader reader, ChannelWriter buffer = result.Buffer; if (result.IsCanceled) @@ -239,26 +248,26 @@ private async Task ProcessPipeAsync(PipeReader reader, ChannelWriter pendingBlocks; + bool processed; + + // All ref-struct usage must complete before any await. + { var seqReader = new SequenceReader(buffer); - bool processed = false; + processed = false; if (_expectGreeting) { - // Try to find the greeting in the buffer (skipping junk if necessary) bool foundGreeting = TryConsumeGreeting(ref seqReader); - - // CRITICAL FIX: Always update 'consumed' to where the reader ended up. - // TryConsumeGreeting now consumes junk bytes up to the potential greeting. consumed = seqReader.Position; if (foundGreeting) { - _expectGreeting = false; // Greeting consumed, switch to data mode + _expectGreeting = false; processed = true; Logger.LogInformation("✅ Greeting consumed. Switching to DATA mode."); - // Check if we consumed everything or have leftovers if (seqReader.Remaining > 0) { if (Logger.IsEnabled(LogLevel.Trace)) @@ -266,40 +275,38 @@ private async Task ProcessPipeAsync(PipeReader reader, ChannelWriter 0 && buffer.Length < 16) - { - // Potential STUCK STATE diagnostic - } + // Write pending blocks to the channel now that no ref-struct is live. + foreach (var block in pendingBlocks) + { + await writer.WriteAsync(block, token).ConfigureAwait(false); + } + + if (!processed && buffer.Length > 0 && buffer.Length < 16) + { + // Potential STUCK STATE diagnostic + } } reader.AdvanceTo(consumed, examined); @@ -320,7 +327,7 @@ private async Task ProcessPipeAsync(PipeReader reader, ChannelWriter @@ -393,40 +400,32 @@ private bool TryConsumeGreeting(ref SequenceReader reader) } - private void ParseProtocol(ref SequenceReader reader, ref uint currentAddress, ChannelWriter writer, CancellationToken token) + private static (List Blocks, SequencePosition Consumed) ParseProtocol( + ref SequenceReader reader, + ref uint currentAddress) { const int BlockSize = 16; // 16 bytes per line - int blocksProcessed = 0; + var blocks = new List(); while (reader.Remaining >= BlockSize) { - token.ThrowIfCancellationRequested(); - ReadOnlySequence blockSeq = reader.Sequence.Slice(reader.Position, BlockSize); // Copy to array for UI consumption (crosses thread boundary) byte[] data = blockSeq.ToArray(); - var memoryBlock = new MemoryBlock(currentAddress, data); - - if (!writer.TryWrite(memoryBlock)) - { - var task = writer.WriteAsync(memoryBlock).AsTask(); - task.Wait(); - } + blocks.Add(new MemoryBlock(currentAddress, data)); currentAddress += BlockSize; reader.Advance(BlockSize); - blocksProcessed++; } - if (blocksProcessed > 0 && Logger.IsEnabled(LogLevel.Trace)) - { - Logger.LogTrace("Parsed {Count} data blocks ({Bytes} bytes). New Addr: 0x{Addr:X}", - blocksProcessed, blocksProcessed * BlockSize, currentAddress); - } + return (blocks, reader.Position); } + /// + /// Executes the WriteAsync operation. + /// public async Task WriteAsync(byte[] data, CancellationToken token) { if (_stream == null) @@ -446,8 +445,34 @@ public async Task WriteAsync(byte[] data, CancellationToken token) } } - public Task StopAsync() + /// + /// Executes the StopAsync operation. + /// + public async Task StopAsync() { + if (_stream != null) + { + // Use a short timeout to prevent StopAsync from hanging indefinitely + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + + try + { + // Send cancellation byte (ETX / Ctrl+C) to abort any active PLC dump payload + await _stream.WriteAsync(new byte[] { 0x03 }, timeoutCts.Token).ConfigureAwait(false); + await _stream.FlushAsync(timeoutCts.Token).ConfigureAwait(false); + Logger.LogDebug("Sent cancellation byte (0x03) to PLC dump payload."); + } + catch (OperationCanceledException ex) + { + // Timed out while attempting to send the cancellation byte; treat as best-effort + Logger.LogTrace(ex, "Timed out sending cancellation byte during StopAsync (stream might be blocked or already closed)."); + } + catch (Exception ex) + { + Logger.LogTrace(ex, "Failed to send cancellation byte during StopAsync (stream might already be closed)."); + } + } + _cts?.Cancel(); if (!_isExternalStream && _stream != null) { @@ -459,9 +484,11 @@ public Task StopAsync() } _stream = null; _socket = null; - return Task.CompletedTask; } + /// + /// Executes the Dispose operation. + /// public void Dispose() { _cts?.Cancel(); diff --git a/src/S7Tools/Services/Adapters/Plc/PlcInternalHelpers.cs b/src/S7Tools/Services/Adapters/Plc/PlcInternalHelpers.cs index 7a334697..b409b84d 100644 --- a/src/S7Tools/Services/Adapters/Plc/PlcInternalHelpers.cs +++ b/src/S7Tools/Services/Adapters/Plc/PlcInternalHelpers.cs @@ -3,8 +3,14 @@ namespace S7Tools.Services.Adapters.Plc { + /// + /// Represents the PlcInternalHelpers. + /// internal static class PlcInternalHelpers { + /// + /// Executes the GetBigEndianBytes operation. + /// public static byte[] GetBigEndianBytes(uint value) { var b = BitConverter.GetBytes(value); @@ -15,6 +21,9 @@ public static byte[] GetBigEndianBytes(uint value) return b; } + /// + /// Executes the EncodeWithXor operation. + /// public static byte[] EncodeWithXor(byte[] chunk) { // Find Key diff --git a/src/S7Tools/Services/Adapters/Plc/PlcMemoryManager.cs b/src/S7Tools/Services/Adapters/Plc/PlcMemoryManager.cs index bd3d6609..938669ee 100644 --- a/src/S7Tools/Services/Adapters/Plc/PlcMemoryManager.cs +++ b/src/S7Tools/Services/Adapters/Plc/PlcMemoryManager.cs @@ -7,12 +7,18 @@ namespace S7Tools.Services.Adapters.Plc { + /// + /// Represents the PlcMemoryManager. + /// internal class PlcMemoryManager { private readonly PlcProtocolHandler _protocol; private readonly MemoryDumpOrchestrator _orchestrator; private readonly Microsoft.Extensions.Logging.ILogger _logger; + /// + /// Initializes a new instance of the class. + /// public PlcMemoryManager( PlcProtocolHandler protocol, MemoryDumpOrchestrator orchestrator, @@ -23,6 +29,9 @@ public PlcMemoryManager( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// + /// Executes the WriteToIramAsync operation. + /// public async Task WriteToIramAsync(uint address, byte[] data, CancellationToken cancellationToken) { // Enter Subprotocol 0x80 (IRAM Mode) @@ -70,6 +79,9 @@ private async Task RawSubprotocolWriteAsync(uint address, byte[] data, Cancellat await _protocol.ReceivePacketAsync(cancellationToken); } + /// + /// Executes the InvokeDumperAsync operation. + /// public async Task InvokeDumperAsync(uint address, uint length, IProgress progress, CancellationToken cancellationToken) { // Protocol: 'A' + Addr + Len @@ -171,6 +183,9 @@ await _orchestrator.InvokeDumpCommandAsync( } } + /// + /// Executes the ReceiveManyAsync operation. + /// public async Task ReceiveManyAsync(IProgress progress, CancellationToken cancellationToken) { using var ms = new MemoryStream(); diff --git a/src/S7Tools/Services/Adapters/Plc/PlcProtocolHandler.cs b/src/S7Tools/Services/Adapters/Plc/PlcProtocolHandler.cs index 24e966a7..a45da5b3 100644 --- a/src/S7Tools/Services/Adapters/Plc/PlcProtocolHandler.cs +++ b/src/S7Tools/Services/Adapters/Plc/PlcProtocolHandler.cs @@ -1,64 +1,97 @@ using System; using System.Threading; using System.Threading.Tasks; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services.Adapters.Plc { + /// + /// Represents the PlcProtocolHandler. + /// internal class PlcProtocolHandler(IPlcProtocol protocol) { private readonly IPlcProtocol _protocol = protocol ?? throw new ArgumentNullException(nameof(protocol)); + /// + /// Executes the DisconnectAsync operation. + /// public async Task DisconnectAsync(CancellationToken cancellationToken) { - await _protocol.DisconnectAsync(cancellationToken); + await _protocol.DisconnectAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Executes the GetStream operation. + /// public Stream? GetStream() => _protocol.GetStream(); + /// + /// Executes the InvokePrimaryHandlerAsync operation. + /// public async Task InvokePrimaryHandlerAsync(byte handlerIndex, byte[] args, bool awaitResponse, CancellationToken cancellationToken) { byte[] payload = new byte[1 + args.Length]; payload[0] = handlerIndex; Array.Copy(args, 0, payload, 1, args.Length); - await _protocol.SendPacketAsync(payload, cancellationToken: cancellationToken); + await _protocol.SendPacketAsync(payload, cancellationToken: cancellationToken).ConfigureAwait(false); if (!awaitResponse) { return null; } - return await _protocol.ReceivePacketAsync(cancellationToken); + return await _protocol.ReceivePacketAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Executes the InvokeAddHookAsync operation. + /// public async Task InvokeAddHookAsync(int hookNo, byte[] args, bool awaitResponse, CancellationToken cancellationToken) { byte[] payload = new byte[1 + args.Length]; payload[0] = (byte)hookNo; Array.Copy(args, 0, payload, 1, args.Length); - return await InvokePrimaryHandlerAsync(0x1C, payload, awaitResponse, cancellationToken); + return await InvokePrimaryHandlerAsync(0x1C, payload, awaitResponse, cancellationToken).ConfigureAwait(false); } + /// + /// Executes the SendPacketAsync operation. + /// public async Task SendPacketAsync(byte[] payload, int? maxChunk, CancellationToken cancellationToken) { - await _protocol.SendPacketAsync(payload, maxChunk, cancellationToken); + await _protocol.SendPacketAsync(payload, maxChunk, cancellationToken).ConfigureAwait(false); } + /// + /// Executes the ReceivePacketAsync operation. + /// public async Task ReceivePacketAsync(CancellationToken cancellationToken) { - return await _protocol.ReceivePacketAsync(cancellationToken); + return await _protocol.ReceivePacketAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Executes the RawWriteAsync operation. + /// public async Task RawWriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - await _protocol.RawWriteAsync(buffer, offset, count, cancellationToken); + await _protocol.RawWriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); } + /// + /// Executes the RawReadAsync operation. + /// public async Task RawReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - return await _protocol.RawReadAsync(buffer, offset, count, cancellationToken); + return await _protocol.RawReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); } + /// + /// Gets or sets the DataAvailable. + /// public bool DataAvailable => _protocol.DataAvailable; + /// + /// Executes the PerformHandshakeAsync operation. + /// public async Task PerformHandshakeAsync(CancellationToken cancellationToken) { const int MaxHandshakeAttempts = 50; // Maximum attempts before giving up @@ -72,7 +105,7 @@ public async Task PerformHandshakeAsync(CancellationToken cancellationToken) for (int attempt = 0; attempt < MaxHandshakeAttempts; attempt++) { cancellationToken.ThrowIfCancellationRequested(); - await _protocol.RawWriteAsync(handshakePayload, 0, handshakePayload.Length, cancellationToken); + await _protocol.RawWriteAsync(handshakePayload, 0, handshakePayload.Length, cancellationToken).ConfigureAwait(false); // Wait for response var sw = System.Diagnostics.Stopwatch.StartNew(); @@ -82,7 +115,7 @@ public async Task PerformHandshakeAsync(CancellationToken cancellationToken) if (_protocol.DataAvailable) { byte[] tmpBuf = new byte[1024]; - int bytesRead = await _protocol.RawReadAsync(tmpBuf, 0, tmpBuf.Length, cancellationToken); + int bytesRead = await _protocol.RawReadAsync(tmpBuf, 0, tmpBuf.Length, cancellationToken).ConfigureAwait(false); if (bytesRead > 0) { responseBuffer.AddRange(System.Linq.Enumerable.Take(tmpBuf, bytesRead)); @@ -93,17 +126,20 @@ public async Task PerformHandshakeAsync(CancellationToken cancellationToken) } } } - await Task.Delay(50, cancellationToken); + await Task.Delay(50, cancellationToken).ConfigureAwait(false); } - await Task.Delay(10, cancellationToken); + await Task.Delay(10, cancellationToken).ConfigureAwait(false); } throw new Exception($"Handshake failed after {MaxHandshakeAttempts} attempts"); } + /// + /// Executes the GetVersionAsync operation. + /// public async Task GetVersionAsync(CancellationToken cancellationToken) { // Handler 0x00 = Get Info - byte[]? response = await InvokePrimaryHandlerAsync(0x00, [], true, cancellationToken); + byte[]? response = await InvokePrimaryHandlerAsync(0x00, [], true, cancellationToken).ConfigureAwait(false); return response ?? []; } } diff --git a/src/S7Tools/Services/Adapters/Plc/PlcStagerManager.cs b/src/S7Tools/Services/Adapters/Plc/PlcStagerManager.cs index b7f9c332..057cb6d5 100644 --- a/src/S7Tools/Services/Adapters/Plc/PlcStagerManager.cs +++ b/src/S7Tools/Services/Adapters/Plc/PlcStagerManager.cs @@ -5,21 +5,30 @@ namespace S7Tools.Services.Adapters.Plc { + /// + /// Represents the PlcStagerManager. + /// internal class PlcStagerManager { private readonly PlcProtocolHandler _protocol; private readonly PlcMemoryManager _memory; + /// + /// Initializes a new instance of the class. + /// public PlcStagerManager(PlcProtocolHandler protocol, PlcMemoryManager memory) { _protocol = protocol ?? throw new ArgumentNullException(nameof(protocol)); _memory = memory ?? throw new ArgumentNullException(nameof(memory)); } + /// + /// Executes the InstallStagerAsync operation. + /// public async Task InstallStagerAsync(byte[] stager, CancellationToken cancellationToken) { // 1. Write Stager Code to IRAM - await _memory.WriteToIramAsync(PlcConstants.IRAM_STAGER_START, stager, cancellationToken); + await _memory.WriteToIramAsync(PlcConstants.IRAM_STAGER_START, stager, cancellationToken).ConfigureAwait(false); // 2. Overwrite Hook Entry // Hook Entry Structure: [Unknown:2] [ArgCheck:2] [Address:4] @@ -32,9 +41,12 @@ public async Task InstallStagerAsync(byte[] stager, CancellationToken cancellati Array.Copy(addrBytes, 0, hookPayload, 2, 4); uint hookEntryAddr = PlcConstants.ADD_HOOK_TABLE_START + (8 * PlcConstants.DEFAULT_STAGER_ADDHOOK_IND) + 2; - await _memory.WriteToIramAsync(hookEntryAddr, hookPayload, cancellationToken); + await _memory.WriteToIramAsync(hookEntryAddr, hookPayload, cancellationToken).ConfigureAwait(false); } + /// + /// Executes the InstallAddHookViaStagerAsync operation. + /// public async Task InstallAddHookViaStagerAsync(uint targetAddr, byte[] payload, int hookNo, CancellationToken cancellationToken) { // 1. Write Hook Entry @@ -46,18 +58,21 @@ public async Task InstallAddHookViaStagerAsync(uint targetAddr, byte[] payload, uint tableAddr = PlcConstants.ADD_HOOK_TABLE_START + (uint)(8 * hookNo); // WRITE VIA STAGER (Hook 7) - await WriteViaStagerAsync(tableAddr, hookEntry, cancellationToken); + await WriteViaStagerAsync(tableAddr, hookEntry, cancellationToken).ConfigureAwait(false); // 2. Write Code - await WriteViaStagerAsync(targetAddr, payload, cancellationToken); + await WriteViaStagerAsync(targetAddr, payload, cancellationToken).ConfigureAwait(false); } + /// + /// Executes the WriteViaStagerAsync operation. + /// public async Task WriteViaStagerAsync(uint address, byte[] data, CancellationToken cancellationToken) { // Ref StagerManager L157 // Invoke Hook 7 with Address -> Then Send Data - await _protocol.InvokeAddHookAsync(PlcConstants.DEFAULT_STAGER_ADDHOOK_IND, PlcInternalHelpers.GetBigEndianBytes(address), false, cancellationToken); - await SendFullMsgViaStagerAsync(data, cancellationToken); + await _protocol.InvokeAddHookAsync(PlcConstants.DEFAULT_STAGER_ADDHOOK_IND, PlcInternalHelpers.GetBigEndianBytes(address), false, cancellationToken).ConfigureAwait(false); + await SendFullMsgViaStagerAsync(data, cancellationToken).ConfigureAwait(false); } private async Task SendFullMsgViaStagerAsync(byte[] msg, CancellationToken cancellationToken) @@ -70,17 +85,17 @@ private async Task SendFullMsgViaStagerAsync(byte[] msg, CancellationToken cance var chunk = msg.Skip(i).Take(size).ToArray(); var encoded = PlcInternalHelpers.EncodeWithXor(chunk); - await _protocol.SendPacketAsync(encoded, 8, cancellationToken: cancellationToken); + await _protocol.SendPacketAsync(encoded, 8, cancellationToken: cancellationToken).ConfigureAwait(false); // Ack - var ack = await _protocol.ReceivePacketAsync(cancellationToken); + var ack = await _protocol.ReceivePacketAsync(cancellationToken).ConfigureAwait(false); if (ack == null || ack.Length != 1) { throw new Exception("Stager ACK fail"); } } // End Packet - await _protocol.SendPacketAsync(PlcInternalHelpers.EncodeWithXor(Array.Empty()), null, cancellationToken: cancellationToken); - await _protocol.ReceivePacketAsync(cancellationToken); + await _protocol.SendPacketAsync(PlcInternalHelpers.EncodeWithXor(Array.Empty()), null, cancellationToken: cancellationToken).ConfigureAwait(false); + await _protocol.ReceivePacketAsync(cancellationToken).ConfigureAwait(false); } } } diff --git a/src/S7Tools/Services/Adapters/PlcClientAdapter.cs b/src/S7Tools/Services/Adapters/PlcClientAdapter.cs index 2c337869..e480314b 100644 --- a/src/S7Tools/Services/Adapters/PlcClientAdapter.cs +++ b/src/S7Tools/Services/Adapters/PlcClientAdapter.cs @@ -2,7 +2,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Services.Adapters.Plc; namespace S7Tools.Services.Adapters @@ -28,6 +28,9 @@ public sealed class PlcClientAdapter : IPlcClient private string _socatHost = "127.0.0.1"; private int _socatPort = 3333; // Default fallback + /// + /// Initializes a new instance of the class. + /// public PlcClientAdapter( IPlcProtocol protocol, ILogger logger, @@ -47,6 +50,9 @@ public PlcClientAdapter( + /// + /// Executes the Configure operation. + /// public void Configure(string host, int port) { // Store socat connection info for streaming dumps @@ -56,11 +62,14 @@ public void Configure(string host, int port) _protocol.Configure(host, port); } + /// + /// Executes the DisposeAsync operation. + /// public async ValueTask DisposeAsync() { if (_protocol is IAsyncDisposable d) { - await d.DisposeAsync(); + await d.DisposeAsync().ConfigureAwait(false); } // Dispose the orchestrator if it implements IDisposable if (_orchestrator is IDisposable disposableOrchestrator) @@ -71,23 +80,32 @@ public async ValueTask DisposeAsync() #region Handshake + /// + /// Executes the ConnectAsync operation. + /// public async Task ConnectAsync(CancellationToken cancellationToken = default) { _logger.LogInformation("Connecting to PLC via Protocol..."); - await _protocol.ConnectAsync(cancellationToken); + await _protocol.ConnectAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Executes the HandshakeAsync operation. + /// public async Task HandshakeAsync(CancellationToken cancellationToken = default) { _logger.LogInformation("Starting handshake..."); - await _protocolHandler.PerformHandshakeAsync(cancellationToken); + await _protocolHandler.PerformHandshakeAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation("Handshake successful!"); } + /// + /// Executes the GetBootloaderVersionAsync operation. + /// public async Task GetBootloaderVersionAsync(CancellationToken cancellationToken = default) { _logger.LogInformation("Getting bootloader version..."); - byte[] versionBytes = await _protocolHandler.GetVersionAsync(cancellationToken); + byte[] versionBytes = await _protocolHandler.GetVersionAsync(cancellationToken).ConfigureAwait(false); if (versionBytes.Length == 0) { @@ -141,10 +159,13 @@ public async Task GetBootloaderVersionAsync(CancellationToken cancellati #region Stager Installation + /// + /// Executes the InstallStagerAsync operation. + /// public async Task InstallStagerAsync(byte[] stager, CancellationToken cancellationToken = default) { _logger.LogInformation("Installing Stager..."); - await _stagerManager.InstallStagerAsync(stager, cancellationToken); + await _stagerManager.InstallStagerAsync(stager, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Stager installed at 0x{Addr:X}", PlcConstants.IRAM_STAGER_START); } @@ -152,6 +173,9 @@ public async Task InstallStagerAsync(byte[] stager, CancellationToken cancellati #region Memory Dump + /// + /// Executes the InstallDumperAsync operation. + /// public async Task InstallDumperAsync(byte[] dumperPayload, CancellationToken cancellationToken = default) { _logger.LogInformation("Installing Dumper Payload via Stager..."); @@ -160,17 +184,23 @@ await _stagerManager.InstallAddHookViaStagerAsync( PlcConstants.DUMPER_PAYLOAD_LOCATION, dumperPayload, PlcConstants.DEFAULT_SECOND_ADD_HOOK_IND, - cancellationToken); + cancellationToken).ConfigureAwait(false); _logger.LogInformation("Dumper payload installed at 0x{Addr:X} (Hook {Hook})", PlcConstants.DUMPER_PAYLOAD_LOCATION, PlcConstants.DEFAULT_SECOND_ADD_HOOK_IND); } + /// + /// Executes the InvokeDumperAsync operation. + /// public async Task InvokeDumperAsync(uint address, uint length, IProgress progress, CancellationToken cancellationToken = default) { _logger.LogInformation("Invoking Dumper (0x{Addr:X}, {Len} bytes)...", address, length); - return await _memoryManager.InvokeDumperAsync(address, length, progress, cancellationToken); + return await _memoryManager.InvokeDumperAsync(address, length, progress, cancellationToken).ConfigureAwait(false); } + /// + /// Executes the InvokeDumperStreamAsync operation. + /// public async Task InvokeDumperStreamAsync( uint address, uint length, @@ -192,9 +222,12 @@ await _memoryManager.InvokeDumperStreamAsync( _socatPort, cancellationToken, keepSessionOpen, - logger); + logger).ConfigureAwait(false); } + /// + /// Executes the StopDumperSessionAsync operation. + /// public async Task StopDumperSessionAsync() { _logger.LogInformation("Stopping persistent dumper session..."); diff --git a/src/S7Tools/Services/Adapters/PlcProtocolAdapter.cs b/src/S7Tools/Services/Adapters/PlcProtocolAdapter.cs index 6fbf061f..a3e59072 100644 --- a/src/S7Tools/Services/Adapters/PlcProtocolAdapter.cs +++ b/src/S7Tools/Services/Adapters/PlcProtocolAdapter.cs @@ -7,7 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services.Adapters { @@ -21,24 +21,39 @@ public sealed class PlcProtocolAdapter : IPlcProtocol private readonly ILogger _logger; private readonly IPlcTransport _transport; + /// + /// Initializes a new instance of the class. + /// public PlcProtocolAdapter(IPlcTransport transport, ILogger logger) { _transport = transport ?? throw new ArgumentNullException(nameof(transport)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// + /// Gets or sets the DataAvailable. + /// public bool DataAvailable => _transport.DataAvailable; + /// + /// Executes the Configure operation. + /// public void Configure(string host, int port) { _transport.Configure(host, port); } + /// + /// Executes the ConnectAsync operation. + /// public async Task ConnectAsync(CancellationToken cancellationToken = default) { await _transport.ConnectAsync(cancellationToken); } + /// + /// Executes the DisconnectAsync operation. + /// public async Task DisconnectAsync(CancellationToken cancellationToken = default) { await _transport.DisconnectAsync(cancellationToken); @@ -56,6 +71,9 @@ private static byte CalculateChecksum(byte[] packetData, int offset, int length) return (byte)-sum; } + /// + /// Executes the EncodePacket operation. + /// public static byte[] EncodePacket(byte[] contents) { if (contents.Length > 254) @@ -98,6 +116,9 @@ private static byte[] DecodePacket(byte[] packet) #endregion + /// + /// Executes the SendPacketAsync operation. + /// public async Task SendPacketAsync(byte[] payload, int? maxChunk = 2, CancellationToken cancellationToken = default) { // Safety delay exactly as in reference @@ -124,6 +145,9 @@ public async Task SendPacketAsync(byte[] payload, int? maxChunk = 2, Cancellatio } } + /// + /// Executes the ReceivePacketAsync operation. + /// public async Task ReceivePacketAsync(CancellationToken cancellationToken = default) { var lengthByte = new byte[1]; @@ -165,16 +189,25 @@ public async Task ReceivePacketAsync(CancellationToken cancellationToken return DecodePacket(fullPacket); } + /// + /// Executes the RawWriteAsync operation. + /// public async Task RawWriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { await _transport.WriteAsync(buffer, offset, count, cancellationToken); } + /// + /// Executes the RawReadAsync operation. + /// public async Task RawReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { return await _transport.ReadAsync(buffer, offset, count, cancellationToken); } + /// + /// Executes the GetStream operation. + /// public Stream? GetStream() => _transport.GetStream(); } } diff --git a/src/S7Tools/Services/Adapters/PlcTransportAdapter.cs b/src/S7Tools/Services/Adapters/PlcTransportAdapter.cs index 2028786b..bfe2e21d 100644 --- a/src/S7Tools/Services/Adapters/PlcTransportAdapter.cs +++ b/src/S7Tools/Services/Adapters/PlcTransportAdapter.cs @@ -4,7 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services.Adapters { @@ -20,22 +20,37 @@ public sealed class PlcTransportAdapter : IPlcTransport private string _host = string.Empty; private int _port; + /// + /// Initializes a new instance of the class. + /// public PlcTransportAdapter(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _client = new TcpClient(); } + /// + /// Gets or sets the IsConnected. + /// public bool IsConnected => _client?.Connected ?? false; + /// + /// Gets or sets the DataAvailable. + /// public bool DataAvailable => _stream?.DataAvailable ?? false; + /// + /// Executes the Configure operation. + /// public void Configure(string host, int port) { _host = host; _port = port; } + /// + /// Executes the ConnectAsync operation. + /// public async Task ConnectAsync(CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(_host) || _port == 0) @@ -43,24 +58,23 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) throw new InvalidOperationException("Transport not configured. Call Configure() first."); } - _logger.LogInformation("Connecting to PLC via Socat at {Host}:{Port}...", _host, _port); - - // Re-create TcpClient if disposed or previously used - if (_client == null || _client.Client == null || !_client.Connected && _client.Client.Connected) - { - _client?.Dispose(); - _client = new TcpClient(); - } - // Handle case where client is already connected or in weird state - if (_client.Connected) + if (_client?.Connected == true) { return; } + _logger.LogInformation("Connecting to PLC via Socat at {Host}:{Port}...", _host, _port); + + // Dispose previous stream and client; always use a fresh TcpClient for each connection attempt + _stream?.Dispose(); + _stream = null; + _client?.Dispose(); + _client = new TcpClient(); + try { _client.NoDelay = true; - await _client.ConnectAsync(_host, _port, cancellationToken); + await _client.ConnectAsync(_host, _port, cancellationToken).ConfigureAwait(false); _logger.LogInformation("TcpClient.NoDelay set to: {Value}", _client.NoDelay); _stream = _client.GetStream(); _logger.LogInformation("Connected successfully."); @@ -72,36 +86,52 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } } - public async Task DisconnectAsync(CancellationToken cancellationToken = default) + /// + /// Executes the DisconnectAsync operation. + /// + public Task DisconnectAsync(CancellationToken cancellationToken = default) { _logger.LogInformation("Disconnecting transport..."); - _stream?.Close(); - _client?.Close(); + _stream?.Dispose(); + _stream = null; + _client?.Dispose(); _client = new TcpClient(); // Reset for next use - await Task.CompletedTask; + return Task.CompletedTask; } + /// + /// Executes the ReadAsync operation. + /// public async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { if (_stream == null) { throw new InvalidOperationException("Transport not connected."); } - return await _stream.ReadAsync(buffer, offset, count, cancellationToken); + return await _stream.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); } + /// + /// Executes the WriteAsync operation. + /// public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { if (_stream == null) { throw new InvalidOperationException("Transport not connected."); } - await _stream.WriteAsync(buffer, offset, count, cancellationToken); + await _stream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); // await _stream.FlushAsync(cancellationToken); // Removed to prevent packet fragmentation logic interference } + /// + /// Executes the GetStream operation. + /// public Stream? GetStream() => _stream; + /// + /// Executes the DisposeAsync operation. + /// public ValueTask DisposeAsync() { _stream?.Dispose(); diff --git a/src/S7Tools/Services/ApplicationSettingsService.cs b/src/S7Tools/Services/ApplicationSettingsService.cs deleted file mode 100644 index 753b9ea8..00000000 --- a/src/S7Tools/Services/ApplicationSettingsService.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using S7Tools.Core.Interfaces.Services; -using S7Tools.Core.Models.Configuration; -using S7Tools.Core.Models.Configuration.StrongSettings; - -namespace S7Tools.Services -{ - public sealed class ApplicationSettingsService : IApplicationSettingsService - { - private readonly ILogger _logger; - private readonly IWritableOptions _options; - - public event EventHandler? SettingsChanged; - - public ApplicationSettingsService(ILogger logger, IWritableOptions options) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - _logger.LogInformation("ApplicationSettingsService initialized as strongly-typed proxy"); - } - - public Task LoadSettingsAsync() - { - // Dummy return to fulfill interface contract for unused return values - return Task.FromResult(new ApplicationSettings()); - } - - public async Task SaveUserSettingsAsync(Dictionary userSettings) - { - foreach (var kvp in userSettings) - { - await SetSettingAsync(kvp.Key, kvp.Value); - } - } - - public T GetSetting(string key) => GetSetting(key, default(T)!); - - public T GetSetting(string key, T defaultValue) - { - if (string.IsNullOrEmpty(key)) return defaultValue; - var settings = _options.CurrentValue; - - object? val = key switch - { - "logging.logDirectory" => settings.Logging.LogDirectory, - "logging.exportDirectory" => settings.Logging.ExportDirectory, - "logging.level" => settings.Logging.Level.ToString(), - "logging.enableFileLogging" => settings.Logging.EnableFileLogging, - "ui.autoScrollLogs" => settings.Ui.AutoScrollLogs, - "ui.showTimestampInLogs" => settings.Ui.ShowTimestampInLogs, - "ui.showCategoryInLogs" => settings.Ui.ShowCategoryInLogs, - "ui.showLogLevelInLogs" => settings.Ui.ShowLogLevelInLogs, - "ui.theme" => settings.Ui.Theme, - "paths.autoCreateDirectories" => settings.Paths.AutoCreateDirectories, - "profiles.serialPath" => settings.Profiles.SerialPath, - "profiles.socatPath" => settings.Profiles.SocatPath, - "profiles.powerSupplyPath" => settings.Profiles.PowerSupplyPath, - "profiles.memoryRegionPath" => settings.Profiles.MemoryRegionPath, - "powerSupply.powerStateChangeDelayMs" => settings.PowerSupply.PowerStateChangeDelayMs, - "memoryDump.defaultFolder" => settings.MemoryDump.DefaultFolder, - _ => null - }; - - if (val == null) return defaultValue; - try - { - if (typeof(T).IsEnum && val is string enumStr) - return (T)Enum.Parse(typeof(T), enumStr, true); - if (typeof(T) == typeof(string) && val.GetType().IsEnum) - return (T)(object)val.ToString()!; - return (T)Convert.ChangeType(val, typeof(T)); - } - catch - { - return defaultValue; - } - } - - public async Task SetSettingAsync(string key, object value) - { - if (string.IsNullOrEmpty(key)) return; - object? oldValue = null; - await _options.UpdateAsync(settings => - { - switch (key) - { - case "logging.logDirectory": oldValue = settings.Logging.LogDirectory; settings.Logging.LogDirectory = value.ToString() ?? ""; break; - case "logging.exportDirectory": oldValue = settings.Logging.ExportDirectory; settings.Logging.ExportDirectory = value.ToString() ?? ""; break; - case "logging.level": - oldValue = settings.Logging.Level; - if (Enum.TryParse(value.ToString(), true, out var lvl)) settings.Logging.Level = lvl.ToString(); - break; - case "logging.enableFileLogging": oldValue = settings.Logging.EnableFileLogging; settings.Logging.EnableFileLogging = Convert.ToBoolean(value); break; - case "ui.autoScrollLogs": oldValue = settings.Ui.AutoScrollLogs; settings.Ui.AutoScrollLogs = Convert.ToBoolean(value); break; - case "ui.showTimestampInLogs": oldValue = settings.Ui.ShowTimestampInLogs; settings.Ui.ShowTimestampInLogs = Convert.ToBoolean(value); break; - case "ui.showCategoryInLogs": oldValue = settings.Ui.ShowCategoryInLogs; settings.Ui.ShowCategoryInLogs = Convert.ToBoolean(value); break; - case "ui.showLogLevelInLogs": oldValue = settings.Ui.ShowLogLevelInLogs; settings.Ui.ShowLogLevelInLogs = Convert.ToBoolean(value); break; - case "ui.theme": oldValue = settings.Ui.Theme; settings.Ui.Theme = value.ToString() ?? ""; break; - case "paths.autoCreateDirectories": oldValue = settings.Paths.AutoCreateDirectories; settings.Paths.AutoCreateDirectories = Convert.ToBoolean(value); break; - case "profiles.serialPath": oldValue = settings.Profiles.SerialPath; settings.Profiles.SerialPath = value.ToString() ?? ""; break; - case "profiles.socatPath": oldValue = settings.Profiles.SocatPath; settings.Profiles.SocatPath = value.ToString() ?? ""; break; - case "profiles.powerSupplyPath": oldValue = settings.Profiles.PowerSupplyPath; settings.Profiles.PowerSupplyPath = value.ToString() ?? ""; break; - case "profiles.memoryRegionPath": - oldValue = settings.Profiles.MemoryRegionPath; - settings.Profiles.MemoryRegionPath = value.ToString() ?? ""; - break; - case "powerSupply.powerStateChangeDelayMs": oldValue = settings.PowerSupply.PowerStateChangeDelayMs; settings.PowerSupply.PowerStateChangeDelayMs = Convert.ToInt32(value); break; - case "memoryDump.defaultFolder": oldValue = settings.MemoryDump.DefaultFolder; settings.MemoryDump.DefaultFolder = value.ToString() ?? ""; break; - } - return Task.CompletedTask; - }); - SettingsChanged?.Invoke(this, new SettingsChangedEventArgs { Key = key, OldValue = oldValue, NewValue = value, IsUserSetting = true }); - } - - public async Task ResetSettingAsync(string key) - { - var def = new AppSettings(); - object? defValue = key switch - { - "logging.logDirectory" => def.Logging.LogDirectory, - "logging.exportDirectory" => def.Logging.ExportDirectory, - "logging.level" => def.Logging.Level, - "logging.enableFileLogging" => def.Logging.EnableFileLogging, - "ui.autoScrollLogs" => def.Ui.AutoScrollLogs, - "ui.showTimestampInLogs" => def.Ui.ShowTimestampInLogs, - "ui.showCategoryInLogs" => def.Ui.ShowCategoryInLogs, - "ui.showLogLevelInLogs" => def.Ui.ShowLogLevelInLogs, - "ui.theme" => def.Ui.Theme, - "paths.autoCreateDirectories" => def.Paths.AutoCreateDirectories, - "profiles.serialPath" => def.Profiles.SerialPath, - "profiles.socatPath" => def.Profiles.SocatPath, - "profiles.powerSupplyPath" => def.Profiles.PowerSupplyPath, - "profiles.memoryRegionPath" => def.Profiles.MemoryRegionPath, - "powerSupply.powerStateChangeDelayMs" => def.PowerSupply.PowerStateChangeDelayMs, - "memoryDump.defaultFolder" => def.MemoryDump.DefaultFolder, - _ => null - }; - if (defValue != null) - await SetSettingAsync(key, defValue); - } - - public async Task ResetAllSettingsAsync() - { - await _options.UpdateAsync(s => { - var def = new AppSettings(); - s.Logging = def.Logging; - s.Ui = def.Ui; - s.Paths = def.Paths; - s.Profiles = def.Profiles; - s.PowerSupply = def.PowerSupply; - s.MemoryDump = def.MemoryDump; - return Task.CompletedTask; - }); - SettingsChanged?.Invoke(this, new SettingsChangedEventArgs { IsUserSetting = false }); - } - - public async Task RestoreDefaultsAsync() => await ResetAllSettingsAsync(); - } -} diff --git a/src/S7Tools/Services/Bootloader/BaseBootloaderService.cs b/src/S7Tools/Services/Bootloader/BaseBootloaderService.cs deleted file mode 100644 index 649a067b..00000000 --- a/src/S7Tools/Services/Bootloader/BaseBootloaderService.cs +++ /dev/null @@ -1,944 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using S7Tools.Core.Models; -using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; - -namespace S7Tools.Services.Bootloader; - -/// -/// shared base class for bootloader services containing common logic like memory dumping. -/// -public abstract class BaseBootloaderService -{ - private readonly ITimeProvider _timeProvider; - - protected BaseBootloaderService(ITimeProvider? timeProvider = null) - { - _timeProvider = timeProvider!; - } - - /// - /// Performs the core memory dump process, iterating through dumps and segments. - /// - protected async Task> PerformDumpProcessAsync( - IPlcClient client, - JobProfileSet profiles, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, - ILogger logger, - double startPercent, - double weight, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(profiles); - ArgumentNullException.ThrowIfNull(progress); - ArgumentNullException.ThrowIfNull(logger); - - List allDumps = []; - - long totalDumpBytes; - var segments = profiles.MemoryMapping?.SelectedSegments?.ToList() ?? []; - - if (profiles.DumpCount <= 0) - { - throw new InvalidOperationException($"Invalid dump count: {profiles.DumpCount}. Must be >= 1."); - } - - try - { - if (segments.Count == 0) - { - totalDumpBytes = checked((long)profiles.Memory.Length * profiles.DumpCount); - } - else - { - long singlePassBytes = segments.Sum(s => (long)s.Size); - totalDumpBytes = checked(singlePassBytes * profiles.DumpCount); - } - } - catch (OverflowException ex) - { - throw new InvalidOperationException( - $"Total dump size calculation overflowed (DumpCount={profiles.DumpCount}).", - ex); - } - - if (totalDumpBytes <= 0) - { - totalDumpBytes = 1; - } - - long globalBytesRead = 0; - DateTime dumpStartTime = _timeProvider?.GetUtcNow() ?? DateTime.UtcNow; - - // Pre-calculate data for efficiency - string[] iterStageNames = new string[profiles.DumpCount]; - string[][]? segStageNames = null; - List? selectedSegments = null; - - if (profiles.MemoryMapping != null && profiles.MemoryMapping.HasSelectedSegments) - { - selectedSegments = profiles.MemoryMapping.SelectedSegments.ToList(); - segStageNames = new string[profiles.DumpCount][]; - for (int iter = 0; iter < profiles.DumpCount; iter++) - { - segStageNames[iter] = new string[selectedSegments.Count]; - string prefix = "Dumping Seg "; - string suffix = $"/{selectedSegments.Count} (Iter {iter + 1}/{profiles.DumpCount})"; - for (int i = 0; i < selectedSegments.Count; i++) - { - segStageNames[iter][i] = $"{prefix}{i + 1}{suffix}"; - } - } - } - else - { - for (int iter = 0; iter < profiles.DumpCount; iter++) - { - iterStageNames[iter] = $"Dumping Memory (Iter {iter + 1}/{profiles.DumpCount})"; - } - } - - for (int iter = 0; iter < profiles.DumpCount; iter++) - { - logger.LogInformation("Starting Dump Iteration {Iter}/{Total}", iter + 1, profiles.DumpCount); - - if (selectedSegments != null && segStageNames != null) - { - List segmentDataList = []; - - for (int i = 0; i < selectedSegments.Count; i++) - { - MemorySegment segment = selectedSegments[i]; - - string startStr = segment.StartAddress; - if (string.IsNullOrEmpty(startStr)) - { - throw new InvalidOperationException("Memory segment start address is null."); - } - - if (startStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) - { - startStr = startStr[2..]; - } - - if (!uint.TryParse(startStr, System.Globalization.NumberStyles.HexNumber, null, out uint segmentStart)) - { - throw new InvalidOperationException($"Invalid memory segment start address '{segment.StartAddress}'."); - } - - uint segmentSize = (uint)segment.Size; - string stageName = segStageNames[iter][i]; - - double currentBasePercent = startPercent + (weight * globalBytesRead / totalDumpBytes); - - progress.Report((stageName, currentBasePercent, globalBytesRead, totalDumpBytes)); - - logger.LogInformation("Dumping segment {Index}/{Total} (Iter {Iter}): '{Name}'", - i + 1, selectedSegments.Count, iter + 1, segment.Name); - - int lastLoggedPercent = -1; - double lastReportedPercent = currentBasePercent; - - var segmentProgress = new Progress(bytesRead => - { - long totalReadSoFar = globalBytesRead + bytesRead; - double percent = startPercent + (weight * totalReadSoFar / totalDumpBytes); - - if (Math.Abs(percent - lastReportedPercent) >= 0.1 || bytesRead == segmentSize) - { - progress.Report((stageName, percent, totalReadSoFar, totalDumpBytes)); - lastReportedPercent = percent; - } - - if (segmentSize > 0) - { - double segPct = (double)bytesRead / segmentSize * 100.0; - if ((int)segPct > lastLoggedPercent && (int)segPct % 5 == 0) - { - lastLoggedPercent = (int)segPct; - logger.LogDebug(" Progress: {Percent:F1}% ({Bytes:N0}/{Total:N0})", segPct, bytesRead, segmentSize); - } - } - }); - - byte[] segmentData = await client.InvokeDumperAsync( - segmentStart, - segmentSize, - segmentProgress, - cancellationToken).ConfigureAwait(false); - - segmentDataList.Add(segmentData); - globalBytesRead += segmentData.Length; - - logger.LogInformation(" ✓ Segment dumped: {Size:N0} bytes", segmentData.Length); - } - - // High-performance flattening using Buffer.BlockCopy - int totalIterSize = segmentDataList.Sum(s => s.Length); - byte[] flattenedData = new byte[totalIterSize]; - int currentPos = 0; - foreach (byte[] segData in segmentDataList) - { - Buffer.BlockCopy(segData, 0, flattenedData, currentPos, segData.Length); - currentPos += segData.Length; - } - allDumps.Add(flattenedData); - } - else - { - string stageName = iterStageNames[iter]; - - double currentBasePercent = startPercent + (weight * globalBytesRead / totalDumpBytes); - progress.Report((stageName, currentBasePercent, globalBytesRead, totalDumpBytes)); - - logger.LogInformation("Dumping single region (Iter {Iter}/{Total})", iter + 1, profiles.DumpCount); - - int lastLoggedPercent = -1; - double lastReportedPercent = currentBasePercent; - - var dumpProgress = new Progress(bytesRead => - { - long totalReadSoFar = globalBytesRead + bytesRead; - double percent = startPercent + (weight * totalReadSoFar / totalDumpBytes); - - if (Math.Abs(percent - lastReportedPercent) >= 0.1 || bytesRead == profiles.Memory.Length) - { - progress.Report((stageName, percent, totalReadSoFar, totalDumpBytes)); - lastReportedPercent = percent; - } - - if (profiles.Memory.Length > 0) - { - double dumpPct = (double)bytesRead / profiles.Memory.Length * 100.0; - if ((int)dumpPct > lastLoggedPercent && (int)dumpPct % 5 == 0) - { - lastLoggedPercent = (int)dumpPct; - logger.LogDebug(" Progress: {Percent:F1}%", dumpPct); - } - } - }); - - byte[] data = await client.InvokeDumperAsync( - profiles.Memory.Start, - profiles.Memory.Length, - dumpProgress, - cancellationToken).ConfigureAwait(false); - - allDumps.Add(data); - globalBytesRead += data.Length; - - logger.LogInformation(" ✓ Iteration {Iter} complete: {Size:N0} bytes", iter + 1, data.Length); - } - - if (iter < profiles.DumpCount - 1) - { - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } - } - - TimeSpan dumpDuration = (_timeProvider?.GetUtcNow() ?? DateTime.UtcNow) - dumpStartTime; - long totalBytesDumped = allDumps.Sum(d => d.Length); - double transferRate = totalBytesDumped > 0 && dumpDuration.TotalSeconds > 0 ? totalBytesDumped / dumpDuration.TotalSeconds : 0; - - logger.LogInformation("✓ All dumps completed: {Size:N0} bytes total", totalBytesDumped); - logger.LogInformation(" Total Duration: {Duration:F1}s, Avg Rate: {Rate:F1} bytes/s", - dumpDuration.TotalSeconds, transferRate); - - return allDumps; - } - - /// - /// Performs memory dump using streaming, writing directly to the final output file to minimize memory usage. - /// Handles both segmented and single-region dumps. - /// - protected async Task PerformDumpProcessStreamingAsync( - IPlcClient client, - JobProfileSet profiles, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, - ILogger logger, - double startPercent, - double weight, - Guid? taskId, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(profiles); - ArgumentNullException.ThrowIfNull(progress); - ArgumentNullException.ThrowIfNull(logger); - - List savedFiles = []; - - try - { - DateTime dumpStartTime = _timeProvider?.GetUtcNow() ?? DateTime.UtcNow; - int iterationCount = profiles.DumpCount > 0 ? profiles.DumpCount : 1; - - logger.LogInformation("Starting streaming dump process ({Count} iterations)", iterationCount); - - // Calculate total expected bytes across all iterations for progress reporting - long totalExpectedBytes = 0; - var segments = profiles.MemoryMapping?.SelectedSegments?.ToList() ?? []; - if (segments.Count > 0) - { - totalExpectedBytes = iterationCount * segments.Sum(s => (long)s.Size); - } - else - { - totalExpectedBytes = iterationCount * (long)profiles.Memory.Length; - } - - // Determine directory and sanitized job name once - string dumpsDir = !string.IsNullOrWhiteSpace(profiles.OutputPath) - ? profiles.OutputPath - : "./dumps"; - - if (!System.IO.Directory.Exists(dumpsDir)) - { - System.IO.Directory.CreateDirectory(dumpsDir); - } - - string rawJobName = segments.FirstOrDefault()?.Name ?? "MemoryDump"; - string jobName = string.Join("_", rawJobName.Split(System.IO.Path.GetInvalidFileNameChars())); - string taskIdStr = taskId.HasValue ? $"_{taskId.Value:N}" : ""; - - for (int iter = 0; iter < iterationCount; iter++) - { - logger.LogInformation("Iteration {Iter}/{Total}", iter + 1, iterationCount); - - // Determine final file path up-front - string timestamp = (_timeProvider?.GetLocalNow() ?? DateTime.Now).ToString("yyyyMMdd_HHmmss"); - string dumpFileName = $"{jobName}_iter{iter + 1}_of_{iterationCount}{taskIdStr}_{timestamp}.bin"; - string finalFilePath = System.IO.Path.Combine(dumpsDir, dumpFileName); - long bytesWrittenInIter = 0; - - // Pass context object to helper methods - var context = new StreamingContext( - client, - progress, - logger, - startPercent, - weight, - iterationCount, - iter, - totalExpectedBytes, - cancellationToken - ); - - if (segments.Count > 0) - { - bytesWrittenInIter = await StreamSegmentedDumpToFileAsync( - context, - segments, - finalFilePath).ConfigureAwait(false); - } - else - { - bytesWrittenInIter = await StreamSingleRegionDumpToFileAsync( - context, - profiles.Memory, - finalFilePath).ConfigureAwait(false); - } - - // The data is now saved to the file at finalFilePath. - // We no longer populate the deprecated `allDumps` list with empty arrays. - // Consumers should rely on `savedFiles` for data access. - savedFiles.Add(finalFilePath); - logger.LogInformation("✓ Dump file created: {File} ({Size:N0} bytes)", dumpFileName, bytesWrittenInIter); - } - - TimeSpan dumpDuration = (_timeProvider?.GetUtcNow() ?? DateTime.UtcNow) - dumpStartTime; - long totalBytes = savedFiles.Sum(path => new System.IO.FileInfo(path).Length); - double rate = totalBytes > 0 && dumpDuration.TotalSeconds > 0 ? totalBytes / dumpDuration.TotalSeconds : 0; - - logger.LogInformation("✓ Streaming dump complete: {Size:N0} bytes total", totalBytes); - logger.LogInformation(" Duration: {Duration:F1}s, Rate: {Rate:F1} bytes/s", dumpDuration.TotalSeconds, rate); - - return new BootloaderResult(savedFiles); - } - finally - { - await client.StopDumperSessionAsync().ConfigureAwait(false); - } - } - - private record StreamingContext( - IPlcClient Client, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> Progress, - ILogger Logger, - double StartPercent, - double Weight, - int IterationCount, - int CurrentIteration, - long TotalExpectedBytes, - CancellationToken CancellationToken); - - private static uint ParseSegmentAddress(MemorySegment segment) - { - string? startStr = segment.StartAddress; - if (string.IsNullOrEmpty(startStr)) - { - throw new InvalidOperationException($"Memory segment '{segment.Name}' has a null or empty start address."); - } - - if (startStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) - { - startStr = startStr[2..]; - } - - if (!uint.TryParse(startStr, System.Globalization.NumberStyles.HexNumber, null, out uint segmentStart)) - { - throw new InvalidOperationException($"Invalid memory segment start address '{segment.StartAddress}'."); - } - return segmentStart; - } - - private async Task StreamSegmentedDumpToFileAsync( - StreamingContext ctx, - List segments, - string finalFilePath) - { - long bytesWrittenInIter = 0; - long totalSegmentsSize = segments.Sum(s => (long)s.Size); - - // Pre-calculate data for efficiency - string[] stageNames = new string[segments.Count]; - long[] segmentOffsets = new long[segments.Count]; - long currentOffset = 0; - string prefix = "Seg "; - string suffix = $"/{segments.Count} (Iter {ctx.CurrentIteration + 1}/{ctx.IterationCount})"; - - for (int i = 0; i < segments.Count; i++) - { - stageNames[i] = $"{prefix}{i + 1}{suffix}"; - segmentOffsets[i] = currentOffset; - currentOffset += segments[i].Size; - } - - await using (var fileStream = new System.IO.FileStream( - finalFilePath, - System.IO.FileMode.Create, - System.IO.FileAccess.ReadWrite, - System.IO.FileShare.None, - 81920, - true)) - { - for (int i = 0; i < segments.Count; i++) - { - var segment = segments[i]; - uint segStart = ParseSegmentAddress(segment); - uint segLength = (uint)segment.Size; - string stageName = stageNames[i]; - long bytesFromPreviousSegmentsThisIter = segmentOffsets[i]; - - if (segLength == 0) - { - ctx.Logger.LogWarning("Skipping zero-length segment {Name}", segment.Name); - - // Report progress for the skipped segment to avoid UI stalls. - long bytesFromPreviousIterations = ctx.CurrentIteration * totalSegmentsSize; - long cumulativeTotalBytes = bytesFromPreviousIterations + bytesFromPreviousSegmentsThisIter; - double percent = ctx.TotalExpectedBytes > 0 - ? ctx.StartPercent + (ctx.Weight * cumulativeTotalBytes / ctx.TotalExpectedBytes) - : ctx.StartPercent; - - ctx.Progress.Report((stageName, percent, cumulativeTotalBytes, ctx.TotalExpectedBytes)); - continue; - } - - double segWeight = ctx.Weight / ctx.IterationCount / segments.Count; - double segStartPercent = ctx.StartPercent + (ctx.Weight * (ctx.CurrentIteration * segments.Count + i) / (ctx.IterationCount * segments.Count)); - - ctx.Logger.LogInformation(" Streaming segment {Index}: {Name} (0x{Addr:X8}, {Size:N0} bytes)", - i + 1, segment.Name, segStart, segLength); - - long segBytesWrittenLocal = 0; - double lastReportedSegPercent = segStartPercent; - - var segProgress = new Progress(bytes => - { - segBytesWrittenLocal = bytes; - double percent = segStartPercent + (segWeight * bytes / segLength); - - // Calculate cumulative bytes - long bytesFromPreviousIterations = ctx.CurrentIteration * totalSegmentsSize; - long cumulativeTotalBytes = bytesFromPreviousIterations + bytesFromPreviousSegmentsThisIter + bytes; - - double threshold = segLength > 1024 * 1024 ? 0.1 : 1.0; - - if (Math.Abs(percent - lastReportedSegPercent) >= threshold || bytes == segLength) - { - ctx.Progress.Report((stageName, percent, cumulativeTotalBytes, ctx.TotalExpectedBytes)); - lastReportedSegPercent = percent; - } - }); - - await ctx.Client.InvokeDumperStreamAsync( - segStart, segLength, - async data => await fileStream.WriteAsync(data, ctx.CancellationToken), - segProgress, - ctx.CancellationToken, - logger: ctx.Logger).ConfigureAwait(false); - - // Use the local variable that was updated by the progress callback - // or fall back to segLength if the callback didn't fire for some reason - long bytesCompleted = segBytesWrittenLocal > 0 ? segBytesWrittenLocal : segLength; - bytesWrittenInIter += bytesCompleted; - - ctx.Logger.LogDebug(" ✓ Segment {Index} streamed: {Size:N0} bytes", i + 1, bytesCompleted); - } - - await fileStream.FlushAsync(ctx.CancellationToken).ConfigureAwait(false); - - // Trim if needed - if (fileStream.Length > totalSegmentsSize) - { - ctx.Logger.LogDebug("Trimmed dump from {Original} to {Expected} bytes", fileStream.Length, totalSegmentsSize); - fileStream.SetLength(totalSegmentsSize); - } - } - - return bytesWrittenInIter; - } - - private async Task StreamSingleRegionDumpToFileAsync( - StreamingContext ctx, - MemoryRegionProfile memoryRegion, - string finalFilePath) - { - long bytesWrittenInIter = 0; - uint segStart = memoryRegion.Start; - uint segLength = (uint)memoryRegion.Length; - - if (segLength == 0) - { - ctx.Logger.LogWarning("Skipping zero-length memory region"); - return 0; - } - - await using (var fileStream = new System.IO.FileStream( - finalFilePath, - System.IO.FileMode.Create, - System.IO.FileAccess.ReadWrite, - System.IO.FileShare.None, - 81920, - true)) - { - string stageName = $"Memory Dump (Iter {ctx.CurrentIteration + 1}/{ctx.IterationCount})"; - double iterWeight = ctx.Weight / ctx.IterationCount; - double iterStartPercent = ctx.StartPercent + (ctx.Weight * ctx.CurrentIteration / ctx.IterationCount); - - ctx.Logger.LogInformation(" Streaming memory: 0x{Start:X8}, {Length:N0} bytes", segStart, segLength); - - double lastReportedPercent = iterStartPercent; - - var regionProgress = new Progress(bytes => - { - bytesWrittenInIter = bytes; - double percent = iterStartPercent + (iterWeight * bytes / segLength); - long cumulativeBytes = (ctx.CurrentIteration * segLength) + bytes; - - double threshold = segLength > 1024 * 1024 ? 0.1 : 1.0; - if (Math.Abs(percent - lastReportedPercent) >= threshold || bytes == segLength) - { - ctx.Progress.Report((stageName, percent, cumulativeBytes, ctx.TotalExpectedBytes)); - lastReportedPercent = percent; - } - }); - - await ctx.Client.InvokeDumperStreamAsync( - segStart, segLength, - async data => await fileStream.WriteAsync(data, ctx.CancellationToken), - regionProgress, - ctx.CancellationToken, - logger: ctx.Logger).ConfigureAwait(false); - - await fileStream.FlushAsync(ctx.CancellationToken).ConfigureAwait(false); - - // Trim if needed - long expectedSize = (long)memoryRegion.Length; - if (fileStream.Length > expectedSize) - { - ctx.Logger.LogDebug("Trimmed dump from {Original} to {Expected} bytes", fileStream.Length, expectedSize); - fileStream.SetLength(expectedSize); - } - } - - return bytesWrittenInIter; - } - - /// - /// Helper to report progress while waiting for a delay. - /// - protected async Task WaitWithProgressAsync( - int delayMs, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, - double startPercent, - double targetPercent, - string stage, - CancellationToken cancellationToken) - { - if (delayMs <= 0) - { - return; - } - - int steps = delayMs / 100; - if (steps <= 0) - { - steps = 1; - } - - double increment = (targetPercent - startPercent) / steps; - - for (int i = 0; i < steps; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - - double currentPercent = startPercent + (increment * (i + 1)); - progress.Report((stage, currentPercent, null, null)); - } - } - - /// - /// Performs the complete bootloader orchestration (13 stages) using streaming for dumps. - /// This centralizes the logic previously duplicated in BootloaderService and EnhancedBootloaderService. - /// - protected async Task PerformBootloaderOrchestrationAsync( - JobProfileSet profiles, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, - ILogger effectiveTaskLogger, - ILogger? processLogger, - ISerialPortService serialPort, - ISocatService socat, - IPowerSupplyService power, - IPayloadProvider payloads, - Func clientFactory, - CancellationToken cancellationToken, - Guid? taskId = null) - { - ArgumentNullException.ThrowIfNull(profiles); - ArgumentNullException.ThrowIfNull(progress); - ArgumentNullException.ThrowIfNull(serialPort); - ArgumentNullException.ThrowIfNull(socat); - ArgumentNullException.ThrowIfNull(power); - ArgumentNullException.ThrowIfNull(payloads); - ArgumentNullException.ThrowIfNull(clientFactory); - - const int InitialPowerOffWaitMs = 10000; - SocatProcessInfo? socatProcess = null; - bool isPowerConnected = false; - - try - { - // Stage 0: Configure serial port (1% progress) - progress.Report(("serial_config", 1.0, null, null)); - effectiveTaskLogger.LogDebug("Configuring serial port {Device} with profile configuration", profiles.Serial.Device); - - bool serialConfigured = await serialPort.ApplyConfigurationAsync( - profiles.Serial.Device, - profiles.Serial.Configuration, - effectiveTaskLogger, - cancellationToken).ConfigureAwait(false); - - if (!serialConfigured) - { - throw new InvalidOperationException($"Failed to configure serial port {profiles.Serial.Device}"); - } - - effectiveTaskLogger.LogInformation("✓ Serial port {Device} configured successfully", profiles.Serial.Device); - - // Stage 1: Setup socat bridge (3% progress) - progress.Report(("socat_setup", 3.0, null, null)); - effectiveTaskLogger.LogDebug("Setting up socat bridge on port {Port}", profiles.Socat.Port); - - if (profiles.Socat.Configuration == null) - { - throw new InvalidOperationException("Socat configuration is required but was null."); - } - - profiles.Socat.Configuration.BaudRate = profiles.Serial.Baud; - - socatProcess = await socat.StartSocatAsync( - profiles.Socat.Configuration, - profiles.Serial.Device, - processLogger, - cancellationToken).ConfigureAwait(false); - - effectiveTaskLogger.LogInformation("✓ Socat bridge started on TCP port {Port} (PID: {ProcessId})", - profiles.Socat.Port, socatProcess.ProcessId); - - // Stage 2: Connect to power supply (5% progress) - progress.Report(("power_connect", 5.0, null, null)); - effectiveTaskLogger.LogDebug("Connecting to power supply at {Host}:{Port}", profiles.Power.Host, profiles.Power.Port); - - if (profiles.Power.Configuration == null) - { - throw new InvalidOperationException("Power supply configuration is required but was null."); - } - - bool connected = await power.ConnectAsync(profiles.Power.Configuration, effectiveTaskLogger, cancellationToken).ConfigureAwait(false); - if (!connected) - { - throw new InvalidOperationException("Failed to connect to power supply."); - } - isPowerConnected = true; - - effectiveTaskLogger.LogInformation("✓ Connected to power supply at {Host}:{Port}", profiles.Power.Host, profiles.Power.Port); - - // Stage 3: Initial Power OFF (6% progress) - progress.Report(("power_off_initial", 6.0, null, null)); - effectiveTaskLogger.LogInformation("--- Stage 3: Initial Power OFF ---"); - - bool powerOff = await power.TurnOffAsync(effectiveTaskLogger, cancellationToken).ConfigureAwait(false); - if (!powerOff) - { - throw new InvalidOperationException("Failed to turn PLC power OFF"); - } - - await WaitWithProgressAsync( - InitialPowerOffWaitMs, - progress, - 6.0, 10.0, - "power_off_wait", - cancellationToken).ConfigureAwait(false); - - effectiveTaskLogger.LogInformation("✓ PLC powered OFF and wait time completed"); - - // Stage 4: Power ON (10% progress) - progress.Report(("power_on", 10.0, null, null)); - effectiveTaskLogger.LogInformation("--- Stage 4: Power ON PLC ---"); - - bool powerOn = await power.TurnOnAsync(effectiveTaskLogger, cancellationToken).ConfigureAwait(false); - if (!powerOn) - { - throw new InvalidOperationException("Failed to turn PLC power ON"); - } - - effectiveTaskLogger.LogInformation("✓ PLC powered ON"); - - // Stage 5: Wait for stabilization (10% -> 11%) - await WaitWithProgressAsync( - profiles.PowerOnTimeMs, - progress, - 10.0, 11.0, - "power_on_stabilize", - cancellationToken).ConfigureAwait(false); - - // Stage 6: PLC Client & Connect (12% progress) - progress.Report(("plc_connect", 12.0, null, null)); - await using IPlcClient client = clientFactory(profiles); - - effectiveTaskLogger.LogDebug("Connecting PLC client to localhost:{Port}", profiles.Socat.Port); - await client.ConnectAsync(cancellationToken).ConfigureAwait(false); - - // Stage 7: Power Cycle (13% -> 15%) - progress.Report(("power_cycle", 13.0, null, null)); - effectiveTaskLogger.LogInformation("--- Stage 7: Power Cycle PLC ---"); - - await power.TurnOffAsync(effectiveTaskLogger, cancellationToken).ConfigureAwait(false); - - await WaitWithProgressAsync( - profiles.PowerOffDelayMs, - progress, - 13.0, 15.0, - "power_cycle_wait", - cancellationToken).ConfigureAwait(false); - - await power.TurnOnAsync(effectiveTaskLogger, cancellationToken).ConfigureAwait(false); - effectiveTaskLogger.LogInformation("✓ PLC power cycled successfully"); - - await client.HandshakeAsync(cancellationToken).ConfigureAwait(false); - - // Stage 8: Handshake Info (15% progress) - progress.Report(("handshake", 15.0, null, null)); - effectiveTaskLogger.LogInformation("--- Stage 8: Bootloader Handshake ---"); - - string version = await client.GetBootloaderVersionAsync(cancellationToken).ConfigureAwait(false); - effectiveTaskLogger.LogInformation("✓ Connected to bootloader version: {Version}", version); - - // Stage 9: Install Stager (16% progress) - progress.Report(("stager_install", 16.0, null, null)); - effectiveTaskLogger.LogInformation("--- Stage 9: Install Stager Payload ---"); - - byte[] stagerPayload = await payloads.GetStagerAsync(profiles.Payloads.BasePath, cancellationToken).ConfigureAwait(false); - - using (var stagerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) - { - var progressTask = SimulateProgressAsync( - stagerPayload.LongLength, - profiles.Serial.Configuration.BaudRate, - progress, - 16.0, 18.0, - "stager_install", - isStagerInstall: true, - stagerCts.Token); - - try - { - await client.InstallStagerAsync(stagerPayload, cancellationToken).ConfigureAwait(false); - } - finally - { - stagerCts.Cancel(); - try - { await progressTask.ConfigureAwait(false); } - catch (OperationCanceledException) { } - } - } - effectiveTaskLogger.LogInformation("✓ Stager payload installed successfully ({Size} bytes)", stagerPayload.Length); - - // Stage 10: Install Dumper (18% progress) - progress.Report(("dumper_install", 18.0, null, null)); - effectiveTaskLogger.LogInformation("--- Stage 10: Install Memory Dumper Payload ---"); - - byte[] dumperPayload = await payloads.GetMemoryDumperAsync(profiles.Payloads.BasePath, cancellationToken).ConfigureAwait(false); - - using (var dumperCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) - { - var progressTask = SimulateProgressAsync( - dumperPayload.LongLength, - profiles.Serial.Configuration.BaudRate, - progress, - 18.0, 20.0, - "dumper_install", - isStagerInstall: false, - dumperCts.Token); - - try - { - await client.InstallDumperAsync(dumperPayload, cancellationToken).ConfigureAwait(false); - } - finally - { - dumperCts.Cancel(); - try - { await progressTask.ConfigureAwait(false); } - catch (OperationCanceledException) { } - } - } - effectiveTaskLogger.LogInformation("✓ Dumper payload installed successfully"); - - // Stage 11: Memory Dump (20% - 95% progress) - 75% weight - effectiveTaskLogger.LogInformation("--- Stage 11: Memory Dump (Streaming) ---"); - - var dumpResult = await PerformDumpProcessStreamingAsync( - client, profiles, - progress, effectiveTaskLogger, - startPercent: 20.0, weight: 75.0, - taskId, - cancellationToken).ConfigureAwait(false); - - // Stage 12: Teardown (95% progress) - progress.Report(("teardown", 95.0, null, null)); - effectiveTaskLogger.LogInformation("--- Stage 12: Teardown ---"); - - // Stage 13: Complete - progress.Report(("complete", 100.0, null, null)); - effectiveTaskLogger.LogInformation("=== BOOTLOADER DUMP OPERATION COMPLETED ==="); - - return dumpResult; - } - catch (Exception ex) - { - processLogger?.LogError(ex, "Dump failed: {ErrorMessage}", ex.Message); - effectiveTaskLogger.LogError("DUMP OPERATION FAILED: {ErrorMessage}", ex.Message); - throw; - } - finally - { - if (socatProcess != null) - { - try - { - await socat.StopSocatAsync(socatProcess, cancellationToken).ConfigureAwait(false); - effectiveTaskLogger.LogDebug("✓ Socat process stopped"); - } - catch (Exception ex) { effectiveTaskLogger.LogWarning(ex, "Failed to stop socat"); } - } - - if (isPowerConnected) - { - try - { - await power.DisconnectAsync(cancellationToken).ConfigureAwait(false); - effectiveTaskLogger.LogDebug("✓ Disconnected from power supply"); - } - catch (Exception ex) { effectiveTaskLogger.LogWarning(ex, "Failed to disconnect power"); } - } - } - } - - /// - /// Simulates progress for payload transfer with ACCURATE protocol overhead calculation. - /// Accounts for: packet framing (length+checksum), chunking, and protocol-specific delays. - /// - /// Raw payload size in bytes - /// UART baud rate (bits per second) - /// Progress reporter - /// Starting progress percentage - /// Target progress percentage - /// Stage name for progress reporting - /// True for stager (uses IRAM write protocol), false for dumper (uses stager protocol) - /// Cancellation token - protected async Task SimulateProgressAsync( - long payloadSize, - int baudRate, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, - double startPercent, - double targetPercent, - string stage, - bool isStagerInstall, - CancellationToken cancellationToken) - { - const int bitsPerByte = 10; - long totalWireBytes; - int totalDelayMs; - - if (isStagerInstall) - { - const int chunkSize = 16; - int numChunks = (int)Math.Ceiling((double)payloadSize / chunkSize); - totalWireBytes = 10 + (numChunks * 36) + 5 + 16; - - int totalPackets = (numChunks * 2) + 4; - int avgPacketSize = (int)(totalWireBytes / totalPackets); - int chunksPerPacket = (avgPacketSize + 1) / 2; - totalDelayMs = totalPackets * (10 + (chunksPerPacket * 10)); - } - else - { - const int maxPacketPayload = 64; - int numPackets = (int)Math.Ceiling((double)payloadSize / maxPacketPayload); - - int avgPayloadPerPacket = (int)((payloadSize + numPackets - 1) / numPackets); - totalWireBytes = numPackets * (1 + avgPayloadPerPacket + 1); - - int avgChunksPerPacket = (avgPayloadPerPacket + 2 + 1) / 2; - totalDelayMs = numPackets * (10 + (avgChunksPerPacket * 10)); - } - - double transferTimeMs = ((double)totalWireBytes * bitsPerByte * 1000.0) / (double)baudRate; - int totalTimeMs = (int)(transferTimeMs + totalDelayMs); - totalTimeMs = (int)(totalTimeMs * 1.1); - - if (totalTimeMs < 1000) - { - totalTimeMs = 1000; - } - - await WaitWithProgressAsync( - totalTimeMs, - progress, - startPercent, - targetPercent, - stage, - cancellationToken).ConfigureAwait(false); - } -} diff --git a/src/S7Tools/Services/Bootloader/BootloaderService.cs b/src/S7Tools/Services/Bootloader/BootloaderService.cs index c9d9be8f..f91d9c31 100644 --- a/src/S7Tools/Services/Bootloader/BootloaderService.cs +++ b/src/S7Tools/Services/Bootloader/BootloaderService.cs @@ -1,15 +1,19 @@ +using System.Linq; using Microsoft.Extensions.Logging; +using S7Tools.Core.Constants; +using S7Tools.Core.Exceptions; +using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Models.Validation; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Validation.Models; +using S7Tools.Extensions; using S7Tools.Resources; namespace S7Tools.Services.Bootloader; /// -/// Orchestrates the complete bootloader memory dump workflow. -/// Coordinates serial configuration, socat bridge setup, power sequencing, PLC communication, and memory dumping. +/// Consolidated bootloader service orchestrating complete memory dump workflow with TaskExecution integration. +/// Provides retry mechanisms, comprehensive error handling, and detailed progress tracking. /// public sealed class BootloaderService( ILogger logger, @@ -17,8 +21,11 @@ public sealed class BootloaderService( ISocatService socat, IPowerSupplyService power, ISerialPortService serialPort, - ITimeProvider timeProvider, - Func clientFactory) : BaseBootloaderService(timeProvider), IBootloaderService + Func clientFactory, + IResourceCoordinator resourceCoordinator, + ITimeProvider? timeProvider = null, + IApplicationSettingsService? settingsService = null) + : IBootloaderService, IDisposable { private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private readonly IPayloadProvider _payloads = payloads ?? throw new ArgumentNullException(nameof(payloads)); @@ -26,8 +33,16 @@ public sealed class BootloaderService( private readonly IPowerSupplyService _power = power ?? throw new ArgumentNullException(nameof(power)); private readonly ISerialPortService _serialPort = serialPort ?? throw new ArgumentNullException(nameof(serialPort)); private readonly Func _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + private readonly IResourceCoordinator _resourceCoordinator = resourceCoordinator ?? throw new ArgumentNullException(nameof(resourceCoordinator)); + private readonly ITimeProvider _timeProvider = timeProvider!; + private readonly IApplicationSettingsService? _settingsService = settingsService; + private readonly SemaphoreSlim _operationSemaphore = new(1, 1); + private RetryConfiguration _retryConfiguration = RetryConfiguration.Default; + private bool _disposed; - + /// + /// Executes the DumpAsync operation. + /// public async Task DumpAsync( JobProfileSet profiles, IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, @@ -38,10 +53,9 @@ public async Task DumpAsync( ArgumentNullException.ThrowIfNull(profiles); ArgumentNullException.ThrowIfNull(progress); - // Use taskLogger for task-level operations, fallback to main logger Microsoft.Extensions.Logging.ILogger effectiveTaskLogger = taskLogger ?? _logger; - _logger.LogInformation("Starting bootloader dump operation (delegating to base orchestration)"); + effectiveTaskLogger.LogInformation("Starting enhanced bootloader dump operation (delegating to base orchestration)"); return await PerformBootloaderOrchestrationAsync( profiles, @@ -56,6 +70,413 @@ public async Task DumpAsync( cancellationToken).ConfigureAwait(false); } + /// + public RetryConfiguration RetryConfiguration => _retryConfiguration; + + /// + public void UpdateRetryConfiguration(RetryConfiguration configuration) + { + _retryConfiguration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + /// + public async Task DumpWithTaskTrackingAsync( + TaskExecution taskExecution, + JobProfileSet profiles, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(taskExecution); + ArgumentNullException.ThrowIfNull(profiles); + + return await _operationSemaphore.ExecuteAsync(async () => + { + _logger.LogInformation("Starting enhanced bootloader dump operation for task {TaskId}", taskExecution.TaskId); + + // Update task to running state + taskExecution.UpdateState(TaskState.Running, "Initializing bootloader operation"); + + // Create a progress reporter that updates the TaskExecution + double lastLoggedPercent = -1.0; + var progressReporter = new Progress<(string stage, double percent, long? bytesRead, long? totalBytes)>(progress => + { + (string? stage, double percent, long? bytesRead, long? totalBytes) = progress; + // Progress is already in 0-100 range from BootloaderService + string operation = GetUserFriendlyOperationName(stage); + + var extraData = new Dictionary(); + if (bytesRead.HasValue && totalBytes.HasValue) + { + extraData["BytesRead"] = bytesRead.Value; + extraData["TotalBytes"] = totalBytes.Value; + } + + taskExecution.UpdateProgress(percent, operation, extraData); + + // Throttle logging to avoid spam (log every 0.1% change or if bytes are involved/important stages) + if (Math.Abs(percent - lastLoggedPercent) >= 0.1 || percent >= 100.0 || percent <= 0.0) + { + lastLoggedPercent = percent; + if (bytesRead.HasValue && totalBytes.HasValue) + { + _logger.LogDebug("Task {TaskId} progress: {Percentage:F1}% - {Operation} ({BytesRead}/{TotalBytes} bytes)", + taskExecution.TaskId, percent, operation, bytesRead, totalBytes); + } + else + { + _logger.LogDebug("Task {TaskId} progress: {Percentage:F1}% - {Operation}", + taskExecution.TaskId, percent, operation); + } + } + }); + + // Estimate operation time + TimeSpan? estimatedTime = await EstimateOperationTimeAsync(profiles, cancellationToken) + .ConfigureAwait(false); + if (estimatedTime.HasValue) + { + taskExecution.EstimatedTimeRemaining = estimatedTime.Value; + } + + try + { + // Get process logger from task execution if available + Microsoft.Extensions.Logging.ILogger? taskLogger = taskExecution.Logger?.MainLogger; + Microsoft.Extensions.Logging.ILogger? processLogger = taskExecution.Logger?.ProcessLogger; + + // Execute the memory dump with retry logic + // Execute the memory dump with retry logic + // Directly call Orchestration to get both data and file paths + var result = await ExecuteWithRetryAsync( + () => PerformBootloaderOrchestrationAsync( + profiles, + progressReporter, + taskLogger ?? _logger, + processLogger, + _serialPort, + _socat, + _power, + _payloads, + _clientFactory, + cancellationToken, + taskExecution.TaskId), + RetryableOperations.All, + taskExecution, + cancellationToken).ConfigureAwait(false); + + // No need to save manually, Orchestration handled it. + // Output paths are in available in result.SavedFiles + string outputFilePath = result.SavedFiles?.FirstOrDefault() ?? string.Empty; + + // Mark task as completed + long totalLength = result.SavedFiles?.Sum(x => (long)x.Length) ?? 0; + taskExecution.MarkAsCompleted(outputFilePath, totalLength); + + _logger.LogInformation("Enhanced bootloader dump completed successfully for task {TaskId}. " + + "Output saved to: {OutputPath}", taskExecution.TaskId, outputFilePath); + + return result; + } + catch (OperationCanceledException) + { + taskExecution.UpdateState(TaskState.Cancelled, "Operation was cancelled"); + _logger.LogWarning("Bootloader dump operation cancelled for task {TaskId}", taskExecution.TaskId); + throw; + } + catch (Exception ex) + { + string errorMessage = $"Bootloader operation failed: {ex.Message}"; + taskExecution.MarkAsFailed(errorMessage, ex.ToString()); + + _logger.LogError(ex, "Enhanced bootloader dump failed for task {TaskId}: {ErrorMessage}", + taskExecution.TaskId, ex.Message); + + throw new BootloaderOperationException(errorMessage, ex); + } + }, cancellationToken); + } + + /// + public async Task ValidateResourcesAsync( + JobProfileSet profiles, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profiles); + + _logger.LogDebug("Validating resources for bootloader operation"); + + var validationErrors = new List(); + + try + { + // Validate resource availability through resource coordinator + IEnumerable resourceKeys = ExtractResourceKeys(profiles); + bool canAcquire = _resourceCoordinator.TryAcquire(resourceKeys); + + if (canAcquire) + { + // Release immediately since this is just a validation check + _resourceCoordinator.Release(resourceKeys); + } + else + { + validationErrors.Add("One or more required resources are not available or are locked by another task"); + } + + // Additional profile validation using the built-in validation + ValidationResult profileValidation = await ValidateProfileSetAsync(profiles, cancellationToken) + .ConfigureAwait(false); + + if (!profileValidation.IsValid) + { + validationErrors.AddRange(profileValidation.Errors.Select(e => e.Message)); + } + + ValidationResult result = validationErrors.Count == 0 + ? ValidationResult.Success() + : ValidationResult.Failure([.. validationErrors.Select(error => + new ValidationError("Resource", error))]); + + _logger.LogDebug("Resource validation completed. Valid: {IsValid}, Errors: {ErrorCount}", + result.IsValid, validationErrors.Count); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Resource validation failed: {ErrorMessage}", ex.Message); + return ValidationResult.Failure("Resource", $"Resource validation failed: {ex.Message}"); + } + } + + /// + public async Task TestConnectionAsync( + JobProfileSet profiles, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profiles); + + _logger.LogDebug("Testing bootloader connection"); + + try + { + // This would involve a lightweight connection test + // For now, we'll simulate it by checking if resources are available + ValidationResult validation = await ValidateResourcesAsync(profiles, cancellationToken) + .ConfigureAwait(false); + + _logger.LogDebug("Connection test completed. Success: {Success}", validation.IsValid); + return validation.IsValid; + } + catch (Exception ex) + { + _logger.LogError(ex, "Connection test failed: {ErrorMessage}", ex.Message); + return false; + } + } + + /// + public Task GetBootloaderInfoAsync( + JobProfileSet profiles, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profiles); + + _logger.LogDebug("Retrieving bootloader information"); + + try + { + // For now, return simulated bootloader info + // In a real implementation, this would establish a connection and query the bootloader + var bootloaderInfo = new BootloaderInfo + { + Version = "1.0.0", + PlcModel = "S7-1200", + FirmwareVersion = "V4.4", + MaxTransferSize = 1024, + SupportsPauseResume = false, + Capabilities = BootloaderCapabilities.MemoryRead | BootloaderCapabilities.Checksums, + AvailableMemoryRegions = + [ + new() + { + Name = "Flash Memory", + StartAddress = profiles.Memory.Start, + Size = profiles.Memory.Length, + AccessFlags = MemoryAccessFlags.Read, + Description = "Main flash memory region" + } + ] + }; + + _logger.LogDebug("Retrieved bootloader info: Version={Version}, Model={Model}", + bootloaderInfo.Version, bootloaderInfo.PlcModel); + + return Task.FromResult(bootloaderInfo); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve bootloader information: {ErrorMessage}", ex.Message); + throw new BootloaderOperationException($"Failed to retrieve bootloader information: {ex.Message}", ex); + } + } + + /// + public Task EstimateOperationTimeAsync( + JobProfileSet profiles, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profiles); + + try + { + // Simple estimation based on memory size + // Assume ~1KB/second transfer rate plus fixed overhead + const double TransferRateBytesPerSecond = 1024.0; + const double FixedOverheadSeconds = 30.0; // Setup, handshake, teardown + + double transferTimeSeconds = profiles.Memory.Length / TransferRateBytesPerSecond; + double totalTimeSeconds = transferTimeSeconds + FixedOverheadSeconds; + + var estimatedTime = TimeSpan.FromSeconds(totalTimeSeconds); + + _logger.LogDebug("Estimated operation time: {EstimatedTime} for {MemorySize} bytes", + estimatedTime, profiles.Memory.Length); + + return Task.FromResult(estimatedTime); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to estimate operation time: {ErrorMessage}", ex.Message); + return Task.FromResult(null); + } + } + + private async Task ExecuteWithRetryAsync( + Func> operation, + RetryableOperations retryableOperation, + TaskExecution taskExecution, + CancellationToken cancellationToken) + { + if (!_retryConfiguration.RetryableOperations.HasFlag(retryableOperation)) + { + return await operation().ConfigureAwait(false); + } + + int maxRetries = GetMaxRetriesForOperation(retryableOperation); + TimeSpan currentDelay = _retryConfiguration.InitialRetryDelay; + + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + if (attempt > 0) + { + taskExecution.UpdateProgress( + taskExecution.ProgressPercentage, + $"Retrying operation (attempt {attempt + 1}/{maxRetries + 1})"); + + _logger.LogInformation("Retrying bootloader operation for task {TaskId}, attempt {Attempt}/{MaxAttempts}", + taskExecution.TaskId, attempt + 1, maxRetries + 1); + + await Task.Delay(currentDelay, cancellationToken).ConfigureAwait(false); + } + + T? result = await operation().ConfigureAwait(false); + + if (attempt > 0) + { + _logger.LogInformation("Bootloader operation succeeded for task {TaskId} on attempt {Attempt}", + taskExecution.TaskId, attempt + 1); + } + + return result; + } + catch (OperationCanceledException) + { + throw; // Don't retry cancellation + } + catch (Exception ex) when (attempt < maxRetries) + { + _logger.LogWarning(ex, "Bootloader operation failed for task {TaskId} on attempt {Attempt}, retrying: {ErrorMessage}", + taskExecution.TaskId, attempt + 1, ex.Message); + + // Calculate next delay with exponential backoff + if (_retryConfiguration.UseExponentialBackoff) + { + currentDelay = TimeSpan.FromMilliseconds( + Math.Min( + currentDelay.TotalMilliseconds * _retryConfiguration.BackoffMultiplier, + _retryConfiguration.MaxRetryDelay.TotalMilliseconds)); + } + } + } + + // If we get here, all retries have been exhausted + throw new BootloaderOperationException($"Bootloader operation failed after {maxRetries + 1} attempts"); + } + + private int GetMaxRetriesForOperation(RetryableOperations operation) + { + return operation switch + { + RetryableOperations.Connection => _retryConfiguration.MaxConnectionRetries, + RetryableOperations.Handshake => _retryConfiguration.MaxCommunicationRetries, + RetryableOperations.PayloadInstallation => _retryConfiguration.MaxCommunicationRetries, + RetryableOperations.MemoryRead => _retryConfiguration.MaxMemoryOperationRetries, + RetryableOperations.PowerControl => _retryConfiguration.MaxConnectionRetries, + RetryableOperations.Network => _retryConfiguration.MaxConnectionRetries, + _ => _retryConfiguration.MaxCommunicationRetries + }; + } + + private static string GetUserFriendlyOperationName(string stage) + { + return stage switch + { + "socat_setup" => "Setting up network bridge", + "power_off_initial" => "Initial Power OFF", + "power_cycle" => "Power cycling PLC", + "handshake" => "Establishing bootloader connection", + "stager_install" => "Installing bootloader stager", + "memory_dump" => "Dumping memory", + "teardown" => "Cleaning up resources", + "complete" => "Operation complete", + _ => stage.Replace("_", " ") + }; + } + + + + + private static ResourceKey[] ExtractResourceKeys(JobProfileSet profiles) + { + return + [ + new ResourceKey("serial", profiles.Serial.Device), + new ResourceKey("tcp", profiles.Socat.Port.ToString()), + new ResourceKey("modbus", $"{profiles.Power.Host}:{profiles.Power.Port}") + ]; + } + + /// + /// Releases the unmanaged resources used by the BootloaderService and optionally releases the managed resources. + /// + /// True to release both managed and unmanaged resources; false to release only unmanaged resources. + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _operationSemaphore?.Dispose(); + } + _disposed = true; + } + } + + + + /// public async Task ValidateProfileSetAsync( @@ -138,11 +559,8 @@ await tcpClient.ConnectAsync( { checked { - uint endAddress = profiles.Memory.Start + profiles.Memory.Length; - if (endAddress < profiles.Memory.Start) - { - errors.Add($"Memory region overflow: start=0x{profiles.Memory.Start:X8}, length=0x{profiles.Memory.Length:X8}"); - } + // This will throw OverflowException if Start + Length > uint.MaxValue + _ = profiles.Memory.Start + profiles.Memory.Length; } } catch (OverflowException) @@ -173,4 +591,1000 @@ public TimeSpan EstimateDuration(MemoryRegionProfile memoryRegion) return TimeSpan.FromSeconds(totalSeconds); } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + + + + /// + /// Gets the delay in milliseconds to wait between segment dumps within a single iteration. + /// Reads from when available; falls back to 5000 ms. + /// + private int SegmentDumpDelayMilliseconds => + _settingsService?.Current.MemoryDump.SegmentDumpDelayMilliseconds ?? 5000; + + /// + /// Gets the delay in milliseconds to wait between dump iterations. + /// Reads from when available; falls back to 5000 ms. + /// + private int IterationDumpDelayMilliseconds => + _settingsService?.Current.MemoryDump.IterationDumpDelayMilliseconds ?? 5000; + + /// + /// Performs the core memory dump process, iterating through dumps and segments. + /// + private async Task> PerformDumpProcessAsync( + IPlcClient client, + JobProfileSet profiles, + IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, + ILogger logger, + double startPercent, + double weight, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(profiles); + ArgumentNullException.ThrowIfNull(progress); + ArgumentNullException.ThrowIfNull(logger); + + // Snapshot configurable delays once so repeated property reads during the loop don't re-query settings. + int segmentDumpDelayMs = SegmentDumpDelayMilliseconds; + int iterationDumpDelayMs = IterationDumpDelayMilliseconds; + + List allDumps = []; + + long totalDumpBytes; + var segments = profiles.MemoryMapping?.SelectedSegments?.ToList() ?? []; + + if (profiles.DumpCount <= 0) + { + throw new InvalidOperationException($"Invalid dump count: {profiles.DumpCount}. Must be >= 1."); + } + + try + { + if (segments.Count == 0) + { + totalDumpBytes = checked((long)profiles.Memory.Length * profiles.DumpCount); + } + else + { + long singlePassBytes = segments.Sum(s => (long)s.Size); + totalDumpBytes = checked(singlePassBytes * profiles.DumpCount); + } + } + catch (OverflowException ex) + { + throw new InvalidOperationException( + $"Total dump size calculation overflowed (DumpCount={profiles.DumpCount}).", + ex); + } + + if (totalDumpBytes <= 0) + { + totalDumpBytes = 1; + } + + long globalBytesRead = 0; + DateTime dumpStartTime = _timeProvider?.GetUtcNow() ?? DateTime.UtcNow; + + // Pre-calculate data for efficiency + string[] iterStageNames = new string[profiles.DumpCount]; + string[][]? segStageNames = null; + List? selectedSegments = null; + + if (profiles.MemoryMapping != null && profiles.MemoryMapping.HasSelectedSegments) + { + selectedSegments = profiles.MemoryMapping.SelectedSegments.ToList(); + segStageNames = new string[profiles.DumpCount][]; + for (int iter = 0; iter < profiles.DumpCount; iter++) + { + segStageNames[iter] = new string[selectedSegments.Count]; + string prefix = "Dumping Seg "; + string suffix = $"/{selectedSegments.Count} (Iter {iter + 1}/{profiles.DumpCount})"; + for (int i = 0; i < selectedSegments.Count; i++) + { + segStageNames[iter][i] = $"{prefix}{i + 1}{suffix}"; + } + } + } + else + { + for (int iter = 0; iter < profiles.DumpCount; iter++) + { + iterStageNames[iter] = $"Dumping Memory (Iter {iter + 1}/{profiles.DumpCount})"; + } + } + + for (int iter = 0; iter < profiles.DumpCount; iter++) + { + logger.LogInformation("Starting Dump Iteration {Iter}/{Total}", iter + 1, profiles.DumpCount); + + if (selectedSegments != null && segStageNames != null) + { + List segmentDataList = []; + + for (int i = 0; i < selectedSegments.Count; i++) + { + if (i > 0 && segmentDumpDelayMs > 0) + { + var delay = TimeSpan.FromMilliseconds(segmentDumpDelayMs); + logger.LogDebug("Waiting {Delay} before next segment dump...", delay); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + + MemorySegment segment = selectedSegments[i]; + + string startStr = segment.StartAddress; + if (string.IsNullOrEmpty(startStr)) + { + throw new InvalidOperationException("Memory segment start address is null."); + } + + if (startStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + startStr = startStr[2..]; + } + + if (!uint.TryParse(startStr, System.Globalization.NumberStyles.HexNumber, null, out uint segmentStart)) + { + throw new InvalidOperationException($"Invalid memory segment start address '{segment.StartAddress}'."); + } + + uint segmentSize = (uint)segment.Size; + string stageName = segStageNames[iter][i]; + + double currentBasePercent = startPercent + (weight * globalBytesRead / totalDumpBytes); + + progress.Report((stageName, currentBasePercent, globalBytesRead, totalDumpBytes)); + + logger.LogInformation("Dumping segment {Index}/{Total} (Iter {Iter}): '{Name}'", + i + 1, selectedSegments.Count, iter + 1, segment.Name); + + int lastLoggedPercent = -1; + double lastReportedPercent = currentBasePercent; + + var segmentProgress = new Progress(bytesRead => + { + long totalReadSoFar = globalBytesRead + bytesRead; + double percent = startPercent + (weight * totalReadSoFar / totalDumpBytes); + + if (Math.Abs(percent - lastReportedPercent) >= 0.1 || bytesRead == segmentSize) + { + progress.Report((stageName, percent, totalReadSoFar, totalDumpBytes)); + lastReportedPercent = percent; + } + + if (segmentSize > 0) + { + double segPct = (double)bytesRead / segmentSize * 100.0; + if ((int)segPct > lastLoggedPercent && (int)segPct % 5 == 0) + { + lastLoggedPercent = (int)segPct; + logger.LogDebug(" Progress: {Percent:F1}% ({Bytes:N0}/{Total:N0})", segPct, bytesRead, segmentSize); + } + } + }); + + byte[] segmentData = await client.InvokeDumperAsync( + segmentStart, + segmentSize, + segmentProgress, + cancellationToken).ConfigureAwait(false); + + segmentDataList.Add(segmentData); + globalBytesRead += segmentData.Length; + + logger.LogInformation(" ✓ Segment dumped: {Size:N0} bytes", segmentData.Length); + } + + // High-performance flattening using Buffer.BlockCopy + int totalIterSize = segmentDataList.Sum(s => s.Length); + byte[] flattenedData = new byte[totalIterSize]; + int currentPos = 0; + foreach (byte[] segData in segmentDataList) + { + Buffer.BlockCopy(segData, 0, flattenedData, currentPos, segData.Length); + currentPos += segData.Length; + } + allDumps.Add(flattenedData); + } + else + { + string stageName = iterStageNames[iter]; + + double currentBasePercent = startPercent + (weight * globalBytesRead / totalDumpBytes); + progress.Report((stageName, currentBasePercent, globalBytesRead, totalDumpBytes)); + + logger.LogInformation("Dumping single region (Iter {Iter}/{Total})", iter + 1, profiles.DumpCount); + + int lastLoggedPercent = -1; + double lastReportedPercent = currentBasePercent; + + var dumpProgress = new Progress(bytesRead => + { + long totalReadSoFar = globalBytesRead + bytesRead; + double percent = startPercent + (weight * totalReadSoFar / totalDumpBytes); + + if (Math.Abs(percent - lastReportedPercent) >= 0.1 || bytesRead == profiles.Memory.Length) + { + progress.Report((stageName, percent, totalReadSoFar, totalDumpBytes)); + lastReportedPercent = percent; + } + + if (profiles.Memory.Length > 0) + { + double dumpPct = (double)bytesRead / profiles.Memory.Length * 100.0; + if ((int)dumpPct > lastLoggedPercent && (int)dumpPct % 5 == 0) + { + lastLoggedPercent = (int)dumpPct; + logger.LogDebug(" Progress: {Percent:F1}%", dumpPct); + } + } + }); + + byte[] data = await client.InvokeDumperAsync( + profiles.Memory.Start, + profiles.Memory.Length, + dumpProgress, + cancellationToken).ConfigureAwait(false); + + allDumps.Add(data); + globalBytesRead += data.Length; + + logger.LogInformation(" ✓ Iteration {Iter} complete: {Size:N0} bytes", iter + 1, data.Length); + } + + if (iter < profiles.DumpCount - 1) + { + if (iterationDumpDelayMs > 0) + { + logger.LogDebug("Waiting {DelayMs}ms before next dump iteration...", iterationDumpDelayMs); + await Task.Delay(iterationDumpDelayMs, cancellationToken).ConfigureAwait(false); + } + } + } + + TimeSpan dumpDuration = (_timeProvider?.GetUtcNow() ?? DateTime.UtcNow) - dumpStartTime; + long totalBytesDumped = allDumps.Sum(d => d.Length); + double transferRate = totalBytesDumped > 0 && dumpDuration.TotalSeconds > 0 ? totalBytesDumped / dumpDuration.TotalSeconds : 0; + + logger.LogInformation("✓ All dumps completed: {Size:N0} bytes total", totalBytesDumped); + logger.LogInformation(" Total Duration: {Duration:F1}s, Avg Rate: {Rate:F1} bytes/s", + dumpDuration.TotalSeconds, transferRate); + + return allDumps; + } + + /// + /// Performs memory dump using streaming, writing directly to the final output file to minimize memory usage. + /// Handles both segmented and single-region dumps. + /// + private async Task PerformDumpProcessStreamingAsync( + IPlcClient client, + JobProfileSet profiles, + IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, + ILogger logger, + double startPercent, + double weight, + Guid? taskId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(profiles); + ArgumentNullException.ThrowIfNull(progress); + ArgumentNullException.ThrowIfNull(logger); + + List savedFiles = []; + + try + { + DateTime dumpStartTime = _timeProvider?.GetUtcNow() ?? DateTime.UtcNow; + int iterationCount = profiles.DumpCount > 0 ? profiles.DumpCount : 1; + + logger.LogInformation("Starting streaming dump process ({Count} iterations)", iterationCount); + + // Calculate total expected bytes across all iterations for progress reporting + long totalExpectedBytes = 0; + var segments = profiles.MemoryMapping?.SelectedSegments?.ToList() ?? []; + if (segments.Count > 0) + { + totalExpectedBytes = iterationCount * segments.Sum(s => (long)s.Size); + } + else + { + totalExpectedBytes = iterationCount * (long)profiles.Memory.Length; + } + + // Determine directory and sanitized job name once + string dumpsDir = !string.IsNullOrWhiteSpace(profiles.OutputPath) + ? profiles.OutputPath + : "./dumps"; + + if (!System.IO.Directory.Exists(dumpsDir)) + { + System.IO.Directory.CreateDirectory(dumpsDir); + } + + string rawJobName = segments.FirstOrDefault()?.Name ?? "MemoryDump"; + string jobName = string.Join("_", rawJobName.Split(System.IO.Path.GetInvalidFileNameChars())); + string taskIdStr = taskId.HasValue ? $"_{taskId.Value:N}" : ""; + + for (int iter = 0; iter < iterationCount; iter++) + { + if (iter > 0) + { + logger.LogInformation("Waiting 5 seconds before next dump iteration..."); + await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + } + + logger.LogInformation("Iteration {Iter}/{Total}", iter + 1, iterationCount); + + // Determine final file path up-front + string timestamp = (_timeProvider?.GetLocalNow() ?? DateTime.Now).ToString("yyyyMMdd_HHmmss"); + string dumpFileName = $"{jobName}_iter{iter + 1}_of_{iterationCount}{taskIdStr}_{timestamp}.bin"; + string finalFilePath = System.IO.Path.Combine(dumpsDir, dumpFileName); + long bytesWrittenInIter = 0; + + // Pass context object to helper methods + var context = new StreamingContext( + client, + progress, + logger, + startPercent, + weight, + iterationCount, + iter, + totalExpectedBytes, + cancellationToken + ); + + if (segments.Count > 0) + { + bytesWrittenInIter = await StreamSegmentedDumpToFileAsync( + context, + segments, + finalFilePath).ConfigureAwait(false); + } + else + { + bytesWrittenInIter = await StreamSingleRegionDumpToFileAsync( + context, + profiles.Memory, + finalFilePath).ConfigureAwait(false); + } + + // The data is now saved to the file at finalFilePath. + // We no longer populate the deprecated `allDumps` list with empty arrays. + // Consumers should rely on `savedFiles` for data access. + savedFiles.Add(finalFilePath); + logger.LogInformation("✓ Dump file created: {File} ({Size:N0} bytes)", dumpFileName, bytesWrittenInIter); + } + + TimeSpan dumpDuration = (_timeProvider?.GetUtcNow() ?? DateTime.UtcNow) - dumpStartTime; + long totalBytes = savedFiles.Sum(path => new System.IO.FileInfo(path).Length); + double rate = totalBytes > 0 && dumpDuration.TotalSeconds > 0 ? totalBytes / dumpDuration.TotalSeconds : 0; + + logger.LogInformation("✓ Streaming dump complete: {Size:N0} bytes total", totalBytes); + logger.LogInformation(" Duration: {Duration:F1}s, Rate: {Rate:F1} bytes/s", dumpDuration.TotalSeconds, rate); + + return new BootloaderResult(savedFiles); + } + finally + { + await client.StopDumperSessionAsync().ConfigureAwait(false); + } + } + + private record StreamingContext( + IPlcClient Client, + IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> Progress, + ILogger Logger, + double StartPercent, + double Weight, + int IterationCount, + int CurrentIteration, + long TotalExpectedBytes, + CancellationToken CancellationToken); + + private static uint ParseSegmentAddress(MemorySegment segment) + { + string? startStr = segment.StartAddress; + if (string.IsNullOrEmpty(startStr)) + { + throw new InvalidOperationException($"Memory segment '{segment.Name}' has a null or empty start address."); + } + + if (startStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + startStr = startStr[2..]; + } + + if (!uint.TryParse(startStr, System.Globalization.NumberStyles.HexNumber, null, out uint segmentStart)) + { + throw new InvalidOperationException($"Invalid memory segment start address '{segment.StartAddress}'."); + } + return segmentStart; + } + + private async Task StreamSegmentedDumpToFileAsync( + StreamingContext ctx, + List segments, + string finalFilePath) + { + long bytesWrittenInIter = 0; + long totalSegmentsSize = segments.Sum(s => (long)s.Size); + + // Pre-calculate data for efficiency + string[] stageNames = new string[segments.Count]; + long[] segmentOffsets = new long[segments.Count]; + long currentOffset = 0; + string prefix = "Seg "; + string suffix = $"/{segments.Count} (Iter {ctx.CurrentIteration + 1}/{ctx.IterationCount})"; + + for (int i = 0; i < segments.Count; i++) + { + stageNames[i] = $"{prefix}{i + 1}{suffix}"; + segmentOffsets[i] = currentOffset; + currentOffset += segments[i].Size; + } + + await using (var fileStream = new System.IO.FileStream( + finalFilePath, + System.IO.FileMode.Create, + System.IO.FileAccess.ReadWrite, + System.IO.FileShare.None, + 81920, + true)) + { + for (int i = 0; i < segments.Count; i++) + { + if (i > 0) + { + ctx.Logger.LogInformation("Waiting 5 seconds before next segment dump..."); + await Task.Delay(5000, ctx.CancellationToken).ConfigureAwait(false); + } + + var segment = segments[i]; + uint segStart = ParseSegmentAddress(segment); + uint segLength = (uint)segment.Size; + string stageName = stageNames[i]; + long bytesFromPreviousSegmentsThisIter = segmentOffsets[i]; + + if (segLength == 0) + { + ctx.Logger.LogWarning("Skipping zero-length segment {Name}", segment.Name); + + // Report progress for the skipped segment to avoid UI stalls. + long bytesFromPreviousIterations = ctx.CurrentIteration * totalSegmentsSize; + long cumulativeTotalBytes = bytesFromPreviousIterations + bytesFromPreviousSegmentsThisIter; + double percent = ctx.TotalExpectedBytes > 0 + ? ctx.StartPercent + (ctx.Weight * cumulativeTotalBytes / ctx.TotalExpectedBytes) + : ctx.StartPercent; + + ctx.Progress.Report((stageName, percent, cumulativeTotalBytes, ctx.TotalExpectedBytes)); + continue; + } + + double segWeight = ctx.Weight / ctx.IterationCount / segments.Count; + double segStartPercent = ctx.StartPercent + (ctx.Weight * (ctx.CurrentIteration * segments.Count + i) / (ctx.IterationCount * segments.Count)); + + ctx.Logger.LogInformation(" Streaming segment {Index}: {Name} (0x{Addr:X8}, {Size:N0} bytes)", + i + 1, segment.Name, segStart, segLength); + + long segBytesWrittenLocal = 0; + double lastReportedSegPercent = segStartPercent; + + var segProgress = new Progress(bytes => + { + segBytesWrittenLocal = bytes; + double percent = segStartPercent + (segWeight * bytes / segLength); + + // Calculate cumulative bytes + long bytesFromPreviousIterations = ctx.CurrentIteration * totalSegmentsSize; + long cumulativeTotalBytes = bytesFromPreviousIterations + bytesFromPreviousSegmentsThisIter + bytes; + + double threshold = segLength > 1024 * 1024 ? 0.1 : 1.0; + + if (Math.Abs(percent - lastReportedSegPercent) >= threshold || bytes == segLength) + { + ctx.Progress.Report((stageName, percent, cumulativeTotalBytes, ctx.TotalExpectedBytes)); + lastReportedSegPercent = percent; + } + }); + + await ctx.Client.InvokeDumperStreamAsync( + segStart, segLength, + async data => await fileStream.WriteAsync(data, ctx.CancellationToken), + segProgress, + ctx.CancellationToken, + logger: ctx.Logger).ConfigureAwait(false); + + // Use the local variable that was updated by the progress callback + // or fall back to segLength if the callback didn't fire for some reason + long bytesCompleted = segBytesWrittenLocal > 0 ? segBytesWrittenLocal : segLength; + bytesWrittenInIter += bytesCompleted; + + ctx.Logger.LogDebug(" ✓ Segment {Index} streamed: {Size:N0} bytes", i + 1, bytesCompleted); + } + + await fileStream.FlushAsync(ctx.CancellationToken).ConfigureAwait(false); + + // Trim if needed + if (fileStream.Length > totalSegmentsSize) + { + ctx.Logger.LogDebug("Trimmed dump from {Original} to {Expected} bytes", fileStream.Length, totalSegmentsSize); + fileStream.SetLength(totalSegmentsSize); + } + } + + return bytesWrittenInIter; + } + + private async Task StreamSingleRegionDumpToFileAsync( + StreamingContext ctx, + MemoryRegionProfile memoryRegion, + string finalFilePath) + { + long bytesWrittenInIter = 0; + uint segStart = memoryRegion.Start; + uint segLength = (uint)memoryRegion.Length; + + if (segLength == 0) + { + ctx.Logger.LogWarning("Skipping zero-length memory region"); + return 0; + } + + await using (var fileStream = new System.IO.FileStream( + finalFilePath, + System.IO.FileMode.Create, + System.IO.FileAccess.ReadWrite, + System.IO.FileShare.None, + 81920, + true)) + { + string stageName = $"Memory Dump (Iter {ctx.CurrentIteration + 1}/{ctx.IterationCount})"; + double iterWeight = ctx.Weight / ctx.IterationCount; + double iterStartPercent = ctx.StartPercent + (ctx.Weight * ctx.CurrentIteration / ctx.IterationCount); + + ctx.Logger.LogInformation(" Streaming memory: 0x{Start:X8}, {Length:N0} bytes", segStart, segLength); + + double lastReportedPercent = iterStartPercent; + + var regionProgress = new Progress(bytes => + { + bytesWrittenInIter = bytes; + double percent = iterStartPercent + (iterWeight * bytes / segLength); + long cumulativeBytes = (ctx.CurrentIteration * segLength) + bytes; + + double threshold = segLength > 1024 * 1024 ? 0.1 : 1.0; + if (Math.Abs(percent - lastReportedPercent) >= threshold || bytes == segLength) + { + ctx.Progress.Report((stageName, percent, cumulativeBytes, ctx.TotalExpectedBytes)); + lastReportedPercent = percent; + } + }); + + await ctx.Client.InvokeDumperStreamAsync( + segStart, segLength, + async data => await fileStream.WriteAsync(data, ctx.CancellationToken), + regionProgress, + ctx.CancellationToken, + logger: ctx.Logger).ConfigureAwait(false); + + await fileStream.FlushAsync(ctx.CancellationToken).ConfigureAwait(false); + + // Trim if needed + long expectedSize = (long)memoryRegion.Length; + if (fileStream.Length > expectedSize) + { + ctx.Logger.LogDebug("Trimmed dump from {Original} to {Expected} bytes", fileStream.Length, expectedSize); + fileStream.SetLength(expectedSize); + } + } + + return bytesWrittenInIter; + } + + /// + /// Helper to report progress while waiting for a delay. + /// + private async Task WaitWithProgressAsync( + int delayMs, + IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, + double startPercent, + double targetPercent, + string stage, + CancellationToken cancellationToken) + { + if (delayMs <= 0) + { + return; + } + + int steps = delayMs / 100; + if (steps <= 0) + { + steps = 1; + } + + double increment = (targetPercent - startPercent) / steps; + + for (int i = 0; i < steps; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + + double currentPercent = startPercent + (increment * (i + 1)); + progress.Report((stage, currentPercent, null, null)); + } + } + + /// + /// Performs the complete bootloader orchestration (13 stages) using streaming for dumps. + /// This centralizes the logic previously duplicated in BootloaderService and EnhancedBootloaderService. + /// + private async Task PerformBootloaderOrchestrationAsync( + JobProfileSet profiles, + IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, + ILogger effectiveTaskLogger, + ILogger? processLogger, + ISerialPortService serialPort, + ISocatService socat, + IPowerSupplyService power, + IPayloadProvider payloads, + Func clientFactory, + CancellationToken cancellationToken, + Guid? taskId = null) + { + ArgumentNullException.ThrowIfNull(profiles); + ArgumentNullException.ThrowIfNull(progress); + ArgumentNullException.ThrowIfNull(serialPort); + ArgumentNullException.ThrowIfNull(socat); + ArgumentNullException.ThrowIfNull(power); + ArgumentNullException.ThrowIfNull(payloads); + ArgumentNullException.ThrowIfNull(clientFactory); + + const int InitialPowerOffWaitMs = 10000; + SocatProcessInfo? socatProcess = null; + bool isPowerConnected = false; + + try + { + // Stage 0: Configure serial port (1% progress) + progress.Report(("serial_config", 1.0, null, null)); + effectiveTaskLogger.LogDebug("Configuring serial port {Device} with profile configuration", profiles.Serial.Device); + + bool serialConfigured = await serialPort.ApplyConfigurationAsync( + profiles.Serial.Device, + profiles.Serial.Configuration, + effectiveTaskLogger, + cancellationToken).ConfigureAwait(false); + + if (!serialConfigured) + { + throw new InvalidOperationException($"Failed to configure serial port {profiles.Serial.Device}"); + } + + effectiveTaskLogger.LogInformation("✓ Serial port {Device} configured successfully", profiles.Serial.Device); + + // Stage 1: Setup socat bridge (3% progress) + progress.Report(("socat_setup", 3.0, null, null)); + effectiveTaskLogger.LogDebug("Setting up socat bridge on port {Port}", profiles.Socat.Port); + + if (profiles.Socat.Configuration == null) + { + throw new InvalidOperationException("Socat configuration is required but was null."); + } + + profiles.Socat.Configuration.BaudRate = profiles.Serial.Baud; + + socatProcess = await socat.StartSocatAsync( + profiles.Socat.Configuration, + profiles.Serial.Device, + processLogger, + cancellationToken).ConfigureAwait(false); + + effectiveTaskLogger.LogInformation("✓ Socat bridge started on TCP port {Port} (PID: {ProcessId})", + profiles.Socat.Port, socatProcess.ProcessId); + + // Stage 2: Connect to power supply (5% progress) + progress.Report(("power_connect", 5.0, null, null)); + effectiveTaskLogger.LogDebug("Connecting to power supply at {Host}:{Port}", profiles.Power.Host, profiles.Power.Port); + + if (profiles.Power.Configuration == null) + { + throw new InvalidOperationException("Power supply configuration is required but was null."); + } + + bool connected = await power.ConnectAsync(profiles.Power.Configuration, effectiveTaskLogger, cancellationToken).ConfigureAwait(false); + if (!connected) + { + throw new InvalidOperationException("Failed to connect to power supply."); + } + isPowerConnected = true; + + effectiveTaskLogger.LogInformation("✓ Connected to power supply at {Host}:{Port}", profiles.Power.Host, profiles.Power.Port); + + // Stage 3: Initial Power OFF (6% progress) + progress.Report(("power_off_initial", 6.0, null, null)); + effectiveTaskLogger.LogInformation("--- Stage 3: Initial Power OFF ---"); + + bool powerOff = await power.TurnOffAsync(effectiveTaskLogger, cancellationToken).ConfigureAwait(false); + if (!powerOff) + { + throw new InvalidOperationException("Failed to turn PLC power OFF"); + } + + await WaitWithProgressAsync( + InitialPowerOffWaitMs, + progress, + 6.0, 10.0, + "power_off_wait", + cancellationToken).ConfigureAwait(false); + + effectiveTaskLogger.LogInformation("✓ PLC powered OFF and wait time completed"); + + // Stage 4: Power ON (10% progress) + progress.Report(("power_on", 10.0, null, null)); + effectiveTaskLogger.LogInformation("--- Stage 4: Power ON PLC ---"); + + bool powerOn = await power.TurnOnAsync(effectiveTaskLogger, cancellationToken).ConfigureAwait(false); + if (!powerOn) + { + throw new InvalidOperationException("Failed to turn PLC power ON"); + } + + effectiveTaskLogger.LogInformation("✓ PLC powered ON"); + + // Stage 5: Wait for stabilization (10% -> 11%) + await WaitWithProgressAsync( + profiles.PowerOnTimeMs, + progress, + 10.0, 11.0, + "power_on_stabilize", + cancellationToken).ConfigureAwait(false); + + // Stage 6: PLC Client & Connect (12% progress) + progress.Report(("plc_connect", 12.0, null, null)); + await using IPlcClient client = clientFactory(profiles); + + effectiveTaskLogger.LogDebug("Connecting PLC client to localhost:{Port}", profiles.Socat.Port); + await client.ConnectAsync(cancellationToken).ConfigureAwait(false); + + // Stage 7: Power Cycle (13% -> 15%) + progress.Report(("power_cycle", 13.0, null, null)); + effectiveTaskLogger.LogInformation("--- Stage 7: Power Cycle PLC ---"); + + await power.TurnOffAsync(effectiveTaskLogger, cancellationToken).ConfigureAwait(false); + + await WaitWithProgressAsync( + profiles.PowerOffDelayMs, + progress, + 13.0, 15.0, + "power_cycle_wait", + cancellationToken).ConfigureAwait(false); + + await power.TurnOnAsync(effectiveTaskLogger, cancellationToken).ConfigureAwait(false); + effectiveTaskLogger.LogInformation("✓ PLC power cycled successfully"); + + await client.HandshakeAsync(cancellationToken).ConfigureAwait(false); + + // Stage 8: Handshake Info (15% progress) + progress.Report(("handshake", 15.0, null, null)); + effectiveTaskLogger.LogInformation("--- Stage 8: Bootloader Handshake ---"); + + string version = await client.GetBootloaderVersionAsync(cancellationToken).ConfigureAwait(false); + effectiveTaskLogger.LogInformation("✓ Connected to bootloader version: {Version}", version); + + // Stage 9: Install Stager (16% progress) + progress.Report(("stager_install", 16.0, null, null)); + effectiveTaskLogger.LogInformation("--- Stage 9: Install Stager Payload ---"); + + byte[] stagerPayload = await payloads.GetStagerAsync(profiles.Payloads.BasePath, cancellationToken).ConfigureAwait(false); + + using (var stagerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + var progressTask = SimulateProgressAsync( + stagerPayload.LongLength, + profiles.Serial.Configuration.BaudRate, + progress, + 16.0, 18.0, + "stager_install", + isStagerInstall: true, + stagerCts.Token); + + try + { + await client.InstallStagerAsync(stagerPayload, cancellationToken).ConfigureAwait(false); + } + finally + { + stagerCts.Cancel(); + try + { await progressTask.ConfigureAwait(false); } + catch (OperationCanceledException) { } + } + } + effectiveTaskLogger.LogInformation("✓ Stager payload installed successfully ({Size} bytes)", stagerPayload.Length); + + // Stage 10: Install Dumper (18% progress) + progress.Report(("dumper_install", 18.0, null, null)); + effectiveTaskLogger.LogInformation("--- Stage 10: Install Memory Dumper Payload ---"); + + byte[] dumperPayload = await payloads.GetMemoryDumperAsync(profiles.Payloads.BasePath, cancellationToken).ConfigureAwait(false); + + using (var dumperCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + var progressTask = SimulateProgressAsync( + dumperPayload.LongLength, + profiles.Serial.Configuration.BaudRate, + progress, + 18.0, 20.0, + "dumper_install", + isStagerInstall: false, + dumperCts.Token); + + try + { + await client.InstallDumperAsync(dumperPayload, cancellationToken).ConfigureAwait(false); + } + finally + { + dumperCts.Cancel(); + try + { await progressTask.ConfigureAwait(false); } + catch (OperationCanceledException) { } + } + } + effectiveTaskLogger.LogInformation("✓ Dumper payload installed successfully"); + + // Stage 11: Memory Dump (20% - 95% progress) - 75% weight + effectiveTaskLogger.LogInformation("--- Stage 11: Memory Dump (Streaming) ---"); + + var dumpResult = await PerformDumpProcessStreamingAsync( + client, profiles, + progress, effectiveTaskLogger, + startPercent: 20.0, weight: 75.0, + taskId, + cancellationToken).ConfigureAwait(false); + + // Stage 12: Teardown (95% progress) + progress.Report(("teardown", 95.0, null, null)); + effectiveTaskLogger.LogInformation("--- Stage 12: Teardown ---"); + + // Stage 13: Complete + progress.Report(("complete", 100.0, null, null)); + effectiveTaskLogger.LogInformation("=== BOOTLOADER DUMP OPERATION COMPLETED ==="); + + return dumpResult; + } + catch (Exception ex) + { + processLogger?.LogError(ex, "Dump failed: {ErrorMessage}", ex.Message); + effectiveTaskLogger.LogError("DUMP OPERATION FAILED: {ErrorMessage}", ex.Message); + throw; + } + finally + { + if (socatProcess != null) + { + try + { + await socat.StopSocatAsync(socatProcess, cancellationToken).ConfigureAwait(false); + effectiveTaskLogger.LogDebug("✓ Socat process stopped"); + } + catch (Exception ex) { effectiveTaskLogger.LogWarning(ex, "Failed to stop socat"); } + } + + if (isPowerConnected) + { + try + { + await power.DisconnectAsync(cancellationToken).ConfigureAwait(false); + effectiveTaskLogger.LogDebug("✓ Disconnected from power supply"); + } + catch (Exception ex) { effectiveTaskLogger.LogWarning(ex, "Failed to disconnect power"); } + } + } + } + + /// + /// Simulates progress for payload transfer with ACCURATE protocol overhead calculation. + /// Accounts for: packet framing (length+checksum), chunking, and protocol-specific delays. + /// + /// Raw payload size in bytes + /// UART baud rate (bits per second) + /// Progress reporter + /// Starting progress percentage + /// Target progress percentage + /// Stage name for progress reporting + /// True for stager (uses IRAM write protocol), false for dumper (uses stager protocol) + /// Cancellation token + private async Task SimulateProgressAsync( + long payloadSize, + int baudRate, + IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, + double startPercent, + double targetPercent, + string stage, + bool isStagerInstall, + CancellationToken cancellationToken) + { + const int bitsPerByte = 10; + long totalWireBytes; + int totalDelayMs; + + if (isStagerInstall) + { + const int chunkSize = 16; + int numChunks = (int)Math.Ceiling((double)payloadSize / chunkSize); + totalWireBytes = 10 + (numChunks * 36) + 5 + 16; + + int totalPackets = (numChunks * 2) + 4; + int avgPacketSize = (int)(totalWireBytes / totalPackets); + int chunksPerPacket = (avgPacketSize + 1) / 2; + totalDelayMs = totalPackets * (10 + (chunksPerPacket * 10)); + } + else + { + const int maxPacketPayload = 64; + int numPackets = (int)Math.Ceiling((double)payloadSize / maxPacketPayload); + + int avgPayloadPerPacket = (int)((payloadSize + numPackets - 1) / numPackets); + totalWireBytes = numPackets * (1 + avgPayloadPerPacket + 1); + + int avgChunksPerPacket = (avgPayloadPerPacket + 2 + 1) / 2; + totalDelayMs = numPackets * (10 + (avgChunksPerPacket * 10)); + } + + double transferTimeMs = ((double)totalWireBytes * bitsPerByte * 1000.0) / (double)baudRate; + int totalTimeMs = (int)(transferTimeMs + totalDelayMs); + totalTimeMs = (int)(totalTimeMs * 1.1); + + if (totalTimeMs < 1000) + { + totalTimeMs = 1000; + } + + await WaitWithProgressAsync( + totalTimeMs, + progress, + startPercent, + targetPercent, + stage, + cancellationToken).ConfigureAwait(false); + } +} + + + +/// +/// Exception thrown when bootloader operations fail. +/// +public class BootloaderOperationException : S7ToolsException +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public BootloaderOperationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception. + public BootloaderOperationException(string message, Exception innerException) : base(message, innerException) + { + } } diff --git a/src/S7Tools/Services/Bootloader/EnhancedBootloaderService.cs b/src/S7Tools/Services/Bootloader/EnhancedBootloaderService.cs deleted file mode 100644 index cf60166b..00000000 --- a/src/S7Tools/Services/Bootloader/EnhancedBootloaderService.cs +++ /dev/null @@ -1,617 +0,0 @@ -using System.Linq; -using Microsoft.Extensions.Logging; -using S7Tools.Core.Constants; -using S7Tools.Core.Exceptions; -using S7Tools.Core.Models; -using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Models.Validation; -using S7Tools.Core.Services.Interfaces; -using S7Tools.Extensions; -using S7Tools.Resources; - -namespace S7Tools.Services.Bootloader; - -/// -/// Consolidated bootloader service orchestrating complete memory dump workflow with TaskExecution integration. -/// Provides retry mechanisms, comprehensive error handling, and detailed progress tracking. -/// -public sealed class EnhancedBootloaderService( - ILogger logger, - IPayloadProvider payloads, - ISocatService socat, - IPowerSupplyService power, - ISerialPortService serialPort, - Func clientFactory, - IResourceCoordinator resourceCoordinator) - : BaseBootloaderService(null), IEnhancedBootloaderService, IDisposable -{ - private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - private readonly IPayloadProvider _payloads = payloads ?? throw new ArgumentNullException(nameof(payloads)); - private readonly ISocatService _socat = socat ?? throw new ArgumentNullException(nameof(socat)); - private readonly IPowerSupplyService _power = power ?? throw new ArgumentNullException(nameof(power)); - private readonly ISerialPortService _serialPort = serialPort ?? throw new ArgumentNullException(nameof(serialPort)); - private readonly Func _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); - private readonly IResourceCoordinator _resourceCoordinator = resourceCoordinator ?? throw new ArgumentNullException(nameof(resourceCoordinator)); - private readonly SemaphoreSlim _operationSemaphore = new(1, 1); - private RetryConfiguration _retryConfiguration = RetryConfiguration.Default; - private bool _disposed; - - public async Task DumpAsync( - JobProfileSet profiles, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, - Microsoft.Extensions.Logging.ILogger? taskLogger = null, - Microsoft.Extensions.Logging.ILogger? processLogger = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(profiles); - ArgumentNullException.ThrowIfNull(progress); - - Microsoft.Extensions.Logging.ILogger effectiveTaskLogger = taskLogger ?? _logger; - - effectiveTaskLogger.LogInformation("Starting enhanced bootloader dump operation (delegating to base orchestration)"); - - return await PerformBootloaderOrchestrationAsync( - profiles, - progress, - effectiveTaskLogger, - processLogger, - _serialPort, - _socat, - _power, - _payloads, - _clientFactory, - cancellationToken).ConfigureAwait(false); - } - - /// - public RetryConfiguration RetryConfiguration => _retryConfiguration; - - /// - public void UpdateRetryConfiguration(RetryConfiguration configuration) - { - _retryConfiguration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - } - - /// - public async Task DumpWithTaskTrackingAsync( - TaskExecution taskExecution, - JobProfileSet profiles, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(taskExecution); - ArgumentNullException.ThrowIfNull(profiles); - - return await _operationSemaphore.ExecuteAsync(async () => - { - _logger.LogInformation("Starting enhanced bootloader dump operation for task {TaskId}", taskExecution.TaskId); - - // Update task to running state - taskExecution.UpdateState(TaskState.Running, "Initializing bootloader operation"); - - // Create a progress reporter that updates the TaskExecution - double lastLoggedPercent = -1.0; - var progressReporter = new Progress<(string stage, double percent, long? bytesRead, long? totalBytes)>(progress => - { - (string? stage, double percent, long? bytesRead, long? totalBytes) = progress; - // Progress is already in 0-100 range from BootloaderService - string operation = GetUserFriendlyOperationName(stage); - - var extraData = new Dictionary(); - if (bytesRead.HasValue && totalBytes.HasValue) - { - extraData["BytesRead"] = bytesRead.Value; - extraData["TotalBytes"] = totalBytes.Value; - } - - taskExecution.UpdateProgress(percent, operation, extraData); - - // Throttle logging to avoid spam (log every 0.1% change or if bytes are involved/important stages) - if (Math.Abs(percent - lastLoggedPercent) >= 0.1 || percent >= 100.0 || percent <= 0.0) - { - lastLoggedPercent = percent; - if (bytesRead.HasValue && totalBytes.HasValue) - { - _logger.LogDebug("Task {TaskId} progress: {Percentage:F1}% - {Operation} ({BytesRead}/{TotalBytes} bytes)", - taskExecution.TaskId, percent, operation, bytesRead, totalBytes); - } - else - { - _logger.LogDebug("Task {TaskId} progress: {Percentage:F1}% - {Operation}", - taskExecution.TaskId, percent, operation); - } - } - }); - - // Estimate operation time - TimeSpan? estimatedTime = await EstimateOperationTimeAsync(profiles, cancellationToken) - .ConfigureAwait(false); - if (estimatedTime.HasValue) - { - taskExecution.EstimatedTimeRemaining = estimatedTime.Value; - } - - try - { - // Get process logger from task execution if available - Microsoft.Extensions.Logging.ILogger? taskLogger = taskExecution.Logger?.MainLogger; - Microsoft.Extensions.Logging.ILogger? processLogger = taskExecution.Logger?.ProcessLogger; - - // Execute the memory dump with retry logic - // Execute the memory dump with retry logic - // Directly call Orchestration to get both data and file paths - var result = await ExecuteWithRetryAsync( - () => PerformBootloaderOrchestrationAsync( - profiles, - progressReporter, - taskLogger ?? _logger, - processLogger, - _serialPort, - _socat, - _power, - _payloads, - _clientFactory, - cancellationToken, - taskExecution.TaskId), - RetryableOperations.All, - taskExecution, - cancellationToken).ConfigureAwait(false); - - // No need to save manually, Orchestration handled it. - // Output paths are in available in result.SavedFiles - string outputFilePath = result.SavedFiles?.FirstOrDefault() ?? string.Empty; - - // Mark task as completed - long totalLength = result.SavedFiles?.Sum(x => (long)x.Length) ?? 0; - taskExecution.MarkAsCompleted(outputFilePath, totalLength); - - _logger.LogInformation("Enhanced bootloader dump completed successfully for task {TaskId}. " + - "Output saved to: {OutputPath}", taskExecution.TaskId, outputFilePath); - - return result; - } - catch (OperationCanceledException) - { - taskExecution.UpdateState(TaskState.Cancelled, "Operation was cancelled"); - _logger.LogWarning("Bootloader dump operation cancelled for task {TaskId}", taskExecution.TaskId); - throw; - } - catch (Exception ex) - { - string errorMessage = $"Bootloader operation failed: {ex.Message}"; - taskExecution.MarkAsFailed(errorMessage, ex.ToString()); - - _logger.LogError(ex, "Enhanced bootloader dump failed for task {TaskId}: {ErrorMessage}", - taskExecution.TaskId, ex.Message); - - throw new BootloaderOperationException(errorMessage, ex); - } - }, cancellationToken); - } - - /// - public async Task ValidateResourcesAsync( - JobProfileSet profiles, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(profiles); - - _logger.LogDebug("Validating resources for bootloader operation"); - - var validationErrors = new List(); - - try - { - // Validate resource availability through resource coordinator - IEnumerable resourceKeys = ExtractResourceKeys(profiles); - bool canAcquire = _resourceCoordinator.TryAcquire(resourceKeys); - - if (canAcquire) - { - // Release immediately since this is just a validation check - _resourceCoordinator.Release(resourceKeys); - } - else - { - validationErrors.Add("One or more required resources are not available or are locked by another task"); - } - - // Additional profile validation using the built-in validation - ValidationResult profileValidation = await ValidateProfileSetAsync(profiles, cancellationToken) - .ConfigureAwait(false); - - if (!profileValidation.IsValid) - { - validationErrors.AddRange(profileValidation.Errors.Select(e => e.Message)); - } - - ValidationResult result = validationErrors.Count == 0 - ? ValidationResult.Success() - : ValidationResult.Failure([.. validationErrors.Select(error => - new ValidationError("Resource", error))]); - - _logger.LogDebug("Resource validation completed. Valid: {IsValid}, Errors: {ErrorCount}", - result.IsValid, validationErrors.Count); - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Resource validation failed: {ErrorMessage}", ex.Message); - return ValidationResult.Failure("Resource", $"Resource validation failed: {ex.Message}"); - } - } - - /// - public async Task TestConnectionAsync( - JobProfileSet profiles, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(profiles); - - _logger.LogDebug("Testing bootloader connection"); - - try - { - // This would involve a lightweight connection test - // For now, we'll simulate it by checking if resources are available - ValidationResult validation = await ValidateResourcesAsync(profiles, cancellationToken) - .ConfigureAwait(false); - - _logger.LogDebug("Connection test completed. Success: {Success}", validation.IsValid); - return validation.IsValid; - } - catch (Exception ex) - { - _logger.LogError(ex, "Connection test failed: {ErrorMessage}", ex.Message); - return false; - } - } - - /// - public Task GetBootloaderInfoAsync( - JobProfileSet profiles, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(profiles); - - _logger.LogDebug("Retrieving bootloader information"); - - try - { - // For now, return simulated bootloader info - // In a real implementation, this would establish a connection and query the bootloader - var bootloaderInfo = new BootloaderInfo - { - Version = "1.0.0", - PlcModel = "S7-1200", - FirmwareVersion = "V4.4", - MaxTransferSize = 1024, - SupportsPauseResume = false, - Capabilities = BootloaderCapabilities.MemoryRead | BootloaderCapabilities.Checksums, - AvailableMemoryRegions = - [ - new() - { - Name = "Flash Memory", - StartAddress = profiles.Memory.Start, - Size = profiles.Memory.Length, - AccessFlags = MemoryAccessFlags.Read, - Description = "Main flash memory region" - } - ] - }; - - _logger.LogDebug("Retrieved bootloader info: Version={Version}, Model={Model}", - bootloaderInfo.Version, bootloaderInfo.PlcModel); - - return Task.FromResult(bootloaderInfo); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve bootloader information: {ErrorMessage}", ex.Message); - throw new BootloaderOperationException($"Failed to retrieve bootloader information: {ex.Message}", ex); - } - } - - /// - public Task EstimateOperationTimeAsync( - JobProfileSet profiles, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(profiles); - - try - { - // Simple estimation based on memory size - // Assume ~1KB/second transfer rate plus fixed overhead - const double TransferRateBytesPerSecond = 1024.0; - const double FixedOverheadSeconds = 30.0; // Setup, handshake, teardown - - double transferTimeSeconds = profiles.Memory.Length / TransferRateBytesPerSecond; - double totalTimeSeconds = transferTimeSeconds + FixedOverheadSeconds; - - var estimatedTime = TimeSpan.FromSeconds(totalTimeSeconds); - - _logger.LogDebug("Estimated operation time: {EstimatedTime} for {MemorySize} bytes", - estimatedTime, profiles.Memory.Length); - - return Task.FromResult(estimatedTime); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to estimate operation time: {ErrorMessage}", ex.Message); - return Task.FromResult(null); - } - } - - private async Task ExecuteWithRetryAsync( - Func> operation, - RetryableOperations retryableOperation, - TaskExecution taskExecution, - CancellationToken cancellationToken) - { - if (!_retryConfiguration.RetryableOperations.HasFlag(retryableOperation)) - { - return await operation().ConfigureAwait(false); - } - - int maxRetries = GetMaxRetriesForOperation(retryableOperation); - TimeSpan currentDelay = _retryConfiguration.InitialRetryDelay; - - for (int attempt = 0; attempt <= maxRetries; attempt++) - { - try - { - if (attempt > 0) - { - taskExecution.UpdateProgress( - taskExecution.ProgressPercentage, - $"Retrying operation (attempt {attempt + 1}/{maxRetries + 1})"); - - _logger.LogInformation("Retrying bootloader operation for task {TaskId}, attempt {Attempt}/{MaxAttempts}", - taskExecution.TaskId, attempt + 1, maxRetries + 1); - - await Task.Delay(currentDelay, cancellationToken).ConfigureAwait(false); - } - - T? result = await operation().ConfigureAwait(false); - - if (attempt > 0) - { - _logger.LogInformation("Bootloader operation succeeded for task {TaskId} on attempt {Attempt}", - taskExecution.TaskId, attempt + 1); - } - - return result; - } - catch (OperationCanceledException) - { - throw; // Don't retry cancellation - } - catch (Exception ex) when (attempt < maxRetries) - { - _logger.LogWarning(ex, "Bootloader operation failed for task {TaskId} on attempt {Attempt}, retrying: {ErrorMessage}", - taskExecution.TaskId, attempt + 1, ex.Message); - - // Calculate next delay with exponential backoff - if (_retryConfiguration.UseExponentialBackoff) - { - currentDelay = TimeSpan.FromMilliseconds( - Math.Min( - currentDelay.TotalMilliseconds * _retryConfiguration.BackoffMultiplier, - _retryConfiguration.MaxRetryDelay.TotalMilliseconds)); - } - } - } - - // If we get here, all retries have been exhausted - throw new BootloaderOperationException($"Bootloader operation failed after {maxRetries + 1} attempts"); - } - - private int GetMaxRetriesForOperation(RetryableOperations operation) - { - return operation switch - { - RetryableOperations.Connection => _retryConfiguration.MaxConnectionRetries, - RetryableOperations.Handshake => _retryConfiguration.MaxCommunicationRetries, - RetryableOperations.PayloadInstallation => _retryConfiguration.MaxCommunicationRetries, - RetryableOperations.MemoryRead => _retryConfiguration.MaxMemoryOperationRetries, - RetryableOperations.PowerControl => _retryConfiguration.MaxConnectionRetries, - RetryableOperations.Network => _retryConfiguration.MaxConnectionRetries, - _ => _retryConfiguration.MaxCommunicationRetries - }; - } - - private static string GetUserFriendlyOperationName(string stage) - { - return stage switch - { - "socat_setup" => "Setting up network bridge", - "power_off_initial" => "Initial Power OFF", - "power_cycle" => "Power cycling PLC", - "handshake" => "Establishing bootloader connection", - "stager_install" => "Installing bootloader stager", - "memory_dump" => "Dumping memory", - "teardown" => "Cleaning up resources", - "complete" => "Operation complete", - _ => stage.Replace("_", " ") - }; - } - - - - - private static ResourceKey[] ExtractResourceKeys(JobProfileSet profiles) - { - return - [ - new ResourceKey("serial", profiles.Serial.Device), - new ResourceKey("tcp", profiles.Socat.Port.ToString()), - new ResourceKey("modbus", $"{profiles.Power.Host}:{profiles.Power.Port}") - ]; - } - - /// - /// Releases the unmanaged resources used by the EnhancedBootloaderService and optionally releases the managed resources. - /// - /// True to release both managed and unmanaged resources; false to release only unmanaged resources. - private void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - _operationSemaphore?.Dispose(); - } - _disposed = true; - } - } - - - - - - /// - public async Task ValidateProfileSetAsync( - JobProfileSet profiles, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(profiles); - - var errors = new List(); - - // Validate serial port accessibility - if (!string.IsNullOrWhiteSpace(profiles.Serial.Device)) - { - bool isAccessible = OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() - ? File.Exists(profiles.Serial.Device) - : System.IO.Ports.SerialPort.GetPortNames().Contains(profiles.Serial.Device); - - if (!isAccessible) - { - errors.Add($"Serial port '{profiles.Serial.Device}' is not accessible"); - } - } - else - { - errors.Add("Serial port device is not specified"); - } - - // Validate TCP port availability - try - { - using var listener = new System.Net.Sockets.TcpListener( - System.Net.IPAddress.Loopback, - profiles.Socat.Port); - listener.Start(); - listener.Stop(); - } - catch (System.Net.Sockets.SocketException) - { - errors.Add($"TCP port {profiles.Socat.Port} is already in use"); - } - - // Validate modbus host reachability (with 5s timeout) - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - - using var tcpClient = new System.Net.Sockets.TcpClient(); - await tcpClient.ConnectAsync( - profiles.Power.Host, - profiles.Power.Port, - cts.Token).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - errors.Add($"Modbus host '{profiles.Power.Host}:{profiles.Power.Port}' is not reachable: {ex.Message}"); - } - - // Validate payloads exist - try - { - await _payloads.GetStagerAsync(profiles.Payloads.BasePath, cancellationToken).ConfigureAwait(false); - } - catch (FileNotFoundException) - { - errors.Add($"Stager payload not found at '{profiles.Payloads.BasePath}'"); - } - - try - { - await _payloads.GetMemoryDumperAsync(profiles.Payloads.BasePath, cancellationToken).ConfigureAwait(false); - } - catch (FileNotFoundException) - { - errors.Add($"Memory dumper payload not found at '{profiles.Payloads.BasePath}'"); - } - - // Validate memory region (check for overflow) - try - { - checked - { - // This will throw OverflowException if Start + Length > uint.MaxValue - _ = profiles.Memory.Start + profiles.Memory.Length; - } - } - catch (OverflowException) - { - errors.Add($"Memory region overflow: start=0x{profiles.Memory.Start:X8}, length=0x{profiles.Memory.Length:X8}"); - } - - return errors.Count == 0 - ? ValidationResult.Success() - : ValidationResult.Failure([.. errors.Select(e => new ValidationError("ProfileSet", e))]); - } - - /// - public TimeSpan EstimateDuration(MemoryRegionProfile memoryRegion) - { - ArgumentNullException.ThrowIfNull(memoryRegion); - - // Base overhead: 15 seconds - // Transfer rate: 256 bytes/sec (conservative estimate) - const double BaseOverheadSeconds = 15.0; - const double BytesPerSecond = 256.0; - - double transferTime = memoryRegion.Length / BytesPerSecond; - double totalSeconds = BaseOverheadSeconds + transferTime; - - // Clamp to 5-300s range per SC-001 - totalSeconds = Math.Clamp(totalSeconds, 5.0, 300.0); - - return TimeSpan.FromSeconds(totalSeconds); - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } -} - -/// -/// Exception thrown when bootloader operations fail. -/// -public class BootloaderOperationException : S7ToolsException -{ - /// - /// Initializes a new instance of the class. - /// - /// The error message. - public BootloaderOperationException(string message) : base(message) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The error message. - /// The inner exception. - public BootloaderOperationException(string message, Exception innerException) : base(message, innerException) - { - } -} diff --git a/src/S7Tools/Services/Bootloader/ModbusPowerSupplyService.cs b/src/S7Tools/Services/Bootloader/ModbusPowerSupplyService.cs index 8035b7d9..57e9e753 100644 --- a/src/S7Tools/Services/Bootloader/ModbusPowerSupplyService.cs +++ b/src/S7Tools/Services/Bootloader/ModbusPowerSupplyService.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Resources; namespace S7Tools.Services.Bootloader; diff --git a/src/S7Tools/Services/Core/ApplicationSettingsService.cs b/src/S7Tools/Services/Core/ApplicationSettingsService.cs new file mode 100644 index 00000000..05b2ced1 --- /dev/null +++ b/src/S7Tools/Services/Core/ApplicationSettingsService.cs @@ -0,0 +1,244 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Core.Models.Configuration.StrongSettings; +using S7Tools.Services.Interfaces; +using System.Collections.Generic; +using System.Text.Json; + +namespace S7Tools.Services +{ + /// + /// Represents the ApplicationSettingsService. + /// + public sealed class ApplicationSettingsService : IApplicationSettingsService + { + private readonly ILogger _logger; + private readonly IWritableOptions _options; + private readonly IConfigurationRoot? _configurationRoot; + private readonly IUIThreadService? _uiThreadService; + + public event EventHandler? SettingsChanged; + + /// + /// Initializes a new instance of the class. + /// + public ApplicationSettingsService( + ILogger logger, + IWritableOptions options, + IConfiguration? configuration = null, + IUIThreadService? uiThreadService = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _configurationRoot = configuration as IConfigurationRoot; + _uiThreadService = uiThreadService; + _logger.LogInformation("ApplicationSettingsService initialized as strongly-typed proxy"); + } + + /// + /// Gets or sets the Current. + /// + public AppSettings Current => _options.CurrentValue; + + /// + /// Executes the LoadSettingsAsync operation. + /// + public async Task LoadSettingsAsync() + { + if (_configurationRoot is null) + { + _logger.LogWarning("Cannot reload application settings because configuration root is not available."); + return; + } + + _logger.LogInformation("Reloading application settings from current configuration source."); + try + { + await Task.Run(() => _configurationRoot.Reload()).ConfigureAwait(false); + RaiseSettingsChanged(new SettingsChangedEventArgs { IsUserSetting = false }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reload application settings from configuration source."); + throw; + } + } + + /// + /// Executes the UpdateSettingsAsync operation. + /// + public async Task UpdateSettingsAsync(Action updateAction) + { + await _options.UpdateAsync(settings => + { + updateAction(settings); + return Task.CompletedTask; + }).ConfigureAwait(false); + RaiseSettingsChanged(new SettingsChangedEventArgs { IsUserSetting = true }); + } + + /// + /// Executes the ResetAllSettingsAsync operation. + /// + public async Task ResetAllSettingsAsync() + { + await _options.UpdateAsync(s => + { + var def = new AppSettings(); + s.Logging = def.Logging; + s.Ui = def.Ui; + s.Paths = def.Paths; + s.Profiles = def.Profiles; + s.PowerSupply = def.PowerSupply; + s.MemoryDump = def.MemoryDump; + s.MemoryRegion = def.MemoryRegion; + s.Export = def.Export; + s.Plc = def.Plc; + s.Jobs = def.Jobs; + s.Tasks = def.Tasks; + s.Serial = def.Serial; + s.Network = def.Network; + s.Socat = def.Socat; + return Task.CompletedTask; + }).ConfigureAwait(false); + RaiseSettingsChanged(new SettingsChangedEventArgs { IsUserSetting = false }); + } + + /// + /// Executes the RestoreDefaultsAsync operation. + /// + public Task RestoreDefaultsAsync() => ResetAllSettingsAsync(); + + /// + /// Executes the ExportSettingsToJson operation. + /// + public string ExportSettingsToJson() + { + try + { + var settings = Current; + var exportDict = new Dictionary + { + ["logging.logDirectory"] = settings.Logging.LogDirectory, + ["logging.exportDirectory"] = settings.Logging.ExportDirectory, + ["logging.level"] = settings.Logging.Level, + ["ui.autoScrollLogs"] = settings.Ui.AutoScrollLogs, + ["logging.enableFileLogging"] = settings.Logging.EnableFileLogging, + ["ui.showTimestampInLogs"] = settings.Ui.ShowTimestampInLogs, + ["ui.showCategoryInLogs"] = settings.Ui.ShowCategoryInLogs, + ["ui.showLogLevelInLogs"] = settings.Ui.ShowLogLevelInLogs + }; + + var optionsFormatter = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + string json = JsonSerializer.Serialize(exportDict, optionsFormatter); + _logger.LogInformation("Settings exported to JSON ({Length} characters)", json.Length); + return json; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export settings to JSON"); + return string.Empty; + } + } + + /// + /// Executes the ImportSettingsFromJsonAsync operation. + /// + public async Task ImportSettingsFromJsonAsync(string json) + { + try + { + if (string.IsNullOrEmpty(json)) + { + return false; + } + + var optionsFormatter = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + var importedSettings = JsonSerializer.Deserialize>(json, optionsFormatter); + if (importedSettings == null) + { + return false; + } + + await UpdateSettingsAsync(s => + { + if (importedSettings.TryGetValue("logging.logDirectory", out object? logDir) && logDir is JsonElement logDirElem && logDirElem.ValueKind == JsonValueKind.String) + { + s.Logging.LogDirectory = logDirElem.GetString() ?? s.Logging.LogDirectory; + } + + if (importedSettings.TryGetValue("logging.exportDirectory", out object? expDir) && expDir is JsonElement expDirElem && expDirElem.ValueKind == JsonValueKind.String) + { + s.Logging.ExportDirectory = expDirElem.GetString() ?? s.Logging.ExportDirectory; + } + + if (importedSettings.TryGetValue("logging.level", out object? level) && level is JsonElement levelElem && levelElem.ValueKind == JsonValueKind.String) + { + s.Logging.Level = levelElem.GetString() ?? s.Logging.Level; + } + + if (importedSettings.TryGetValue("ui.autoScrollLogs", out object? autoScroll) && autoScroll is JsonElement autoScrollElem && (autoScrollElem.ValueKind == JsonValueKind.True || autoScrollElem.ValueKind == JsonValueKind.False)) + { + s.Ui.AutoScrollLogs = autoScrollElem.GetBoolean(); + } + + if (importedSettings.TryGetValue("logging.enableFileLogging", out object? enableFileLog) && enableFileLog is JsonElement enableFileLogElem && (enableFileLogElem.ValueKind == JsonValueKind.True || enableFileLogElem.ValueKind == JsonValueKind.False)) + { + s.Logging.EnableFileLogging = enableFileLogElem.GetBoolean(); + } + + if (importedSettings.TryGetValue("ui.showTimestampInLogs", out object? showTimestamp) && showTimestamp is JsonElement showTimestampElem && (showTimestampElem.ValueKind == JsonValueKind.True || showTimestampElem.ValueKind == JsonValueKind.False)) + { + s.Ui.ShowTimestampInLogs = showTimestampElem.GetBoolean(); + } + + if (importedSettings.TryGetValue("ui.showCategoryInLogs", out object? showCat) && showCat is JsonElement showCatElem && (showCatElem.ValueKind == JsonValueKind.True || showCatElem.ValueKind == JsonValueKind.False)) + { + s.Ui.ShowCategoryInLogs = showCatElem.GetBoolean(); + } + + if (importedSettings.TryGetValue("ui.showLogLevelInLogs", out object? showLogLevel) && showLogLevel is JsonElement showLogLevelElem && (showLogLevelElem.ValueKind == JsonValueKind.True || showLogLevelElem.ValueKind == JsonValueKind.False)) + { + s.Ui.ShowLogLevelInLogs = showLogLevelElem.GetBoolean(); + } + }).ConfigureAwait(false); + + _logger.LogInformation("Settings imported successfully via service"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to import settings from JSON in service"); + return false; + } + } + + private void RaiseSettingsChanged(SettingsChangedEventArgs args) + { + var handler = SettingsChanged; + if (handler is null) return; + + if (_uiThreadService is not null) + { + _uiThreadService.PostToUIThread(() => handler(this, args)); + } + else + { + handler(this, args); + } + } + } +} diff --git a/src/S7Tools/Services/Core/BufferedCollectionUpdater.cs b/src/S7Tools/Services/Core/BufferedCollectionUpdater.cs new file mode 100644 index 00000000..425c7763 --- /dev/null +++ b/src/S7Tools/Services/Core/BufferedCollectionUpdater.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using S7Tools.Services.Interfaces; + +namespace S7Tools.Services.Core; + +/// +/// A helper class to buffer valid items and update a collection in batches on the UI thread. +/// +/// The type of items to buffer. +public sealed class BufferedCollectionUpdater : IDisposable +{ + private readonly Action> _updateAction; + private readonly IUIThreadService _uiThreadService; + private readonly ConcurrentQueue _queue = new(); + private readonly PeriodicTimer _timer; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _processTask; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The action to execute on the UI thread with the buffered items. + /// The interval at which to process the buffer. + /// The UI thread service. + public BufferedCollectionUpdater(Action> updateAction, TimeSpan interval, IUIThreadService uiThreadService) + { + _updateAction = updateAction ?? throw new ArgumentNullException(nameof(updateAction)); + _uiThreadService = uiThreadService ?? throw new ArgumentNullException(nameof(uiThreadService)); + _timer = new PeriodicTimer(interval); + _processTask = StartProcessingAsync(); + } + + /// + /// Enqueues an item for processing. + /// + /// The item to enqueue. + public void Enqueue(T item) + { + _queue.Enqueue(item); + } + + private async Task StartProcessingAsync() + { + try + { + while (await _timer.WaitForNextTickAsync(_cts.Token)) + { + ProcessQueue(); + } + } + catch (OperationCanceledException) + { + // Normal cancellation + } + catch + { + // Ignored to prevent crash + } + } + + private void ProcessQueue() + { + if (_queue.IsEmpty) + { + return; + } + + var items = new List(); + while (_queue.TryDequeue(out var item)) + { + items.Add(item); + } + + if (items.Any()) + { + _uiThreadService.InvokeOnUIThread(() => _updateAction(items)); + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _cts.Cancel(); + _timer.Dispose(); + _cts.Dispose(); + _disposed = true; + } +} diff --git a/src/S7Tools/Services/ClipboardService.cs b/src/S7Tools/Services/Core/ClipboardService.cs similarity index 100% rename from src/S7Tools/Services/ClipboardService.cs rename to src/S7Tools/Services/Core/ClipboardService.cs diff --git a/src/S7Tools/Services/CommandDispatcher.cs b/src/S7Tools/Services/Core/CommandDispatcher.cs similarity index 100% rename from src/S7Tools/Services/CommandDispatcher.cs rename to src/S7Tools/Services/Core/CommandDispatcher.cs diff --git a/src/S7Tools/Services/GreetingService.cs b/src/S7Tools/Services/Core/GreetingService.cs similarity index 96% rename from src/S7Tools/Services/GreetingService.cs rename to src/S7Tools/Services/Core/GreetingService.cs index f7845d2c..bd4bcc92 100644 --- a/src/S7Tools/Services/GreetingService.cs +++ b/src/S7Tools/Services/Core/GreetingService.cs @@ -1,4 +1,4 @@ -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Resources.Strings; using S7Tools.Services.Interfaces; diff --git a/src/S7Tools/Services/LocalizationService.cs b/src/S7Tools/Services/Core/LocalizationService.cs similarity index 100% rename from src/S7Tools/Services/LocalizationService.cs rename to src/S7Tools/Services/Core/LocalizationService.cs diff --git a/src/S7Tools/Services/PathDiagnosticsService.cs b/src/S7Tools/Services/Core/PathDiagnosticsService.cs similarity index 100% rename from src/S7Tools/Services/PathDiagnosticsService.cs rename to src/S7Tools/Services/Core/PathDiagnosticsService.cs diff --git a/src/S7Tools/Services/PathManagementDemoService.cs b/src/S7Tools/Services/Core/PathManagementDemoService.cs similarity index 83% rename from src/S7Tools/Services/PathManagementDemoService.cs rename to src/S7Tools/Services/Core/PathManagementDemoService.cs index 493d4d88..557d20e6 100644 --- a/src/S7Tools/Services/PathManagementDemoService.cs +++ b/src/S7Tools/Services/Core/PathManagementDemoService.cs @@ -103,28 +103,11 @@ private async Task DemoSettingsHierarchyAsync() { _logger.LogInformation("--- Demo 2: Settings Hierarchy ---"); - // Load settings - ApplicationSettings settings = await _settingsService.LoadSettingsAsync().ConfigureAwait(false); - _logger.LogInformation("Settings loaded - Defaults: {DefaultCount}, User: {UserCount}, Effective: {EffectiveCount}", - settings.DefaultSettings.Count, settings.UserSettings.Count, settings.EffectiveSettings.Count); - // Show some default settings _logger.LogInformation("Default Settings Examples:"); - _logger.LogInformation(" logging.level: {Value}", _settingsService.GetSetting("logging.level")); - _logger.LogInformation(" ui.theme: {Value}", _settingsService.GetSetting("ui.theme")); - _logger.LogInformation(" paths.autoCreateDirectories: {Value}", _settingsService.GetSetting("paths.autoCreateDirectories")); - - // Demonstrate user override - await _settingsService.SetSettingAsync("demo.customSetting", "Custom Value").ConfigureAwait(false); - _logger.LogInformation("Set user setting: demo.customSetting = 'Custom Value'"); - - string customValue = _settingsService.GetSetting("demo.customSetting"); - _logger.LogInformation("Retrieved user setting: demo.customSetting = '{Value}'", customValue); - - // Reset to default - await _settingsService.ResetSettingAsync("demo.customSetting").ConfigureAwait(false); - string resetValue = _settingsService.GetSetting("demo.customSetting", "DEFAULT"); - _logger.LogInformation("After reset: demo.customSetting = '{Value}'", resetValue); + _logger.LogInformation(" logging.level: {Value}", _settingsService.Current.Logging.Level); + _logger.LogInformation(" ui.theme: {Value}", _settingsService.Current.Ui.Theme); + _logger.LogInformation(" paths.autoCreateDirectories: {Value}", _settingsService.Current.Paths.AutoCreateDirectories); } /// diff --git a/src/S7Tools/Services/PathService.cs b/src/S7Tools/Services/Core/PathService.cs similarity index 97% rename from src/S7Tools/Services/PathService.cs rename to src/S7Tools/Services/Core/PathService.cs index 26dbb106..7219a3c4 100644 --- a/src/S7Tools/Services/PathService.cs +++ b/src/S7Tools/Services/Core/PathService.cs @@ -93,17 +93,9 @@ public PathService(IServiceProvider serviceProvider) public string MemoryRegionProfilesPath => ResolvePath(Path.Combine( ResourcePaths.ResourcesFolder, ResourcePaths.ProfilesFolder, - ResourcePaths.MemoryRegionsFolder, + ResourcePaths.MemoryRegionFolder, ResourcePaths.MemoryRegionProfilesFile)); - /// - /// Gets the path to Resources/Profiles/MemoryRegions directory - /// - public string MemoryRegionsDirectory => ResolvePath(Path.Combine( - ResourcePaths.ResourcesFolder, - ResourcePaths.ProfilesFolder, - ResourcePaths.MemoryRegionsFolder)); - /// /// Gets the path to Resources/Profiles/PayloadSets/PayloadSetProfiles.json /// @@ -190,7 +182,7 @@ public async Task InitializeAsync() Path.Combine(ProfilesDirectory, ResourcePaths.SerialFolder), Path.Combine(ProfilesDirectory, ResourcePaths.SocatFolder), Path.Combine(ProfilesDirectory, ResourcePaths.PowerSupplyFolder), - MemoryRegionsDirectory, + Path.Combine(ProfilesDirectory, ResourcePaths.MemoryRegionFolder), LogsDirectory, MainLogsDirectory, ExportedLogsDirectory, @@ -348,7 +340,7 @@ public async Task ValidatePathsAsync() { "Socat Profiles Path", Path.GetDirectoryName(SocatProfilesPath)! }, { "PowerSupply Profiles Path", Path.GetDirectoryName(PowerSupplyProfilesPath)! }, { "Memory Region Profiles Path", Path.GetDirectoryName(MemoryRegionProfilesPath)! }, - { "Memory Regions Directory", MemoryRegionsDirectory }, + { "Memory Region Directory", Path.Combine(ProfilesDirectory, ResourcePaths.MemoryRegionFolder) }, { "Logs Directory", LogsDirectory }, { "Main Logs Directory", MainLogsDirectory }, { "Exported Logs Directory", ExportedLogsDirectory }, diff --git a/src/S7Tools/Services/ResourceManagerService.cs b/src/S7Tools/Services/Core/ResourceManagerService.cs similarity index 95% rename from src/S7Tools/Services/ResourceManagerService.cs rename to src/S7Tools/Services/Core/ResourceManagerService.cs index d0ef6b1c..1b3c47e1 100644 --- a/src/S7Tools/Services/ResourceManagerService.cs +++ b/src/S7Tools/Services/Core/ResourceManagerService.cs @@ -95,7 +95,7 @@ public async Task ValidateResourcesAsync() try { // Validate required directories - foreach (Core.Models.Configuration.DirectoryInfo dirInfo in _resourceManifest.RequiredDirectories) + foreach (global::S7Tools.Core.Models.Configuration.DirectoryInfo dirInfo in _resourceManifest.RequiredDirectories) { if (string.IsNullOrEmpty(dirInfo.AbsolutePath)) { @@ -119,7 +119,7 @@ public async Task ValidateResourcesAsync() } // Validate required files - foreach (Core.Models.Configuration.FileInfo fileInfo in _resourceManifest.RequiredFiles) + foreach (global::S7Tools.Core.Models.Configuration.FileInfo fileInfo in _resourceManifest.RequiredFiles) { if (string.IsNullOrEmpty(fileInfo.AbsolutePath)) { @@ -172,7 +172,7 @@ public async Task CreateMissingResourcesAsync() try { // Create missing directories - foreach (Core.Models.Configuration.DirectoryInfo dirInfo in _resourceManifest.RequiredDirectories) + foreach (global::S7Tools.Core.Models.Configuration.DirectoryInfo dirInfo in _resourceManifest.RequiredDirectories) { if (string.IsNullOrEmpty(dirInfo.AbsolutePath)) { @@ -205,7 +205,7 @@ public async Task CreateMissingResourcesAsync() } // Create missing files - foreach (Core.Models.Configuration.FileInfo fileInfo in _resourceManifest.RequiredFiles) + foreach (global::S7Tools.Core.Models.Configuration.FileInfo fileInfo in _resourceManifest.RequiredFiles) { if (string.IsNullOrEmpty(fileInfo.AbsolutePath)) { @@ -412,7 +412,7 @@ public async Task EnsureResourceExistsAsync(ResourceInfo resourceInfo) private void ResolveManifestPaths() { // Resolve directory paths - foreach (Core.Models.Configuration.DirectoryInfo dirInfo in _resourceManifest.RequiredDirectories) + foreach (global::S7Tools.Core.Models.Configuration.DirectoryInfo dirInfo in _resourceManifest.RequiredDirectories) { if (string.IsNullOrEmpty(dirInfo.AbsolutePath)) { @@ -421,7 +421,7 @@ private void ResolveManifestPaths() } // Resolve file paths - foreach (Core.Models.Configuration.FileInfo fileInfo in _resourceManifest.RequiredFiles) + foreach (global::S7Tools.Core.Models.Configuration.FileInfo fileInfo in _resourceManifest.RequiredFiles) { if (string.IsNullOrEmpty(fileInfo.AbsolutePath)) { @@ -435,7 +435,7 @@ private void ResolveManifestPaths() /// private async Task CreateRequiredDirectoriesAsync(ResourceInitializationResult result) { - foreach (Core.Models.Configuration.DirectoryInfo dirInfo in _resourceManifest.RequiredDirectories) + foreach (global::S7Tools.Core.Models.Configuration.DirectoryInfo dirInfo in _resourceManifest.RequiredDirectories) { if (!Directory.Exists(dirInfo.AbsolutePath) && dirInfo.CreateIfMissing) { @@ -463,7 +463,7 @@ private async Task CreateRequiredDirectoriesAsync(ResourceInitializationResult r /// private async Task CreateRequiredFilesAsync(ResourceInitializationResult result) { - foreach (Core.Models.Configuration.FileInfo fileInfo in _resourceManifest.RequiredFiles) + foreach (global::S7Tools.Core.Models.Configuration.FileInfo fileInfo in _resourceManifest.RequiredFiles) { if (!File.Exists(fileInfo.AbsolutePath)) { diff --git a/src/S7Tools/Services/ValidationService.cs b/src/S7Tools/Services/Core/ValidationService.cs similarity index 100% rename from src/S7Tools/Services/ValidationService.cs rename to src/S7Tools/Services/Core/ValidationService.cs diff --git a/src/S7Tools/Services/WritableOptions.cs b/src/S7Tools/Services/Core/WritableOptions.cs similarity index 53% rename from src/S7Tools/Services/WritableOptions.cs rename to src/S7Tools/Services/Core/WritableOptions.cs index f2e37ba2..c6895175 100644 --- a/src/S7Tools/Services/WritableOptions.cs +++ b/src/S7Tools/Services/Core/WritableOptions.cs @@ -1,17 +1,22 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Threading; using Microsoft.Extensions.Options; using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services { - public class WritableOptions : IWritableOptions where T : class, new() + /// + /// Represents the WritableOptions. + /// + public sealed class WritableOptions : IWritableOptions, IDisposable where T : class, new() { private readonly string _basePath; private readonly IOptionsMonitor _options; private readonly string _section; private readonly string _file; - private readonly object _lock = new object(); + private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1); + private bool _disposed; private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { @@ -19,6 +24,9 @@ namespace S7Tools.Services PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + /// + /// Initializes a new instance of the class. + /// public WritableOptions( string basePath, IOptionsMonitor options, @@ -31,15 +39,28 @@ public WritableOptions( _file = file; } + /// + /// Gets or sets the CurrentValue. + /// public T CurrentValue => _options.CurrentValue; + /// + /// Executes the Get operation. + /// public T Get(string? name) => _options.Get(name); + /// + /// Executes the OnChange operation. + /// public IDisposable? OnChange(Action listener) => _options.OnChange(listener); + /// + /// Executes the Update operation. + /// public void Update(Action applyChanges) { - lock (_lock) + _writeLock.Wait(); + try { var physicalPath = Path.IsPathRooted(_file) ? _file : Path.Combine(_basePath, _file); @@ -94,55 +115,83 @@ public void Update(Action applyChanges) File.WriteAllText(tempPath, jObject.ToJsonString(JsonOptions)); File.Move(tempPath, physicalPath, overwrite: true); } + finally + { + _writeLock.Release(); + } } + /// + /// Executes the UpdateAsync operation. + /// public async Task UpdateAsync(Func applyChanges) { - var physicalPath = Path.IsPathRooted(_file) ? _file : Path.Combine(_basePath, _file); + await _writeLock.WaitAsync().ConfigureAwait(false); + try + { + var physicalPath = Path.IsPathRooted(_file) ? _file : Path.Combine(_basePath, _file); - JsonNode? rootNode = null; + JsonNode? rootNode = null; - if (File.Exists(physicalPath)) - { - try + if (File.Exists(physicalPath)) { - var jsonContent = await File.ReadAllTextAsync(physicalPath).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(jsonContent)) + try + { + var jsonContent = await File.ReadAllTextAsync(physicalPath).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(jsonContent)) + { + rootNode = JsonNode.Parse(jsonContent); + } + } + catch { - rootNode = JsonNode.Parse(jsonContent); + rootNode = null; } } - catch + + if (rootNode == null) { - rootNode = null; + rootNode = new JsonObject(); } - } - if (rootNode == null) - { - rootNode = new JsonObject(); - } + if (rootNode is not JsonObject jObject) + { + jObject = new JsonObject(); + } - if (rootNode is not JsonObject jObject) - { - jObject = new JsonObject(); - } + var sectionObject = CurrentValue; + await applyChanges(sectionObject).ConfigureAwait(false); - var sectionObject = CurrentValue; - await applyChanges(sectionObject).ConfigureAwait(false); + var sectionNode = JsonSerializer.SerializeToNode(sectionObject, JsonOptions); + jObject[_section] = sectionNode; - var sectionNode = JsonSerializer.SerializeToNode(sectionObject, JsonOptions); - jObject[_section] = sectionNode; + var dir = Path.GetDirectoryName(physicalPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } - var dir = Path.GetDirectoryName(physicalPath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + var tempPath = physicalPath + ".tmp"; + await File.WriteAllTextAsync(tempPath, jObject.ToJsonString(JsonOptions)).ConfigureAwait(false); + File.Move(tempPath, physicalPath, overwrite: true); + } + finally { - Directory.CreateDirectory(dir); + _writeLock.Release(); } + } - var tempPath = physicalPath + ".tmp"; - await File.WriteAllTextAsync(tempPath, jObject.ToJsonString(JsonOptions)).ConfigureAwait(false); - File.Move(tempPath, physicalPath, overwrite: true); + /// + /// Executes the Dispose operation. + /// + public void Dispose() + { + if (!_disposed) + { + _writeLock.Dispose(); + _disposed = true; + GC.SuppressFinalize(this); + } } } } diff --git a/src/S7Tools/Services/AvaloniaFileDialogService.cs b/src/S7Tools/Services/Dialogs/AvaloniaFileDialogService.cs similarity index 100% rename from src/S7Tools/Services/AvaloniaFileDialogService.cs rename to src/S7Tools/Services/Dialogs/AvaloniaFileDialogService.cs diff --git a/src/S7Tools/Services/DesignTimeDialogService.cs b/src/S7Tools/Services/Dialogs/DesignTimeDialogService.cs similarity index 61% rename from src/S7Tools/Services/DesignTimeDialogService.cs rename to src/S7Tools/Services/Dialogs/DesignTimeDialogService.cs index 2e107740..b4c682df 100644 --- a/src/S7Tools/Services/DesignTimeDialogService.cs +++ b/src/S7Tools/Services/Dialogs/DesignTimeDialogService.cs @@ -1,9 +1,10 @@ +using System.Reactive; using System.Threading.Tasks; -using S7Tools.Models; -using S7Tools.Services.Interfaces; using ReactiveUI; -using System.Reactive; using S7Tools.Core.Models.Jobs; +using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; +using S7Tools.Services.Interfaces; namespace S7Tools.Services; @@ -12,14 +13,38 @@ namespace S7Tools.Services; /// public class DesignTimeDialogService : IDialogService { + /// + /// Gets or sets the ShowConfirmation. + /// public Interaction ShowConfirmation { get; } = new(); + /// + /// Gets or sets the ShowError. + /// public Interaction ShowError { get; } = new(); + /// + /// Gets or sets the ShowInput. + /// public Interaction ShowInput { get; } = new(); + /// + /// Gets or sets the ShowJobSelection. + /// public Interaction ShowJobSelection { get; } = new(); + /// + /// Executes the ShowConfirmationAsync operation. + /// public Task ShowConfirmationAsync(string title, string message) => Task.FromResult(false); + /// + /// Executes the ShowErrorAsync operation. + /// public Task ShowErrorAsync(string title, string message) => Task.CompletedTask; + /// + /// Executes the ShowInputAsync operation. + /// public Task ShowInputAsync(string title, string message, string? defaultValue = null, string? placeholder = null) => Task.FromResult(InputResult.Cancelled()); + /// + /// Executes the ShowJobSelectionAsync operation. + /// public Task ShowJobSelectionAsync() => Task.FromResult(null); } diff --git a/src/S7Tools/Services/DialogService.cs b/src/S7Tools/Services/Dialogs/DialogService.cs similarity index 98% rename from src/S7Tools/Services/DialogService.cs rename to src/S7Tools/Services/Dialogs/DialogService.cs index 72cc88b1..990b80c4 100644 --- a/src/S7Tools/Services/DialogService.cs +++ b/src/S7Tools/Services/Dialogs/DialogService.cs @@ -4,6 +4,7 @@ using ReactiveUI; using S7Tools.Core.Models.Jobs; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; using S7Tools.Services.Interfaces; namespace S7Tools.Services; diff --git a/src/S7Tools/Services/Dialogs/UnifiedProfileDialogService.cs b/src/S7Tools/Services/Dialogs/UnifiedProfileDialogService.cs new file mode 100644 index 00000000..affd46de --- /dev/null +++ b/src/S7Tools/Services/Dialogs/UnifiedProfileDialogService.cs @@ -0,0 +1,534 @@ +using S7Tools.ViewModels.Base; +using System; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using Microsoft.Extensions.Logging; +using ReactiveUI; +using S7Tools.Core.Models; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; +using S7Tools.Services.Interfaces; +using S7Tools.ViewModels.Profiles; +using S7Tools.ViewModels; +using CoreProfileEditRequest = S7Tools.Core.Interfaces.Services.ProfileEditRequest; + +namespace S7Tools.Services; + +/// +/// Unified service implementation for displaying profile editing dialogs across all profile types. +/// +public class UnifiedProfileDialogService : IUnifiedProfileDialogService +{ + private static bool _handlerRegistered; + private static readonly object _lockObject = new(); + + private readonly ISerialPortProfileService _serialPortProfileService; + private readonly ISocatProfileService _socatProfileService; + private readonly IPowerSupplyProfileService _powerSupplyProfileService; + private readonly ISerialPortService _serialPortService; + private readonly ISocatService _socatService; + private readonly IClipboardService _clipboardService; + private readonly IDialogService _dialogService; + private readonly ILogger _logger; + + private static readonly Interaction _staticInteraction = new(); + + /// + /// Gets or sets the ShowProfileEditDialog. + /// + public Interaction ShowProfileEditDialog => _staticInteraction; + + /// + /// Initializes a new instance of the class. + /// + public UnifiedProfileDialogService( + ISerialPortProfileService serialPortProfileService, + ISocatProfileService socatProfileService, + IPowerSupplyProfileService powerSupplyProfileService, + ISerialPortService serialPortService, + ISocatService socatService, + IClipboardService clipboardService, + IDialogService dialogService, + ILogger logger) + { + _serialPortProfileService = serialPortProfileService ?? throw new ArgumentNullException(nameof(serialPortProfileService)); + _socatProfileService = socatProfileService ?? throw new ArgumentNullException(nameof(socatProfileService)); + _powerSupplyProfileService = powerSupplyProfileService ?? throw new ArgumentNullException(nameof(powerSupplyProfileService)); + _serialPortService = serialPortService ?? throw new ArgumentNullException(nameof(serialPortService)); + _socatService = socatService ?? throw new ArgumentNullException(nameof(socatService)); + _clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService)); + _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + RegisterInteractionHandler(); + } + + private void RegisterInteractionHandler() + { + lock (_lockObject) + { + if (_handlerRegistered) + { + return; + } + + _staticInteraction.RegisterHandler(async interaction => + { + try + { + var dialog = new Views.Dialogs.ProfileEditDialog(); + dialog.SetupDialog(interaction.Input); + + Window? mainWindow = Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop + ? desktop.MainWindow + : null; + + if (mainWindow != null) + { + await dialog.ShowDialog(mainWindow); + interaction.SetOutput(dialog.Result); + } + else + { + interaction.SetOutput(ProfileEditResult.Cancelled()); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception in UnifiedProfileDialogService interaction handler"); + interaction.SetOutput(ProfileEditResult.Cancelled()); + } + }); + + _handlerRegistered = true; + } + } + + private async Task ShowEditDialogAsync(string title, ViewModelBase profileViewModel, ProfileType profileType) + { + var request = new global::S7Tools.ViewModels.Dialogs.Models.ProfileEditRequest(title, profileViewModel, profileType); + return await ShowProfileEditDialog.Handle(request).FirstAsync(); + } + + #region Serial Port Profile Operations + + /// + /// Executes the ShowSerialCreateDialogAsync operation. + /// + public async Task> ShowSerialCreateDialogAsync(ProfileCreateRequest request) + { + try + { + _logger.LogDebug("Showing create dialog for serial port profile with default name: {DefaultName}", request.DefaultName); + + var profileViewModel = new SerialPortProfileViewModel( + _serialPortProfileService, + _serialPortService, + _clipboardService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) + { + ProfileName = request.DefaultName, + HasChanges = true + }; + + ProfileEditResult result = await ShowEditDialogAsync("Create Serial Port Profile", profileViewModel, ProfileType.Serial).ConfigureAwait(false); + + if (result.IsSuccess && result.ProfileViewModel is SerialPortProfileViewModel viewModel) + { + SerialPortProfile profile = viewModel.CreateProfile(); + return ProfileDialogResult.Success(profile); + } + + return ProfileDialogResult.Cancelled(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + /// + /// Executes the ShowSerialEditDialogAsync operation. + /// + public async Task> ShowSerialEditDialogAsync(CoreProfileEditRequest request) + { + try + { + SerialPortProfile? profile = await _serialPortProfileService.GetByIdAsync(request.ProfileId); + if (profile == null) return ProfileDialogResult.Failure("Profile not found"); + + var profileViewModel = new SerialPortProfileViewModel( + _serialPortProfileService, + _serialPortService, + _clipboardService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + profileViewModel.LoadProfile(profile); + + ProfileEditResult result = await ShowEditDialogAsync("Edit Serial Port Profile", profileViewModel, ProfileType.Serial).ConfigureAwait(false); + + if (result.IsSuccess && result.ProfileViewModel is SerialPortProfileViewModel viewModel) + { + SerialPortProfile savedProfile = viewModel.CreateProfile(); + return ProfileDialogResult.Success(savedProfile); + } + + return ProfileDialogResult.Cancelled(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error editing profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + /// + /// Executes the ShowSerialDuplicateDialogAsync operation. + /// + public async Task> ShowSerialDuplicateDialogAsync(ProfileDuplicateRequest request) + { + try + { + SerialPortProfile? sourceProfile = await _serialPortProfileService.GetByIdAsync(request.SourceProfileId); + if (sourceProfile == null) return ProfileDialogResult.Failure("Source profile not found"); + + InputResult inputResult = await _dialogService.ShowInputAsync( + "Duplicate Serial Port Profile", + "Enter a name for the duplicated profile:", + $"{sourceProfile.Name}_Copy", + "Profile name"); + + if (inputResult.IsCancelled || string.IsNullOrWhiteSpace(inputResult.Value)) + return ProfileDialogResult.Cancelled(); + + string newName = inputResult.Value.Trim(); + if (!await _serialPortProfileService.IsNameUniqueAsync(newName)) + return ProfileDialogResult.Failure("Profile name already exists"); + + return ProfileDialogResult.Success(newName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error duplicating profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + #endregion + + #region Socat Profile Operations + + /// + /// Executes the ShowSocatCreateDialogAsync operation. + /// + public async Task> ShowSocatCreateDialogAsync(ProfileCreateRequest request) + { + try + { + var profileViewModel = new SocatProfileViewModel( + _socatProfileService, + _socatService, + _clipboardService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) + { + ProfileName = request.DefaultName, + HasChanges = true + }; + + ProfileEditResult result = await ShowEditDialogAsync("Create Socat Profile", profileViewModel, ProfileType.Socat).ConfigureAwait(false); + + if (result.IsSuccess && result.ProfileViewModel is SocatProfileViewModel viewModel) + { + SocatProfile profile = viewModel.CreateProfile(); + return ProfileDialogResult.Success(profile); + } + + return ProfileDialogResult.Cancelled(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + /// + /// Executes the ShowSocatEditDialogAsync operation. + /// + public async Task> ShowSocatEditDialogAsync(CoreProfileEditRequest request) + { + try + { + SocatProfile? profile = await _socatProfileService.GetByIdAsync(request.ProfileId); + if (profile == null) return ProfileDialogResult.Failure("Profile not found"); + + var profileViewModel = new SocatProfileViewModel( + _socatProfileService, + _socatService, + _clipboardService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + profileViewModel.LoadProfile(profile); + + ProfileEditResult result = await ShowEditDialogAsync("Edit Socat Profile", profileViewModel, ProfileType.Socat).ConfigureAwait(false); + + if (result.IsSuccess && result.ProfileViewModel is SocatProfileViewModel viewModel) + { + SocatProfile savedProfile = viewModel.CreateProfile(); + return ProfileDialogResult.Success(savedProfile); + } + + return ProfileDialogResult.Cancelled(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error editing profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + /// + /// Executes the ShowSocatDuplicateDialogAsync operation. + /// + public async Task> ShowSocatDuplicateDialogAsync(ProfileDuplicateRequest request) + { + try + { + SocatProfile? sourceProfile = await _socatProfileService.GetByIdAsync(request.SourceProfileId); + if (sourceProfile == null) return ProfileDialogResult.Failure("Source profile not found"); + + InputResult inputResult = await _dialogService.ShowInputAsync( + "Duplicate Socat Profile", + "Enter a name for the duplicated profile:", + $"{sourceProfile.Name}_Copy", + "Profile name"); + + if (inputResult.IsCancelled || string.IsNullOrWhiteSpace(inputResult.Value)) + return ProfileDialogResult.Cancelled(); + + string newName = inputResult.Value.Trim(); + if (!await _socatProfileService.IsNameUniqueAsync(newName)) + return ProfileDialogResult.Failure("Profile name already exists"); + + return ProfileDialogResult.Success(newName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error duplicating profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + #endregion + + #region Power Supply Profile Operations + + /// + /// Executes the ShowPowerSupplyCreateDialogAsync operation. + /// + public async Task> ShowPowerSupplyCreateDialogAsync(ProfileCreateRequest request) + { + try + { + var profileViewModel = new PowerSupplyProfileViewModel( + _powerSupplyProfileService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) + { + ProfileName = request.DefaultName, + HasChanges = true + }; + + ProfileEditResult result = await ShowEditDialogAsync("Create Power Supply Profile", profileViewModel, ProfileType.PowerSupply).ConfigureAwait(false); + + if (result.IsSuccess && result.ProfileViewModel is PowerSupplyProfileViewModel viewModel) + { + PowerSupplyProfile profile = viewModel.CreateProfile(); + return ProfileDialogResult.Success(profile); + } + + return ProfileDialogResult.Cancelled(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + /// + /// Executes the ShowPowerSupplyEditDialogAsync operation. + /// + public async Task> ShowPowerSupplyEditDialogAsync(CoreProfileEditRequest request) + { + try + { + PowerSupplyProfile? profile = await _powerSupplyProfileService.GetByIdAsync(request.ProfileId); + if (profile == null) return ProfileDialogResult.Failure("Profile not found"); + + var profileViewModel = new PowerSupplyProfileViewModel( + _powerSupplyProfileService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + profileViewModel.LoadProfile(profile); + + ProfileEditResult result = await ShowEditDialogAsync("Edit Power Supply Profile", profileViewModel, ProfileType.PowerSupply).ConfigureAwait(false); + + if (result.IsSuccess && result.ProfileViewModel is PowerSupplyProfileViewModel viewModel) + { + PowerSupplyProfile savedProfile = viewModel.CreateProfile(); + return ProfileDialogResult.Success(savedProfile); + } + + return ProfileDialogResult.Cancelled(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error editing profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + /// + /// Executes the ShowPowerSupplyDuplicateDialogAsync operation. + /// + public async Task> ShowPowerSupplyDuplicateDialogAsync(ProfileDuplicateRequest request) + { + try + { + PowerSupplyProfile? sourceProfile = await _powerSupplyProfileService.GetByIdAsync(request.SourceProfileId); + if (sourceProfile == null) return ProfileDialogResult.Failure("Source profile not found"); + + InputResult inputResult = await _dialogService.ShowInputAsync( + "Duplicate Power Supply Profile", + "Enter a name for the duplicated profile:", + $"{sourceProfile.Name}_Copy", + "Profile name"); + + if (inputResult.IsCancelled || string.IsNullOrWhiteSpace(inputResult.Value)) + return ProfileDialogResult.Cancelled(); + + string newName = inputResult.Value.Trim(); + if (!await _powerSupplyProfileService.IsNameUniqueAsync(newName)) + return ProfileDialogResult.Failure("Profile name already exists"); + + return ProfileDialogResult.Success(newName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error duplicating profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + #endregion + + #region Job Profile Operations + + /// + /// Executes the ShowJobCreateDialogAsync operation. + /// + public async Task> ShowJobCreateDialogAsync(ProfileCreateRequest request) + { + await Task.CompletedTask; + return ProfileDialogResult.Cancelled(); + } + + /// + /// Executes the ShowJobEditDialogAsync operation. + /// + public async Task> ShowJobEditDialogAsync(CoreProfileEditRequest request) + { + await Task.CompletedTask; + return ProfileDialogResult.Cancelled(); + } + + /// + /// Executes the ShowJobDuplicateDialogAsync operation. + /// + public async Task> ShowJobDuplicateDialogAsync(ProfileDuplicateRequest request) + { + try + { + ProfileDialogResult result = await ShowNameInputDialogAsync( + "Duplicate Job", + "Enter a name for the duplicated job:", + request.SuggestedName).ConfigureAwait(false); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error duplicating profile"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + #endregion + + #region Common Dialog Operations + + /// + /// Executes the ShowDeleteConfirmationDialogAsync operation. + /// + public async Task ShowDeleteConfirmationDialogAsync(string profileName, string profileType) + { + try + { + string title = $"Delete {profileType} Profile"; + string message = $"Are you sure you want to delete the profile '{profileName}'?\n\nThis action cannot be undone."; + + return await _dialogService.ShowConfirmationAsync(title, message).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error showing delete confirmation"); + return false; + } + } + + /// + /// Executes the ShowNameInputDialogAsync operation. + /// + public async Task> ShowNameInputDialogAsync( + string title, + string prompt, + string defaultValue = "", + Func>? validator = null) + { + try + { + global::S7Tools.ViewModels.Dialogs.Models.InputResult result = await _dialogService.ShowInputAsync(title, prompt, defaultValue).ConfigureAwait(false); + + if (!result.IsCancelled && !string.IsNullOrEmpty(result.Value)) + { + if (validator != null) + { + ProfileValidationResult validationResult = await validator(result.Value).ConfigureAwait(false); + if (!validationResult.IsValid) + { + return ProfileDialogResult.Failure(validationResult.ErrorMessage); + } + } + + return ProfileDialogResult.Success(result.Value); + } + + if (result.IsCancelled) + { + return ProfileDialogResult.Cancelled(); + } + + return ProfileDialogResult.Failure("Name input failed"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error showing input dialog"); + return ProfileDialogResult.Failure(ex.Message); + } + } + + #endregion +} \ No newline at end of file diff --git a/src/S7Tools/Services/DesignTimeViewModelFactory.cs b/src/S7Tools/Services/Factories/DesignTimeViewModelFactory.cs similarity index 94% rename from src/S7Tools/Services/DesignTimeViewModelFactory.cs rename to src/S7Tools/Services/Factories/DesignTimeViewModelFactory.cs index 09ed4303..4387e88d 100644 --- a/src/S7Tools/Services/DesignTimeViewModelFactory.cs +++ b/src/S7Tools/Services/Factories/DesignTimeViewModelFactory.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using S7Tools.Services.Interfaces; using S7Tools.ViewModels; @@ -20,4 +21,4 @@ public ViewModelBase Create(Type viewModelType) { return (ViewModelBase)Activator.CreateInstance(viewModelType)!; } -} +} \ No newline at end of file diff --git a/src/S7Tools/Services/EnhancedViewModelFactory.cs b/src/S7Tools/Services/Factories/EnhancedViewModelFactory.cs similarity index 99% rename from src/S7Tools/Services/EnhancedViewModelFactory.cs rename to src/S7Tools/Services/Factories/EnhancedViewModelFactory.cs index e9b89a5e..a8616937 100644 --- a/src/S7Tools/Services/EnhancedViewModelFactory.cs +++ b/src/S7Tools/Services/Factories/EnhancedViewModelFactory.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; @@ -225,4 +226,4 @@ protected override void RegisterFactories() // Add more custom factory registrations as needed Logger.LogDebug("Registered {Count} custom ViewModel factories", Factories.Count); } -} +} \ No newline at end of file diff --git a/src/S7Tools/Services/TaskLogDataStoreFactory.cs b/src/S7Tools/Services/Factories/TaskLogDataStoreFactory.cs similarity index 91% rename from src/S7Tools/Services/TaskLogDataStoreFactory.cs rename to src/S7Tools/Services/Factories/TaskLogDataStoreFactory.cs index fc19061b..1e5c1692 100644 --- a/src/S7Tools/Services/TaskLogDataStoreFactory.cs +++ b/src/S7Tools/Services/Factories/TaskLogDataStoreFactory.cs @@ -1,11 +1,14 @@ using Microsoft.Extensions.Options; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Infrastructure.Logging.Core.Configuration; using S7Tools.Infrastructure.Logging.Core.Storage; using S7Tools.Services.Interfaces; namespace S7Tools.Services; +/// +/// Represents the TaskLogDataStoreFactory. +/// public class TaskLogDataStoreFactory(IOptions options) : ITaskLogDataStoreFactory { private readonly IOptions _options = options ?? throw new ArgumentNullException(nameof(options)); diff --git a/src/S7Tools/Services/Hex/BinarySearchService.cs b/src/S7Tools/Services/Hex/BinarySearchService.cs index 3f704161..06d1dc83 100644 --- a/src/S7Tools/Services/Hex/BinarySearchService.cs +++ b/src/S7Tools/Services/Hex/BinarySearchService.cs @@ -7,17 +7,26 @@ namespace S7Tools.Services.Hex { + /// + /// Represents the IBinarySearchService. + /// public interface IBinarySearchService { Task> FindAllAsync(IBinaryDocument doc, byte[] pattern, CancellationToken ct = default); Task FindNextAsync(IBinaryDocument doc, byte[] pattern, long startOffset, CancellationToken ct = default); } + /// + /// Represents the BinarySearchService. + /// public class BinarySearchService : IBinarySearchService { // 64KB buffer size for reading private const int BufferSize = 64 * 1024; + /// + /// Executes the FindAllAsync operation. + /// public async Task> FindAllAsync(IBinaryDocument doc, byte[] pattern, CancellationToken ct = default) { var results = new List(); @@ -110,11 +119,14 @@ await Task.Run(() => // Move forward, but back up by overlap to catch boundary cases currentOffset += (readSize - overlap); } - }, ct); + }, ct).ConfigureAwait(false); return results; } + /// + /// Executes the FindNextAsync operation. + /// public async Task FindNextAsync(IBinaryDocument doc, byte[] pattern, long startOffset, CancellationToken ct = default) { if (doc == null || pattern == null || pattern.Length == 0) @@ -160,7 +172,7 @@ public async Task FindNextAsync(IBinaryDocument doc, byte[] pattern, long currentOffset += (readSize - overlap); } return -1; - }, ct); + }, ct).ConfigureAwait(false); } private bool IsMatch(ReadOnlySpan buffer, int offset, byte[] pattern) diff --git a/src/S7Tools/Services/Interfaces/IDialogService.cs b/src/S7Tools/Services/Interfaces/IDialogService.cs index 0fb74d3c..caa9fbb8 100644 --- a/src/S7Tools/Services/Interfaces/IDialogService.cs +++ b/src/S7Tools/Services/Interfaces/IDialogService.cs @@ -3,6 +3,7 @@ using ReactiveUI; using S7Tools.Core.Models.Jobs; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; namespace S7Tools.Services.Interfaces; diff --git a/src/S7Tools/Services/IProfileDetailsService.cs b/src/S7Tools/Services/Interfaces/IProfileDetailsService.cs similarity index 98% rename from src/S7Tools/Services/IProfileDetailsService.cs rename to src/S7Tools/Services/Interfaces/IProfileDetailsService.cs index 4dadac7c..86078ca9 100644 --- a/src/S7Tools/Services/IProfileDetailsService.cs +++ b/src/S7Tools/Services/Interfaces/IProfileDetailsService.cs @@ -1,5 +1,5 @@ using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.ViewModels.Profiles; namespace S7Tools.Services; diff --git a/src/S7Tools/Services/Interfaces/IProfileEditDialogService.cs b/src/S7Tools/Services/Interfaces/IProfileEditDialogService.cs deleted file mode 100644 index 17eff8bb..00000000 --- a/src/S7Tools/Services/Interfaces/IProfileEditDialogService.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Reactive; -using System.Threading.Tasks; -using ReactiveUI; -using S7Tools.Models; -using S7Tools.ViewModels; -using S7Tools.ViewModels.Profiles; - -namespace S7Tools.Services.Interfaces; - -/// -/// Service for displaying profile editing dialogs. -/// -public interface IProfileEditDialogService -{ - /// - /// Gets the interaction for showing profile editing dialogs. - /// - Interaction ShowProfileEditDialog { get; } - - // Legacy methods - maintained for backward compatibility - - /// - /// Shows a profile editing dialog for a serial port profile. - /// - /// The dialog title. - /// The SerialPortProfileViewModel to edit. - /// A task that represents the asynchronous operation. The task result contains the edit result. - Task ShowSerialProfileEditAsync(string title, SerialPortProfileViewModel profileViewModel); - - /// - /// Shows a profile editing dialog for a socat profile. - /// - /// The dialog title. - /// The SocatProfileViewModel to edit. - /// A task that represents the asynchronous operation. The task result contains the edit result. - Task ShowSocatProfileEditAsync(string title, SocatProfileViewModel profileViewModel); - - /// - /// Shows a profile editing dialog for a power supply profile. - /// - /// The dialog title. - /// The PowerSupplyProfileViewModel to edit. - /// A task that represents the asynchronous operation. The task result contains the edit result. - Task ShowPowerSupplyProfileEditAsync(string title, PowerSupplyProfileViewModel profileViewModel); - - // Enhanced methods for Phase 6 - Unified Dialog System - - /// - /// Shows a profile creation dialog for a new serial port profile with default values. - /// - /// The default name for the new profile. - /// A task that represents the asynchronous operation. The task result contains the edit result. - Task CreateSerialProfileAsync(string defaultName = "SerialDefault"); - - /// - /// Shows a profile creation dialog for a new socat profile with default values. - /// - /// The default name for the new profile. - /// A task that represents the asynchronous operation. The task result contains the edit result. - Task CreateSocatProfileAsync(string defaultName = "SocatDefault"); - - /// - /// Shows a profile creation dialog for a new power supply profile with default values. - /// - /// The default name for the new profile. - /// A task that represents the asynchronous operation. The task result contains the edit result. - Task CreatePowerSupplyProfileAsync(string defaultName = "PowerSupplyDefault"); - - /// - /// Shows a dialog to edit an existing serial port profile. - /// - /// The ID of the profile to edit. - /// A task that represents the asynchronous operation. The task result contains the edit result. - Task EditSerialProfileAsync(int profileId); - - /// - /// Shows a dialog to edit an existing socat profile. - /// - /// The ID of the profile to edit. - /// A task that represents the asynchronous operation. The task result contains the edit result. - Task EditSocatProfileAsync(int profileId); - - /// - /// Shows a dialog to edit an existing power supply profile. - /// - /// The ID of the profile to edit. - /// A task that represents the asynchronous operation. The task result contains the edit result. - Task EditPowerSupplyProfileAsync(int profileId); - - /// - /// Shows a name input dialog for duplicating a serial port profile. - /// - /// The ID of the source profile to duplicate. - /// A task that represents the asynchronous operation. The task result contains the new profile name. - Task DuplicateSerialProfileAsync(int sourceProfileId); - - /// - /// Shows a name input dialog for duplicating a socat profile. - /// - /// The ID of the source profile to duplicate. - /// A task that represents the asynchronous operation. The task result contains the new profile name. - Task DuplicateSocatProfileAsync(int sourceProfileId); - - /// - /// Shows a name input dialog for duplicating a power supply profile. - /// - /// The ID of the source profile to duplicate. - /// A task that represents the asynchronous operation. The task result contains the new profile name. - Task DuplicatePowerSupplyProfileAsync(int sourceProfileId); -} - -/// -/// Result of a profile duplication operation. -/// -public class ProfileDuplicateResult -{ - /// - /// Gets a value indicating whether the duplication was successful. - /// - public bool IsSuccess { get; init; } - - /// - /// Gets the new profile name if successful. - /// - public string? NewName { get; init; } - - /// - /// Gets the error message if the operation failed. - /// - public string? ErrorMessage { get; init; } - - /// - /// Gets a value indicating whether the operation was cancelled. - /// - public bool IsCancelled => !IsSuccess && string.IsNullOrEmpty(ErrorMessage); - - /// - /// Creates a successful duplication result. - /// - /// The new profile name. - /// A successful duplication result. - public static ProfileDuplicateResult Success(string newName) => new() { IsSuccess = true, NewName = newName }; - - /// - /// Creates a cancelled duplication result. - /// - /// A cancelled duplication result. - public static ProfileDuplicateResult Cancelled() => new() { IsSuccess = false }; - - /// - /// Creates a failed duplication result. - /// - /// The error message. - /// A failed duplication result. - public static ProfileDuplicateResult Failed(string errorMessage) => new() { IsSuccess = false, ErrorMessage = errorMessage }; -} diff --git a/src/S7Tools/Services/Interfaces/ITaskLogDataStoreFactory.cs b/src/S7Tools/Services/Interfaces/ITaskLogDataStoreFactory.cs index 6d571d99..21c5b5ea 100644 --- a/src/S7Tools/Services/Interfaces/ITaskLogDataStoreFactory.cs +++ b/src/S7Tools/Services/Interfaces/ITaskLogDataStoreFactory.cs @@ -1,7 +1,10 @@ -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services.Interfaces; +/// +/// Represents the ITaskLogDataStoreFactory. +/// public interface ITaskLogDataStoreFactory { (ITaskLogDataStore MainLog, ITaskLogDataStore ProcessLog, ITaskLogDataStore ProtocolLog) CreateLogDataStores(); diff --git a/src/S7Tools/Services/Interfaces/IViewModelFactory.cs b/src/S7Tools/Services/Interfaces/IViewModelFactory.cs index a804c7d8..9092ccb2 100644 --- a/src/S7Tools/Services/Interfaces/IViewModelFactory.cs +++ b/src/S7Tools/Services/Interfaces/IViewModelFactory.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using S7Tools.ViewModels; @@ -21,4 +22,4 @@ public interface IViewModelFactory /// The ViewModel type to create. /// The created ViewModel instance. ViewModelBase Create(Type viewModelType); -} +} \ No newline at end of file diff --git a/src/S7Tools/Services/Jobs/JobManager.cs b/src/S7Tools/Services/Jobs/JobManager.cs index c92f824c..593dd5c4 100644 --- a/src/S7Tools/Services/Jobs/JobManager.cs +++ b/src/S7Tools/Services/Jobs/JobManager.cs @@ -9,7 +9,7 @@ using S7Tools.Core.Exceptions; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Validation; using S7Tools.Extensions; using S7Tools.Services; diff --git a/src/S7Tools/Services/Jobs/JobProfileSetFactory.cs b/src/S7Tools/Services/Jobs/JobProfileSetFactory.cs index 6907ccdb..1d9a0f71 100644 --- a/src/S7Tools/Services/Jobs/JobProfileSetFactory.cs +++ b/src/S7Tools/Services/Jobs/JobProfileSetFactory.cs @@ -5,7 +5,7 @@ using S7Tools.Core.Exceptions; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services.Jobs; diff --git a/src/S7Tools/Services/CentralizedTaskLogService.cs b/src/S7Tools/Services/Logging/CentralizedTaskLogService.cs similarity index 62% rename from src/S7Tools/Services/CentralizedTaskLogService.cs rename to src/S7Tools/Services/Logging/CentralizedTaskLogService.cs index ae6a83f0..8f51de73 100644 --- a/src/S7Tools/Services/CentralizedTaskLogService.cs +++ b/src/S7Tools/Services/Logging/CentralizedTaskLogService.cs @@ -1,16 +1,25 @@ using System; using System.Collections.Concurrent; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Infrastructure.Logging.Core.Storage; using S7Tools.Services.Interfaces; namespace S7Tools.Services; +/// +/// Centralizes the management of task log data stores, providing per-task log store access. +/// +/// +/// Uses a to provide thread-safe +/// creation and retrieval of log data stores for main, process, and protocol log categories. +/// +/// The factory used to create task log data stores. public class CentralizedTaskLogService(ITaskLogDataStoreFactory taskLogDataStoreFactory) : ICentralizedTaskLogService { private readonly ConcurrentDictionary _taskLogs = new(); private readonly ITaskLogDataStoreFactory _taskLogDataStoreFactory = taskLogDataStoreFactory ?? throw new ArgumentNullException(nameof(taskLogDataStoreFactory)); + /// public (ITaskLogDataStore MainLog, ITaskLogDataStore ProcessLog, ITaskLogDataStore ProtocolLog) GetOrCreateStoresForTask(Guid taskId) { return _taskLogs.GetOrAdd(taskId, id => _taskLogDataStoreFactory.CreateLogDataStores()); diff --git a/src/S7Tools/Services/LogExportService.cs b/src/S7Tools/Services/Logging/LogExportService.cs similarity index 99% rename from src/S7Tools/Services/LogExportService.cs rename to src/S7Tools/Services/Logging/LogExportService.cs index 7b4ad79e..8cdad24d 100644 --- a/src/S7Tools/Services/LogExportService.cs +++ b/src/S7Tools/Services/Logging/LogExportService.cs @@ -9,7 +9,6 @@ using S7Tools.Core.Constants; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Infrastructure.Logging.Core.Models; using S7Tools.Services.Interfaces; diff --git a/src/S7Tools/Services/StructuredLogger.cs b/src/S7Tools/Services/Logging/StructuredLogger.cs similarity index 99% rename from src/S7Tools/Services/StructuredLogger.cs rename to src/S7Tools/Services/Logging/StructuredLogger.cs index 9d5ed0f2..cf6a3eb0 100644 --- a/src/S7Tools/Services/StructuredLogger.cs +++ b/src/S7Tools/Services/Logging/StructuredLogger.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using Microsoft.Extensions.Logging; -using S7Tools.Core.Logging; +using S7Tools.Core.Interfaces.Logging; namespace S7Tools.Services; @@ -68,7 +68,6 @@ public void LogStructured(LogLevel logLevel, Exception exception, string message ["ExceptionMessage"] = exception.Message }; - using IDisposable? scope = BeginScope(enrichedProperties); _baseLogger.Log(logLevel, exception, "{Message}", message); } diff --git a/src/S7Tools/Services/Logging/TaskLoggerFactory.cs b/src/S7Tools/Services/Logging/TaskLoggerFactory.cs index fb87c80f..74742863 100644 --- a/src/S7Tools/Services/Logging/TaskLoggerFactory.cs +++ b/src/S7Tools/Services/Logging/TaskLoggerFactory.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; using S7Tools.Extensions; using S7Tools.Infrastructure.Logging.Core.Configuration; using S7Tools.Infrastructure.Logging.Core.Models; @@ -18,13 +17,13 @@ namespace S7Tools.Services.Logging; public class TaskLoggerFactory( IPathService pathService, ILogger logger, - S7Tools.Core.Services.Interfaces.ICentralizedTaskLogService centralizedTaskLogService, + S7Tools.Core.Interfaces.Services.ICentralizedTaskLogService centralizedTaskLogService, IApplicationSettingsService applicationSettingsService, ITimeProvider timeProvider) : ITaskLoggerFactory, IDisposable { private readonly IPathService _pathService = pathService ?? throw new ArgumentNullException(nameof(pathService)); private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - private readonly S7Tools.Core.Services.Interfaces.ICentralizedTaskLogService _centralizedTaskLogService = centralizedTaskLogService ?? throw new ArgumentNullException(nameof(centralizedTaskLogService)); + private readonly S7Tools.Core.Interfaces.Services.ICentralizedTaskLogService _centralizedTaskLogService = centralizedTaskLogService ?? throw new ArgumentNullException(nameof(centralizedTaskLogService)); private readonly IApplicationSettingsService _applicationSettingsService = applicationSettingsService ?? throw new ArgumentNullException(nameof(applicationSettingsService)); private readonly ITimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); private readonly ConcurrentDictionary _activeLoggers = new(); @@ -70,7 +69,7 @@ public async Task CreateTaskLoggerAsync( LogDataStore? processLogDataStore = captureProcessOutput ? (LogDataStore?)processDataStore : null; // Create logger providers with DataStores - string logLevelString = _applicationSettingsService.GetSetting("logging.level", "Information"); + string logLevelString = _applicationSettingsService.Current.Logging.Level; if (!Enum.TryParse(logLevelString, true, out LogLevel configuredLogLevel)) { @@ -272,12 +271,18 @@ private static string SanitizeFileName(string fileName) return string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)); } + /// + /// Executes the Dispose operation. + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + /// + /// Executes the Dispose operation. + /// protected virtual void Dispose(bool disposing) { if (_disposed) @@ -312,12 +317,33 @@ protected virtual void Dispose(bool disposing) private class TaskLoggerContext { + /// + /// Gets or sets the TaskLogger. + /// public TaskLogger TaskLogger { get; set; } = null!; + /// + /// Gets or sets the MainDataStore. + /// public LogDataStore? MainDataStore { get; set; } + /// + /// Gets or sets the ProcessDataStore. + /// public LogDataStore? ProcessDataStore { get; set; } + /// + /// Gets or sets the MainProvider. + /// public DataStoreLoggerProvider? MainProvider { get; set; } + /// + /// Gets or sets the ProcessProvider. + /// public DataStoreLoggerProvider? ProcessProvider { get; set; } + /// + /// Gets or sets the FileLoggers. + /// public List FileLoggers { get; set; } = []; + /// + /// Gets or sets the LogDirectory. + /// public string LogDirectory { get; set; } = string.Empty; } } @@ -334,6 +360,9 @@ internal class CompositeLogger(ILogger[] loggers) : ILogger return new CompositeScope([.. _loggers.Select(l => l.BeginScope(state))]); } + /// + /// Executes the IsEnabled operation. + /// public bool IsEnabled(LogLevel logLevel) { return _loggers.Any(l => l.IsEnabled(logLevel)); @@ -359,6 +388,9 @@ private class CompositeScope(IDisposable?[] scopes) : IDisposable { private readonly IDisposable?[] _scopes = scopes; + /// + /// Executes the Dispose operation. + /// public void Dispose() { foreach (IDisposable? scope in _scopes) @@ -382,6 +414,9 @@ internal class AsyncFileLogger : ILogger, IAsyncDisposable private readonly LogLevel _minLevel; private bool _disposed; + /// + /// Initializes a new instance of the class. + /// public AsyncFileLogger(string filePath, LogLevel minLevel) { _minLevel = minLevel; @@ -402,6 +437,9 @@ public AsyncFileLogger(string filePath, LogLevel minLevel) return null; // Simple implementation without scope support } + /// + /// Executes the IsEnabled operation. + /// public bool IsEnabled(LogLevel logLevel) { return logLevel >= _minLevel; @@ -488,6 +526,9 @@ private async Task ProcessLogQueueAsync(string filePath) } } + /// + /// Executes the DisposeAsync operation. + /// public async ValueTask DisposeAsync() { if (_disposed) @@ -530,9 +571,21 @@ public async ValueTask DisposeAsync() /// private record LogEntry { + /// + /// Gets or sets the Timestamp. + /// public DateTime Timestamp { get; init; } + /// + /// Gets or sets the Level. + /// public LogLevel Level { get; init; } + /// + /// Gets or sets the Message. + /// public string Message { get; init; } = string.Empty; + /// + /// Gets or sets the Exception. + /// public Exception? Exception { get; init; } } } diff --git a/src/S7Tools/Services/Plc/Adapters/PlcClientAdapter.cs b/src/S7Tools/Services/Plc/Adapters/PlcClientAdapter.cs new file mode 100644 index 00000000..3c7216c7 --- /dev/null +++ b/src/S7Tools/Services/Plc/Adapters/PlcClientAdapter.cs @@ -0,0 +1,241 @@ +using System; +using S7Tools.Services.Core; +using S7Tools.Services.Plc; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using global::S7Tools.Core.Interfaces.Services; +using S7Tools.Services.Adapters.Plc; + +namespace S7Tools.Services.Plc.Adapters +{ + /// + /// Real implementation of PlcClientAdapter. + /// Orchestrates the entire bootloader flow (Handshake -> Install Stager -> Dump Memory) + /// utilizing the refactored, SOLID-compliant PLC components. + /// + public sealed class PlcClientAdapter : IPlcClient + { + private readonly ILogger _logger; + + private readonly IPlcProtocol _protocol; + + // Components + private readonly PlcProtocolHandler _protocolHandler; + private readonly PlcMemoryManager _memoryManager; + private readonly PlcStagerManager _stagerManager; + private readonly MemoryDumpOrchestrator _orchestrator; + + // Socat connection info (set via Configure) + private string _socatHost = "127.0.0.1"; + private int _socatPort = 3333; // Default fallback + + /// + /// Initializes a new instance of the class. + /// + public PlcClientAdapter( + IPlcProtocol protocol, + ILogger logger, + ILoggerFactory loggerFactory, + MemoryDumpOrchestrator orchestrator) + { + _protocol = protocol ?? throw new ArgumentNullException(nameof(protocol)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator)); + ArgumentNullException.ThrowIfNull(loggerFactory); + + // Initialize SOLID components + _protocolHandler = new PlcProtocolHandler(protocol); + _memoryManager = new PlcMemoryManager(_protocolHandler, _orchestrator, loggerFactory.CreateLogger()); + _stagerManager = new PlcStagerManager(_protocolHandler, _memoryManager); + } + + + + /// + /// Executes the Configure operation. + /// + public void Configure(string host, int port) + { + // Store socat connection info for streaming dumps + _socatHost = host; + _socatPort = port; + + _protocol.Configure(host, port); + } + + /// + /// Executes the DisposeAsync operation. + /// + public async ValueTask DisposeAsync() + { + if (_protocol is IAsyncDisposable d) + { + await d.DisposeAsync(); + } + // Dispose the orchestrator if it implements IDisposable + if (_orchestrator is IDisposable disposableOrchestrator) + { + disposableOrchestrator.Dispose(); + } + } + + #region Handshake + + /// + /// Executes the ConnectAsync operation. + /// + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Connecting to PLC via Protocol..."); + await _protocol.ConnectAsync(cancellationToken); + } + + /// + /// Executes the HandshakeAsync operation. + /// + public async Task HandshakeAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Starting handshake..."); + await _protocolHandler.PerformHandshakeAsync(cancellationToken); + _logger.LogInformation("Handshake successful!"); + } + + /// + /// Executes the GetBootloaderVersionAsync operation. + /// + public async Task GetBootloaderVersionAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Getting bootloader version..."); + byte[] versionBytes = await _protocolHandler.GetVersionAsync(cancellationToken); + + if (versionBytes.Length == 0) + { + return "Unknown"; + } + + // Log raw bytes for debugging + _logger.LogDebug("Version Response Hex: {Hex}", BitConverter.ToString(versionBytes)); + + // Specific parsing for binary version format: + // Look for 0x56 ('V') followed by 3 bytes (Major, Minor, Patch) + // Example: ... 56 04 02 01 ... -> V4.02.1 + int vIndex = Array.IndexOf(versionBytes, (byte)0x56); + if (vIndex >= 0 && vIndex + 3 < versionBytes.Length) + { + byte major = versionBytes[vIndex + 1]; + byte minor = versionBytes[vIndex + 2]; + byte patch = versionBytes[vIndex + 3]; + + // Format: V{Major}.{Minor:00}.{Patch} + string formatted = $"V{major}.{minor:D2}.{patch}"; + _logger.LogDebug("Decoded binary version: {Version}", formatted); + return formatted; + } + + // Fallback: Try decoding as UTF8 string if binary pattern not found + try + { + // Filter to printable ASCII/UTF8 chars + string raw = System.Text.Encoding.UTF8.GetString(versionBytes); + // Keep only valid version characters (alphanumeric, dot, space, hyphen) + // This strips out any control characters or nulls that might be confusing the output + char[] validChars = [.. raw.Where(c => + char.IsLetterOrDigit(c) || + c == '.' || + c == '-' || + c == '_' || + c == ' ')]; + + string cleaned = new string(validChars).Trim(); + return string.IsNullOrEmpty(cleaned) ? "Unknown" : cleaned; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to decode version string"); + return "DecodeError"; + } + } + + #endregion + + #region Stager Installation + + /// + /// Executes the InstallStagerAsync operation. + /// + public async Task InstallStagerAsync(byte[] stager, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Installing Stager..."); + await _stagerManager.InstallStagerAsync(stager, cancellationToken); + _logger.LogInformation("Stager installed at 0x{Addr:X}", PlcConstants.IRAM_STAGER_START); + } + + #endregion + + #region Memory Dump + + /// + /// Executes the InstallDumperAsync operation. + /// + public async Task InstallDumperAsync(byte[] dumperPayload, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Installing Dumper Payload via Stager..."); + // Install to DUMPER_PAYLOAD_LOCATION and Hook 2 + await _stagerManager.InstallAddHookViaStagerAsync( + PlcConstants.DUMPER_PAYLOAD_LOCATION, + dumperPayload, + PlcConstants.DEFAULT_SECOND_ADD_HOOK_IND, + cancellationToken); + _logger.LogInformation("Dumper payload installed at 0x{Addr:X} (Hook {Hook})", + PlcConstants.DUMPER_PAYLOAD_LOCATION, PlcConstants.DEFAULT_SECOND_ADD_HOOK_IND); + } + + /// + /// Executes the InvokeDumperAsync operation. + /// + public async Task InvokeDumperAsync(uint address, uint length, IProgress progress, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Invoking Dumper (0x{Addr:X}, {Len} bytes)...", address, length); + return await _memoryManager.InvokeDumperAsync(address, length, progress, cancellationToken); + } + + /// + /// Executes the InvokeDumperStreamAsync operation. + /// + public async Task InvokeDumperStreamAsync( + uint address, + uint length, + Func, ValueTask> dataCallback, + IProgress progress, + CancellationToken cancellationToken = default, + bool keepSessionOpen = false, + ILogger? logger = null) + { + _logger.LogInformation("Invoking Dumper (Streaming) (0x{Addr:X}, {Len} bytes)...", address, length); + + // Use socat connection info from Configure() call + await _memoryManager.InvokeDumperStreamAsync( + address, + length, + dataCallback, + progress, + _socatHost, + _socatPort, + cancellationToken, + keepSessionOpen, + logger); + } + + /// + /// Executes the StopDumperSessionAsync operation. + /// + public async Task StopDumperSessionAsync() + { + _logger.LogInformation("Stopping persistent dumper session..."); + await _orchestrator.StopAsync().ConfigureAwait(false); + } + + #endregion + } +} diff --git a/src/S7Tools/Services/Plc/Adapters/PlcProtocolAdapter.cs b/src/S7Tools/Services/Plc/Adapters/PlcProtocolAdapter.cs new file mode 100644 index 00000000..5cbdb744 --- /dev/null +++ b/src/S7Tools/Services/Plc/Adapters/PlcProtocolAdapter.cs @@ -0,0 +1,213 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using global::S7Tools.Core.Interfaces.Services; + +namespace S7Tools.Services.Plc.Adapters +{ + /// + /// Real implementation of PlcProtocolAdapter. + /// Combines logic from Reference Project's PlcProtocol.cs (Framing/Checksum) + /// and PlcProtocolHandler.cs (Handshake/High-Level Send). + /// + public sealed class PlcProtocolAdapter : IPlcProtocol + { + private readonly ILogger _logger; + private readonly IPlcTransport _transport; + + /// + /// Initializes a new instance of the class. + /// + public PlcProtocolAdapter(IPlcTransport transport, ILogger logger) + { + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Gets or sets the DataAvailable. + /// + public bool DataAvailable => _transport.DataAvailable; + + /// + /// Executes the Configure operation. + /// + public void Configure(string host, int port) + { + _transport.Configure(host, port); + } + + /// + /// Executes the ConnectAsync operation. + /// + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + await _transport.ConnectAsync(cancellationToken); + } + + /// + /// Executes the DisconnectAsync operation. + /// + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + await _transport.DisconnectAsync(cancellationToken); + } + + #region Protocol Utils (Encoding/Decoding) + + private static byte CalculateChecksum(byte[] packetData, int offset, int length) + { + int sum = 0; + for (int i = 0; i < length; i++) + { + sum += packetData[offset + i]; + } + return (byte)-sum; + } + + /// + /// Executes the EncodePacket operation. + /// + public static byte[] EncodePacket(byte[] contents) + { + if (contents.Length > 254) + { + throw new ArgumentException("Packet contents too large. Max size is 254 bytes.", nameof(contents)); + } + + var packet = new byte[contents.Length + 2]; + packet[0] = (byte)(contents.Length + 1); + Array.Copy(contents, 0, packet, 1, contents.Length); + packet[packet.Length - 1] = CalculateChecksum(packet, 0, packet.Length - 1); + return packet; + } + + private static byte[] DecodePacket(byte[] packet) + { + if (packet.Length < 2) + { + throw new ArgumentException("Invalid packet length."); + } + + var lengthByte = packet[0]; + if (lengthByte != packet.Length - 1) + { + throw new ArgumentException("Packet length mismatch."); + } + + byte receivedChecksum = packet.Last(); + byte calculatedChecksum = CalculateChecksum(packet, 0, packet.Length - 1); + + if (receivedChecksum != calculatedChecksum) + { + throw new Exception("ChecksumMismatchException"); // Using general exception to avoid dependency hell + } + + var contents = new byte[lengthByte - 1]; + Array.Copy(packet, 1, contents, 0, contents.Length); + return contents; + } + + #endregion + + /// + /// Executes the SendPacketAsync operation. + /// + public async Task SendPacketAsync(byte[] payload, int? maxChunk = 2, CancellationToken cancellationToken = default) + { + // Safety delay exactly as in reference + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + + var packet = EncodePacket(payload); + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("-> SEND: {Hex}", BitConverter.ToString(packet).Replace("-", "")); + } + + int step = maxChunk ?? 2; + int sleepMs = 10; + + for (int i = 0; i < packet.Length; i += step) + { + cancellationToken.ThrowIfCancellationRequested(); + int bytesToSend = Math.Min(step, packet.Length - i); + await _transport.WriteAsync(packet, i, bytesToSend, cancellationToken).ConfigureAwait(false); + if (sleepMs > 0) + { + await Task.Delay(sleepMs, cancellationToken).ConfigureAwait(false); + } + } + } + + /// + /// Executes the ReceivePacketAsync operation. + /// + public async Task ReceivePacketAsync(CancellationToken cancellationToken = default) + { + var lengthByte = new byte[1]; + int lengthBytesRead = await _transport.ReadAsync(lengthByte, 0, 1, cancellationToken).ConfigureAwait(false); + + if (lengthBytesRead == 0) + { + throw new InvalidOperationException("Transport stream reached EOF while reading packet length"); + } + + int bytesToRead = lengthByte[0]; + + if (bytesToRead == 0) + { + return Array.Empty(); + } + + var fullPacket = new byte[bytesToRead + 1]; + fullPacket[0] = lengthByte[0]; + + int bytesRead = 0; + while (bytesRead < bytesToRead) + { + cancellationToken.ThrowIfCancellationRequested(); + int currentBytesRead = await _transport.ReadAsync(fullPacket, 1 + bytesRead, bytesToRead - bytesRead, cancellationToken).ConfigureAwait(false); + + if (currentBytesRead == 0) + { + throw new InvalidOperationException($"Transport stream reached EOF while reading packet data. Expected {bytesToRead} bytes, got {bytesRead} bytes"); + } + + bytesRead += currentBytesRead; + } + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("<- RECV: {Hex}", BitConverter.ToString(fullPacket).Replace("-", "")); + } + return DecodePacket(fullPacket); + } + + /// + /// Executes the RawWriteAsync operation. + /// + public async Task RawWriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + { + await _transport.WriteAsync(buffer, offset, count, cancellationToken); + } + + /// + /// Executes the RawReadAsync operation. + /// + public async Task RawReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + { + return await _transport.ReadAsync(buffer, offset, count, cancellationToken); + } + + /// + /// Executes the GetStream operation. + /// + public Stream? GetStream() => _transport.GetStream(); + } +} diff --git a/src/S7Tools/Services/MemoryDumpOrchestrator.cs b/src/S7Tools/Services/Plc/MemoryDumpOrchestrator.cs similarity index 99% rename from src/S7Tools/Services/MemoryDumpOrchestrator.cs rename to src/S7Tools/Services/Plc/MemoryDumpOrchestrator.cs index ee20dc41..5ff65c14 100644 --- a/src/S7Tools/Services/MemoryDumpOrchestrator.cs +++ b/src/S7Tools/Services/Plc/MemoryDumpOrchestrator.cs @@ -221,7 +221,7 @@ public async Task InvokeDumpCommandAsync(byte[] args, uint startAddress, long le Array.Copy(hookPayload, 0, primaryPayload, 1, hookPayload.Length); // Final packet framing (Length + Data + Checksum) - byte[] packet = Adapters.PlcProtocolAdapter.EncodePacket(primaryPayload); + byte[] packet = S7Tools.Services.Plc.Adapters.PlcProtocolAdapter.EncodePacket(primaryPayload); _logger.LogInformation("Sending dump command (framed) via dump connection..."); await _dumperService.WriteAsync(packet, ct).ConfigureAwait(false); diff --git a/src/S7Tools/Services/PlcDataService.cs b/src/S7Tools/Services/Plc/PlcDataService.cs similarity index 99% rename from src/S7Tools/Services/PlcDataService.cs rename to src/S7Tools/Services/Plc/PlcDataService.cs index c57aa5f1..8ad2e774 100644 --- a/src/S7Tools/Services/PlcDataService.cs +++ b/src/S7Tools/Services/Plc/PlcDataService.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Models; using S7Tools.Core.Models.ValueObjects; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services; diff --git a/src/S7Tools/Services/PowerSupply/ModbusPowerSupplyService.cs b/src/S7Tools/Services/PowerSupply/ModbusPowerSupplyService.cs new file mode 100644 index 00000000..6901e436 --- /dev/null +++ b/src/S7Tools/Services/PowerSupply/ModbusPowerSupplyService.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Logging; +using S7Tools.Core.Models; +using global::S7Tools.Core.Interfaces.Services; +using S7Tools.Resources; + +namespace S7Tools.Services.PowerSupply; + +/// +/// Provides power supply control operations via Modbus protocol. +/// Manages PLC power cycling for bootloader entry. +/// +/// +/// This is a stub implementation that requires a Modbus library integration. +/// Replace with actual Modbus communication once reference implementation is available. +/// +public sealed class ModbusPowerSupplyService : IPowerSupplyService +{ + private readonly ILogger _logger; + private PowerSupplyConfiguration? _currentConfiguration; + private bool _isConnected; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for diagnostics. + public ModbusPowerSupplyService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public bool IsConnected => _isConnected; + + /// + public PowerSupplyConfiguration? CurrentConfiguration => _currentConfiguration; + + /// + public Task ConnectAsync(PowerSupplyConfiguration configuration, Microsoft.Extensions.Logging.ILogger? taskLogger = null, CancellationToken cancellationToken = default) + { + var effectiveLogger = taskLogger ?? _logger; + ArgumentNullException.ThrowIfNull(configuration); + effectiveLogger.LogInformation("Connecting to power supply with configuration: {Config}", configuration.GenerateConnectionString()); + _currentConfiguration = configuration; + _isConnected = true; + return Task.FromResult(true); + } + + /// + public Task DisconnectAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Disconnecting from power supply"); + _isConnected = false; + return Task.CompletedTask; + } + + /// + public Task TestConnectionAsync(PowerSupplyConfiguration configuration, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(configuration); + _logger.LogInformation("Testing connection to power supply: {Config}", configuration.GenerateConnectionString()); + return Task.FromResult(true); + } + + /// + public Task TurnOnAsync(Microsoft.Extensions.Logging.ILogger? taskLogger = null, CancellationToken cancellationToken = default) + { + var effectiveLogger = taskLogger ?? _logger; + if (!_isConnected) + { + throw new InvalidOperationException(UIStrings.Exception_NotConnectedToPowerSupply); + } + + effectiveLogger.LogInformation("Turning power ON"); + return Task.FromResult(true); + } + + /// + public Task TurnOffAsync(Microsoft.Extensions.Logging.ILogger? taskLogger = null, CancellationToken cancellationToken = default) + { + var effectiveLogger = taskLogger ?? _logger; + if (!_isConnected) + { + throw new InvalidOperationException(UIStrings.Exception_NotConnectedToPowerSupply); + } + + effectiveLogger.LogInformation("Turning power OFF"); + return Task.FromResult(true); + } + + /// + public Task ReadPowerStateAsync(CancellationToken cancellationToken = default) + { + if (!_isConnected) + { + throw new InvalidOperationException(UIStrings.Exception_NotConnectedToPowerSupply); + } + + _logger.LogDebug("Reading power state"); + return Task.FromResult(false); // Stub: returns OFF + } + + /// + public async Task PowerCycleAsync(int delayMs = 5000, Microsoft.Extensions.Logging.ILogger? taskLogger = null, CancellationToken cancellationToken = default) + { + var effectiveLogger = taskLogger ?? _logger; + if (!_isConnected) + { + throw new InvalidOperationException(UIStrings.Exception_NotConnectedToPowerSupply); + } + + effectiveLogger.LogInformation("Starting power cycle with {Delay}ms delay", delayMs); + + await TurnOffAsync(taskLogger, cancellationToken).ConfigureAwait(false); + await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false); + await TurnOnAsync(taskLogger, cancellationToken).ConfigureAwait(false); + + effectiveLogger.LogInformation("Power cycle complete"); + return true; + } + +} diff --git a/src/S7Tools/Services/PowerSupplyService.cs b/src/S7Tools/Services/PowerSupply/PowerSupplyService.cs similarity index 99% rename from src/S7Tools/Services/PowerSupplyService.cs rename to src/S7Tools/Services/PowerSupply/PowerSupplyService.cs index bfac43e9..1850ddbe 100644 --- a/src/S7Tools/Services/PowerSupplyService.cs +++ b/src/S7Tools/Services/PowerSupply/PowerSupplyService.cs @@ -6,7 +6,7 @@ using NModbus; using S7Tools.Core.Exceptions; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Extensions; namespace S7Tools.Services; diff --git a/src/S7Tools/Services/ProfileEditDialogService.cs b/src/S7Tools/Services/ProfileEditDialogService.cs deleted file mode 100644 index 5d3a4e89..00000000 --- a/src/S7Tools/Services/ProfileEditDialogService.cs +++ /dev/null @@ -1,578 +0,0 @@ -using System; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Avalonia.Controls; -using Microsoft.Extensions.Logging; -using ReactiveUI; -using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; -using S7Tools.Models; -using S7Tools.Services.Interfaces; -using S7Tools.ViewModels; -using S7Tools.ViewModels.Profiles; -using S7Tools.Views; -using S7Tools.Views.Dialogs; - -namespace S7Tools.Services; - -/// -/// Service implementation for displaying profile editing dialogs. -/// -public class ProfileEditDialogService : IProfileEditDialogService -{ - private static bool _handlerRegistered; - private static readonly object _lockObject = new(); - - // Dependencies for enhanced dialog system - private readonly ISerialPortProfileService _serialPortProfileService; - private readonly ISocatProfileService _socatProfileService; - private readonly IPowerSupplyProfileService _powerSupplyProfileService; - private readonly ISerialPortService _serialPortService; - private readonly ISocatService _socatService; - private readonly IClipboardService _clipboardService; - private readonly IDialogService _dialogService; - private readonly ILogger _logger; - - /// - /// Static interaction shared across all service instances. - /// - private static readonly Interaction _staticInteraction = new(); - - /// - /// Gets the interaction for showing profile editing dialogs. - /// - public Interaction ShowProfileEditDialog => _staticInteraction; - - /// - /// Initializes a new instance of the ProfileEditDialogService class. - /// - /// The serial port profile service. - /// The socat profile service. - /// The power supply profile service. - /// The serial port service. - /// The socat service. - /// The clipboard service. - /// The dialog service. - /// The logger. - public ProfileEditDialogService( - ISerialPortProfileService serialPortProfileService, - ISocatProfileService socatProfileService, - IPowerSupplyProfileService powerSupplyProfileService, - ISerialPortService serialPortService, - ISocatService socatService, - IClipboardService clipboardService, - IDialogService dialogService, - ILogger logger) - { - _serialPortProfileService = serialPortProfileService ?? throw new ArgumentNullException(nameof(serialPortProfileService)); - _socatProfileService = socatProfileService ?? throw new ArgumentNullException(nameof(socatProfileService)); - _powerSupplyProfileService = powerSupplyProfileService ?? throw new ArgumentNullException(nameof(powerSupplyProfileService)); - _serialPortService = serialPortService ?? throw new ArgumentNullException(nameof(serialPortService)); - _socatService = socatService ?? throw new ArgumentNullException(nameof(socatService)); - _clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService)); - _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - RegisterInteractionHandler(); - } - - /// - /// Registers the interaction handler for profile edit dialogs (thread-safe, one-time only). - /// - private void RegisterInteractionHandler() - { - lock (_lockObject) - { - if (_handlerRegistered) - { - return; - } - - _staticInteraction.RegisterHandler(async interaction => - { - System.Diagnostics.Debug.WriteLine($"DEBUG: ProfileEditDialogService interaction handler called"); - try - { - System.Diagnostics.Debug.WriteLine($"DEBUG: Creating ProfileEditDialog for {interaction.Input.ProfileType}"); - - // Create and setup profile edit dialog - var dialog = new Views.Dialogs.ProfileEditDialog(); - dialog.SetupDialog(interaction.Input); - - System.Diagnostics.Debug.WriteLine($"DEBUG: Dialog created and setup completed"); - - // Get the main window as parent - Window? mainWindow = Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop - ? desktop.MainWindow - : null; - - if (mainWindow != null) - { - System.Diagnostics.Debug.WriteLine($"DEBUG: Showing dialog with main window as parent"); - await dialog.ShowDialog(mainWindow); - System.Diagnostics.Debug.WriteLine($"DEBUG: Dialog closed, result: {dialog.Result.IsSuccess}"); - interaction.SetOutput(dialog.Result); - } - else - { - System.Diagnostics.Debug.WriteLine($"ERROR: MainWindow not found for dialog parent"); - interaction.SetOutput(ProfileEditResult.Cancelled()); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"ERROR: Exception in ProfileEditDialogService interaction handler: {ex.Message}"); - System.Diagnostics.Debug.WriteLine($"ERROR: Exception details: {ex}"); - interaction.SetOutput(ProfileEditResult.Cancelled()); - } - }); - - _handlerRegistered = true; - } - } - - /// - /// Shows a profile editing dialog for a serial port profile. - /// - /// The dialog title. - /// The SerialPortProfileViewModel to edit. - /// A task that represents the asynchronous operation. The task result contains the edit result. - public async Task ShowSerialProfileEditAsync(string title, SerialPortProfileViewModel profileViewModel) - { - ArgumentNullException.ThrowIfNull(title); - ArgumentNullException.ThrowIfNull(profileViewModel); - - var request = new S7Tools.Models.ProfileEditRequest(title, profileViewModel, ProfileType.Serial); - return await ShowProfileEditDialog.Handle(request).FirstAsync(); - } - - /// - /// Shows a profile editing dialog for a socat profile. - /// - /// The dialog title. - /// The SocatProfileViewModel to edit. - /// A task that represents the asynchronous operation. The task result contains the edit result. - public async Task ShowSocatProfileEditAsync(string title, SocatProfileViewModel profileViewModel) - { - ArgumentNullException.ThrowIfNull(title); - ArgumentNullException.ThrowIfNull(profileViewModel); - - var request = new S7Tools.Models.ProfileEditRequest(title, profileViewModel, ProfileType.Socat); - return await ShowProfileEditDialog.Handle(request).FirstAsync(); - } - - /// - public async Task ShowPowerSupplyProfileEditAsync(string title, PowerSupplyProfileViewModel profileViewModel) - { - ArgumentNullException.ThrowIfNull(title); - ArgumentNullException.ThrowIfNull(profileViewModel); - - var request = new S7Tools.Models.ProfileEditRequest(title, profileViewModel, ProfileType.PowerSupply); - return await ShowProfileEditDialog.Handle(request).FirstAsync(); - } - - // Enhanced methods for Phase 6 - Unified Dialog System - - /// - public async Task CreateSerialProfileAsync(string defaultName = "SerialDefault") - { - try - { - _logger.LogInformation("Creating new serial port profile with default name: {DefaultName}", defaultName); - - // Create ViewModel with default values - var profileViewModel = new SerialPortProfileViewModel( - _serialPortProfileService, - _serialPortService, - _clipboardService, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - // Set the default name - profileViewModel.ProfileName = defaultName; - - // CRITICAL: For new profiles, mark as having changes so SaveCommand is enabled - profileViewModel.HasChanges = true; - - // Show the edit dialog for the new profile - ProfileEditResult result = await ShowSerialProfileEditAsync("Create Serial Port Profile", profileViewModel); - - if (result.IsSuccess) - { - _logger.LogInformation("Serial port profile created successfully"); - } - else - { - _logger.LogInformation("Serial port profile creation cancelled or failed"); - } - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating serial port profile"); - return ProfileEditResult.Cancelled(); - } - } - - /// - public async Task CreateSocatProfileAsync(string defaultName = "SocatDefault") - { - try - { - _logger.LogInformation("Creating new socat profile with default name: {DefaultName}", defaultName); - - // Create ViewModel with default values - including ISocatService dependency - var profileViewModel = new SocatProfileViewModel( - _socatProfileService, - _socatService, - _clipboardService, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - // Set the default name - profileViewModel.ProfileName = defaultName; - - // CRITICAL: For new profiles, mark as having changes so SaveCommand is enabled - profileViewModel.HasChanges = true; - - // Show the edit dialog for the new profile - ProfileEditResult result = await ShowSocatProfileEditAsync("Create Socat Profile", profileViewModel); - - if (result.IsSuccess) - { - _logger.LogInformation("Socat profile created successfully"); - } - else - { - _logger.LogInformation("Socat profile creation cancelled or failed"); - } - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating socat profile"); - return ProfileEditResult.Cancelled(); - } - } - - /// - public async Task CreatePowerSupplyProfileAsync(string defaultName = "PowerSupplyDefault") - { - try - { - _logger.LogInformation("Creating new power supply profile with default name: {DefaultName}", defaultName); - System.Diagnostics.Debug.WriteLine($"DEBUG: CreatePowerSupplyProfileAsync called with name: {defaultName}"); - - // Create ViewModel with default values - var profileViewModel = new PowerSupplyProfileViewModel( - _powerSupplyProfileService, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - // Set the default name - profileViewModel.ProfileName = defaultName; - - // CRITICAL: For new profiles, mark as having changes so SaveCommand is enabled - profileViewModel.HasChanges = true; - - // Show the edit dialog for the new profile - ProfileEditResult result = await ShowPowerSupplyProfileEditAsync("Create Power Supply Profile", profileViewModel); - - if (result.IsSuccess) - { - _logger.LogInformation("Power supply profile created successfully"); - } - else - { - _logger.LogInformation("Power supply profile creation cancelled or failed"); - } - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating power supply profile"); - return ProfileEditResult.Cancelled(); - } - } - - /// - public async Task EditSerialProfileAsync(int profileId) - { - try - { - _logger.LogInformation("Editing serial port profile with ID: {ProfileId}", profileId); - - // Load the existing profile - SerialPortProfile? profile = await _serialPortProfileService.GetByIdAsync(profileId); - if (profile == null) - { - _logger.LogWarning("Serial port profile with ID {ProfileId} not found", profileId); - return ProfileEditResult.Failed("Profile not found"); - } - - // Create ViewModel with existing profile data - var profileViewModel = new SerialPortProfileViewModel( - _serialPortProfileService, - _serialPortService, - _clipboardService, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - // Load the existing profile data into the ViewModel - profileViewModel.LoadProfile(profile); - - // Show the edit dialog - ProfileEditResult result = await ShowSerialProfileEditAsync("Edit Serial Port Profile", profileViewModel); - - if (result.IsSuccess) - { - _logger.LogInformation("Serial port profile edited successfully"); - } - else - { - _logger.LogInformation("Serial port profile edit cancelled or failed"); - } - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error editing serial port profile with ID: {ProfileId}", profileId); - return ProfileEditResult.Failed($"Error editing profile: {ex.Message}"); - } - } - - /// - public async Task EditSocatProfileAsync(int profileId) - { - try - { - _logger.LogInformation("Editing socat profile with ID: {ProfileId}", profileId); - - // Load the existing profile - SocatProfile? profile = await _socatProfileService.GetByIdAsync(profileId); - if (profile == null) - { - _logger.LogWarning("Socat profile with ID {ProfileId} not found", profileId); - return ProfileEditResult.Failed("Profile not found"); - } - - // Create ViewModel with existing profile data - var profileViewModel = new SocatProfileViewModel( - _socatProfileService, - _socatService, - _clipboardService, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - // Load the existing profile data into the ViewModel - profileViewModel.LoadProfile(profile); - - // Show the edit dialog - ProfileEditResult result = await ShowSocatProfileEditAsync("Edit Socat Profile", profileViewModel); - - if (result.IsSuccess) - { - _logger.LogInformation("Socat profile edited successfully"); - } - else - { - _logger.LogInformation("Socat profile edit cancelled or failed"); - } - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error editing socat profile with ID: {ProfileId}", profileId); - return ProfileEditResult.Failed($"Error editing profile: {ex.Message}"); - } - } - - /// - public async Task EditPowerSupplyProfileAsync(int profileId) - { - try - { - _logger.LogInformation("Editing power supply profile with ID: {ProfileId}", profileId); - - // Load the existing profile - PowerSupplyProfile? profile = await _powerSupplyProfileService.GetByIdAsync(profileId); - if (profile == null) - { - _logger.LogWarning("Power supply profile with ID {ProfileId} not found", profileId); - return ProfileEditResult.Failed("Profile not found"); - } - - // Create ViewModel with existing profile data - var profileViewModel = new PowerSupplyProfileViewModel( - _powerSupplyProfileService, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - // Load the existing profile data into the ViewModel - profileViewModel.LoadProfile(profile); - - // Show the edit dialog - ProfileEditResult result = await ShowPowerSupplyProfileEditAsync("Edit Power Supply Profile", profileViewModel); - - if (result.IsSuccess) - { - _logger.LogInformation("Power supply profile edited successfully"); - } - else - { - _logger.LogInformation("Power supply profile edit cancelled or failed"); - } - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error editing power supply profile with ID: {ProfileId}", profileId); - return ProfileEditResult.Failed($"Error editing profile: {ex.Message}"); - } - } - - /// - public async Task DuplicateSerialProfileAsync(int sourceProfileId) - { - try - { - _logger.LogInformation("Duplicating serial port profile with ID: {ProfileId}", sourceProfileId); - - // Load the source profile - SerialPortProfile? sourceProfile = await _serialPortProfileService.GetByIdAsync(sourceProfileId); - if (sourceProfile == null) - { - _logger.LogWarning("Source serial port profile with ID {ProfileId} not found", sourceProfileId); - return ProfileDuplicateResult.Failed("Source profile not found"); - } - - // Show input dialog for new name using the correct method - InputResult inputResult = await _dialogService.ShowInputAsync( - "Duplicate Serial Port Profile", - "Enter a name for the duplicated profile:", - $"{sourceProfile.Name}_Copy", - "Profile name"); - - if (inputResult.IsCancelled || string.IsNullOrWhiteSpace(inputResult.Value)) - { - _logger.LogInformation("Serial port profile duplication cancelled"); - return ProfileDuplicateResult.Cancelled(); - } - - string newName = inputResult.Value.Trim(); - - // Check if name is available - bool isAvailable = await _serialPortProfileService.IsNameUniqueAsync(newName); - if (!isAvailable) - { - _logger.LogWarning("Serial port profile name already exists: {ProfileName}", newName); - return ProfileDuplicateResult.Failed("Profile name already exists"); - } - - _logger.LogInformation("Serial port profile duplication name confirmed: {NewName}", newName); - return ProfileDuplicateResult.Success(newName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error duplicating serial port profile with ID: {ProfileId}", sourceProfileId); - return ProfileDuplicateResult.Failed($"Error duplicating profile: {ex.Message}"); - } - } - - /// - public async Task DuplicateSocatProfileAsync(int sourceProfileId) - { - try - { - _logger.LogInformation("Duplicating socat profile with ID: {ProfileId}", sourceProfileId); - - // Load the source profile - SocatProfile? sourceProfile = await _socatProfileService.GetByIdAsync(sourceProfileId); - if (sourceProfile == null) - { - _logger.LogWarning("Source socat profile with ID {ProfileId} not found", sourceProfileId); - return ProfileDuplicateResult.Failed("Source profile not found"); - } - - // Show input dialog for new name - InputResult inputResult = await _dialogService.ShowInputAsync( - "Duplicate Socat Profile", - "Enter a name for the duplicated profile:", - $"{sourceProfile.Name}_Copy", - "Profile name"); - - if (inputResult.IsCancelled || string.IsNullOrWhiteSpace(inputResult.Value)) - { - _logger.LogInformation("Socat profile duplication cancelled"); - return ProfileDuplicateResult.Cancelled(); - } - - string newName = inputResult.Value.Trim(); - - // Check if name is available - bool isAvailable = await _socatProfileService.IsNameUniqueAsync(newName); - if (!isAvailable) - { - _logger.LogWarning("Socat profile name already exists: {ProfileName}", newName); - return ProfileDuplicateResult.Failed("Profile name already exists"); - } - - _logger.LogInformation("Socat profile duplication name confirmed: {NewName}", newName); - return ProfileDuplicateResult.Success(newName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error duplicating socat profile with ID: {ProfileId}", sourceProfileId); - return ProfileDuplicateResult.Failed($"Error duplicating profile: {ex.Message}"); - } - } - - /// - public async Task DuplicatePowerSupplyProfileAsync(int sourceProfileId) - { - try - { - _logger.LogInformation("Duplicating power supply profile with ID: {ProfileId}", sourceProfileId); - - // Load the source profile - PowerSupplyProfile? sourceProfile = await _powerSupplyProfileService.GetByIdAsync(sourceProfileId); - if (sourceProfile == null) - { - _logger.LogWarning("Source power supply profile with ID {ProfileId} not found", sourceProfileId); - return ProfileDuplicateResult.Failed("Source profile not found"); - } - - // Show input dialog for new name - InputResult inputResult = await _dialogService.ShowInputAsync( - "Duplicate Power Supply Profile", - "Enter a name for the duplicated profile:", - $"{sourceProfile.Name}_Copy", - "Profile name"); - - if (inputResult.IsCancelled || string.IsNullOrWhiteSpace(inputResult.Value)) - { - _logger.LogInformation("Power supply profile duplication cancelled"); - return ProfileDuplicateResult.Cancelled(); - } - - string newName = inputResult.Value.Trim(); - - // Check if name is available - bool isAvailable = await _powerSupplyProfileService.IsNameUniqueAsync(newName); - if (!isAvailable) - { - _logger.LogWarning("Power supply profile name already exists: {ProfileName}", newName); - return ProfileDuplicateResult.Failed("Profile name already exists"); - } - - _logger.LogInformation("Power supply profile duplication name confirmed: {NewName}", newName); - return ProfileDuplicateResult.Success(newName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error duplicating power supply profile with ID: {ProfileId}", sourceProfileId); - return ProfileDuplicateResult.Failed($"Error duplicating profile: {ex.Message}"); - } - } -} diff --git a/src/S7Tools/Services/MemoryRegionProfileService.cs b/src/S7Tools/Services/Profiles/MemoryRegionProfileService.cs similarity index 98% rename from src/S7Tools/Services/MemoryRegionProfileService.cs rename to src/S7Tools/Services/Profiles/MemoryRegionProfileService.cs index 111f8f45..572128d1 100644 --- a/src/S7Tools/Services/MemoryRegionProfileService.cs +++ b/src/S7Tools/Services/Profiles/MemoryRegionProfileService.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; namespace S7Tools.Services; diff --git a/src/S7Tools/Services/PayloadSetProfileService.cs b/src/S7Tools/Services/Profiles/PayloadSetProfileService.cs similarity index 98% rename from src/S7Tools/Services/PayloadSetProfileService.cs rename to src/S7Tools/Services/Profiles/PayloadSetProfileService.cs index d5833861..6bbb339c 100644 --- a/src/S7Tools/Services/PayloadSetProfileService.cs +++ b/src/S7Tools/Services/Profiles/PayloadSetProfileService.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; namespace S7Tools.Services; diff --git a/src/S7Tools/Services/PowerSupplyProfileService.cs b/src/S7Tools/Services/Profiles/PowerSupplyProfileService.cs similarity index 98% rename from src/S7Tools/Services/PowerSupplyProfileService.cs rename to src/S7Tools/Services/Profiles/PowerSupplyProfileService.cs index 5d3b4748..bbc00aa1 100644 --- a/src/S7Tools/Services/PowerSupplyProfileService.cs +++ b/src/S7Tools/Services/Profiles/PowerSupplyProfileService.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; namespace S7Tools.Services; diff --git a/src/S7Tools/Services/ProfileDetailsService.cs b/src/S7Tools/Services/Profiles/ProfileDetailsService.cs similarity index 99% rename from src/S7Tools/Services/ProfileDetailsService.cs rename to src/S7Tools/Services/Profiles/ProfileDetailsService.cs index 5872736b..7a81bd07 100644 --- a/src/S7Tools/Services/ProfileDetailsService.cs +++ b/src/S7Tools/Services/Profiles/ProfileDetailsService.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Constants; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.ViewModels.Controls; using S7Tools.ViewModels.Profiles; @@ -16,6 +16,9 @@ public class ProfileDetailsService : IProfileDetailsService { private readonly ILogger _logger; + /// + /// Initializes a new instance of the class. + /// public ProfileDetailsService(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); diff --git a/src/S7Tools/Services/ProfileValidationResult.cs b/src/S7Tools/Services/Profiles/ProfileValidationResult.cs similarity index 100% rename from src/S7Tools/Services/ProfileValidationResult.cs rename to src/S7Tools/Services/Profiles/ProfileValidationResult.cs diff --git a/src/S7Tools/Services/SerialPortProfileService.cs b/src/S7Tools/Services/Profiles/SerialPortProfileService.cs similarity index 98% rename from src/S7Tools/Services/SerialPortProfileService.cs rename to src/S7Tools/Services/Profiles/SerialPortProfileService.cs index 592c0bcf..02bb123d 100644 --- a/src/S7Tools/Services/SerialPortProfileService.cs +++ b/src/S7Tools/Services/Profiles/SerialPortProfileService.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; namespace S7Tools.Services; diff --git a/src/S7Tools/Services/SocatProfileService.cs b/src/S7Tools/Services/Profiles/SocatProfileService.cs similarity index 98% rename from src/S7Tools/Services/SocatProfileService.cs rename to src/S7Tools/Services/Profiles/SocatProfileService.cs index c6ee6e23..e458b00f 100644 --- a/src/S7Tools/Services/SocatProfileService.cs +++ b/src/S7Tools/Services/Profiles/SocatProfileService.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; namespace S7Tools.Services; diff --git a/src/S7Tools/Services/StandardProfileManager.cs b/src/S7Tools/Services/Profiles/StandardProfileManager.cs similarity index 99% rename from src/S7Tools/Services/StandardProfileManager.cs rename to src/S7Tools/Services/Profiles/StandardProfileManager.cs index ec4e1d6a..4198779d 100644 --- a/src/S7Tools/Services/StandardProfileManager.cs +++ b/src/S7Tools/Services/Profiles/StandardProfileManager.cs @@ -7,7 +7,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using S7Tools.Core.Exceptions; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Extensions; using S7Tools.Resources; @@ -685,7 +685,7 @@ private async Task LoadProfilesAsync(CancellationToken cancellationToken) catch (Exception ex) { _logger.LogError(ex, "Failed to load {ProfileType} profiles from: {Path}", ProfileTypeName, _profilesPath); - + // Backup corrupted profile file before starting with an empty collection try { diff --git a/src/S7Tools/Services/SerialPort/SerialPortConfigurationService.cs b/src/S7Tools/Services/SerialPort/SerialPortConfigurationService.cs index c5da2b25..1f739cfc 100644 --- a/src/S7Tools/Services/SerialPort/SerialPortConfigurationService.cs +++ b/src/S7Tools/Services/SerialPort/SerialPortConfigurationService.cs @@ -7,8 +7,8 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Exceptions; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; -using S7Tools.Core.Services.Shell; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Core.Interfaces.Shell; namespace S7Tools.Services.SerialPort; diff --git a/src/S7Tools/Services/SerialPort/SerialPortDiscoveryService.cs b/src/S7Tools/Services/SerialPort/SerialPortDiscoveryService.cs index 0ae10d89..b3cff2b9 100644 --- a/src/S7Tools/Services/SerialPort/SerialPortDiscoveryService.cs +++ b/src/S7Tools/Services/SerialPort/SerialPortDiscoveryService.cs @@ -9,8 +9,8 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Exceptions; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; -using S7Tools.Core.Services.Shell; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Core.Interfaces.Shell; namespace S7Tools.Services.SerialPort; @@ -79,17 +79,26 @@ public async Task> ScanAvailablePortsAsync( foreach (var port in availableSystemPorts) { if (includeUsbPorts && port.Contains("ttyUSB")) + { portsToScan.Add(port); + } else if (includeAcmPorts && port.Contains("ttyACM")) + { portsToScan.Add(port); + } else if (includeStandardPorts && port.Contains("ttyS") && !port.Contains("ttyUSB") && !port.Contains("ttyACM")) + { portsToScan.Add(port); + } } // Scan gathered valid hardware ports, ensuring unique and sorted sequence foreach (var portPath in portsToScan.Distinct().OrderBy(p => p)) { - if (cancellationToken.IsCancellationRequested) break; + if (cancellationToken.IsCancellationRequested) + { + break; + } // GetPortInfoAsync will do accessibility test (stty) BUT only for ports that actually structurally exist! SerialPortInfo? portInfo = await GetPortInfoAsync(portPath, 1000, cancellationToken).ConfigureAwait(false); diff --git a/src/S7Tools/Services/SerialPort/SerialPortMonitoringService.cs b/src/S7Tools/Services/SerialPort/SerialPortMonitoringService.cs index 1ecee7aa..3024adfa 100644 --- a/src/S7Tools/Services/SerialPort/SerialPortMonitoringService.cs +++ b/src/S7Tools/Services/SerialPort/SerialPortMonitoringService.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Extensions; namespace S7Tools.Services.SerialPort; diff --git a/src/S7Tools/Services/SerialPortService.cs b/src/S7Tools/Services/SerialPort/SerialPortService.cs similarity index 90% rename from src/S7Tools/Services/SerialPortService.cs rename to src/S7Tools/Services/SerialPort/SerialPortService.cs index c3cd92b0..d0a02210 100644 --- a/src/S7Tools/Services/SerialPortService.cs +++ b/src/S7Tools/Services/SerialPort/SerialPortService.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Services.SerialPort; namespace S7Tools.Services; @@ -76,10 +75,10 @@ public SerialPortService( public async Task> ScanAvailablePortsAsync(CancellationToken cancellationToken = default) { // Get settings from application settings service - bool includeUsbPorts = _settingsService.GetSetting("serial.includeUsbPorts", true); - bool includeAcmPorts = _settingsService.GetSetting("serial.includeAcmPorts", true); - bool includeStandardPorts = _settingsService.GetSetting("serial.includeStandardPorts", true); - int maxScanPorts = _settingsService.GetSetting("serial.maxScanPorts", 32); + bool includeUsbPorts = _settingsService.Current.Serial.IncludeUsbPorts; + bool includeAcmPorts = _settingsService.Current.Serial.IncludeAcmPorts; + bool includeStandardPorts = _settingsService.Current.Serial.IncludeStandardPorts; + int maxScanPorts = _settingsService.Current.Serial.MaxScanPorts; return await _discoveryService.ScanAvailablePortsAsync( includeUsbPorts, @@ -98,7 +97,7 @@ public async Task> ScanAvailablePortsAsync(Cancellat } // Get port test timeout from settings and clamp to a safe range - int configuredTimeoutMs = _settingsService.GetSetting("serial.portTestTimeoutMs", 1000); + int configuredTimeoutMs = _settingsService.Current.Serial.PortTestTimeoutMs; int portTestTimeoutMs = Math.Clamp(configuredTimeoutMs, 100, 10_000); if (portTestTimeoutMs != configuredTimeoutMs) { @@ -116,13 +115,13 @@ public Task IsPortAccessibleAsync(string portPath, int timeoutMs = 1000, C public async Task StartPortMonitoringAsync(CancellationToken cancellationToken = default) { // Get settings from application settings service - bool includeUsbPorts = _settingsService.GetSetting("serial.includeUsbPorts", true); - bool includeAcmPorts = _settingsService.GetSetting("serial.includeAcmPorts", true); - bool includeStandardPorts = _settingsService.GetSetting("serial.includeStandardPorts", true); - int maxScanPorts = _settingsService.GetSetting("serial.maxScanPorts", 32); + bool includeUsbPorts = _settingsService.Current.Serial.IncludeUsbPorts; + bool includeAcmPorts = _settingsService.Current.Serial.IncludeAcmPorts; + bool includeStandardPorts = _settingsService.Current.Serial.IncludeStandardPorts; + int maxScanPorts = _settingsService.Current.Serial.MaxScanPorts; // Get scan interval from settings and clamp to a safe range - int configuredInterval = _settingsService.GetSetting("serial.scanIntervalSeconds", 5); + int configuredInterval = _settingsService.Current.Serial.ScanIntervalSeconds; int scanIntervalSeconds = Math.Clamp(configuredInterval, 1, 3600); if (scanIntervalSeconds != configuredInterval) { diff --git a/src/S7Tools/Services/Shell/ShellCommandExecutor.cs b/src/S7Tools/Services/Shell/ShellCommandExecutor.cs index db6ef7c1..6f1f222c 100644 --- a/src/S7Tools/Services/Shell/ShellCommandExecutor.cs +++ b/src/S7Tools/Services/Shell/ShellCommandExecutor.cs @@ -6,7 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using S7Tools.Core.Services.Shell; +using S7Tools.Core.Interfaces.Shell; namespace S7Tools.Services.Shell; @@ -17,6 +17,9 @@ public sealed class ShellCommandExecutor : IShellCommandExecutor { private readonly ILogger _logger; + /// + /// Initializes a new instance of the class. + /// public ShellCommandExecutor(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); diff --git a/src/S7Tools/Services/Socat/SocatCommandBuilder.cs b/src/S7Tools/Services/Socat/SocatCommandBuilder.cs index a97aaeb9..189302cb 100644 --- a/src/S7Tools/Services/Socat/SocatCommandBuilder.cs +++ b/src/S7Tools/Services/Socat/SocatCommandBuilder.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Constants; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services.Socat; @@ -15,6 +15,9 @@ public partial class SocatCommandBuilder { private readonly ILogger _logger; + /// + /// Initializes a new instance of the class. + /// public SocatCommandBuilder(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); diff --git a/src/S7Tools/Services/Socat/SocatConfigurationService.cs b/src/S7Tools/Services/Socat/SocatConfigurationService.cs index c14fd127..4faf1086 100644 --- a/src/S7Tools/Services/Socat/SocatConfigurationService.cs +++ b/src/S7Tools/Services/Socat/SocatConfigurationService.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; namespace S7Tools.Services.Socat; @@ -18,6 +17,9 @@ public class SocatConfigurationService private readonly ILogger _logger; private readonly ISerialPortService _serialPortService; + /// + /// Initializes a new instance of the class. + /// public SocatConfigurationService( ILogger logger, ISerialPortService serialPortService) diff --git a/src/S7Tools/Services/Socat/SocatPortManager.cs b/src/S7Tools/Services/Socat/SocatPortManager.cs index 1a639c3f..3ed89a5e 100644 --- a/src/S7Tools/Services/Socat/SocatPortManager.cs +++ b/src/S7Tools/Services/Socat/SocatPortManager.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Constants; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services.Socat; @@ -21,6 +21,9 @@ public class SocatPortManager { private readonly ILogger _logger; + /// + /// Initializes a new instance of the class. + /// public SocatPortManager(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); diff --git a/src/S7Tools/Services/Socat/SocatProcessManager.cs b/src/S7Tools/Services/Socat/SocatProcessManager.cs index f9182292..ec60f09d 100644 --- a/src/S7Tools/Services/Socat/SocatProcessManager.cs +++ b/src/S7Tools/Services/Socat/SocatProcessManager.cs @@ -9,8 +9,7 @@ using S7Tools.Core.Exceptions; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; -using S7Tools.Core.Services.Shell; +using S7Tools.Core.Interfaces.Shell; using S7Tools.Extensions; namespace S7Tools.Services.Socat; @@ -29,6 +28,12 @@ public partial class SocatProcessManager : IDisposable private readonly SemaphoreSlim _semaphore = new(1, 1); private bool _disposed; + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + /// The time provider for timestamping process info. + /// The shell command executor for OS-level operations. public SocatProcessManager( ILogger logger, ITimeProvider timeProvider, @@ -490,12 +495,17 @@ private async Task WaitForProcessExitAsync(Process process, int timeoutMs, [GeneratedRegex(@"^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} ")] private static partial Regex SocatLogTimestampRegex(); + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + /// + /// Releases resources used by this manager. + /// + /// to release managed resources. protected virtual void Dispose(bool disposing) { if (_disposed) @@ -533,9 +543,17 @@ protected virtual void Dispose(bool disposing) /// public class ProcessExitedEventArgs : EventArgs { + /// Gets the OS process ID of the exited process. public int ProcessId { get; } + + /// Gets the exit code reported by the process. public int ExitCode { get; } + /// + /// Initializes a new instance of the class. + /// + /// The process ID of the exited process. + /// The exit code of the process. public ProcessExitedEventArgs(int processId, int exitCode) { ProcessId = processId; diff --git a/src/S7Tools/Services/SocatService.cs b/src/S7Tools/Services/Socat/SocatService.cs similarity index 97% rename from src/S7Tools/Services/SocatService.cs rename to src/S7Tools/Services/Socat/SocatService.cs index 88e0242b..c72c8227 100644 --- a/src/S7Tools/Services/SocatService.cs +++ b/src/S7Tools/Services/Socat/SocatService.cs @@ -14,7 +14,6 @@ using S7Tools.Core.Exceptions; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Extensions; namespace S7Tools.Services; @@ -189,8 +188,8 @@ public async Task StartSocatAsync(SocatConfiguration configura } // Get settings from application settings service - int maxConcurrentInstances = _settingsService.GetSetting("socat.maxConcurrentInstances", 5); - bool autoConfigureSerialDevice = _settingsService.GetSetting("socat.autoConfigureSerialDevice", true); + int maxConcurrentInstances = _settingsService.Current.Socat.MaxConcurrentInstances; + bool autoConfigureSerialDevice = _settingsService.Current.Socat.AutoConfigureSerialDevice; // Check concurrent instances limit return await _semaphore.ExecuteAsync(async () => @@ -277,8 +276,8 @@ public async Task StartSocatWithProfileAsync(SocatProfile prof _logger.LogDebug("Getting socat settings - MaxConcurrentInstances query"); // Get settings from application settings service - int maxConcurrentInstances = _settingsService.GetSetting("socat.maxConcurrentInstances", 5); - bool autoConfigureSerialDevice = _settingsService.GetSetting("socat.autoConfigureSerialDevice", true); + int maxConcurrentInstances = _settingsService.Current.Socat.MaxConcurrentInstances; + bool autoConfigureSerialDevice = _settingsService.Current.Socat.AutoConfigureSerialDevice; // PERFORM VALIDATIONS BEFORE ACQUIRING SEMAPHORE to reduce lock duration @@ -403,7 +402,7 @@ public async Task StopSocatByIdAsync(int processId, CancellationToken canc } // Get shutdown timeout from settings - int configuredShutdownSeconds = _settingsService.GetSetting("socat.processShutdownTimeoutSeconds", 5); + int configuredShutdownSeconds = _settingsService.Current.Socat.ProcessShutdownTimeoutSeconds; int timeoutMs = Math.Clamp(configuredShutdownSeconds, 1, 120) * 1000; // Delegate process stop to ProcessManager @@ -547,7 +546,7 @@ await _semaphore.ExecuteAsync(async () => } // Get status refresh interval from settings and clamp to a safe range - int configuredInterval = _settingsService.GetSetting("socat.statusRefreshIntervalSeconds", 2); + int configuredInterval = _settingsService.Current.Socat.StatusRefreshIntervalSeconds; int statusRefreshIntervalSeconds = Math.Clamp(configuredInterval, 1, 3600); if (statusRefreshIntervalSeconds != configuredInterval) { @@ -573,7 +572,7 @@ await _semaphore.ExecuteAsync(async () => await UpdateProcessStatusAsync(processInfo).ConfigureAwait(false); // Re-read the setting to get the latest value for dynamic updates - int updatedConfiguredInterval = _settingsService.GetSetting("socat.statusRefreshIntervalSeconds", 2); + int updatedConfiguredInterval = _settingsService.Current.Socat.StatusRefreshIntervalSeconds; int updatedInterval = Math.Clamp(updatedConfiguredInterval, 1, 3600); // Only reschedule if this timer is still the active one for the process @@ -598,7 +597,7 @@ await _semaphore.ExecuteAsync(async () => { try { - int updatedConfiguredInterval = _settingsService.GetSetting("socat.statusRefreshIntervalSeconds", 2); + int updatedConfiguredInterval = _settingsService.Current.Socat.StatusRefreshIntervalSeconds; int updatedInterval = Math.Clamp(updatedConfiguredInterval, 1, 3600); monitor.Change(TimeSpan.FromSeconds(updatedInterval), Timeout.InfiniteTimeSpan); } @@ -725,7 +724,6 @@ public async Task ValidateSerialDeviceAsync(string /// The socat configuration. /// The serial device path. /// The profile used (if any). - /// Optional logger for capturing protocol-level communication logs. /// Optional logger for capturing process stdout/stderr output. /// Token to cancel the operation. /// Process information for the started socat process. @@ -738,7 +736,7 @@ private async Task StartSocatProcessAsync( CancellationToken cancellationToken) { // Get settings from application settings service - bool captureProcessOutput = _settingsService.GetSetting("socat.captureProcessOutput", true); + bool captureProcessOutput = _settingsService.Current.Socat.CaptureProcessOutput; try { @@ -1132,7 +1130,6 @@ private async Task DiscoverExternalSocatProcessesAsync(CancellationToken cancell /// Updates the status of a specific process. /// /// The process information to update. - /// Token to cancel the operation. private Task UpdateProcessStatusAsync(SocatProcessInfo processInfo) { try @@ -1292,11 +1289,11 @@ protected virtual void Dispose(bool disposing) { if (!_disposed && disposing) { - // Stop all running processes + // Stop running processes — run on thread-pool to avoid deadlocks + // when Dispose() is called from a synchronisation context (e.g. UI thread). try { - Task stopTask = StopAllSocatProcessesAsync(CancellationToken.None); - stopTask.GetAwaiter().GetResult(); + Task.Run(() => StopAllSocatProcessesAsync(CancellationToken.None)).GetAwaiter().GetResult(); } catch (Exception ex) { diff --git a/src/S7Tools/Services/Tasking/JobScheduler.cs b/src/S7Tools/Services/Tasking/JobScheduler.cs index bc7abd1c..7248a858 100644 --- a/src/S7Tools/Services/Tasking/JobScheduler.cs +++ b/src/S7Tools/Services/Tasking/JobScheduler.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Extensions; namespace S7Tools.Services.Tasking; diff --git a/src/S7Tools/Services/Tasking/ResourceCoordinator.cs b/src/S7Tools/Services/Tasking/ResourceCoordinator.cs index c7d72efc..8c57b58f 100644 --- a/src/S7Tools/Services/Tasking/ResourceCoordinator.cs +++ b/src/S7Tools/Services/Tasking/ResourceCoordinator.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services.Tasking; diff --git a/src/S7Tools/Services/Tasking/EnhancedTaskScheduler.cs b/src/S7Tools/Services/Tasking/TaskScheduler.cs similarity index 99% rename from src/S7Tools/Services/Tasking/EnhancedTaskScheduler.cs rename to src/S7Tools/Services/Tasking/TaskScheduler.cs index 2c51a234..3f788677 100644 --- a/src/S7Tools/Services/Tasking/EnhancedTaskScheduler.cs +++ b/src/S7Tools/Services/Tasking/TaskScheduler.cs @@ -8,7 +8,6 @@ using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; using S7Tools.Extensions; namespace S7Tools.Services.Tasking; @@ -1283,10 +1282,11 @@ protected virtual void Dispose(bool disposing) } if (disposing) { - // Save tasks before disposing + // Save tasks before disposing — run on thread-pool to avoid deadlocks + // when Dispose() is called from a synchronisation context (e.g. UI thread). try { - SaveTasksAsync().GetAwaiter().GetResult(); + Task.Run(SaveTasksAsync).GetAwaiter().GetResult(); } catch (Exception ex) { diff --git a/src/S7Tools/Services/Time/TimeProvider.cs b/src/S7Tools/Services/Time/TimeProvider.cs index 8f56f247..db11e21e 100644 --- a/src/S7Tools/Services/Time/TimeProvider.cs +++ b/src/S7Tools/Services/Time/TimeProvider.cs @@ -1,4 +1,4 @@ -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; namespace S7Tools.Services.Time; diff --git a/src/S7Tools/Services/ActivityBarService.cs b/src/S7Tools/Services/UI/ActivityBarService.cs similarity index 100% rename from src/S7Tools/Services/ActivityBarService.cs rename to src/S7Tools/Services/UI/ActivityBarService.cs diff --git a/src/S7Tools/Services/AvaloniaUIThreadService.cs b/src/S7Tools/Services/UI/AvaloniaUIThreadService.cs similarity index 100% rename from src/S7Tools/Services/AvaloniaUIThreadService.cs rename to src/S7Tools/Services/UI/AvaloniaUIThreadService.cs diff --git a/src/S7Tools/Services/DockingService.cs b/src/S7Tools/Services/UI/DockingService.cs similarity index 97% rename from src/S7Tools/Services/DockingService.cs rename to src/S7Tools/Services/UI/DockingService.cs index d7c9bb00..69174b40 100644 --- a/src/S7Tools/Services/DockingService.cs +++ b/src/S7Tools/Services/UI/DockingService.cs @@ -30,7 +30,9 @@ public PanelState GetPanelState(string panelId) public void SetPanelVisible(string panelId, bool isVisible) { if (_panelStates.TryGetValue(panelId, out var state)) + { state.IsVisible = isVisible; + } } /// @@ -39,7 +41,9 @@ public void SetPanelVisible(string panelId, bool isVisible) public void SetPanelSize(string panelId, double size) { if (_panelStates.TryGetValue(panelId, out var state)) + { state.Size = size; + } } } diff --git a/src/S7Tools/Services/ErrorDisplayService.cs b/src/S7Tools/Services/UI/ErrorDisplayService.cs similarity index 100% rename from src/S7Tools/Services/ErrorDisplayService.cs rename to src/S7Tools/Services/UI/ErrorDisplayService.cs diff --git a/src/S7Tools/Services/LayoutService.cs b/src/S7Tools/Services/UI/LayoutService.cs similarity index 100% rename from src/S7Tools/Services/LayoutService.cs rename to src/S7Tools/Services/UI/LayoutService.cs diff --git a/src/S7Tools/Services/ThemeService.cs b/src/S7Tools/Services/UI/ThemeService.cs similarity index 98% rename from src/S7Tools/Services/ThemeService.cs rename to src/S7Tools/Services/UI/ThemeService.cs index c4bdaf26..0c81cb50 100644 --- a/src/S7Tools/Services/ThemeService.cs +++ b/src/S7Tools/Services/UI/ThemeService.cs @@ -417,7 +417,13 @@ private static string GetConfigFilePath() private sealed class ThemeConfiguration { + /// + /// Gets or sets the CurrentTheme. + /// public ThemeMode CurrentTheme { get; set; } + /// + /// Gets or sets the CustomColors. + /// public Dictionary? CustomColors { get; set; } } } diff --git a/src/S7Tools/Services/UIRefreshService.cs b/src/S7Tools/Services/UI/UIRefreshService.cs similarity index 100% rename from src/S7Tools/Services/UIRefreshService.cs rename to src/S7Tools/Services/UI/UIRefreshService.cs diff --git a/src/S7Tools/Services/UnifiedProfileDialogService.cs b/src/S7Tools/Services/UnifiedProfileDialogService.cs deleted file mode 100644 index 5c6e0156..00000000 --- a/src/S7Tools/Services/UnifiedProfileDialogService.cs +++ /dev/null @@ -1,519 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; -using S7Tools.Services.Interfaces; -using S7Tools.ViewModels; -using S7Tools.ViewModels.Profiles; - -namespace S7Tools.Services; - -/// -/// Unified service implementation for displaying profile editing dialogs across all profile types. -/// Delegates to existing type-specific dialog services while providing a consistent interface. -/// -/// -/// This service implements the adapter pattern to integrate with the existing ProfileEditDialogService -/// infrastructure while providing the unified interface required by ProfileManagementViewModelBase. -/// -/// Architecture principles: -/// - Single Responsibility: Coordinates profile dialog operations -/// - Open/Closed Principle: Extensible for new profile types without modification -/// - Dependency Inversion: Depends on abstractions, delegates to existing services -/// - Adapter Pattern: Bridges new unified interface with existing implementations -/// -public class UnifiedProfileDialogService : IUnifiedProfileDialogService -{ - private readonly IProfileEditDialogService _profileEditDialogService; - private readonly ISerialPortProfileService _serialPortProfileService; - private readonly ISocatProfileService _socatProfileService; - private readonly IPowerSupplyProfileService _powerSupplyProfileService; - private readonly IDialogService _dialogService; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The existing profile edit dialog service. - /// The serial port profile service. - /// The socat profile service. - /// The power supply profile service. - /// The general dialog service. - /// The logger. - public UnifiedProfileDialogService( - IProfileEditDialogService profileEditDialogService, - ISerialPortProfileService serialPortProfileService, - ISocatProfileService socatProfileService, - IPowerSupplyProfileService powerSupplyProfileService, - IDialogService dialogService, - ILogger logger) - { - _profileEditDialogService = profileEditDialogService ?? throw new ArgumentNullException(nameof(profileEditDialogService)); - _serialPortProfileService = serialPortProfileService ?? throw new ArgumentNullException(nameof(serialPortProfileService)); - _socatProfileService = socatProfileService ?? throw new ArgumentNullException(nameof(socatProfileService)); - _powerSupplyProfileService = powerSupplyProfileService ?? throw new ArgumentNullException(nameof(powerSupplyProfileService)); - _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - #region Serial Port Profile Operations - - /// - public async Task> ShowSerialCreateDialogAsync(ProfileCreateRequest request) - { - try - { - _logger.LogDebug("Showing create dialog for serial port profile with default name: {DefaultName}", request.DefaultName); - - Models.ProfileEditResult result = await _profileEditDialogService.CreateSerialProfileAsync(request.DefaultName).ConfigureAwait(false); - - if (result.IsSuccess && result.ProfileViewModel is SerialPortProfileViewModel viewModel) - { - SerialPortProfile profile = viewModel.CreateProfile(); - _logger.LogInformation("Serial port profile created successfully: {ProfileName}", profile.Name); - return ProfileDialogResult.Success(profile); - } - - if (!result.IsSuccess) - { - _logger.LogDebug("Serial port profile creation cancelled or failed"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Serial port profile creation failed: unexpected result type"); - return ProfileDialogResult.Failure("Unexpected result type from dialog"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing serial port profile create dialog"); - return ProfileDialogResult.Failure($"Error creating profile: {ex.Message}"); - } - } - - /// - public async Task> ShowSerialEditDialogAsync(ProfileEditRequest request) - { - try - { - _logger.LogDebug("Showing edit dialog for serial port profile ID: {ProfileId}", request.ProfileId); - - Models.ProfileEditResult result = await _profileEditDialogService.EditSerialProfileAsync(request.ProfileId).ConfigureAwait(false); - - if (result.IsSuccess && result.ProfileViewModel is SerialPortProfileViewModel viewModel) - { - SerialPortProfile profile = viewModel.CreateProfile(); - _logger.LogInformation("Serial port profile edited successfully: {ProfileName}", profile.Name); - return ProfileDialogResult.Success(profile); - } - - if (!result.IsSuccess) - { - _logger.LogDebug("Serial port profile edit cancelled or failed"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Serial port profile edit failed: unexpected result type"); - return ProfileDialogResult.Failure("Unexpected result type from dialog"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing serial port profile edit dialog for ID: {ProfileId}", request.ProfileId); - return ProfileDialogResult.Failure($"Error editing profile: {ex.Message}"); - } - } - - /// - public async Task> ShowSerialDuplicateDialogAsync(ProfileDuplicateRequest request) - { - try - { - _logger.LogDebug("Showing duplicate dialog for serial port profile ID: {SourceProfileId}", request.SourceProfileId); - - ProfileDuplicateResult result = await _profileEditDialogService.DuplicateSerialProfileAsync(request.SourceProfileId).ConfigureAwait(false); - - if (result.IsSuccess && !string.IsNullOrEmpty(result.NewName)) - { - _logger.LogInformation("Serial port profile duplicate name entered: {NewName}", result.NewName); - return ProfileDialogResult.Success(result.NewName); - } - - if (!result.IsSuccess) - { - _logger.LogDebug("Serial port profile duplicate cancelled or failed"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Serial port profile duplicate failed: {ErrorMessage}", result.ErrorMessage); - return ProfileDialogResult.Failure(result.ErrorMessage ?? "Profile duplication failed"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing serial port profile duplicate dialog for ID: {SourceProfileId}", request.SourceProfileId); - return ProfileDialogResult.Failure($"Error duplicating profile: {ex.Message}"); - } - } - - #endregion - - #region Socat Profile Operations - - /// - public async Task> ShowSocatCreateDialogAsync(ProfileCreateRequest request) - { - try - { - _logger.LogDebug("Showing create dialog for socat profile with default name: {DefaultName}", request.DefaultName); - - Models.ProfileEditResult result = await _profileEditDialogService.CreateSocatProfileAsync(request.DefaultName).ConfigureAwait(false); - - if (result.IsSuccess && result.ProfileViewModel is SocatProfileViewModel viewModel) - { - SocatProfile profile = viewModel.CreateProfile(); - _logger.LogInformation("Socat profile created successfully: {ProfileName}", profile.Name); - return ProfileDialogResult.Success(profile); - } - - if (!result.IsSuccess) - { - _logger.LogDebug("Socat profile creation cancelled or failed"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Socat profile creation failed: unexpected result type"); - return ProfileDialogResult.Failure("Unexpected result type from dialog"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing socat profile create dialog"); - return ProfileDialogResult.Failure($"Error creating profile: {ex.Message}"); - } - } - - /// - public async Task> ShowSocatEditDialogAsync(ProfileEditRequest request) - { - try - { - _logger.LogDebug("Showing edit dialog for socat profile ID: {ProfileId}", request.ProfileId); - - Models.ProfileEditResult result = await _profileEditDialogService.EditSocatProfileAsync(request.ProfileId).ConfigureAwait(false); - - if (result.IsSuccess && result.ProfileViewModel is SocatProfileViewModel viewModel) - { - SocatProfile profile = viewModel.CreateProfile(); - _logger.LogInformation("Socat profile edited successfully: {ProfileName}", profile.Name); - return ProfileDialogResult.Success(profile); - } - - if (!result.IsSuccess) - { - _logger.LogDebug("Socat profile edit cancelled or failed"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Socat profile edit failed: unexpected result type"); - return ProfileDialogResult.Failure("Unexpected result type from dialog"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing socat profile edit dialog for ID: {ProfileId}", request.ProfileId); - return ProfileDialogResult.Failure($"Error editing profile: {ex.Message}"); - } - } - - /// - public async Task> ShowSocatDuplicateDialogAsync(ProfileDuplicateRequest request) - { - try - { - _logger.LogDebug("Showing duplicate dialog for socat profile ID: {SourceProfileId}", request.SourceProfileId); - - ProfileDuplicateResult result = await _profileEditDialogService.DuplicateSocatProfileAsync(request.SourceProfileId).ConfigureAwait(false); - - if (result.IsSuccess && !string.IsNullOrEmpty(result.NewName)) - { - _logger.LogInformation("Socat profile duplicate name entered: {NewName}", result.NewName); - return ProfileDialogResult.Success(result.NewName); - } - - if (!result.IsSuccess) - { - _logger.LogDebug("Socat profile duplicate cancelled or failed"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Socat profile duplicate failed: {ErrorMessage}", result.ErrorMessage); - return ProfileDialogResult.Failure(result.ErrorMessage ?? "Profile duplication failed"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing socat profile duplicate dialog for ID: {SourceProfileId}", request.SourceProfileId); - return ProfileDialogResult.Failure($"Error duplicating profile: {ex.Message}"); - } - } - - #endregion - - #region Power Supply Profile Operations - - /// - public async Task> ShowPowerSupplyCreateDialogAsync(ProfileCreateRequest request) - { - try - { - _logger.LogDebug("Showing create dialog for power supply profile with default name: {DefaultName}", request.DefaultName); - System.Diagnostics.Debug.WriteLine($"DEBUG: ShowPowerSupplyCreateDialogAsync called with name: {request.DefaultName}"); - - Models.ProfileEditResult result = await _profileEditDialogService.CreatePowerSupplyProfileAsync(request.DefaultName).ConfigureAwait(false); - - if (result.IsSuccess && result.ProfileViewModel is PowerSupplyProfileViewModel viewModel) - { - PowerSupplyProfile profile = viewModel.CreateProfile(); - _logger.LogInformation("Power supply profile created successfully: {ProfileName}", profile.Name); - return ProfileDialogResult.Success(profile); - } - - if (!result.IsSuccess) - { - _logger.LogDebug("Power supply profile creation cancelled or failed"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Power supply profile creation failed: unexpected result type"); - return ProfileDialogResult.Failure("Unexpected result type from dialog"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing power supply profile create dialog"); - return ProfileDialogResult.Failure($"Error creating profile: {ex.Message}"); - } - } - - /// - public async Task> ShowPowerSupplyEditDialogAsync(ProfileEditRequest request) - { - try - { - _logger.LogDebug("Showing edit dialog for power supply profile ID: {ProfileId}", request.ProfileId); - - Models.ProfileEditResult result = await _profileEditDialogService.EditPowerSupplyProfileAsync(request.ProfileId).ConfigureAwait(false); - - if (result.IsSuccess && result.ProfileViewModel is PowerSupplyProfileViewModel viewModel) - { - PowerSupplyProfile profile = viewModel.CreateProfile(); - _logger.LogInformation("Power supply profile edited successfully: {ProfileName}", profile.Name); - return ProfileDialogResult.Success(profile); - } - - if (!result.IsSuccess) - { - _logger.LogDebug("Power supply profile edit cancelled or failed"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Power supply profile edit failed: unexpected result type"); - return ProfileDialogResult.Failure("Unexpected result type from dialog"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing power supply profile edit dialog for ID: {ProfileId}", request.ProfileId); - return ProfileDialogResult.Failure($"Error editing profile: {ex.Message}"); - } - } - - /// - public async Task> ShowPowerSupplyDuplicateDialogAsync(ProfileDuplicateRequest request) - { - try - { - _logger.LogDebug("Showing duplicate dialog for power supply profile ID: {SourceProfileId}", request.SourceProfileId); - - ProfileDuplicateResult result = await _profileEditDialogService.DuplicatePowerSupplyProfileAsync(request.SourceProfileId).ConfigureAwait(false); - - if (result.IsSuccess && !string.IsNullOrEmpty(result.NewName)) - { - _logger.LogInformation("Power supply profile duplicate name entered: {NewName}", result.NewName); - return ProfileDialogResult.Success(result.NewName); - } - - if (!result.IsSuccess) - { - _logger.LogDebug("Power supply profile duplicate cancelled or failed"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Power supply profile duplicate failed: {ErrorMessage}", result.ErrorMessage); - return ProfileDialogResult.Failure(result.ErrorMessage ?? "Profile duplication failed"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing power supply profile duplicate dialog for ID: {SourceProfileId}", request.SourceProfileId); - return ProfileDialogResult.Failure($"Error duplicating profile: {ex.Message}"); - } - } - - #endregion - - #region Job Profile Operations - - /// - public async Task> ShowJobCreateDialogAsync(ProfileCreateRequest request) - { - try - { - _logger.LogDebug("Showing job create dialog with title: {Title}, default name: {DefaultName}", - request.Title, request.DefaultName); - - await Task.CompletedTask; - - - // Job profiles are created via the Job Wizard, not a simple dialog - // Direct the user to use the Job Wizard instead - _logger.LogInformation("Job creation should be done through Job Wizard"); - - // Return cancelled to indicate this operation should be done through the wizard - return ProfileDialogResult.Cancelled(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing job create dialog"); - return ProfileDialogResult.Failure($"Error creating profile: {ex.Message}"); - } - } - - /// - public async Task> ShowJobEditDialogAsync(ProfileEditRequest request) - { - try - { - _logger.LogDebug("Showing job edit dialog for profile ID: {ProfileId}", request.ProfileId); - - await Task.CompletedTask; - - - // Job profiles are edited via the Job Wizard, not a simple dialog - // Direct the user to use the Job Wizard instead - _logger.LogInformation("Job editing should be done through Job Wizard"); - - // Return cancelled to indicate this operation should be done through the wizard - return ProfileDialogResult.Cancelled(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing job edit dialog for ID: {ProfileId}", request.ProfileId); - return ProfileDialogResult.Failure($"Error editing profile: {ex.Message}"); - } - } - - /// - public async Task> ShowJobDuplicateDialogAsync(ProfileDuplicateRequest request) - { - try - { - _logger.LogDebug("Showing job duplicate dialog for source profile ID: {SourceProfileId}, suggested name: {SuggestedName}", - request.SourceProfileId, request.SuggestedName); - - // Show input dialog to get new name for duplicated job - ProfileDialogResult result = await ShowNameInputDialogAsync( - "Duplicate Job", - "Enter a name for the duplicated job:", - request.SuggestedName).ConfigureAwait(false); - - if (result.IsSuccess && !string.IsNullOrEmpty(result.Result)) - { - _logger.LogInformation("Job duplicate name entered: {NewName}", result.Result); - return ProfileDialogResult.Success(result.Result); - } - - if (!result.IsSuccess && string.IsNullOrEmpty(result.ErrorMessage)) - { - // Cancelled (IsSuccess = false with no error message) - _logger.LogDebug("Job duplicate dialog cancelled by user"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogWarning("Job duplicate failed: {ErrorMessage}", result.ErrorMessage); - return ProfileDialogResult.Failure(result.ErrorMessage ?? "Profile duplication failed"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing job duplicate dialog for ID: {SourceProfileId}", request.SourceProfileId); - return ProfileDialogResult.Failure($"Error duplicating profile: {ex.Message}"); - } - } - - #endregion - - #region Common Dialog Operations - - /// - public async Task ShowDeleteConfirmationDialogAsync(string profileName, string profileType) - { - try - { - _logger.LogDebug("Showing delete confirmation dialog for {ProfileType} profile: {ProfileName}", profileType, profileName); - - string title = $"Delete {profileType} Profile"; - string message = $"Are you sure you want to delete the profile '{profileName}'?\n\nThis action cannot be undone."; - - bool result = await _dialogService.ShowConfirmationAsync(title, message).ConfigureAwait(false); - - _logger.LogDebug("Delete confirmation result for {ProfileName}: {Result}", profileName, result); - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing delete confirmation dialog for profile: {ProfileName}", profileName); - return false; - } - } - - /// - public async Task> ShowNameInputDialogAsync( - string title, - string prompt, - string defaultValue = "", - Func>? validator = null) - { - try - { - _logger.LogDebug("Showing name input dialog with title: {Title}", title); - - Models.InputResult result = await _dialogService.ShowInputAsync(title, prompt, defaultValue).ConfigureAwait(false); - - if (!result.IsCancelled && !string.IsNullOrEmpty(result.Value)) - { - // Apply validation if provided - if (validator != null) - { - ProfileValidationResult validationResult = await validator(result.Value).ConfigureAwait(false); - if (!validationResult.IsValid) - { - _logger.LogDebug("Name input validation failed: {ErrorMessage}", validationResult.ErrorMessage); - return ProfileDialogResult.Failure(validationResult.ErrorMessage); - } - } - - _logger.LogDebug("Name input dialog completed successfully: {Value}", result.Value); - return ProfileDialogResult.Success(result.Value); - } - - if (result.IsCancelled) - { - _logger.LogDebug("Name input dialog cancelled by user"); - return ProfileDialogResult.Cancelled(); - } - - _logger.LogDebug("Name input dialog failed: empty value"); - return ProfileDialogResult.Failure("Name input failed"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while showing name input dialog"); - return ProfileDialogResult.Failure($"Error showing input dialog: {ex.Message}"); - } - } - - #endregion -} diff --git a/src/S7Tools/Services/ViewModelFactory.cs b/src/S7Tools/Services/ViewModelFactory.cs deleted file mode 100644 index bc729227..00000000 --- a/src/S7Tools/Services/ViewModelFactory.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using Microsoft.Extensions.DependencyInjection; -using S7Tools.Services.Interfaces; -using S7Tools.ViewModels; - -namespace S7Tools.Services; - -/// -/// Factory for creating ViewModels through dependency injection. -/// -public class ViewModelFactory : IViewModelFactory -{ - private readonly IServiceProvider _serviceProvider; - - /// - /// Initializes a new instance of the class. - /// - /// The service provider for dependency injection. - public ViewModelFactory(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - } - - /// - public T Create() where T : ViewModelBase - { - return _serviceProvider.GetRequiredService(); - } - - /// - public ViewModelBase Create(Type viewModelType) - { - if (!typeof(ViewModelBase).IsAssignableFrom(viewModelType)) - { - throw new ArgumentException($"Type {viewModelType.Name} must inherit from ViewModelBase", nameof(viewModelType)); - } - - return (ViewModelBase)_serviceProvider.GetRequiredService(viewModelType); - } -} diff --git a/src/S7Tools/ViewLocator.cs b/src/S7Tools/ViewLocator.cs index 3298245c..42436690 100644 --- a/src/S7Tools/ViewLocator.cs +++ b/src/S7Tools/ViewLocator.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.Concurrent; using System.Linq; @@ -125,4 +126,4 @@ public bool Match(object? data) { return data is ViewModelBase || data is IDockable; } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Base/ProfileManagementViewModelBase.cs b/src/S7Tools/ViewModels/Base/ProfileManagementViewModelBase.cs index 8293dd71..0350e8d4 100644 --- a/src/S7Tools/ViewModels/Base/ProfileManagementViewModelBase.cs +++ b/src/S7Tools/ViewModels/Base/ProfileManagementViewModelBase.cs @@ -2,16 +2,16 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.IO; using System.Linq; using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; -using System.Threading.Tasks; -using System.IO; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using ReactiveUI; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -41,6 +41,9 @@ public abstract class ProfileManagementViewModelBase : ViewModelBase, { private readonly ILogger _logger; private readonly IUnifiedProfileDialogService _profileDialogService; + /// + /// Gets or sets the UnifiedDialogService. + /// protected IUnifiedProfileDialogService UnifiedDialogService => _profileDialogService; private readonly IDialogService _dialogService; private readonly IUIThreadService _uiThreadService; @@ -252,8 +255,17 @@ public string SearchText /// public ReactiveCommand SetDefaultCommand { get; private set; } = null!; + /// + /// Gets or sets the ExportProfilesCommand. + /// public ReactiveCommand ExportProfilesCommand { get; private set; } = null!; + /// + /// Gets or sets the ImportProfilesCommand. + /// public ReactiveCommand ImportProfilesCommand { get; private set; } = null!; + /// + /// Gets or sets the ExportSelectedProfileCommand. + /// public ReactiveCommand ExportSelectedProfileCommand { get; private set; } = null!; #endregion @@ -737,7 +749,7 @@ private async Task ImportProfilesAsync() IEnumerable importedProfiles = await GetProfileManager().ImportAsync(profiles, replaceExisting: false); int importedCount = importedProfiles.Count(); - await LoadProfilesAsync(); + await LoadProfilesAsync(); StatusMessage = $"Imported {importedCount} profile(s) from {Path.GetFileName(fileName)}"; _logger.LogInformation("Imported {ImportedCount} profiles from {FileName}", importedCount, fileName); diff --git a/src/S7Tools/ViewModels/ViewModelBase.cs b/src/S7Tools/ViewModels/Base/ViewModelBase.cs similarity index 97% rename from src/S7Tools/ViewModels/ViewModelBase.cs rename to src/S7Tools/ViewModels/Base/ViewModelBase.cs index edf68fa8..fe8ff567 100644 --- a/src/S7Tools/ViewModels/ViewModelBase.cs +++ b/src/S7Tools/ViewModels/Base/ViewModelBase.cs @@ -3,7 +3,7 @@ using System.Linq; using ReactiveUI; -namespace S7Tools.ViewModels; +namespace S7Tools.ViewModels.Base; /// /// Base class for all view models. diff --git a/src/S7Tools/ViewModels/Components/TaskLogsPanelViewModel.cs b/src/S7Tools/ViewModels/Components/TaskLogsPanelViewModel.cs index f5c5597a..3021e357 100644 --- a/src/S7Tools/ViewModels/Components/TaskLogsPanelViewModel.cs +++ b/src/S7Tools/ViewModels/Components/TaskLogsPanelViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections; using System.Collections.Generic; @@ -7,14 +8,18 @@ using System.Text; using ReactiveUI; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Infrastructure.Logging.Core.Storage; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; using S7Tools.Services; using S7Tools.Services.Interfaces; namespace S7Tools.ViewModels.Components; +/// +/// Represents the TaskLogsPanelViewModel. +/// public class TaskLogsPanelViewModel : ViewModelBase, IDisposable { private const int MaxLogEntries = 1000; @@ -48,6 +53,9 @@ public bool InvertAutoScroll } private bool _invertAutoScroll; + /// + /// Initializes a new instance of the class. + /// public TaskLogsPanelViewModel( TaskExecution task, IClipboardService clipboardService, @@ -157,9 +165,15 @@ public TaskLogsPanelViewModel( } } - if (needsMainSort) ApplySortToMain(); - if (needsProcessSort) ApplySortToProcess(); + if (needsMainSort) + { + ApplySortToMain(); + } + if (needsProcessSort) + { + ApplySortToProcess(); + } }, TimeSpan.FromMilliseconds(500), _uiThreadService!); _mainHandler = (s, e) => HandleLogCollectionChanged(s, e, "Main"); @@ -190,7 +204,10 @@ private void SortByColumn(string column) { InvertAutoScroll = true; // Also enable auto scroll if moving to this default - if (!AutoScroll) AutoScroll = true; + if (!AutoScroll) + { + AutoScroll = true; + } } else { @@ -222,11 +239,17 @@ private void ApplySortToProcess() private IEnumerable SortLogEntries(IEnumerable source) { if (_sortColumn == "Level") + { return _sortAscending ? source.OrderBy(e => e.Level).ThenBy(e => e.Timestamp) : source.OrderByDescending(e => e.Level).ThenByDescending(e => e.Timestamp); + } else if (_sortColumn == "Message") + { return _sortAscending ? source.OrderBy(e => e.Message).ThenBy(e => e.Timestamp) : source.OrderByDescending(e => e.Message).ThenByDescending(e => e.Timestamp); + } else // Timestamp + { return _sortAscending ? source.OrderBy(e => e.Timestamp) : source.OrderByDescending(e => e.Timestamp); + } } private void InitializeLogs() @@ -254,10 +277,16 @@ private void InitializeLogs() private void PopulateInitialLogEntries(ITaskLogDataStore? store, ObservableCollection sourceCollection, ObservableCollection targetCollection) { - if (store == null) return; + if (store == null) + { + return; + } int count = store.Count(); - if (count == 0) return; + if (count == 0) + { + return; + } var initialEntries = new List(); int skipCount = Math.Max(0, count - MaxLogEntries); @@ -313,12 +342,18 @@ private void HandleLogCollectionChanged(object? sender, System.Collections.Speci } } + /// + /// Executes the Dispose operation. + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + /// + /// Executes the Dispose operation. + /// protected virtual void Dispose(bool disposing) { if (disposing) @@ -343,9 +378,15 @@ public TaskExecution Task set => this.RaiseAndSetIfChanged(ref _task, value); } + /// + /// Gets or sets the MainLogEntries. + /// public ObservableCollection MainLogEntries { get; } + /// + /// Gets or sets the ProcessLogEntries. + /// public ObservableCollection ProcessLogEntries { get; } - + private ObservableCollection _filteredMainLogEntries = new(); public ObservableCollection FilteredMainLogEntries { @@ -360,7 +401,16 @@ public ObservableCollection FilteredProcessLogEntries private set => this.RaiseAndSetIfChanged(ref _filteredProcessLogEntries, value); } + /// + /// Gets or sets the CopySelectedEntryCommand. + /// public ReactiveCommand CopySelectedEntryCommand { get; } + /// + /// Gets or sets the CopySelectedMessageCommand. + /// public ReactiveCommand CopySelectedMessageCommand { get; } + /// + /// Gets or sets the SortCommand. + /// public ReactiveCommand SortCommand { get; } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Controls/SerialPortDiscoveryViewModel.cs b/src/S7Tools/ViewModels/Controls/SerialPortDiscoveryViewModel.cs index 18698cb6..de8fbe7f 100644 --- a/src/S7Tools/ViewModels/Controls/SerialPortDiscoveryViewModel.cs +++ b/src/S7Tools/ViewModels/Controls/SerialPortDiscoveryViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.ObjectModel; using System.Linq; @@ -10,7 +11,6 @@ using ReactiveUI; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -68,9 +68,9 @@ public SerialPortDiscoveryViewModel( ScanHistory = new ObservableCollection(); // Load initial filter preferences from application settings - _includeUsbPorts = _settingsService.GetSetting("serial.includeUsbPorts", true); - _includeAcmPorts = _settingsService.GetSetting("serial.includeAcmPorts", true); - _includeSerialPorts = _settingsService.GetSetting("serial.includeStandardPorts", true); + _includeUsbPorts = _settingsService.Current.Serial.IncludeUsbPorts; + _includeAcmPorts = _settingsService.Current.Serial.IncludeAcmPorts; + _includeSerialPorts = _settingsService.Current.Serial.IncludeStandardPorts; // Initialize commands InitializeCommands(); @@ -293,7 +293,7 @@ private void SetFilterProperty(ref bool field, bool value, string propertyName) { _logger.LogDebug("Unchecking {PropertyName} would leave no filters - forcing USB to true", propertyName); _includeUsbPorts = true; - _ = _settingsService.SetSettingAsync("serial.includeUsbPorts", true); + _ = _settingsService.UpdateSettingsAsync(s => s.Serial.IncludeUsbPorts = true); // The UIRefreshService will handle the property change notifications automatically this.RaisePropertyChanged(nameof(IncludeUsbPorts)); @@ -303,18 +303,21 @@ private void SetFilterProperty(ref bool field, bool value, string propertyName) // Normal property change - UIRefreshService handles the rest if (this.RaiseAndSetIfChanged(ref field, value)) { - string settingKey = propertyName switch + _ = _settingsService.UpdateSettingsAsync(s => { - nameof(IncludeUsbPorts) => "serial.includeUsbPorts", - nameof(IncludeAcmPorts) => "serial.includeAcmPorts", - nameof(IncludeSerialPorts) => "serial.includeStandardPorts", - _ => string.Empty - }; - - if (!string.IsNullOrEmpty(settingKey)) - { - _ = _settingsService.SetSettingAsync(settingKey, value); - } + switch (propertyName) + { + case nameof(IncludeUsbPorts): + s.Serial.IncludeUsbPorts = value; + break; + case nameof(IncludeAcmPorts): + s.Serial.IncludeAcmPorts = value; + break; + case nameof(IncludeSerialPorts): + s.Serial.IncludeStandardPorts = value; + break; + } + }); _logger.LogDebug("{PropertyName} changed to {Value}", propertyName, value); ApplyFiltersToDiscoveredPorts(); @@ -477,7 +480,7 @@ await _uiThreadService.InvokeOnUIThreadAsync(() => CancellationToken cancellationToken = _scanCancellationTokenSource.Token; // Get available ports - IEnumerable availablePorts = await _portService.ScanAvailablePortsAsync(cancellationToken); + IEnumerable availablePorts = await _portService.ScanAvailablePortsAsync(cancellationToken); // Map port info objects directly from backend response to avoid double I/O testing var portInfos = new List(); @@ -619,7 +622,7 @@ private async Task RefreshPortInfoAsync() { try { - Core.Services.Interfaces.SerialPortInfo? portDetails = await _portService.GetPortInfoAsync(portName).ConfigureAwait(false); + Core.Interfaces.Services.SerialPortInfo? portDetails = await _portService.GetPortInfoAsync(portName).ConfigureAwait(false); if (portDetails != null) { SelectedPort.Description = portDetails.Description ?? ""; @@ -1037,6 +1040,9 @@ public enum PortTypeEnum Unknown } +/// +/// Represents the SerialPortInfo. +/// public class SerialPortInfo : ReactiveObject { private string _portName = string.Empty; diff --git a/src/S7Tools/ViewModels/Dialogs/ConfirmationDialogViewModel.cs b/src/S7Tools/ViewModels/Dialogs/ConfirmationDialogViewModel.cs index a21f5a0e..f4607dda 100644 --- a/src/S7Tools/ViewModels/Dialogs/ConfirmationDialogViewModel.cs +++ b/src/S7Tools/ViewModels/Dialogs/ConfirmationDialogViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System.Reactive; using ReactiveUI; @@ -55,4 +56,4 @@ public ConfirmationDialogViewModel(string title, string message, bool showCancel public ConfirmationDialogViewModel() : this("Confirmation", "Are you sure?") { } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Dialogs/CreateMemoryRegionProfileDialogViewModel.cs b/src/S7Tools/ViewModels/Dialogs/CreateMemoryRegionProfileDialogViewModel.cs index 4ec85ab1..5100f5cc 100644 --- a/src/S7Tools/ViewModels/Dialogs/CreateMemoryRegionProfileDialogViewModel.cs +++ b/src/S7Tools/ViewModels/Dialogs/CreateMemoryRegionProfileDialogViewModel.cs @@ -9,10 +9,10 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using ReactiveUI; -using S7Tools.Services.Interfaces; using S7Tools.Core.Constants; using S7Tools.Core.Models; using S7Tools.Resources; +using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Base; namespace S7Tools.ViewModels.Dialogs; diff --git a/src/S7Tools/ViewModels/Dialogs/InputDialogViewModel.cs b/src/S7Tools/ViewModels/Dialogs/InputDialogViewModel.cs index b3647c28..58507ca9 100644 --- a/src/S7Tools/ViewModels/Dialogs/InputDialogViewModel.cs +++ b/src/S7Tools/ViewModels/Dialogs/InputDialogViewModel.cs @@ -1,6 +1,8 @@ +using S7Tools.ViewModels.Base; using System.Reactive; using ReactiveUI; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; namespace S7Tools.ViewModels.Dialogs; @@ -95,4 +97,4 @@ private void OnCancel() _result = InputResult.Cancelled(); CloseRequested?.Invoke(_result); } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Dialogs/JobSelectionDialogViewModel.cs b/src/S7Tools/ViewModels/Dialogs/JobSelectionDialogViewModel.cs index 93e675c4..f2c18554 100644 --- a/src/S7Tools/ViewModels/Dialogs/JobSelectionDialogViewModel.cs +++ b/src/S7Tools/ViewModels/Dialogs/JobSelectionDialogViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.ObjectModel; using System.Linq; @@ -107,4 +108,4 @@ private void FilterJobs() FilteredJobs = new ObservableCollection(filtered); } } -} +} \ No newline at end of file diff --git a/src/S7Tools/Models/ConfirmationRequest.cs b/src/S7Tools/ViewModels/Dialogs/Models/ConfirmationRequest.cs similarity index 76% rename from src/S7Tools/Models/ConfirmationRequest.cs rename to src/S7Tools/ViewModels/Dialogs/Models/ConfirmationRequest.cs index 1861253c..dacf6192 100644 --- a/src/S7Tools/Models/ConfirmationRequest.cs +++ b/src/S7Tools/ViewModels/Dialogs/Models/ConfirmationRequest.cs @@ -1,4 +1,4 @@ -namespace S7Tools.Models; +namespace S7Tools.ViewModels.Dialogs.Models; /// /// Represents a request for user confirmation. diff --git a/src/S7Tools/Models/InputRequest.cs b/src/S7Tools/ViewModels/Dialogs/Models/InputRequest.cs similarity index 96% rename from src/S7Tools/Models/InputRequest.cs rename to src/S7Tools/ViewModels/Dialogs/Models/InputRequest.cs index 3326751f..8eff5a55 100644 --- a/src/S7Tools/Models/InputRequest.cs +++ b/src/S7Tools/ViewModels/Dialogs/Models/InputRequest.cs @@ -1,4 +1,4 @@ -namespace S7Tools.Models; +namespace S7Tools.ViewModels.Dialogs.Models; /// /// Represents a request for user text input. diff --git a/src/S7Tools/Models/JobSelectionRequest.cs b/src/S7Tools/ViewModels/Dialogs/Models/JobSelectionRequest.cs similarity index 92% rename from src/S7Tools/Models/JobSelectionRequest.cs rename to src/S7Tools/ViewModels/Dialogs/Models/JobSelectionRequest.cs index 08b69934..cf61ed8c 100644 --- a/src/S7Tools/Models/JobSelectionRequest.cs +++ b/src/S7Tools/ViewModels/Dialogs/Models/JobSelectionRequest.cs @@ -1,6 +1,6 @@ -using S7Tools.Core.Models.Jobs; +using global::S7Tools.Core.Models.Jobs; -namespace S7Tools.Models; +namespace S7Tools.ViewModels.Dialogs.Models; /// /// Request model for job selection dialog interaction. diff --git a/src/S7Tools/ViewModels/Dialogs/Models/ProfileEditRequest.cs b/src/S7Tools/ViewModels/Dialogs/Models/ProfileEditRequest.cs new file mode 100644 index 00000000..0a7dffd3 --- /dev/null +++ b/src/S7Tools/ViewModels/Dialogs/Models/ProfileEditRequest.cs @@ -0,0 +1,32 @@ +using S7Tools.ViewModels.Base; + +namespace S7Tools.ViewModels.Dialogs.Models; + +/// +/// Represents the ProfileEditRequest. +/// +public class ProfileEditRequest +{ + /// + /// Gets or sets the Title. + /// + public string Title { get; } + /// + /// Gets or sets the ProfileViewModel. + /// + public ViewModelBase ProfileViewModel { get; } + /// + /// Gets or sets the ProfileType. + /// + public ProfileType ProfileType { get; } + + /// + /// Initializes a new instance of the class. + /// + public ProfileEditRequest(string title, ViewModelBase profileViewModel, ProfileType profileType) + { + Title = title; + ProfileViewModel = profileViewModel; + ProfileType = profileType; + } +} diff --git a/src/S7Tools/ViewModels/Dialogs/Models/ProfileEditResult.cs b/src/S7Tools/ViewModels/Dialogs/Models/ProfileEditResult.cs new file mode 100644 index 00000000..8fb1c653 --- /dev/null +++ b/src/S7Tools/ViewModels/Dialogs/Models/ProfileEditResult.cs @@ -0,0 +1,43 @@ +using S7Tools.ViewModels.Base; + +namespace S7Tools.ViewModels.Dialogs.Models; + +/// +/// Represents the ProfileEditResult. +/// +public class ProfileEditResult +{ + /// + /// Gets or sets the IsSuccess. + /// + public bool IsSuccess { get; private set; } + /// + /// Gets or sets the ProfileViewModel. + /// + public ViewModelBase? ProfileViewModel { get; private set; } + + private ProfileEditResult() {} + + /// + /// Executes the Success operation. + /// + public static ProfileEditResult Success(ViewModelBase profileViewModel) + { + return new ProfileEditResult + { + IsSuccess = true, + ProfileViewModel = profileViewModel + }; + } + + /// + /// Executes the Cancelled operation. + /// + public static ProfileEditResult Cancelled() + { + return new ProfileEditResult + { + IsSuccess = false + }; + } +} diff --git a/src/S7Tools/ViewModels/Dialogs/Models/ProfileType.cs b/src/S7Tools/ViewModels/Dialogs/Models/ProfileType.cs new file mode 100644 index 00000000..e8f6f92b --- /dev/null +++ b/src/S7Tools/ViewModels/Dialogs/Models/ProfileType.cs @@ -0,0 +1,11 @@ +namespace S7Tools.ViewModels.Dialogs.Models; + +/// +/// Represents the ProfileType. +/// +public enum ProfileType +{ + Serial, + Socat, + PowerSupply +} diff --git a/src/S7Tools/ViewModels/Hex/DataInspectorViewModel.cs b/src/S7Tools/ViewModels/Hex/DataInspectorViewModel.cs index 5d433d36..cdde7fe1 100644 --- a/src/S7Tools/ViewModels/Hex/DataInspectorViewModel.cs +++ b/src/S7Tools/ViewModels/Hex/DataInspectorViewModel.cs @@ -14,7 +14,13 @@ namespace S7Tools.ViewModels.Hex; public partial class DataInspectorViewModel : ObservableObject { // Navigation & Editing Actions + /// + /// Gets or sets the RequestGoToOffset. + /// public Action? RequestGoToOffset { get; set; } + /// + /// Gets or sets the RequestFillSelection. + /// public Action? RequestFillSelection { get; set; } [ObservableProperty] @@ -150,6 +156,9 @@ private void OnIsBigEndianChanged(bool value) private byte[]? _lastBytes; + /// + /// Executes the Update operation. + /// public void Update(byte[]? data) { _lastBytes = data; @@ -241,8 +250,14 @@ private void Clear() // Helpers not strictly needed with the array logic above but good for clarity if reused private static byte[] ReverseIfBig(byte[] b) { if (!BitConverter.IsLittleEndian) { Array.Reverse(b); } return b; } + /// + /// Gets or sets the Search. + /// public SearchViewModel Search { get; } + /// + /// Initializes a new instance of the class. + /// public DataInspectorViewModel() { // Initialize SearchViewModel @@ -255,6 +270,9 @@ private void OnSearchRequestNavigation(long offset) RequestGoToOffset?.Invoke(offset); } + /// + /// Executes the SetDocument operation. + /// public void SetDocument(AvaloniaHex.Document.IBinaryDocument? document) { Search.SetDocument(document); diff --git a/src/S7Tools/ViewModels/Hex/HexViewerViewModel.cs b/src/S7Tools/ViewModels/Hex/HexViewerViewModel.cs index 83b1a4e0..91731ecf 100644 --- a/src/S7Tools/ViewModels/Hex/HexViewerViewModel.cs +++ b/src/S7Tools/ViewModels/Hex/HexViewerViewModel.cs @@ -11,6 +11,9 @@ namespace S7Tools.ViewModels.Hex { + /// + /// Represents the HexViewerViewModel. + /// public class HexViewerViewModel : ReactiveObject, IDisposable { private readonly ILogger _logger; @@ -28,6 +31,9 @@ public HexViewerViewModel() : this(Microsoft.Extensions.Logging.Abstractions.Nul { } + /// + /// Initializes a new instance of the class. + /// public HexViewerViewModel(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -83,6 +89,9 @@ private void OnRequestFillSelection(byte[] pattern) } } + /// + /// Gets or sets the DataInspector. + /// public DataInspectorViewModel? DataInspector { get; set; } public IBinaryDocument? Document @@ -103,6 +112,9 @@ public long FileSize private set => this.RaiseAndSetIfChanged(ref _fileSize, value); } + /// + /// Gets or sets the IsFileOpen. + /// public bool IsFileOpen => Document != null; public int DisplayBase @@ -126,9 +138,18 @@ public long SelectionLength // View configuration logic has been moved to the View's code-behind // to better support AvaloniaHex control features directly. + /// + /// Gets or sets the CloseFileCommand. + /// public ReactiveCommand CloseFileCommand { get; } + /// + /// Gets or sets the CopyCommand. + /// public ReactiveCommand CopyCommand { get; set; } = null!; // Set by View or initialized later if we move logic here + /// + /// Executes the OpenStream operation. + /// public void OpenStream(string path) { CloseFile(); @@ -185,12 +206,18 @@ private void UpdateInspector() } } + /// + /// Executes the Dispose operation. + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + /// + /// Executes the Dispose operation. + /// protected virtual void Dispose(bool disposing) { if (disposing) diff --git a/src/S7Tools/ViewModels/Hex/SearchViewModel.cs b/src/S7Tools/ViewModels/Hex/SearchViewModel.cs index dedf878e..10c8982b 100644 --- a/src/S7Tools/ViewModels/Hex/SearchViewModel.cs +++ b/src/S7Tools/ViewModels/Hex/SearchViewModel.cs @@ -11,6 +11,9 @@ namespace S7Tools.ViewModels.Hex { + /// + /// Represents the SearchMode. + /// public enum SearchMode { Hex, @@ -18,6 +21,9 @@ public enum SearchMode Binary // Interpreted as bit string "0101" } + /// + /// Represents the SearchViewModel. + /// public class SearchViewModel : ReactiveObject { private readonly IBinarySearchService _searchService; @@ -29,6 +35,9 @@ public class SearchViewModel : ReactiveObject private int _currentResultIndex = -1; private ObservableCollection _searchResults = new(); + /// + /// Initializes a new instance of the class. + /// public SearchViewModel(IBinarySearchService searchService) { _searchService = searchService; @@ -45,9 +54,21 @@ public SearchViewModel(IBinarySearchService searchService) CloseCommand = ReactiveCommand.Create(() => { IsVisible = false; }); } + /// + /// Gets or sets the FindCommand. + /// public ReactiveCommand FindCommand { get; } + /// + /// Gets or sets the FindNextCommand. + /// public ReactiveCommand FindNextCommand { get; } + /// + /// Gets or sets the FindPreviousCommand. + /// public ReactiveCommand FindPreviousCommand { get; } + /// + /// Gets or sets the CloseCommand. + /// public ReactiveCommand CloseCommand { get; } public event Action? RequestNavigation; @@ -102,6 +123,9 @@ public int CurrentResultIndex } } + /// + /// Executes the SetDocument operation. + /// public void SetDocument(IBinaryDocument? document) { _document = document; diff --git a/src/S7Tools/ViewModels/Jobs/JobInfoDisplayViewModel.cs b/src/S7Tools/ViewModels/Jobs/JobInfoDisplayViewModel.cs index 05beef97..969207aa 100644 --- a/src/S7Tools/ViewModels/Jobs/JobInfoDisplayViewModel.cs +++ b/src/S7Tools/ViewModels/Jobs/JobInfoDisplayViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.ObjectModel; using System.Linq; @@ -10,7 +11,7 @@ using S7Tools.Core.Constants; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Services; using S7Tools.ViewModels; using S7Tools.ViewModels.Controls; @@ -41,6 +42,9 @@ public class JobInfoDisplayViewModel : ViewModelBase, IDisposable private bool _hasMissingProfiles; private readonly ObservableCollection _missingProfileWarnings = []; + /// + /// Initializes a new instance of the class. + /// public JobInfoDisplayViewModel( IProfileDetailsService profileDetailsService, ISerialPortProfileService serialService, @@ -511,12 +515,18 @@ private async Task RefreshAsync() #region IDisposable + /// + /// Executes the Dispose operation. + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + /// + /// Executes the Dispose operation. + /// protected virtual void Dispose(bool disposing) { if (disposing) @@ -526,4 +536,4 @@ protected virtual void Dispose(bool disposing) } #endregion -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Jobs/JobWizardMemoryRegionStepViewModel.cs b/src/S7Tools/ViewModels/Jobs/JobWizardMemoryRegionStepViewModel.cs index 02386dd0..d940712c 100644 --- a/src/S7Tools/ViewModels/Jobs/JobWizardMemoryRegionStepViewModel.cs +++ b/src/S7Tools/ViewModels/Jobs/JobWizardMemoryRegionStepViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -10,7 +11,7 @@ using Microsoft.Extensions.Logging; using ReactiveUI; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Resources.Strings; using S7Tools.Services.Interfaces; @@ -44,6 +45,9 @@ public class JobWizardMemoryRegionStepViewModel : ViewModelBase, IDisposable #region Constructor + /// + /// Initializes a new instance of the class. + /// public JobWizardMemoryRegionStepViewModel( ILogger logger, IMemoryRegionProfileService memoryRegionService, @@ -509,12 +513,18 @@ private void OnSegmentPropertyChanged(object? sender, PropertyChangedEventArgs e #region IDisposable + /// + /// Executes the Dispose operation. + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + /// + /// Executes the Dispose operation. + /// protected virtual void Dispose(bool disposing) { if (disposing) @@ -525,4 +535,4 @@ protected virtual void Dispose(bool disposing) } #endregion -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Jobs/JobWizardPlaceholderViewModel.cs b/src/S7Tools/ViewModels/Jobs/JobWizardPlaceholderViewModel.cs index 7b215692..10f49a03 100644 --- a/src/S7Tools/ViewModels/Jobs/JobWizardPlaceholderViewModel.cs +++ b/src/S7Tools/ViewModels/Jobs/JobWizardPlaceholderViewModel.cs @@ -4,7 +4,7 @@ namespace S7Tools.ViewModels.Jobs; /// Temporary placeholder ViewModel for job wizard functionality. /// This will be replaced with a proper JobWizardViewModel once the wizard UI is implemented. /// -public class JobWizardPlaceholderViewModel(string title, string message) : ViewModelBase +public class JobWizardPlaceholderViewModel(string title, string message) : S7Tools.ViewModels.Base.ViewModelBase { /// /// Gets the title of the wizard. diff --git a/src/S7Tools/ViewModels/Jobs/JobWizardViewModel.cs b/src/S7Tools/ViewModels/Jobs/JobWizardViewModel.cs index aa749740..8a61b6ee 100644 --- a/src/S7Tools/ViewModels/Jobs/JobWizardViewModel.cs +++ b/src/S7Tools/ViewModels/Jobs/JobWizardViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -15,7 +16,7 @@ using S7Tools.Core.Constants; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Resources.Strings; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Controls; @@ -44,6 +45,9 @@ public class JobWizardViewModel : ViewModelBase, IDisposable // Removed power scan resources + /// + /// Represents the WizardStep. + /// public enum WizardStep { Serial = 0, @@ -78,7 +82,13 @@ public enum WizardStep // Memory private uint _memoryStart = MemoryConstants.DefaultUserMemoryStart; private uint _memoryLength = MemoryConstants.DefaultDumpSize; + /// + /// Represents the MemoryPreset. + /// public sealed record MemoryPreset(string Name, uint Start, uint Length); + /// + /// Gets or sets the MemoryPresets. + /// public ObservableCollection MemoryPresets { get; } = []; private MemoryPreset? _selectedMemoryPreset; @@ -88,6 +98,9 @@ public sealed record MemoryPreset(string Name, uint Start, uint Length); private string _payloadsBasePath = "./bootloader-payloads"; private string _outputPath = "./dumps"; + /// + /// Initializes a new instance of the class. + /// public JobWizardViewModel( ILogger logger, ISerialPortProfileService serialService, @@ -225,10 +238,25 @@ public JobWizardViewModel( } // Optional preselection inputs (set by parent VM before showing wizard) + /// + /// Gets or sets the PreselectSerialId. + /// public int? PreselectSerialId { get; set; } + /// + /// Gets or sets the PreselectSocatId. + /// public int? PreselectSocatId { get; set; } + /// + /// Gets or sets the PreselectPowerId. + /// public int? PreselectPowerId { get; set; } + /// + /// Gets or sets the PreselectJobName. + /// public string? PreselectJobName { get; set; } + /// + /// Gets or sets the PreselectJobDescription. + /// public string? PreselectJobDescription { get; set; } /// @@ -240,22 +268,64 @@ public void SetJobToEdit(int jobId) IsEditMode = true; } + /// + /// Gets or sets the SerialProfiles. + /// public ObservableCollection SerialProfiles { get; } + /// + /// Gets or sets the SocatProfiles. + /// public ObservableCollection SocatProfiles { get; } + /// + /// Gets or sets the PowerProfiles. + /// public ObservableCollection PowerProfiles { get; } + /// + /// Gets or sets the MemoryProfiles. + /// public ObservableCollection MemoryProfiles { get; } + /// + /// Gets or sets the AvailablePorts. + /// public ObservableCollection AvailablePorts { get; } // Port scanner VM for UI embedding + /// + /// Gets or sets the PortScanner. + /// public SerialPortDiscoveryViewModel PortScanner { get; } // Memory region step VM for wizard integration + /// + /// Gets or sets the MemoryRegionStepViewModel. + /// public JobWizardMemoryRegionStepViewModel MemoryRegionStepViewModel { get; } + /// + /// Gets or sets the BackCommand. + /// public ReactiveCommand BackCommand { get; } + /// + /// Gets or sets the NextCommand. + /// public ReactiveCommand NextCommand { get; } + /// + /// Gets or sets the CancelCommand. + /// public ReactiveCommand CancelCommand { get; } + /// + /// Gets or sets the FinishCommand. + /// public ReactiveCommand FinishCommand { get; } + /// + /// Gets or sets the BrowsePayloadsPathCommand. + /// public ReactiveCommand BrowsePayloadsPathCommand { get; } + /// + /// Gets or sets the BrowseOutputPathCommand. + /// public ReactiveCommand BrowseOutputPathCommand { get; } + /// + /// Gets or sets the ScanPortsCommand. + /// public ReactiveCommand ScanPortsCommand { get; } private bool _cancelRequested; @@ -287,11 +357,29 @@ public WizardStep CurrentStep } // Helper properties for step visibility + /// + /// Gets or sets the IsSerialStep. + /// public bool IsSerialStep => CurrentStep == WizardStep.Serial; + /// + /// Gets or sets the IsSocatStep. + /// public bool IsSocatStep => CurrentStep == WizardStep.Socat; + /// + /// Gets or sets the IsPowerStep. + /// public bool IsPowerStep => CurrentStep == WizardStep.Power; + /// + /// Gets or sets the IsMemoryStep. + /// public bool IsMemoryStep => CurrentStep == WizardStep.Memory; + /// + /// Gets or sets the IsTimingOutputStep. + /// public bool IsTimingOutputStep => CurrentStep == WizardStep.TimingOutput; + /// + /// Gets or sets the IsReviewStep. + /// public bool IsReviewStep => CurrentStep == WizardStep.Review; public string JobName @@ -318,6 +406,9 @@ public bool IsEditMode } // Dynamic title bound in the view's header + /// + /// Gets or sets the WizardTitle. + /// public string WizardTitle => IsEditMode ? "Edit Job Wizard" : "Create Job Wizard"; public bool IsBusy @@ -617,8 +708,17 @@ public string OutputPath } // Computed, type-safe accessors for power configuration shown in UI + /// + /// Gets or sets the PowerConfigurationType. + /// public string PowerConfigurationType => SelectedPower?.Configuration?.Type.ToString() ?? string.Empty; + /// + /// Gets or sets the PowerConfigurationHost. + /// public string PowerConfigurationHost => (SelectedPower?.Configuration as ModbusTcpConfiguration)?.Host ?? string.Empty; + /// + /// Gets or sets the PowerConfigurationPort. + /// public int PowerConfigurationPort => (SelectedPower?.Configuration as ModbusTcpConfiguration)?.Port ?? 0; private async Task LoadAsync() @@ -877,6 +977,9 @@ private async Task InitializeFromJobIdAsync(int jobId) } } + /// + /// Executes the InitializeFromJob operation. + /// public void InitializeFromJob(JobProfile job) { if (job == null) @@ -1375,12 +1478,18 @@ await _uiThreadService.InvokeOnUIThreadAsync(() => } } + /// + /// Executes the Dispose operation. + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + /// + /// Executes the Dispose operation. + /// protected virtual void Dispose(bool disposing) { if (disposing) @@ -1390,4 +1499,4 @@ protected virtual void Dispose(bool disposing) // no resources } } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Jobs/JobsManagementViewModel.cs b/src/S7Tools/ViewModels/Jobs/JobsManagementViewModel.cs index 68c6ee11..d370c3de 100644 --- a/src/S7Tools/ViewModels/Jobs/JobsManagementViewModel.cs +++ b/src/S7Tools/ViewModels/Jobs/JobsManagementViewModel.cs @@ -9,7 +9,7 @@ using ReactiveUI; using S7Tools.Core.Interfaces.ViewModels; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Validation; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -28,6 +28,10 @@ public class JobsMainContentViewModel : ViewModelBase, IDisposable private readonly CompositeDisposable _disposables = []; private bool _disposed; + /// + /// Initializes a new instance of the class. + /// + /// The parent whose state and commands are forwarded. public JobsMainContentViewModel(JobsManagementViewModel parent) { _parent = parent ?? throw new ArgumentNullException(nameof(parent)); @@ -70,42 +74,86 @@ public JobsMainContentViewModel(JobsManagementViewModel parent) public ViewModels.Jobs.JobInfoDisplayViewModel? JobInfoDisplayViewModel { get; internal set; } // Expose parent properties for data binding + /// Gets the collection of all job profiles. public ObservableCollection Profiles => _parent.Profiles; + + /// Gets or sets the currently selected job profile. public JobProfile? SelectedProfile { get => _parent.SelectedProfile; set => _parent.SelectedProfile = value; } + + /// Gets the collection of all job profiles. public ObservableCollection AllJobs => _parent.AllJobs; + + /// Gets the collection of job template profiles. public ObservableCollection JobTemplates => _parent.JobTemplates; + + /// Gets the collection of user-created job profiles. public ObservableCollection UserJobs => _parent.UserJobs; + + /// Gets the current status message from the parent ViewModel. public string StatusMessage => _parent.StatusMessage ?? string.Empty; + + /// Gets a value indicating whether the parent ViewModel is loading data. public bool IsLoading => _parent.IsLoading; // Expose parent commands + /// Gets the command to open the job creation wizard. public ReactiveCommand CreateCommand => _parent.CreateWizardCommand; // Use wizard instead of base create + + /// Gets the command to open the edit wizard for the selected job. public ReactiveCommand EditCommand => _parent.EditCommand; + + /// Gets the command to duplicate the selected job profile. public ReactiveCommand DuplicateCommand => _parent.DuplicateCommand; + + /// Gets the command to delete the selected job profile. public ReactiveCommand DeleteCommand => _parent.DeleteCommand; + + /// Gets the command to refresh the list of job profiles. public ReactiveCommand RefreshCommand => _parent.RefreshCommand; + + /// Gets the command to set the selected job as the default profile. public ReactiveCommand SetDefaultCommand => _parent.SetDefaultCommand; // Job-specific commands + /// Gets the command to create a new job from a template. public ReactiveCommand CreateFromTemplateCommand => _parent.CreateFromTemplateCommand; + + /// Gets the command to save the selected job as a template. public ReactiveCommand SaveAsTemplateCommand => _parent.SaveAsTemplateCommand; + + /// Gets the command to import job profiles from a file. public ReactiveCommand ImportJobCommand => _parent.ImportJobCommand; + + /// Gets the command to export the selected job profile to a file. public ReactiveCommand ExportJobCommand => _parent.ExportJobCommand; + + /// Gets the command to create a new task from the selected job. public ReactiveCommand CreateTaskFromJobCommand => _parent.CreateTaskFromJobCommand; + + /// Gets the command to schedule a task from the selected job. public ReactiveCommand ScheduleTaskFromJobCommand => _parent.ScheduleTaskFromJobCommand; + + /// Gets the command to enqueue a task from the selected job. public ReactiveCommand EnqueueTaskFromJobCommand => _parent.EnqueueTaskFromJobCommand; + + /// Gets the command to validate the selected job configuration. public ReactiveCommand ValidateJobCommand => _parent.ValidateJobCommand; + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + /// + /// Releases managed resources used by this ViewModel. + /// + /// to release managed resources. protected virtual void Dispose(bool disposing) { if (!_disposed && disposing) @@ -137,9 +185,21 @@ protected virtual void Dispose(bool disposing) public class JobsManagementViewModel : ProfileManagementViewModelBase, IDockableViewModel { // IDockableViewModel implementation + /// + /// Gets or sets the DockId. + /// public string DockId => "Jobs"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Jobs Management"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; private readonly IJobManager _jobManager; @@ -275,7 +335,11 @@ public string? SelectedSideMenuItem get => _selectedSideMenuItem; set { - if (string.IsNullOrWhiteSpace(value)) return; + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + this.RaiseAndSetIfChanged(ref _selectedSideMenuItem, value); this.RaisePropertyChanged(nameof(SelectedContentViewModel)); } @@ -449,7 +513,7 @@ protected override async Task> ShowDuplicateDialogAs _logger.LogDebug("Showing duplicate input dialog for job profile ID: {SourceProfileId}", request.SourceProfileId); // Use the input dialog service since the job-specific duplicate dialog is not implemented yet - Models.InputResult inputResult = await _dialogService.ShowInputAsync( + global::S7Tools.ViewModels.Dialogs.Models.InputResult inputResult = await _dialogService.ShowInputAsync( request.Title, "Enter a name for the duplicated job profile:", request.SuggestedName, @@ -812,7 +876,7 @@ await _dialogService.ShowErrorAsync("No Templates", string templateListText = string.Join("\n", JobTemplates.Select((t, i) => $"{i + 1}. {t.Name}")); string message = $"Select a template number:\n\n{templateListText}"; - Models.InputResult inputResult = await _dialogService.ShowInputAsync( + global::S7Tools.ViewModels.Dialogs.Models.InputResult inputResult = await _dialogService.ShowInputAsync( "Select Template", message, "1", @@ -837,7 +901,7 @@ await _dialogService.ShowErrorAsync("Invalid Selection", JobProfile template = JobTemplates[templateIndex - 1]; // Ask for new job name - Models.InputResult nameResult = await _dialogService.ShowInputAsync( + global::S7Tools.ViewModels.Dialogs.Models.InputResult nameResult = await _dialogService.ShowInputAsync( "New Job Name", $"Enter a name for the job created from template '{template.Name}':", $"Job from {template.Name}", @@ -884,7 +948,7 @@ private async Task ExecuteSaveAsTemplateAsync() StatusMessage = UIStrings.Status_SavingJobAsTemplate; // Show template name input dialog - Models.InputResult nameResult = await _dialogService.ShowInputAsync( + global::S7Tools.ViewModels.Dialogs.Models.InputResult nameResult = await _dialogService.ShowInputAsync( "Save as Template", $"Enter a name for the template based on job '{SelectedProfile.Name}':", $"{SelectedProfile.Name} Template", @@ -1195,7 +1259,7 @@ private async Task ExecuteScheduleTaskFromJobAsync() // Show date/time picker dialog using input dialog string currentTime = DateTime.UtcNow.ToLocalTime().AddMinutes(5).ToString(S7Tools.Constants.AppConstants.StandardUserInputDateFormat); - Models.InputResult inputResult = await _dialogService.ShowInputAsync( + global::S7Tools.ViewModels.Dialogs.Models.InputResult inputResult = await _dialogService.ShowInputAsync( "Schedule Task", $"Enter the scheduled execution time for job '{SelectedProfile.Name}':\n\nFormat: {S7Tools.Constants.AppConstants.StandardUserInputDateFormat} (24-hour format)", currentTime, diff --git a/src/S7Tools/ViewModels/Layout/MainWindowViewModel.cs b/src/S7Tools/ViewModels/Layout/MainWindowViewModel.cs index 68e5c7a9..ab1e6176 100644 --- a/src/S7Tools/ViewModels/Layout/MainWindowViewModel.cs +++ b/src/S7Tools/ViewModels/Layout/MainWindowViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Reactive; using System.Reactive.Disposables; @@ -63,11 +64,29 @@ private static IApplicationSettingsService CreateDesignTimeApplicationSettingsSe private class DummyOptions : S7Tools.Core.Interfaces.Services.IWritableOptions { + /// + /// Gets or sets the CurrentValue. + /// public S7Tools.Core.Models.Configuration.StrongSettings.AppSettings CurrentValue { get; } = new(); + /// + /// Gets or sets the Value. + /// public S7Tools.Core.Models.Configuration.StrongSettings.AppSettings Value => CurrentValue; + /// + /// Executes the Get operation. + /// public S7Tools.Core.Models.Configuration.StrongSettings.AppSettings Get(string? name) => CurrentValue; + /// + /// Executes the OnChange operation. + /// public IDisposable? OnChange(Action listener) => null; - public void Update(Action applyChanges) {} + /// + /// Executes the Update operation. + /// + public void Update(Action applyChanges) { } + /// + /// Executes the UpdateAsync operation. + /// public Task UpdateAsync(Func applyChanges) => Task.CompletedTask; } @@ -85,7 +104,6 @@ private static ILogger CreateDesignTimeLogger() /// Initializes a new instance of the class. /// /// The navigation ViewModel. - /// The bottom panel ViewModel. /// The settings management ViewModel. /// The dialog service. /// The clipboard service. @@ -623,11 +641,8 @@ private async Task SaveConfigurationAsync() { try { - // Create a minimal set of current settings to save - var currentSettings = new Dictionary(); - // Save the current user settings - await _settingsService.SaveUserSettingsAsync(currentSettings); + await _settingsService.UpdateSettingsAsync(_ => { }); StatusMessage = UIStrings.Status_ConfigurationSavedSuccessfully; _logger.LogInformation("Configuration saved to settings file"); } diff --git a/src/S7Tools/ViewModels/Layout/NavigationItemViewModel.cs b/src/S7Tools/ViewModels/Layout/NavigationItemViewModel.cs index e1d56566..e4a0b78d 100644 --- a/src/S7Tools/ViewModels/Layout/NavigationItemViewModel.cs +++ b/src/S7Tools/ViewModels/Layout/NavigationItemViewModel.cs @@ -4,12 +4,27 @@ namespace S7Tools.ViewModels.Layout; +/// +/// Represents the NavigationItemViewModel. +/// public class NavigationItemViewModel : ReactiveObject { + /// + /// Gets or sets the Header. + /// public string Header { get; } + /// + /// Gets or sets the Icon. + /// public string Icon { get; } + /// + /// Gets or sets the ContentViewModelType. + /// public Type ContentViewModelType { get; } + /// + /// Initializes a new instance of the class. + /// public NavigationItemViewModel(string header, string icon, Type contentViewModelType) { Header = header; diff --git a/src/S7Tools/ViewModels/Layout/NavigationViewModel.cs b/src/S7Tools/ViewModels/Layout/NavigationViewModel.cs index b957fa24..fea54a04 100644 --- a/src/S7Tools/ViewModels/Layout/NavigationViewModel.cs +++ b/src/S7Tools/ViewModels/Layout/NavigationViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Globalization; using System.Reactive; @@ -42,6 +43,9 @@ public class NavigationViewModel : ReactiveObject /// Initializes a new instance of the class for design-time. /// // Default constructor for design-time data + /// + /// Initializes a new instance of the class. + /// public NavigationViewModel() : this( new ActivityBarService(), new DesignTimeFactory(), @@ -556,4 +560,4 @@ public void NavigateTo(Type viewModelType) _logger.LogError(ex, "Failed to navigate to ViewModel type: {ViewModelType}", viewModelType.Name); } } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Layout/SettingsManagementViewModel.cs b/src/S7Tools/ViewModels/Layout/SettingsManagementViewModel.cs index bffe9e4f..c553caff 100644 --- a/src/S7Tools/ViewModels/Layout/SettingsManagementViewModel.cs +++ b/src/S7Tools/ViewModels/Layout/SettingsManagementViewModel.cs @@ -74,11 +74,29 @@ private static IApplicationSettingsService CreateDesignTimeApplicationSettingsSe private class DummyOptions : S7Tools.Core.Interfaces.Services.IWritableOptions { + /// + /// Gets or sets the CurrentValue. + /// public S7Tools.Core.Models.Configuration.StrongSettings.AppSettings CurrentValue { get; } = new(); + /// + /// Gets or sets the Value. + /// public S7Tools.Core.Models.Configuration.StrongSettings.AppSettings Value => CurrentValue; + /// + /// Executes the Get operation. + /// public S7Tools.Core.Models.Configuration.StrongSettings.AppSettings Get(string? name) => CurrentValue; + /// + /// Executes the OnChange operation. + /// public IDisposable? OnChange(Action listener) => null; - public void Update(Action applyChanges) {} + /// + /// Executes the Update operation. + /// + public void Update(Action applyChanges) { } + /// + /// Executes the UpdateAsync operation. + /// public Task UpdateAsync(Func applyChanges) => Task.CompletedTask; } @@ -335,14 +353,14 @@ private void RefreshFromSettings() try { // Load settings using the new ApplicationSettingsService - DefaultLogPath = _settingsService.GetSetting("logging.logDirectory", "Resources/Logs/Main"); - ExportPath = _settingsService.GetSetting("logging.exportDirectory", "Resources/Logs/Exported"); - MinimumLogLevel = _settingsService.GetSetting("logging.level", "Information"); - AutoScrollLogs = _settingsService.GetSetting("ui.autoScrollLogs", true); - EnableRollingLogs = _settingsService.GetSetting("logging.enableFileLogging", true); - ShowTimestampInLogs = _settingsService.GetSetting("ui.showTimestampInLogs", true); - ShowCategoryInLogs = _settingsService.GetSetting("ui.showCategoryInLogs", true); - ShowLogLevelInLogs = _settingsService.GetSetting("ui.showLogLevelInLogs", true); + DefaultLogPath = _settingsService.Current.Logging.LogDirectory; + ExportPath = _settingsService.Current.Logging.ExportDirectory; + MinimumLogLevel = _settingsService.Current.Logging.Level; + AutoScrollLogs = _settingsService.Current.Ui.AutoScrollLogs; + EnableRollingLogs = _settingsService.Current.Logging.EnableFileLogging; + ShowTimestampInLogs = _settingsService.Current.Ui.ShowTimestampInLogs; + ShowCategoryInLogs = _settingsService.Current.Ui.ShowCategoryInLogs; + ShowLogLevelInLogs = _settingsService.Current.Ui.ShowLogLevelInLogs; CurrentSettingsFilePath = "Resources/AppSettings/AppSettings.json"; @@ -373,20 +391,18 @@ private async Task SaveSettingsAsync() { SettingsStatusMessage = UIStrings.Status_SavingSettings; - // Create dictionary of settings to save - var userSettings = new Dictionary + // Update settings through strongly-typed abstraction + await _settingsService.UpdateSettingsAsync(settings => { - ["logging.logDirectory"] = DefaultLogPath, - ["logging.exportDirectory"] = ExportPath, - ["logging.level"] = MinimumLogLevel, - ["ui.autoScrollLogs"] = AutoScrollLogs, - ["logging.enableFileLogging"] = EnableRollingLogs, - ["ui.showTimestampInLogs"] = ShowTimestampInLogs, - ["ui.showCategoryInLogs"] = ShowCategoryInLogs, - ["ui.showLogLevelInLogs"] = ShowLogLevelInLogs - }; - - await _settingsService.SaveUserSettingsAsync(userSettings); + settings.Logging.LogDirectory = DefaultLogPath; + settings.Logging.ExportDirectory = ExportPath; + settings.Logging.Level = MinimumLogLevel; + settings.Ui.AutoScrollLogs = AutoScrollLogs; + settings.Logging.EnableFileLogging = EnableRollingLogs; + settings.Ui.ShowTimestampInLogs = ShowTimestampInLogs; + settings.Ui.ShowCategoryInLogs = ShowCategoryInLogs; + settings.Ui.ShowLogLevelInLogs = ShowLogLevelInLogs; + }); SettingsStatusMessage = UIStrings.Status_SettingsSavedSuccessfully; SettingsLastModified = DateTime.UtcNow.ToLocalTime(); @@ -525,38 +541,7 @@ public bool ValidateSettings() /// JSON representation of the current settings. public string ExportSettingsToJson() { - try - { - // Create a representation of current settings from ViewModel - var currentSettings = new Dictionary - { - ["logging.logDirectory"] = DefaultLogPath, - ["logging.exportDirectory"] = ExportPath, - ["logging.level"] = MinimumLogLevel, - ["ui.autoScrollLogs"] = AutoScrollLogs, - ["logging.enableFileLogging"] = EnableRollingLogs, - ["ui.showTimestampInLogs"] = ShowTimestampInLogs, - ["ui.showCategoryInLogs"] = ShowCategoryInLogs, - ["ui.showLogLevelInLogs"] = ShowLogLevelInLogs - }; - - // Serialize to JSON with pretty formatting - var options = new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - string json = JsonSerializer.Serialize(currentSettings, options); - _logger.LogInformation("Settings exported to JSON ({Length} characters)", json.Length); - - return json; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to export settings to JSON"); - return string.Empty; - } + return _settingsService.ExportSettingsToJson(); } /// @@ -564,7 +549,7 @@ public string ExportSettingsToJson() /// /// The JSON string containing settings. /// True if import was successful, false otherwise. - public bool ImportSettingsFromJson(string json) + public async Task ImportSettingsFromJson(string json) { try { @@ -575,33 +560,20 @@ public bool ImportSettingsFromJson(string json) return false; } - // Deserialize JSON to Dictionary - var options = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true - }; - - Dictionary? importedSettings = JsonSerializer.Deserialize>(json, options); + bool result = await _settingsService.ImportSettingsFromJsonAsync(json); - if (importedSettings == null) + if (result) + { + RefreshFromSettings(); + SettingsStatusMessage = UIStrings.Status_SettingsImportedSuccessfully; + SettingsLastModified = DateTime.UtcNow.ToLocalTime(); + } + else { - _logger.LogWarning("Failed to deserialize settings from JSON"); SettingsStatusMessage = UIStrings.Status_InvalidSettingsFormat; - return false; } - // Save settings using the service - _ = _settingsService.SaveUserSettingsAsync(importedSettings); - - // Update ViewModel properties from imported settings - RefreshFromSettings(); - - _logger.LogInformation("Settings imported from JSON successfully"); - SettingsStatusMessage = UIStrings.Status_SettingsImportedSuccessfully; - SettingsLastModified = DateTime.UtcNow.ToLocalTime(); - - return true; + return result; } catch (Exception ex) { diff --git a/src/S7Tools/ViewModels/Layout/SplashScreenViewModel.cs b/src/S7Tools/ViewModels/Layout/SplashScreenViewModel.cs index 02033dfd..c0d4f079 100644 --- a/src/S7Tools/ViewModels/Layout/SplashScreenViewModel.cs +++ b/src/S7Tools/ViewModels/Layout/SplashScreenViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Reactive; using System.Threading.Tasks; @@ -10,6 +11,9 @@ namespace S7Tools.ViewModels.Layout; +/// +/// Represents the SplashScreenViewModel. +/// public class SplashScreenViewModel(IServiceProvider serviceProvider, ILogger logger) : ViewModelBase { private readonly IServiceProvider _serviceProvider = serviceProvider; @@ -29,6 +33,9 @@ public double Progress set => this.RaiseAndSetIfChanged(ref _progress, value); } + /// + /// Executes the InitializeAsync operation. + /// public async Task InitializeAsync() { try @@ -84,4 +91,4 @@ public async Task InitializeAsync() throw; } } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Layout/TaskManagerShellViewModel.cs b/src/S7Tools/ViewModels/Layout/TaskManagerShellViewModel.cs index 802ff4e8..a6682949 100644 --- a/src/S7Tools/ViewModels/Layout/TaskManagerShellViewModel.cs +++ b/src/S7Tools/ViewModels/Layout/TaskManagerShellViewModel.cs @@ -16,9 +16,21 @@ namespace S7Tools.ViewModels.Layout; public sealed class TaskManagerShellViewModel : ViewModelBase, IDockableViewModel { // IDockableViewModel implementation + /// + /// Gets or sets the DockId. + /// public string DockId => "TaskManager"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Task Manager"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; private readonly IServiceProvider _serviceProvider; @@ -28,6 +40,9 @@ public sealed class TaskManagerShellViewModel : ViewModelBase, IDockableViewMode private readonly HistoryTasksViewModel _historyTasksViewModel; private readonly TaskCreatorViewModel _taskCreatorViewModel; + /// + /// Initializes a new instance of the class. + /// public TaskManagerShellViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); @@ -60,6 +75,9 @@ public TaskManagerShellViewModel(IServiceProvider serviceProvider) }); } + /// + /// Gets or sets the Categories. + /// public ObservableCollection Categories { get; } private string _selectedCategory = string.Empty; @@ -68,7 +86,11 @@ public string SelectedCategory get => _selectedCategory; set { - if (string.IsNullOrWhiteSpace(value)) return; + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + this.RaiseAndSetIfChanged(ref _selectedCategory, value); // Keep the main content in sync whenever the category changes via binding SelectedCategoryViewModel = GetCategoryViewModel(value); @@ -82,6 +104,9 @@ public ViewModelBase? SelectedCategoryViewModel set => this.RaiseAndSetIfChanged(ref _selectedCategoryViewModel, value); } + /// + /// Gets or sets the SelectCategoryCommand. + /// public ReactiveCommand SelectCategoryCommand { get; } private ViewModelBase GetCategoryViewModel(string category) diff --git a/src/S7Tools/ViewModels/Pages/AboutViewModel.cs b/src/S7Tools/ViewModels/Pages/AboutViewModel.cs index 40fed7c8..fd37121c 100644 --- a/src/S7Tools/ViewModels/Pages/AboutViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/AboutViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using ReactiveUI; using S7Tools.Resources; @@ -12,4 +13,4 @@ public class AboutViewModel : ViewModelBase /// Gets the greeting message for the About view. /// public string Greeting => UIStrings.About_Greeting; -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Pages/ConnectionsViewModel.cs b/src/S7Tools/ViewModels/Pages/ConnectionsViewModel.cs index d4a218ce..f74697d1 100644 --- a/src/S7Tools/ViewModels/Pages/ConnectionsViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/ConnectionsViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using ReactiveUI; using S7Tools.Core.Interfaces.ViewModels; using S7Tools.Services.Interfaces; @@ -10,9 +11,21 @@ namespace S7Tools.ViewModels.Pages; public class ConnectionsViewModel : ViewModelBase, IDockableViewModel { // IDockableViewModel implementation + /// + /// Gets or sets the DockId. + /// public string DockId => "Connections"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Connections"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; private readonly IViewModelFactory _viewModelFactory; @@ -48,4 +61,4 @@ public ConnectionsViewModel(IViewModelFactory viewModelFactory) public ConnectionsViewModel() : this(new DesignTimeViewModelFactory()) { } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Pages/FileMemoryDumpDocumentViewModel.cs b/src/S7Tools/ViewModels/Pages/FileMemoryDumpDocumentViewModel.cs index 2ee45d4e..1141bfef 100644 --- a/src/S7Tools/ViewModels/Pages/FileMemoryDumpDocumentViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/FileMemoryDumpDocumentViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.IO; using Microsoft.Extensions.DependencyInjection; @@ -19,12 +20,24 @@ public string FilePath set => this.RaiseAndSetIfChanged(ref _filePath, value); } + /// + /// Gets or sets the DockId. + /// public string DockId => FilePath; - + + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => Path.GetFileName(FilePath); - + + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; - + + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; private HexViewerViewModel? _hexViewer; @@ -34,27 +47,39 @@ public HexViewerViewModel? HexViewer set => this.RaiseAndSetIfChanged(ref _hexViewer, value); } + /// + /// Initializes a new instance of the class. + /// public FileMemoryDumpDocumentViewModel(IServiceProvider serviceProvider) { // Resolve a transient instance of HexViewerViewModel HexViewer = serviceProvider.GetRequiredService(); } + /// + /// Executes the OpenFile operation. + /// public void OpenFile(string path) { FilePath = path; this.RaisePropertyChanged(nameof(DockId)); this.RaisePropertyChanged(nameof(DockTitle)); - + HexViewer?.OpenStream(path); } + /// + /// Executes the Dispose operation. + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + /// + /// Executes the Dispose operation. + /// protected virtual void Dispose(bool disposing) { if (disposing) @@ -62,4 +87,4 @@ protected virtual void Dispose(bool disposing) HexViewer?.Dispose(); } } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Pages/FileMemoryDumpViewModel.cs b/src/S7Tools/ViewModels/Pages/FileMemoryDumpViewModel.cs index 907fca33..63cab2bd 100644 --- a/src/S7Tools/ViewModels/Pages/FileMemoryDumpViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/FileMemoryDumpViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.ObjectModel; using System.Threading.Tasks; @@ -23,6 +24,9 @@ public partial class FileMemoryDumpViewModel : ViewModelBase private readonly IServiceProvider _serviceProvider; private readonly S7Tools.Core.Interfaces.Services.IApplicationSettingsService _settingsService; + /// + /// Initializes a new instance of the class. + /// public FileMemoryDumpViewModel( ILogger logger, IFileDialogService fileDialogService, @@ -35,7 +39,7 @@ public FileMemoryDumpViewModel( _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); // Load default folder from settings - string defaultFolder = _settingsService.GetSetting("memoryDump.defaultFolder", string.Empty); + string defaultFolder = _settingsService.Current.MemoryDump.DefaultFolder; if (!string.IsNullOrEmpty(defaultFolder) && Directory.Exists(defaultFolder)) { RootFolderPath = defaultFolder; @@ -45,7 +49,13 @@ public FileMemoryDumpViewModel( FileTreeItems.CollectionChanged += (_, _) => this.RaisePropertyChanged(nameof(HasItems)); } + /// + /// Gets or sets the Title. + /// public string Title => "File PLC Memory Viewer"; + /// + /// Gets or sets the Description. + /// public string Description => "Select a folder to explore and open memory dump files within the system."; private string _rootFolderPath = string.Empty; @@ -55,8 +65,14 @@ public string RootFolderPath set => this.RaiseAndSetIfChanged(ref _rootFolderPath, value); } + /// + /// Gets or sets the HasItems. + /// public bool HasItems => FileTreeItems.Count > 0; + /// + /// Gets or sets the FileTreeItems. + /// public ObservableCollection FileTreeItems { get; } = new(); /// @@ -71,11 +87,11 @@ private async Task SelectFolderAsync() try { string? folderPath = await _fileDialogService.ShowFolderBrowserDialogAsync("Select Folder containing Memory Dumps"); - + if (!string.IsNullOrEmpty(folderPath) && Directory.Exists(folderPath)) { RootFolderPath = folderPath; - await _settingsService.SetSettingAsync("memoryDump.defaultFolder", folderPath); + await _settingsService.UpdateSettingsAsync(s => s.MemoryDump.DefaultFolder = folderPath); LoadTree(); } } @@ -95,7 +111,10 @@ private void LoadTree() { FileTreeItems.Clear(); - if (string.IsNullOrEmpty(RootFolderPath)) return; + if (string.IsNullOrEmpty(RootFolderPath)) + { + return; + } try { @@ -113,13 +132,16 @@ private void LoadTree() [RelayCommand] private void OpenFile(FileTreeItemViewModel? item) { - if (item == null || item.IsDirectory || item.IsDummyNode || string.IsNullOrEmpty(item.FullPath) || OpenDocumentAction == null) return; + if (item == null || item.IsDirectory || item.IsDummyNode || string.IsNullOrEmpty(item.FullPath) || OpenDocumentAction == null) + { + return; + } try { var docVm = _serviceProvider.GetRequiredService(); docVm.OpenFile(item.FullPath); - + OpenDocumentAction.Invoke(docVm); } catch (Exception ex) @@ -127,4 +149,4 @@ private void OpenFile(FileTreeItemViewModel? item) _logger.LogError(ex, "Error opening document for file {Path}", item.FullPath); } } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Pages/FileTreeItemViewModel.cs b/src/S7Tools/ViewModels/Pages/FileTreeItemViewModel.cs index 2592fb8e..7a6e8208 100644 --- a/src/S7Tools/ViewModels/Pages/FileTreeItemViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/FileTreeItemViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.ObjectModel; using System.IO; @@ -49,15 +50,24 @@ public bool IsExpanded } } + /// + /// Gets or sets the Children. + /// public ObservableCollection Children { get; } = new(); + /// + /// Gets or sets the IsDummyNode. + /// public bool IsDummyNode { get; private set; } + /// + /// Initializes a new instance of the class. + /// public FileTreeItemViewModel(string path, bool isDirectory) { FullPath = path; Name = Path.GetFileName(path); - + if (string.IsNullOrEmpty(Name)) { Name = path; // Root drives might not have a file name @@ -79,6 +89,9 @@ private FileTreeItemViewModel(string dummyName) IsDummyNode = true; } + /// + /// Executes the CreateDummyNode operation. + /// public static FileTreeItemViewModel CreateDummyNode(string dummyName) { return new FileTreeItemViewModel(dummyName); @@ -116,4 +129,4 @@ private void LoadChildren() Children.Add(CreateDummyNode($"Error: {ex.Message}")); } } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Pages/HomeViewModel.cs b/src/S7Tools/ViewModels/Pages/HomeViewModel.cs index 1d078941..6e9626e1 100644 --- a/src/S7Tools/ViewModels/Pages/HomeViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/HomeViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using ReactiveUI; using S7Tools.Services.Interfaces; @@ -59,6 +60,9 @@ public T Create() where T : ViewModelBase throw new NotSupportedException($"Design-time factory does not support type {typeof(T).Name}"); } + /// + /// Executes the Create operation. + /// public ViewModelBase Create(Type viewModelType) { if (viewModelType == typeof(AboutViewModel)) @@ -68,4 +72,4 @@ public ViewModelBase Create(Type viewModelType) throw new NotSupportedException($"Design-time factory does not support type {viewModelType.Name}"); } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Pages/LogViewerViewModel.cs b/src/S7Tools/ViewModels/Pages/LogViewerViewModel.cs index 378413bd..7dc3793d 100644 --- a/src/S7Tools/ViewModels/Pages/LogViewerViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/LogViewerViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.ComponentModel; @@ -11,6 +12,7 @@ using S7Tools.Infrastructure.Logging.Core.Models; using S7Tools.Infrastructure.Logging.Core.Storage; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -486,7 +488,10 @@ private void SortByColumn(string column) { InvertAutoScroll = true; // Optional: re-enable auto scroll when clicking descending timestamp for newest at top - if (!AutoScroll) AutoScroll = true; + if (!AutoScroll) + { + AutoScroll = true; + } } else { @@ -678,28 +683,28 @@ private void ApplyFiltersInternal() IEnumerable sortedList; if (_sortColumn == "Level") { - sortedList = _sortAscending ? filtered.OrderBy(e => e.Level).ThenBy(e => e.Timestamp) + sortedList = _sortAscending ? filtered.OrderBy(e => e.Level).ThenBy(e => e.Timestamp) : filtered.OrderByDescending(e => e.Level).ThenByDescending(e => e.Timestamp); } else if (_sortColumn == "Category") { - sortedList = _sortAscending ? filtered.OrderBy(e => e.Category).ThenBy(e => e.Timestamp) + sortedList = _sortAscending ? filtered.OrderBy(e => e.Category).ThenBy(e => e.Timestamp) : filtered.OrderByDescending(e => e.Category).ThenByDescending(e => e.Timestamp); } else if (_sortColumn == "Message") { - sortedList = _sortAscending ? filtered.OrderBy(e => e.Message).ThenBy(e => e.Timestamp) + sortedList = _sortAscending ? filtered.OrderBy(e => e.Message).ThenBy(e => e.Timestamp) : filtered.OrderByDescending(e => e.Message).ThenByDescending(e => e.Timestamp); } else // Timestamp { - sortedList = _sortAscending ? filtered.OrderBy(e => e.Timestamp) + sortedList = _sortAscending ? filtered.OrderBy(e => e.Timestamp) : filtered.OrderByDescending(e => e.Timestamp); } var filteredList = sortedList.ToList(); - _uiThreadService.InvokeOnUIThread(() => + _uiThreadService.InvokeOnUIThread(() => { FilteredLogEntries = new ObservableCollection(filteredList); FilteredLogCount = FilteredLogEntries.Count; @@ -745,6 +750,9 @@ internal class DesignTimeLogDataStore : ILogDataStore public event System.Collections.Specialized.NotifyCollectionChangedEventHandler? CollectionChanged; #pragma warning restore CS0067 + /// + /// Initializes a new instance of the class. + /// public DesignTimeLogDataStore() { // Ensure analyzers see events as "used" without runtime impact @@ -761,6 +769,9 @@ private void SuppressUnusedEventWarnings() } } + /// + /// Gets or sets the Entries. + /// public IReadOnlyList Entries { get; } = new List { new() { Timestamp = DateTimeOffset.Now.AddMinutes(-5).DateTime, Level = LogLevel.Information, Category = "S7Tools.Services", Message = "Application started successfully" }, @@ -768,16 +779,46 @@ private void SuppressUnusedEventWarnings() new() { Timestamp = DateTimeOffset.Now.AddMinutes(-1).DateTime, Level = LogLevel.Error, Category = "S7Tools.Data", Message = "Failed to read tag value", Exception = new InvalidOperationException("Tag not found") } }; + /// + /// Gets or sets the Count. + /// public int Count => Entries.Count; + /// + /// Gets or sets the MaxEntries. + /// public int MaxEntries => 10000; + /// + /// Gets or sets the IsFull. + /// public bool IsFull => false; + /// + /// Executes the AddEntry operation. + /// public void AddEntry(LogModel logEntry) { } + /// + /// Executes the AddEntries operation. + /// public void AddEntries(IEnumerable logEntries) { } + /// + /// Executes the Clear operation. + /// public void Clear() { } + /// + /// Executes the GetFilteredEntries operation. + /// public IEnumerable GetFilteredEntries(Func filter) => Entries.Where(filter); + /// + /// Executes the GetEntriesInTimeRange operation. + /// public IEnumerable GetEntriesInTimeRange(DateTimeOffset startTime, DateTimeOffset endTime) => Entries.Where(e => e.Timestamp >= startTime && e.Timestamp <= endTime); + /// + /// Executes the ExportAsync operation. + /// public Task ExportAsync(string format = "txt") => Task.FromResult("Design-time export data"); + /// + /// Executes the Dispose operation. + /// public void Dispose() { } } @@ -786,14 +827,33 @@ public void Dispose() { } /// internal class DesignTimeUIThreadService : IUIThreadService { + /// + /// Gets or sets the IsUIThread. + /// public bool IsUIThread => true; + /// + /// Executes the InvokeOnUIThread operation. + /// public void InvokeOnUIThread(Action action) => action?.Invoke(); - public Task InvokeOnUIThreadAsync(Action action) => Task.Run(action); + /// + /// Executes the InvokeOnUIThreadAsync operation. + /// + public Task InvokeOnUIThreadAsync(Action action) + { + action?.Invoke(); + return Task.CompletedTask; + } public T InvokeOnUIThread(Func function) => function(); - public Task InvokeOnUIThreadAsync(Func function) => Task.Run(function); + public Task InvokeOnUIThreadAsync(Func function) => Task.FromResult(function()); + /// + /// Executes the InvokeOnUIThreadAsync operation. + /// public Task InvokeOnUIThreadAsync(Func asyncAction) => asyncAction(); public Task InvokeOnUIThreadAsync(Func> asyncFunction) => asyncFunction(); + /// + /// Executes the PostToUIThread operation. + /// public void PostToUIThread(Action action) => action?.Invoke(); } @@ -802,7 +862,13 @@ internal class DesignTimeUIThreadService : IUIThreadService /// internal class DesignTimeClipboardService : IClipboardService { + /// + /// Executes the GetTextAsync operation. + /// public Task GetTextAsync() => Task.FromResult("Design-time clipboard text"); + /// + /// Executes the SetTextAsync operation. + /// public Task SetTextAsync(string? text) => Task.CompletedTask; } @@ -811,22 +877,46 @@ internal class DesignTimeClipboardService : IClipboardService /// internal class DesignTimeDialogService : IDialogService { + /// + /// Gets or sets the ShowConfirmation. + /// public Interaction ShowConfirmation { get; } = new(); + /// + /// Gets or sets the ShowError. + /// public Interaction ShowError { get; } = new(); + /// + /// Gets or sets the ShowInput. + /// public Interaction ShowInput { get; } = new(); + /// + /// Gets or sets the ShowJobSelection. + /// public Interaction ShowJobSelection { get; } = new(); + /// + /// Executes the ShowConfirmationAsync operation. + /// public Task ShowConfirmationAsync(string title, string message) => Task.FromResult(false); + /// + /// Executes the ShowErrorAsync operation. + /// public Task ShowErrorAsync(string title, string message) => Task.CompletedTask; + /// + /// Executes the ShowInputAsync operation. + /// public Task ShowInputAsync(string title, string message, string? defaultValue = null, string? placeholder = null) { return Task.FromResult(InputResult.Cancelled()); } + /// + /// Executes the ShowJobSelectionAsync operation. + /// public Task ShowJobSelectionAsync() { return Task.FromResult(null); } } -#endregion +#endregion \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Pages/LoggingTestViewModel.cs b/src/S7Tools/ViewModels/Pages/LoggingTestViewModel.cs index 241c5f66..96aa064c 100644 --- a/src/S7Tools/ViewModels/Pages/LoggingTestViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/LoggingTestViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Reactive; using System.Reactive.Disposables; @@ -20,9 +21,21 @@ namespace S7Tools.ViewModels.Pages; public sealed class LoggingTestViewModel : ViewModelBase, IDockableViewModel, IDisposable { // IDockableViewModel implementation + /// + /// Gets or sets the DockId. + /// public string DockId => "Welcome"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Welcome"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; private readonly IDialogService _dialogService; @@ -310,4 +323,4 @@ public void Dispose() } #endregion -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Pages/MemoryDumpViewerViewModel.cs b/src/S7Tools/ViewModels/Pages/MemoryDumpViewerViewModel.cs index 865dc93d..e7c2dc16 100644 --- a/src/S7Tools/ViewModels/Pages/MemoryDumpViewerViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/MemoryDumpViewerViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.ObjectModel; using System.Reactive; @@ -15,13 +16,28 @@ namespace S7Tools.ViewModels.Pages; public sealed class MemoryDumpViewerViewModel : ViewModelBase, IDockableViewModel, IDisposable { // IDockableViewModel implementation + /// + /// Gets or sets the DockId. + /// public string DockId => "MemoryDump"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Memory Dump Viewer"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; private readonly IServiceProvider _serviceProvider; + /// + /// Initializes a new instance of the class. + /// public MemoryDumpViewerViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); @@ -45,8 +61,14 @@ public MemoryDumpViewerViewModel(IServiceProvider serviceProvider) }); } + /// + /// Gets or sets the FileExplorer. + /// public FileMemoryDumpViewModel FileExplorer { get; } + /// + /// Gets or sets the Categories. + /// public ObservableCollection Categories { get; } private string _selectedCategory = string.Empty; @@ -83,8 +105,14 @@ public Action? OpenDocumentAction } } + /// + /// Gets or sets the SelectCategoryCommand. + /// public ReactiveCommand SelectCategoryCommand { get; } + /// + /// Executes the GetDockableForOpen operation. + /// public IDockableViewModel? GetDockableForOpen() { return SelectedCategory switch @@ -94,7 +122,10 @@ public Action? OpenDocumentAction }; } + /// + /// Executes the Dispose operation. + /// public void Dispose() { } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Pages/PlcInputViewModel.cs b/src/S7Tools/ViewModels/Pages/PlcInputViewModel.cs index 62cb86c1..50fe2544 100644 --- a/src/S7Tools/ViewModels/Pages/PlcInputViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/PlcInputViewModel.cs @@ -64,16 +64,25 @@ private void Validate() /// internal class DesignTimeValidatorFactory : IKeyedFactory { + /// + /// Executes the Create operation. + /// public IValidator Create(string key) { return new DesignTimeValidator(); } + /// + /// Executes the GetAvailableKeys operation. + /// public IEnumerable GetAvailableKeys() { return new[] { "PlcAddress" }; } + /// + /// Executes the CanCreate operation. + /// public bool CanCreate(string key) { return key == "PlcAddress"; @@ -85,17 +94,26 @@ public bool CanCreate(string key) /// internal class DesignTimeValidator : IValidator { + /// + /// Executes the Validate operation. + /// public ValidationResult Validate(object instance) { // Return a valid result for design-time return new ValidationResult { IsValid = true }; } + /// + /// Executes the CanValidate operation. + /// public bool CanValidate(Type type) { return true; } + /// + /// Executes the ValidateAsync operation. + /// public Task ValidateAsync(object instance, CancellationToken cancellationToken = default) { return Task.FromResult(Validate(instance)); diff --git a/src/S7Tools/ViewModels/Pages/StreamedMemoryDumpViewModel.cs b/src/S7Tools/ViewModels/Pages/StreamedMemoryDumpViewModel.cs index 29be10c2..c4c42112 100644 --- a/src/S7Tools/ViewModels/Pages/StreamedMemoryDumpViewModel.cs +++ b/src/S7Tools/ViewModels/Pages/StreamedMemoryDumpViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.ObjectModel; using System.Threading; @@ -17,9 +18,21 @@ namespace S7Tools.ViewModels.Pages; public partial class StreamedMemoryDumpViewModel : ViewModelBase, S7Tools.Core.Interfaces.ViewModels.IDockableViewModel, IDisposable { // IDockableViewModel implementation + /// + /// Gets or sets the DockId. + /// public string DockId => "StreamedMemoryDump"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Streamed PLC Memory Viewer"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; private readonly MemoryDumpOrchestrator _orchestrator; @@ -166,7 +179,7 @@ private async Task ConnectAsync() catch (Exception ex) { _logger.LogError(ex, "Memory dump session error"); - await DisconnectInternalAsync(); + Avalonia.Threading.Dispatcher.UIThread.Post(() => { _ = DisconnectInternalAsync(); }); } }, _cts.Token); } @@ -234,6 +247,9 @@ public void Dispose() GC.SuppressFinalize(this); } + /// + /// Executes the Dispose operation. + /// protected virtual void Dispose(bool disposing) { if (disposing) @@ -274,4 +290,4 @@ protected virtual void Dispose(bool disposing) } } } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Profiles/IProfileDetailsViewModel.cs b/src/S7Tools/ViewModels/Profiles/IProfileDetailsViewModel.cs index 7445f0e0..5990119c 100644 --- a/src/S7Tools/ViewModels/Profiles/IProfileDetailsViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/IProfileDetailsViewModel.cs @@ -1,5 +1,5 @@ using System.Collections.ObjectModel; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.ViewModels.Controls; namespace S7Tools.ViewModels.Profiles; diff --git a/src/S7Tools/ViewModels/Profiles/MemoryRegionProfilesViewModel.cs b/src/S7Tools/ViewModels/Profiles/MemoryRegionProfilesViewModel.cs index 92053765..0018f32c 100644 --- a/src/S7Tools/ViewModels/Profiles/MemoryRegionProfilesViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/MemoryRegionProfilesViewModel.cs @@ -4,7 +4,6 @@ using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Interfaces.ViewModels; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Base; using S7Tools.ViewModels.Dialogs; @@ -12,11 +11,17 @@ namespace S7Tools.ViewModels.Profiles; +/// +/// Represents the MemoryRegionProfilesViewModel. +/// public class MemoryRegionProfilesViewModel : ProfileManagementViewModelBase, IDockableViewModel { private readonly IMemoryRegionProfileService _profileService; private readonly IUIThreadService _uiThreadService; + /// + /// Initializes a new instance of the class. + /// public MemoryRegionProfilesViewModel( IMemoryRegionProfileService profileService, IUnifiedProfileDialogService unifiedDialogService, @@ -28,7 +33,7 @@ public MemoryRegionProfilesViewModel( { _profileService = profileService ?? throw new ArgumentNullException(nameof(profileService)); _uiThreadService = uiThreadService; - + _ = Task.Run(async () => { await InitializeAsync(); @@ -36,16 +41,43 @@ public MemoryRegionProfilesViewModel( }); } + /// + /// Gets or sets the DockId. + /// public string DockId => "MemoryRegionProfiles"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Memory Regions"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; + /// + /// Executes the GetProfileManager operation. + /// protected override IProfileManager GetProfileManager() => _profileService; + /// + /// Executes the GetDefaultProfileName operation. + /// protected override string GetDefaultProfileName() => "Memory Region Default"; + /// + /// Executes the GetProfileTypeName operation. + /// protected override string GetProfileTypeName() => "Memory Region Profile"; + /// + /// Executes the CreateDefaultProfile operation. + /// protected override MemoryMappingProfile CreateDefaultProfile() => MemoryMappingProfile.CreateDefaultProfile(); - + + /// + /// Executes the ShowCreateDialogAsync operation. + /// protected override async Task> ShowCreateDialogAsync(ProfileCreateRequest request) { var nameResult = await UnifiedDialogService.ShowNameInputDialogAsync( @@ -64,11 +96,16 @@ protected override async Task> ShowCre return ProfileDialogResult.Success(savedProfile); } - + + /// + /// Executes the ShowEditDialogAsync operation. + /// protected override async Task> ShowEditDialogAsync(ProfileEditRequest request) { if (SelectedProfile == null) + { return ProfileDialogResult.Failure("No profile selected"); + } ILogger dialogLogger = Microsoft.Extensions.Logging.LoggerFactory.Create(builder => { }).CreateLogger(); var dialogViewModel = new EditMemoryRegionProfileDialogViewModel(SelectedProfile, dialogLogger); @@ -79,8 +116,12 @@ protected override async Task> ShowEdi Avalonia.Controls.Window? mainWindow = Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null; - - if (mainWindow == null) throw new InvalidOperationException("No main window"); + + if (mainWindow == null) + { + throw new InvalidOperationException("No main window"); + } + return await dialog.ShowDialog(mainWindow); }).ConfigureAwait(false); @@ -94,10 +135,13 @@ protected override async Task> ShowEdi } return ProfileDialogResult.Failure("Failed to modify"); } - + return ProfileDialogResult.Cancelled(); } + /// + /// Executes the ShowDuplicateDialogAsync operation. + /// protected override async Task> ShowDuplicateDialogAsync(ProfileDuplicateRequest request) { return await UnifiedDialogService.ShowNameInputDialogAsync( diff --git a/src/S7Tools/ViewModels/Profiles/PowerSupplyProfileViewModel.cs b/src/S7Tools/ViewModels/Profiles/PowerSupplyProfileViewModel.cs index 36449b77..15202e8f 100644 --- a/src/S7Tools/ViewModels/Profiles/PowerSupplyProfileViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/PowerSupplyProfileViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.ComponentModel; using System.Reactive; @@ -8,7 +9,7 @@ using ReactiveUI; using S7Tools.Core.Constants; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Resources; namespace S7Tools.ViewModels.Profiles; @@ -564,4 +565,4 @@ protected virtual void Dispose(bool disposing) } // Partial class implementation for disposal pattern -// Removed duplicate partial Dispose implementation to avoid conflicts +// Removed duplicate partial Dispose implementation to avoid conflicts \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Profiles/PowerSupplyProfilesViewModel.cs b/src/S7Tools/ViewModels/Profiles/PowerSupplyProfilesViewModel.cs index a0246139..893ece4b 100644 --- a/src/S7Tools/ViewModels/Profiles/PowerSupplyProfilesViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/PowerSupplyProfilesViewModel.cs @@ -12,14 +12,12 @@ using Microsoft.Extensions.Logging; using ReactiveUI; using S7Tools.Core.Interfaces.Services; +using S7Tools.Core.Interfaces.ViewModels; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Helpers; using S7Tools.Resources.Strings; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Base; - -using S7Tools.Core.Interfaces.ViewModels; namespace S7Tools.ViewModels.Profiles; /// @@ -29,9 +27,21 @@ namespace S7Tools.ViewModels.Profiles; public class PowerSupplyProfilesViewModel : ProfileManagementViewModelBase, IDockableViewModel { + /// + /// Gets or sets the DockId. + /// public string DockId => "PowerSupplyProfiles"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Power Supply"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; #region Fields @@ -439,7 +449,7 @@ private async Task DuplicateProfileAsync() { _specificLogger.LogDebug("Duplicating power supply profile: {ProfileName}", SelectedProfile.Name); - Models.InputResult inputResult = await _dialogService.ShowInputAsync( + global::S7Tools.ViewModels.Dialogs.Models.InputResult inputResult = await _dialogService.ShowInputAsync( "Duplicate Profile", "Enter a name for the duplicate profile:", $"{SelectedProfile.Name} (Copy)").ConfigureAwait(false); @@ -699,7 +709,7 @@ private async Task TurnOnAsync() if (success) { - await Task.Delay(_settingsService.GetSetting("powerSupply.powerStateChangeDelayMs", 1000)).ConfigureAwait(false); + await Task.Delay(_settingsService.Current.PowerSupply.PowerStateChangeDelayMs).ConfigureAwait(false); await ReadStateCoreAsync().ConfigureAwait(false); await _uiThreadService.InvokeOnUIThreadAsync(() => StatusMessage = UIStrings.Status_PowerTurnedOn); _specificLogger.LogInformation("Power turned ON successfully"); @@ -735,7 +745,7 @@ private async Task TurnOffAsync() if (success) { - await Task.Delay(_settingsService.GetSetting("powerSupply.powerStateChangeDelayMs", 1000)).ConfigureAwait(false); + await Task.Delay(_settingsService.Current.PowerSupply.PowerStateChangeDelayMs).ConfigureAwait(false); await ReadStateCoreAsync().ConfigureAwait(false); await _uiThreadService.InvokeOnUIThreadAsync(() => StatusMessage = UIStrings.Status_PowerTurnedOff); _specificLogger.LogInformation("Power turned OFF successfully"); @@ -805,7 +815,7 @@ private async Task PowerCycleAsync() await _uiThreadService.InvokeOnUIThreadAsync(() => IsBusy = true); try { - int delayMs = _settingsService.GetSetting("powerSupply.powerStateChangeDelayMs", 1000); + int delayMs = _settingsService.Current.PowerSupply.PowerStateChangeDelayMs; _specificLogger.LogInformation("Starting power cycle (delay={Delay}ms)", delayMs); diff --git a/src/S7Tools/ViewModels/Profiles/ProfileDetailsViewModel.cs b/src/S7Tools/ViewModels/Profiles/ProfileDetailsViewModel.cs index 152427d0..21d823f7 100644 --- a/src/S7Tools/ViewModels/Profiles/ProfileDetailsViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/ProfileDetailsViewModel.cs @@ -1,7 +1,7 @@ using System.Collections.ObjectModel; using ReactiveUI; using S7Tools.Core.Constants; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.ViewModels.Controls; namespace S7Tools.ViewModels.Profiles; diff --git a/src/S7Tools/ViewModels/Profiles/ProfilesViewModel.cs b/src/S7Tools/ViewModels/Profiles/ProfilesViewModel.cs index 08b2cfd9..b75235d5 100644 --- a/src/S7Tools/ViewModels/Profiles/ProfilesViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/ProfilesViewModel.cs @@ -4,23 +4,43 @@ using Microsoft.Extensions.Logging; using ReactiveUI; using S7Tools.Core.Interfaces.ViewModels; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.ViewModels.Base; using S7Tools.ViewModels.Settings; namespace S7Tools.ViewModels.Profiles; +/// +/// ViewModel for the Profiles management page. +/// Manages navigation between profile category sub-views (Serial Ports, Servers, Power Supply, Memory Regions). +/// public class ProfilesViewModel : ViewModelBase, IDockableViewModel { // IDockableViewModel implementation + /// + /// Gets or sets the DockId. + /// public string DockId => "Profiles"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Profiles"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; private readonly IServiceProvider _serviceProvider; private readonly Dictionary _categoryViewModels; + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve category-specific view models. public ProfilesViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); @@ -48,9 +68,17 @@ public ProfilesViewModel(IServiceProvider serviceProvider) }); } + /// + /// Gets the collection of available profile category names. + /// public ObservableCollection Categories { get; } private string _selectedCategory = "Serial Ports"; + + /// + /// Gets or sets the currently selected profile category name. + /// Setting this property also updates . + /// public string SelectedCategory { get => _selectedCategory; @@ -67,28 +95,39 @@ public string SelectedCategory } private ViewModelBase? _selectedCategoryViewModel; + + /// + /// Gets or sets the ViewModel corresponding to the currently selected profile category. + /// public ViewModelBase? SelectedCategoryViewModel { get => _selectedCategoryViewModel; - set + set { this.RaiseAndSetIfChanged(ref _selectedCategoryViewModel, value); this.RaisePropertyChanged("SidebarItemTapped"); } } + /// + /// Gets the dockable view model for the currently selected profile category, if applicable. + /// + /// The selected category view model as , or if the selected view model is not dockable. public IDockableViewModel? GetDockableForOpen() { return SelectedCategoryViewModel as IDockableViewModel; } + /// + /// Gets the command to select a profile category by name. + /// public ReactiveCommand SelectCategoryCommand { get; } private ViewModelBase GetCategoryViewModel(string category) { if (string.IsNullOrWhiteSpace(category)) { - category = "Serial Ports"; + category = "Serial Ports"; } if (_categoryViewModels.TryGetValue(category, out ViewModelBase? existingViewModel)) @@ -114,7 +153,7 @@ private ViewModelBase GetCategoryViewModel(string category) { var logger = _serviceProvider.GetService>(); logger?.LogError(ex, "Error creating ViewModel for profile category: {Category}", category); - + // Fallback return _serviceProvider.GetRequiredService(); } diff --git a/src/S7Tools/ViewModels/Profiles/SerialPortProfileViewModel.cs b/src/S7Tools/ViewModels/Profiles/SerialPortProfileViewModel.cs index 69345664..27f412f6 100644 --- a/src/S7Tools/ViewModels/Profiles/SerialPortProfileViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/SerialPortProfileViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -9,7 +10,7 @@ using Microsoft.Extensions.Logging; using ReactiveUI; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -1099,4 +1100,4 @@ public void Dispose() } #endregion -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Profiles/SerialPortProfilesViewModel.cs b/src/S7Tools/ViewModels/Profiles/SerialPortProfilesViewModel.cs index 6d7c3a68..f336fa6a 100644 --- a/src/S7Tools/ViewModels/Profiles/SerialPortProfilesViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/SerialPortProfilesViewModel.cs @@ -4,17 +4,22 @@ using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Interfaces.ViewModels; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Base; using S7Tools.ViewModels.Dialogs; namespace S7Tools.ViewModels.Profiles; +/// +/// Represents the SerialPortProfilesViewModel. +/// public class SerialPortProfilesViewModel : ProfileManagementViewModelBase, IDockableViewModel { private readonly ISerialPortProfileService _profileService; + /// + /// Initializes a new instance of the class. + /// public SerialPortProfilesViewModel( ISerialPortProfileService profileService, IUnifiedProfileDialogService unifiedDialogService, @@ -25,33 +30,66 @@ public SerialPortProfilesViewModel( : base(logger, unifiedDialogService, dialogService, uiThreadService, fileDialogService) { _profileService = profileService ?? throw new ArgumentNullException(nameof(profileService)); - + _ = Task.Run(async () => { await InitializeAsync(); }); } + /// + /// Gets or sets the DockId. + /// public string DockId => "SerialPortProfiles"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Serial Ports"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; + /// + /// Executes the GetProfileManager operation. + /// protected override IProfileManager GetProfileManager() => _profileService; + /// + /// Executes the GetDefaultProfileName operation. + /// protected override string GetDefaultProfileName() => "SerialDefault"; + /// + /// Executes the GetProfileTypeName operation. + /// protected override string GetProfileTypeName() => "Serial Port"; + /// + /// Executes the CreateDefaultProfile operation. + /// protected override SerialPortProfile CreateDefaultProfile() => SerialPortProfile.CreateDefaultProfile(); - + + /// + /// Executes the ShowCreateDialogAsync operation. + /// protected override async Task> ShowCreateDialogAsync(ProfileCreateRequest request) { return await UnifiedDialogService.ShowSerialCreateDialogAsync(request).ConfigureAwait(false); } - + + /// + /// Executes the ShowEditDialogAsync operation. + /// protected override async Task> ShowEditDialogAsync(ProfileEditRequest request) { return await UnifiedDialogService.ShowSerialEditDialogAsync(request).ConfigureAwait(false); } + /// + /// Executes the ShowDuplicateDialogAsync operation. + /// protected override async Task> ShowDuplicateDialogAsync(ProfileDuplicateRequest request) { return await UnifiedDialogService.ShowSerialDuplicateDialogAsync(request).ConfigureAwait(false); diff --git a/src/S7Tools/ViewModels/Profiles/SocatProfileViewModel.cs b/src/S7Tools/ViewModels/Profiles/SocatProfileViewModel.cs index 48f6417f..8efdeb9d 100644 --- a/src/S7Tools/ViewModels/Profiles/SocatProfileViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/SocatProfileViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -10,7 +11,7 @@ using Microsoft.Extensions.Logging; using ReactiveUI; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -923,4 +924,4 @@ public void Dispose() } #endregion -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Profiles/SocatProfilesViewModel.cs b/src/S7Tools/ViewModels/Profiles/SocatProfilesViewModel.cs index 50554b01..e4f87f00 100644 --- a/src/S7Tools/ViewModels/Profiles/SocatProfilesViewModel.cs +++ b/src/S7Tools/ViewModels/Profiles/SocatProfilesViewModel.cs @@ -4,17 +4,22 @@ using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Interfaces.ViewModels; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Base; using S7Tools.ViewModels.Dialogs; namespace S7Tools.ViewModels.Profiles; +/// +/// Represents the SocatProfilesViewModel. +/// public class SocatProfilesViewModel : ProfileManagementViewModelBase, IDockableViewModel { private readonly ISocatProfileService _profileService; + /// + /// Initializes a new instance of the class. + /// public SocatProfilesViewModel( ISocatProfileService profileService, IUnifiedProfileDialogService unifiedDialogService, @@ -25,33 +30,66 @@ public SocatProfilesViewModel( : base(logger, unifiedDialogService, dialogService, uiThreadService, fileDialogService) { _profileService = profileService ?? throw new ArgumentNullException(nameof(profileService)); - + _ = Task.Run(async () => { await InitializeAsync(); }); } + /// + /// Gets or sets the DockId. + /// public string DockId => "SocatProfiles"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Servers"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; + /// + /// Executes the GetProfileManager operation. + /// protected override IProfileManager GetProfileManager() => _profileService; + /// + /// Executes the GetDefaultProfileName operation. + /// protected override string GetDefaultProfileName() => "ServerDefault"; + /// + /// Executes the GetProfileTypeName operation. + /// protected override string GetProfileTypeName() => "Server Configuration"; + /// + /// Executes the CreateDefaultProfile operation. + /// protected override SocatProfile CreateDefaultProfile() => SocatProfile.CreateDefaultProfile(); - + + /// + /// Executes the ShowCreateDialogAsync operation. + /// protected override async Task> ShowCreateDialogAsync(ProfileCreateRequest request) { return await UnifiedDialogService.ShowSocatCreateDialogAsync(request).ConfigureAwait(false); } - + + /// + /// Executes the ShowEditDialogAsync operation. + /// protected override async Task> ShowEditDialogAsync(ProfileEditRequest request) { return await UnifiedDialogService.ShowSocatEditDialogAsync(request).ConfigureAwait(false); } + /// + /// Executes the ShowDuplicateDialogAsync operation. + /// protected override async Task> ShowDuplicateDialogAsync(ProfileDuplicateRequest request) { return await UnifiedDialogService.ShowSocatDuplicateDialogAsync(request).ConfigureAwait(false); diff --git a/src/S7Tools/ViewModels/Settings/AppearanceSettingsViewModel.cs b/src/S7Tools/ViewModels/Settings/AppearanceSettingsViewModel.cs index f5061283..787a884d 100644 --- a/src/S7Tools/ViewModels/Settings/AppearanceSettingsViewModel.cs +++ b/src/S7Tools/ViewModels/Settings/AppearanceSettingsViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System.Reactive; using ReactiveUI; using S7Tools.Core.Interfaces.Services; @@ -18,7 +19,7 @@ public class AppearanceSettingsViewModel : ViewModelBase public AppearanceSettingsViewModel(IApplicationSettingsService settingsService) { _settingsService = settingsService; - _theme = _settingsService.GetSetting("ui.theme", "System"); + _theme = _settingsService.Current.Ui.Theme; RestoreDefaultsCommand = ReactiveCommand.Create(RestoreDefaults); @@ -26,9 +27,9 @@ public AppearanceSettingsViewModel(IApplicationSettingsService settingsService) this.WhenAnyValue(x => x.Theme) .Subscribe(newTheme => { - if (newTheme != _settingsService.GetSetting("ui.theme", "System")) + if (newTheme != _settingsService.Current.Ui.Theme) { - _ = _settingsService.SetSettingAsync("ui.theme", newTheme); + _ = _settingsService.UpdateSettingsAsync(settings => settings.Ui.Theme = newTheme); } }); } @@ -40,7 +41,7 @@ public AppearanceSettingsViewModel() { _settingsService = null!; // Dummy for designer _theme = "Dark"; - RestoreDefaultsCommand = ReactiveCommand.Create(() => {}); + RestoreDefaultsCommand = ReactiveCommand.Create(() => { }); } /// @@ -62,4 +63,4 @@ private void RestoreDefaults() { Theme = "System"; } -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Settings/GeneralSettingsViewModel.cs b/src/S7Tools/ViewModels/Settings/GeneralSettingsViewModel.cs index cc99d845..ed5296bd 100644 --- a/src/S7Tools/ViewModels/Settings/GeneralSettingsViewModel.cs +++ b/src/S7Tools/ViewModels/Settings/GeneralSettingsViewModel.cs @@ -1,6 +1,9 @@ +using S7Tools.ViewModels.Base; using System; using System.IO; using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using ReactiveUI; @@ -14,15 +17,21 @@ namespace S7Tools.ViewModels.Settings; /// /// ViewModel for general settings configuration. /// -public class GeneralSettingsViewModel : ViewModelBase +public class GeneralSettingsViewModel : ViewModelBase, IDisposable { private readonly IApplicationSettingsService _settingsService; private readonly IPathService _pathService; private readonly ILogger _logger; + private readonly CompositeDisposable _disposables = new(); + private bool _isInitializing; + private bool _disposed; /// - /// Initializes a new instance of the GeneralSettingsViewModel class. + /// Initializes a new instance of the class. /// + /// The application settings service for reading and writing settings. + /// The path service for resolving application paths. + /// The logger instance. public GeneralSettingsViewModel( IApplicationSettingsService settingsService, IPathService pathService, @@ -37,11 +46,42 @@ public GeneralSettingsViewModel( ResetSettingsCommand = ReactiveCommand.CreateFromTask(ResetSettingsAsync); OpenSettingsFolderCommand = ReactiveCommand.CreateFromTask(OpenSettingsFolderAsync); + _isInitializing = true; RefreshFromSettings(); - _settingsService.SettingsChanged += (_, _) => RefreshFromSettings(); + _isInitializing = false; + + // Subscribe to settings-changed and unsubscribe on disposal via _disposables + Observable.FromEventPattern( + h => _settingsService.SettingsChanged += h, + h => _settingsService.SettingsChanged -= h) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(_ => + { + _isInitializing = true; + RefreshFromSettings(); + _isInitializing = false; + }) + .DisposeWith(_disposables); + + // Auto-save when any relevant property changes, throttled to avoid excessive disk I/O + this.Changed + .Where(e => e.PropertyName is + nameof(MemoryDumpDefaultFolder) or + nameof(SegmentDumpDelayMs) or + nameof(IterationDumpDelayMs) or + nameof(PlcConnectionTimeout) or + nameof(PlcReadTimeout) or + nameof(PlcRetryAttempts)) + .Where(_ => !_isInitializing) + .Throttle(TimeSpan.FromMilliseconds(500)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(__ => { _ = SaveGeneralSettingsAsync(); }) + .DisposeWith(_disposables); } - // Default constructor for designer + /// + /// Initializes a new instance of the class for XAML designer use. + /// public GeneralSettingsViewModel() { _settingsService = null!; @@ -50,6 +90,10 @@ public GeneralSettingsViewModel() } private string _settingsStatusMessage = UIStrings.Status_SettingsReady; + + /// + /// Gets or sets the user-facing status message for settings operations. + /// public string SettingsStatusMessage { get => _settingsStatusMessage; @@ -57,6 +101,10 @@ public string SettingsStatusMessage } private string _currentSettingsFilePath = string.Empty; + + /// + /// Gets or sets the full file-system path of the current settings file. + /// public string CurrentSettingsFilePath { get => _currentSettingsFilePath; @@ -64,21 +112,111 @@ public string CurrentSettingsFilePath } private DateTime _settingsLastModified = DateTime.UtcNow.ToLocalTime(); + + /// + /// Gets or sets the last-modified timestamp of the settings file. + /// public DateTime SettingsLastModified { get => _settingsLastModified; set => this.RaiseAndSetIfChanged(ref _settingsLastModified, value); } + // MemoryDump settings + private string _memoryDumpDefaultFolder = string.Empty; + + /// + /// Gets or sets the default folder path for memory dump output files. + /// + public string MemoryDumpDefaultFolder + { + get => _memoryDumpDefaultFolder; + set => this.RaiseAndSetIfChanged(ref _memoryDumpDefaultFolder, value); + } + + private int _segmentDumpDelayMs = 5000; + + /// + /// Gets or sets the delay in milliseconds between dumping each memory segment. + /// + public int SegmentDumpDelayMs + { + get => _segmentDumpDelayMs; + set => this.RaiseAndSetIfChanged(ref _segmentDumpDelayMs, value); + } + + private int _iterationDumpDelayMs = 5000; + + /// + /// Gets or sets the delay in milliseconds between full dump iterations. + /// + public int IterationDumpDelayMs + { + get => _iterationDumpDelayMs; + set => this.RaiseAndSetIfChanged(ref _iterationDumpDelayMs, value); + } + + // PLC settings + private int _plcConnectionTimeout = 5000; + + /// + /// Gets or sets the PLC connection timeout in milliseconds. + /// + public int PlcConnectionTimeout + { + get => _plcConnectionTimeout; + set => this.RaiseAndSetIfChanged(ref _plcConnectionTimeout, value); + } + + private int _plcReadTimeout = 2000; + + /// + /// Gets or sets the PLC read operation timeout in milliseconds. + /// + public int PlcReadTimeout + { + get => _plcReadTimeout; + set => this.RaiseAndSetIfChanged(ref _plcReadTimeout, value); + } + + private int _plcRetryAttempts = 3; + + /// + /// Gets or sets the number of retry attempts for failed PLC operations. + /// + public int PlcRetryAttempts + { + get => _plcRetryAttempts; + set => this.RaiseAndSetIfChanged(ref _plcRetryAttempts, value); + } + + /// + /// Gets the command to manually save the current general settings. + /// public ReactiveCommand? SaveSettingsCommand { get; } + + /// + /// Gets the command to reload general settings from the settings file. + /// public ReactiveCommand? LoadSettingsCommand { get; } + + /// + /// Gets the command to reset all general settings to their default values. + /// public ReactiveCommand? ResetSettingsCommand { get; } + + /// + /// Gets the command to open the settings file directory in the system file explorer. + /// public ReactiveCommand? OpenSettingsFolderCommand { get; } private void RefreshFromSettings() { - if (_pathService == null) return; - + if (_pathService == null) + { + return; + } + CurrentSettingsFilePath = _pathService.AppSettingsPath; try @@ -90,6 +228,34 @@ private void RefreshFromSettings() { SettingsLastModified = DateTime.UtcNow.ToLocalTime(); } + + var current = _settingsService.Current; + MemoryDumpDefaultFolder = current.MemoryDump.DefaultFolder; + SegmentDumpDelayMs = current.MemoryDump.SegmentDumpDelayMilliseconds; + IterationDumpDelayMs = current.MemoryDump.IterationDumpDelayMilliseconds; + PlcConnectionTimeout = current.Plc.ConnectionTimeout; + PlcReadTimeout = current.Plc.ReadTimeout; + PlcRetryAttempts = current.Plc.RetryAttempts; + } + + private async Task SaveGeneralSettingsAsync() + { + try + { + await _settingsService.UpdateSettingsAsync(s => + { + s.MemoryDump.DefaultFolder = MemoryDumpDefaultFolder; + s.MemoryDump.SegmentDumpDelayMilliseconds = SegmentDumpDelayMs; + s.MemoryDump.IterationDumpDelayMilliseconds = IterationDumpDelayMs; + s.Plc.ConnectionTimeout = PlcConnectionTimeout; + s.Plc.ReadTimeout = PlcReadTimeout; + s.Plc.RetryAttempts = PlcRetryAttempts; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error auto-saving general settings"); + } } private async Task SaveSettingsAsync() @@ -97,8 +263,7 @@ private async Task SaveSettingsAsync() try { SettingsStatusMessage = UIStrings.Status_SavingSettings; - // No local properties to save in General yet, but trigger global settings validation/save - await _settingsService.SaveUserSettingsAsync(new System.Collections.Generic.Dictionary()); + await SaveGeneralSettingsAsync(); SettingsStatusMessage = UIStrings.Status_SettingsSavedSuccessfully; _logger.LogInformation("General settings saved successfully"); } @@ -164,4 +329,24 @@ private async Task OpenSettingsFolderAsync() SettingsStatusMessage = UIStrings.Status_ErrorOpeningSettingsDirectory; } } -} + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases resources used by this ViewModel. + /// + protected virtual void Dispose(bool disposing) + { + if (_disposed) { return; } + if (disposing) + { + _disposables.Dispose(); + } + _disposed = true; + } +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Settings/LoggingSettingsViewModel.cs b/src/S7Tools/ViewModels/Settings/LoggingSettingsViewModel.cs index 725843c7..0db2b51d 100644 --- a/src/S7Tools/ViewModels/Settings/LoggingSettingsViewModel.cs +++ b/src/S7Tools/ViewModels/Settings/LoggingSettingsViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System.Reactive; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -50,7 +51,7 @@ public LoggingSettingsViewModel( _isInitializing = false; // Subscribe to settings changes - _settingsService.SettingsChanged += (_, _) => + _settingsService.SettingsChanged += (_, _) => { _isInitializing = true; RefreshFromSettings(); @@ -60,10 +61,13 @@ public LoggingSettingsViewModel( // Auto-save when properties change this.PropertyChanged += (s, e) => { - if (_isInitializing) return; + if (_isInitializing) + { + return; + } - if (e.PropertyName is nameof(DefaultLogPath) or nameof(ExportPath) or nameof(MinimumLogLevel) or - nameof(AutoScrollLogs) or nameof(EnableRollingLogs) or nameof(ShowTimestampInLogs) or + if (e.PropertyName is nameof(DefaultLogPath) or nameof(ExportPath) or nameof(MinimumLogLevel) or + nameof(AutoScrollLogs) or nameof(EnableRollingLogs) or nameof(ShowTimestampInLogs) or nameof(ShowCategoryInLogs) or nameof(ShowLogLevelInLogs)) { _ = SaveLoggingSettingsAsync(); @@ -148,9 +152,21 @@ public bool ShowLogLevelInLogs #region Commands + /// + /// Gets or sets the BrowseDefaultLogPathCommand. + /// public ReactiveCommand BrowseDefaultLogPathCommand { get; } + /// + /// Gets or sets the BrowseExportPathCommand. + /// public ReactiveCommand BrowseExportPathCommand { get; } + /// + /// Gets or sets the OpenDefaultLogPathCommand. + /// public ReactiveCommand OpenDefaultLogPathCommand { get; } + /// + /// Gets or sets the OpenExportPathCommand. + /// public ReactiveCommand OpenExportPathCommand { get; } #endregion @@ -160,14 +176,15 @@ public bool ShowLogLevelInLogs private void RefreshFromSettings() { // Load settings using the new structured approach - DefaultLogPath = _settingsService.GetSetting("logging.logDirectory", "Resources/Logs/Main"); - ExportPath = _settingsService.GetSetting("logging.exportDirectory", "Resources/Logs/Exported"); - MinimumLogLevel = _settingsService.GetSetting("logging.level", "Information"); - AutoScrollLogs = _settingsService.GetSetting("ui.autoScrollLogs", true); - EnableRollingLogs = _settingsService.GetSetting("logging.enableFileLogging", true); - ShowTimestampInLogs = _settingsService.GetSetting("ui.showTimestampInLogs", true); - ShowCategoryInLogs = _settingsService.GetSetting("ui.showCategoryInLogs", true); - ShowLogLevelInLogs = _settingsService.GetSetting("ui.showLogLevelInLogs", true); + var current = _settingsService.Current; + DefaultLogPath = current.Logging.LogDirectory; + ExportPath = current.Logging.ExportDirectory; + MinimumLogLevel = current.Logging.Level.ToString(); + AutoScrollLogs = current.Ui.AutoScrollLogs; + EnableRollingLogs = current.Logging.EnableFileLogging; + ShowTimestampInLogs = current.Ui.ShowTimestampInLogs; + ShowCategoryInLogs = current.Ui.ShowCategoryInLogs; + ShowLogLevelInLogs = current.Ui.ShowLogLevelInLogs; } private async Task BrowseDefaultLogPathAsync() @@ -218,19 +235,17 @@ private async Task SaveLoggingSettingsAsync() { try { - var userSettings = new Dictionary + await _settingsService.UpdateSettingsAsync(settings => { - ["logging.logDirectory"] = DefaultLogPath, - ["logging.exportDirectory"] = ExportPath, - ["logging.level"] = MinimumLogLevel, - ["ui.autoScrollLogs"] = AutoScrollLogs, - ["logging.enableFileLogging"] = EnableRollingLogs, - ["ui.showTimestampInLogs"] = ShowTimestampInLogs, - ["ui.showCategoryInLogs"] = ShowCategoryInLogs, - ["ui.showLogLevelInLogs"] = ShowLogLevelInLogs - }; - - await _settingsService.SaveUserSettingsAsync(userSettings); + settings.Logging.LogDirectory = DefaultLogPath; + settings.Logging.ExportDirectory = ExportPath; + settings.Logging.Level = MinimumLogLevel; + settings.Ui.AutoScrollLogs = AutoScrollLogs; + settings.Logging.EnableFileLogging = EnableRollingLogs; + settings.Ui.ShowTimestampInLogs = ShowTimestampInLogs; + settings.Ui.ShowCategoryInLogs = ShowCategoryInLogs; + settings.Ui.ShowLogLevelInLogs = ShowLogLevelInLogs; + }); } catch (Exception ex) { @@ -293,4 +308,4 @@ private async Task OpenExportPathAsync() } #endregion -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Settings/PathSettingsViewModel.cs b/src/S7Tools/ViewModels/Settings/PathSettingsViewModel.cs index 962cc32a..bc88de0d 100644 --- a/src/S7Tools/ViewModels/Settings/PathSettingsViewModel.cs +++ b/src/S7Tools/ViewModels/Settings/PathSettingsViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.IO; using System.Reactive; @@ -22,6 +23,9 @@ public class PathSettingsViewModel : ViewModelBase private readonly ILogger _logger; private EventHandler? _settingsChangedHandler; + /// + /// Initializes a new instance of the class. + /// public PathSettingsViewModel( S7Tools.Core.Interfaces.Services.IApplicationSettingsService settingsService, IPathService pathService, @@ -55,13 +59,7 @@ public PathSettingsViewModel( // Subscribe to settings changes _settingsChangedHandler = (_, args) => { - if (args.Key == "profiles.serialPath" || - args.Key == "profiles.socatPath" || - args.Key == "profiles.powerSupplyPath" || - args.Key == "profiles.memoryRegionPath") - { - RefreshFromSettings(); - } + RefreshFromSettings(); }; _settingsService.SettingsChanged += _settingsChangedHandler; } @@ -80,8 +78,17 @@ public string SerialProfilesPath get => _serialProfilesPath; set => this.RaiseAndSetIfChanged(ref _serialProfilesPath, value); } + /// + /// Gets or sets the BrowseSerialProfilesPathCommand. + /// public ReactiveCommand BrowseSerialProfilesPathCommand { get; } + /// + /// Gets or sets the OpenSerialProfilesPathCommand. + /// public ReactiveCommand OpenSerialProfilesPathCommand { get; } + /// + /// Gets or sets the ResetSerialProfilesPathCommand. + /// public ReactiveCommand ResetSerialProfilesPathCommand { get; } // Socat @@ -91,8 +98,17 @@ public string SocatProfilesPath get => _socatProfilesPath; set => this.RaiseAndSetIfChanged(ref _socatProfilesPath, value); } + /// + /// Gets or sets the BrowseSocatProfilesPathCommand. + /// public ReactiveCommand BrowseSocatProfilesPathCommand { get; } + /// + /// Gets or sets the OpenSocatProfilesPathCommand. + /// public ReactiveCommand OpenSocatProfilesPathCommand { get; } + /// + /// Gets or sets the ResetSocatProfilesPathCommand. + /// public ReactiveCommand ResetSocatProfilesPathCommand { get; } // Power Supply @@ -102,8 +118,17 @@ public string PowerSupplyProfilesPath get => _powerSupplyProfilesPath; set => this.RaiseAndSetIfChanged(ref _powerSupplyProfilesPath, value); } + /// + /// Gets or sets the BrowsePowerSupplyProfilesPathCommand. + /// public ReactiveCommand BrowsePowerSupplyProfilesPathCommand { get; } + /// + /// Gets or sets the OpenPowerSupplyProfilesPathCommand. + /// public ReactiveCommand OpenPowerSupplyProfilesPathCommand { get; } + /// + /// Gets or sets the ResetPowerSupplyProfilesPathCommand. + /// public ReactiveCommand ResetPowerSupplyProfilesPathCommand { get; } // Memory Region @@ -113,27 +138,38 @@ public string MemoryRegionProfilesPath get => _memoryRegionProfilesPath; set => this.RaiseAndSetIfChanged(ref _memoryRegionProfilesPath, value); } + /// + /// Gets or sets the BrowseMemoryRegionProfilesPathCommand. + /// public ReactiveCommand BrowseMemoryRegionProfilesPathCommand { get; } + /// + /// Gets or sets the OpenMemoryRegionProfilesPathCommand. + /// public ReactiveCommand OpenMemoryRegionProfilesPathCommand { get; } + /// + /// Gets or sets the ResetMemoryRegionProfilesPathCommand. + /// public ReactiveCommand ResetMemoryRegionProfilesPathCommand { get; } private void RefreshFromSettings() { + var current = _settingsService.Current; + // Serial - string serialPath = _settingsService.GetSetting("profiles.serialPath", _pathService.SerialProfilesPath); + string serialPath = !string.IsNullOrEmpty(current.Profiles.SerialPath) ? current.Profiles.SerialPath : _pathService.SerialProfilesPath; SerialProfilesPath = GetDirectoryFromPath(serialPath, _pathService.SerialProfilesPath); // Socat - string socatPath = _settingsService.GetSetting("profiles.socatPath", _pathService.SocatProfilesPath); + string socatPath = !string.IsNullOrEmpty(current.Profiles.SocatPath) ? current.Profiles.SocatPath : _pathService.SocatProfilesPath; SocatProfilesPath = GetDirectoryFromPath(socatPath, _pathService.SocatProfilesPath); // Power Supply - string powerSupplyPath = _settingsService.GetSetting("profiles.powerSupplyPath", _pathService.PowerSupplyProfilesPath); + string powerSupplyPath = !string.IsNullOrEmpty(current.Profiles.PowerSupplyPath) ? current.Profiles.PowerSupplyPath : _pathService.PowerSupplyProfilesPath; PowerSupplyProfilesPath = GetDirectoryFromPath(powerSupplyPath, _pathService.PowerSupplyProfilesPath); // Memory Region - string memoryRegionPath = _settingsService.GetSetting("profiles.memoryRegionPath", _pathService.MemoryRegionProfilesPath); + string memoryRegionPath = !string.IsNullOrEmpty(current.Profiles.MemoryRegionPath) ? current.Profiles.MemoryRegionPath : _pathService.MemoryRegionProfilesPath; MemoryRegionProfilesPath = GetDirectoryFromPath(memoryRegionPath, _pathService.MemoryRegionProfilesPath); } @@ -144,7 +180,11 @@ private string GetDirectoryFromPath(string fullPath, string defaultPath) string? directoryPath = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directoryPath)) { - if (Path.IsPathRooted(directoryPath)) return directoryPath; + if (Path.IsPathRooted(directoryPath)) + { + return directoryPath; + } + return _pathService.ResolvePath(directoryPath); } } @@ -152,7 +192,7 @@ private string GetDirectoryFromPath(string fullPath, string defaultPath) return _pathService.ProfilesDirectory; } - private async Task HandleBrowsePathAsync(string currentPath, Action pathSetter, string settingsKey, string fileName) + private async Task HandleBrowsePathAsync(string currentPath, Action pathSetter, Action updateAction, string fileName) { if (_fileDialogService == null) { @@ -166,7 +206,7 @@ private async Task HandleBrowsePathAsync(string currentPath, Action path if (!string.IsNullOrEmpty(result)) { pathSetter(result); - await UpdatePathInSettingsAsync(settingsKey, result, fileName); + await UpdatePathInSettingsAsync(updateAction, result, fileName); } } catch (Exception ex) @@ -201,13 +241,13 @@ private async Task HandleOpenPathAsync(string path) } } - private async Task HandleResetPathAsync(string defaultPathValue, Action pathSetter, string settingsKey, string fileName) + private async Task HandleResetPathAsync(string defaultPathValue, Action pathSetter, Action updateAction, string fileName) { try { string defaultFolder = Path.GetDirectoryName(defaultPathValue) ?? _pathService.ProfilesDirectory; pathSetter(defaultFolder); - await UpdatePathInSettingsAsync(settingsKey, defaultFolder, fileName); + await UpdatePathInSettingsAsync(updateAction, defaultFolder, fileName); } catch (Exception ex) { @@ -216,11 +256,11 @@ private async Task HandleResetPathAsync(string defaultPathValue, Action } } - private async Task UpdatePathInSettingsAsync(string key, string folderPath, string fileName) + private async Task UpdatePathInSettingsAsync(Action updateAction, string folderPath, string fileName) { try { - await _settingsService.SetSettingAsync(key, Path.Combine(folderPath, fileName)).ConfigureAwait(false); + await _settingsService.UpdateSettingsAsync(s => updateAction(s, Path.Combine(folderPath, fileName))).ConfigureAwait(false); StatusMessage = "Path updated"; } catch (Exception ex) @@ -231,22 +271,22 @@ private async Task UpdatePathInSettingsAsync(string key, string folderPath, stri } // Serial - private Task BrowseSerialProfilesPathAsync() => HandleBrowsePathAsync(SerialProfilesPath, p => SerialProfilesPath = p, "profiles.serialPath", "SerialProfiles.json"); + private Task BrowseSerialProfilesPathAsync() => HandleBrowsePathAsync(SerialProfilesPath, p => SerialProfilesPath = p, (s, path) => s.Profiles.SerialPath = path, "SerialProfiles.json"); private Task OpenSerialProfilesPathAsync() => HandleOpenPathAsync(SerialProfilesPath); - private Task ResetSerialProfilesPathAsync() => HandleResetPathAsync(_pathService.SerialProfilesPath, p => SerialProfilesPath = p, "profiles.serialPath", "SerialProfiles.json"); + private Task ResetSerialProfilesPathAsync() => HandleResetPathAsync(_pathService.SerialProfilesPath, p => SerialProfilesPath = p, (s, path) => s.Profiles.SerialPath = path, "SerialProfiles.json"); // Socat - private Task BrowseSocatProfilesPathAsync() => HandleBrowsePathAsync(SocatProfilesPath, p => SocatProfilesPath = p, "profiles.socatPath", "SocatProfiles.json"); + private Task BrowseSocatProfilesPathAsync() => HandleBrowsePathAsync(SocatProfilesPath, p => SocatProfilesPath = p, (s, path) => s.Profiles.SocatPath = path, "SocatProfiles.json"); private Task OpenSocatProfilesPathAsync() => HandleOpenPathAsync(SocatProfilesPath); - private Task ResetSocatProfilesPathAsync() => HandleResetPathAsync(_pathService.SocatProfilesPath, p => SocatProfilesPath = p, "profiles.socatPath", "SocatProfiles.json"); + private Task ResetSocatProfilesPathAsync() => HandleResetPathAsync(_pathService.SocatProfilesPath, p => SocatProfilesPath = p, (s, path) => s.Profiles.SocatPath = path, "SocatProfiles.json"); // Power Supply - private Task BrowsePowerSupplyProfilesPathAsync() => HandleBrowsePathAsync(PowerSupplyProfilesPath, p => PowerSupplyProfilesPath = p, "profiles.powerSupplyPath", "PowerSupplyProfiles.json"); + private Task BrowsePowerSupplyProfilesPathAsync() => HandleBrowsePathAsync(PowerSupplyProfilesPath, p => PowerSupplyProfilesPath = p, (s, path) => s.Profiles.PowerSupplyPath = path, "PowerSupplyProfiles.json"); private Task OpenPowerSupplyProfilesPathAsync() => HandleOpenPathAsync(PowerSupplyProfilesPath); - private Task ResetPowerSupplyProfilesPathAsync() => HandleResetPathAsync(_pathService.PowerSupplyProfilesPath, p => PowerSupplyProfilesPath = p, "profiles.powerSupplyPath", "PowerSupplyProfiles.json"); + private Task ResetPowerSupplyProfilesPathAsync() => HandleResetPathAsync(_pathService.PowerSupplyProfilesPath, p => PowerSupplyProfilesPath = p, (s, path) => s.Profiles.PowerSupplyPath = path, "PowerSupplyProfiles.json"); // Memory Region - private Task BrowseMemoryRegionProfilesPathAsync() => HandleBrowsePathAsync(MemoryRegionProfilesPath, p => MemoryRegionProfilesPath = p, "profiles.memoryRegionPath", "MemoryMappingProfiles.json"); + private Task BrowseMemoryRegionProfilesPathAsync() => HandleBrowsePathAsync(MemoryRegionProfilesPath, p => MemoryRegionProfilesPath = p, (s, path) => s.Profiles.MemoryRegionPath = path, "MemoryMappingProfiles.json"); private Task OpenMemoryRegionProfilesPathAsync() => HandleOpenPathAsync(MemoryRegionProfilesPath); - private Task ResetMemoryRegionProfilesPathAsync() => HandleResetPathAsync(_pathService.MemoryRegionProfilesPath, p => MemoryRegionProfilesPath = p, "profiles.memoryRegionPath", "MemoryMappingProfiles.json"); -} + private Task ResetMemoryRegionProfilesPathAsync() => HandleResetPathAsync(_pathService.MemoryRegionProfilesPath, p => MemoryRegionProfilesPath = p, (s, path) => s.Profiles.MemoryRegionPath = path, "MemoryMappingProfiles.json"); +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Settings/SerialPortsSettingsViewModel.cs b/src/S7Tools/ViewModels/Settings/SerialPortsSettingsViewModel.cs index c8cb07c2..f40f01f3 100644 --- a/src/S7Tools/ViewModels/Settings/SerialPortsSettingsViewModel.cs +++ b/src/S7Tools/ViewModels/Settings/SerialPortsSettingsViewModel.cs @@ -13,7 +13,6 @@ using S7Tools.Core.Constants; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Helpers; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -36,7 +35,6 @@ public class SerialPortsSettingsViewModel : ProfileManagementViewModelBase _specificLogger; @@ -58,7 +56,6 @@ public class SerialPortsSettingsViewModel : ProfileManagementViewModelBaseThe serial port profile service. /// The serial port service. /// The dialog service. - /// The profile edit dialog service. /// The clipboard service. /// The file dialog service. /// The settings service used to persist application settings. @@ -71,7 +68,6 @@ public SerialPortsSettingsViewModel( ISerialPortProfileService profileService, ISerialPortService portService, IDialogService dialogService, - IProfileEditDialogService profileEditDialogService, IClipboardService clipboardService, IFileDialogService? fileDialogService, S7Tools.Core.Interfaces.Services.IApplicationSettingsService settingsService, @@ -85,7 +81,6 @@ public SerialPortsSettingsViewModel( _profileService = profileService ?? throw new ArgumentNullException(nameof(profileService)); _portService = portService ?? throw new ArgumentNullException(nameof(portService)); _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); - _profileEditDialogService = profileEditDialogService ?? throw new ArgumentNullException(nameof(profileEditDialogService)); _clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService)); _fileDialogService = fileDialogService; _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); @@ -107,8 +102,15 @@ public SerialPortsSettingsViewModel( // Load profiles and scan ports in background but marshal collection updates to UI thread _ = Task.Run(async () => { - await base.InitializeAsync(); - await ScanPortsAsync(); + try + { + await base.InitializeAsync(); + await ScanPortsAsync(); + } + catch (Exception ex) + { + _specificLogger.LogError(ex, "Failed to initialize SerialPortsSettingsViewModel"); + } }); _specificLogger.LogInformation("SerialPortsSettingsViewModel initialized"); @@ -325,36 +327,48 @@ private async Task ScanPortsAsync() { try { - IsScanning = true; - StatusMessage = UIStrings.Status_ScanningForPorts; + await _uiThreadService.InvokeOnUIThreadAsync(() => + { + IsScanning = true; + StatusMessage = UIStrings.Status_ScanningForPorts; + }); - IEnumerable portInfos = await _portService.ScanAvailablePortsAsync(); + IEnumerable portInfos = await _portService.ScanAvailablePortsAsync(); - AvailablePorts.Clear(); + await _uiThreadService.InvokeOnUIThreadAsync(() => + { + AvailablePorts.Clear(); - // Sort ports with ttyUSB* first (external serial adapters), then others alphabetically - IOrderedEnumerable sortedPortInfos = portInfos - .OrderBy(p => !p.PortPath.Contains("/ttyUSB")) // ttyUSB* ports come first (false sorts before true) - .ThenBy(p => p.PortPath); // Then sort alphabetically within each group + // Sort ports with ttyUSB* first (external serial adapters), then others alphabetically + IOrderedEnumerable sortedPortInfos = portInfos + .OrderBy(p => !p.PortPath.Contains("/ttyUSB")) // ttyUSB* ports come first (false sorts before true) + .ThenBy(p => p.PortPath); // Then sort alphabetically within each group - foreach (Core.Services.Interfaces.SerialPortInfo? portInfo in sortedPortInfos) - { - AvailablePorts.Add(portInfo.PortPath); - } + foreach (Core.Interfaces.Services.SerialPortInfo? portInfo in sortedPortInfos) + { + AvailablePorts.Add(portInfo.PortPath); + } - PortCount = AvailablePorts.Count; - StatusMessage = $"Found {PortCount} port(s)"; + PortCount = AvailablePorts.Count; + StatusMessage = $"Found {PortCount} port(s)"; + }); _specificLogger.LogInformation("Found {PortCount} available ports", PortCount); } catch (Exception ex) { _specificLogger.LogError(ex, "Error scanning for ports"); - StatusMessage = UIStrings.Status_ErrorScanningForPorts; + await _uiThreadService.InvokeOnUIThreadAsync(() => + { + StatusMessage = UIStrings.Status_ErrorScanningForPorts; + }); } finally { - IsScanning = false; + await _uiThreadService.InvokeOnUIThreadAsync(() => + { + IsScanning = false; + }); } } diff --git a/src/S7Tools/ViewModels/Settings/SettingsViewModel.cs b/src/S7Tools/ViewModels/Settings/SettingsViewModel.cs index f703f37e..3a852ce1 100644 --- a/src/S7Tools/ViewModels/Settings/SettingsViewModel.cs +++ b/src/S7Tools/ViewModels/Settings/SettingsViewModel.cs @@ -6,24 +6,41 @@ using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Interfaces.ViewModels; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Base; using S7Tools.ViewModels.Controls; namespace S7Tools.ViewModels.Settings; +/// +/// Represents the SettingsViewModel. +/// public class SettingsViewModel : ViewModelBase, IDockableViewModel { // IDockableViewModel implementation + /// + /// Gets or sets the DockId. + /// public string DockId => "Settings"; + /// + /// Gets or sets the DockTitle. + /// public string DockTitle => "Settings"; + /// + /// Gets or sets the CanClose. + /// public bool CanClose => true; + /// + /// Gets or sets the CanFloat. + /// public bool CanFloat => true; private readonly IServiceProvider _serviceProvider; private readonly Dictionary _categoryViewModels; + /// + /// Initializes a new instance of the class. + /// public SettingsViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); @@ -53,6 +70,9 @@ public SettingsViewModel(IServiceProvider serviceProvider) }); } + /// + /// Gets or sets the Categories. + /// public ObservableCollection Categories { get; } private string _selectedCategory = "Logging"; @@ -79,6 +99,9 @@ public ViewModelBase? SelectedCategoryViewModel set => this.RaiseAndSetIfChanged(ref _selectedCategoryViewModel, value); } + /// + /// Gets or sets the SelectCategoryCommand. + /// public ReactiveCommand SelectCategoryCommand { get; } private ViewModelBase GetCategoryViewModel(string category) @@ -163,7 +186,6 @@ private SerialPortsSettingsViewModel CreateSerialPortsSettingsViewModel() ISerialPortProfileService profileService = _serviceProvider.GetRequiredService(); ISerialPortService portService = _serviceProvider.GetRequiredService(); IDialogService dialogService = _serviceProvider.GetRequiredService(); - IProfileEditDialogService profileEditDialogService = _serviceProvider.GetRequiredService(); IClipboardService clipboardService = _serviceProvider.GetRequiredService(); IFileDialogService? fileDialogService = _serviceProvider.GetService(); S7Tools.Core.Interfaces.Services.IApplicationSettingsService settingsService = _serviceProvider.GetRequiredService(); @@ -173,7 +195,7 @@ private SerialPortsSettingsViewModel CreateSerialPortsSettingsViewModel() SerialPortDiscoveryViewModel portScanner = _serviceProvider.GetRequiredService(); ILogger logger = _serviceProvider.GetRequiredService>(); - return new SerialPortsSettingsViewModel(profileService, portService, dialogService, profileEditDialogService, clipboardService, fileDialogService, settingsService, uiThreadService, unifiedProfileDialogService, pathService, portScanner, logger); + return new SerialPortsSettingsViewModel(profileService, portService, dialogService, clipboardService, fileDialogService, settingsService, uiThreadService, unifiedProfileDialogService, pathService, portScanner, logger); } private SocatSettingsViewModel CreateSocatSettingsViewModel() diff --git a/src/S7Tools/ViewModels/Settings/SocatSettingsViewModel.cs b/src/S7Tools/ViewModels/Settings/SocatSettingsViewModel.cs index ca5a9146..6f8e6ba1 100644 --- a/src/S7Tools/ViewModels/Settings/SocatSettingsViewModel.cs +++ b/src/S7Tools/ViewModels/Settings/SocatSettingsViewModel.cs @@ -13,7 +13,6 @@ using S7Tools.Core.Constants; using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; using S7Tools.Helpers; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -110,10 +109,17 @@ public SocatSettingsViewModel( // Load initial data _ = Task.Run(async () => { - await base.InitializeAsync(); - await RefreshCommand.Execute(); - await ScanSerialDevicesAsync(); - await RefreshRunningProcessesAsync(); + try + { + await base.InitializeAsync(); + await RefreshCommand.Execute(); + await ScanSerialDevicesAsync(); + await RefreshRunningProcessesAsync(); + } + catch (Exception ex) + { + _specificLogger.LogError(ex, "Failed to initialize SocatSettingsViewModel"); + } }); _specificLogger.LogInformation("SocatSettingsViewModel initialized"); @@ -493,7 +499,7 @@ private async Task ScanSerialDevicesAsync() IsScanning = true; StatusMessage = UIStrings.Status_ScanningDevices; - IEnumerable deviceInfos = await _serialPortService.ScanAvailablePortsAsync(); + IEnumerable deviceInfos = await _serialPortService.ScanAvailablePortsAsync(); IEnumerable devices = deviceInfos.Select(info => info.PortPath); await _uiThreadService.InvokeOnUIThreadAsync(() => @@ -649,7 +655,7 @@ private async Task DuplicateProfileAsync() { _specificLogger.LogDebug("Duplicating socat profile: {ProfileName}", SelectedProfile.Name); - Models.InputResult inputResult = await _dialogService.ShowInputAsync( + global::S7Tools.ViewModels.Dialogs.Models.InputResult inputResult = await _dialogService.ShowInputAsync( "Duplicate Profile", "Enter a name for the duplicate profile:", $"{SelectedProfile.Name} (Copy)").ConfigureAwait(false); diff --git a/src/S7Tools/ViewModels/Tasks/ActiveTasksViewModel.cs b/src/S7Tools/ViewModels/Tasks/ActiveTasksViewModel.cs index eb5cd655..eb8f23eb 100644 --- a/src/S7Tools/ViewModels/Tasks/ActiveTasksViewModel.cs +++ b/src/S7Tools/ViewModels/Tasks/ActiveTasksViewModel.cs @@ -7,10 +7,16 @@ namespace S7Tools.ViewModels.Tasks; /// public sealed class ActiveTasksViewModel : ViewModelBase { + /// + /// Initializes a new instance of the class. + /// public ActiveTasksViewModel(TaskManagerViewModel manager) { Manager = manager ?? throw new ArgumentNullException(nameof(manager)); } + /// + /// Gets or sets the Manager. + /// public TaskManagerViewModel Manager { get; } } diff --git a/src/S7Tools/ViewModels/Tasks/HistoryTasksViewModel.cs b/src/S7Tools/ViewModels/Tasks/HistoryTasksViewModel.cs index 0b025296..5f7106c8 100644 --- a/src/S7Tools/ViewModels/Tasks/HistoryTasksViewModel.cs +++ b/src/S7Tools/ViewModels/Tasks/HistoryTasksViewModel.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using ReactiveUI; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Helpers; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Base; @@ -24,6 +24,9 @@ public sealed class HistoryTasksViewModel : ViewModelBase, IDisposable private readonly ILogger _logger; private TaskExecution? _selectedHistoryTask; + /// + /// Initializes a new instance of the class. + /// public HistoryTasksViewModel( TaskManagerViewModel manager, IJobManager jobManager, @@ -41,6 +44,9 @@ public HistoryTasksViewModel( .DisposeWith(_disposables); } + /// + /// Gets or sets the Manager. + /// public TaskManagerViewModel Manager { get; } /// @@ -138,6 +144,9 @@ private async Task ExecuteOpenDumpsFolderAsync() } } + /// + /// Executes the Dispose operation. + /// public void Dispose() { _disposables.Dispose(); diff --git a/src/S7Tools/ViewModels/Tasks/ScheduledTasksViewModel.cs b/src/S7Tools/ViewModels/Tasks/ScheduledTasksViewModel.cs index 648f88b5..7279df60 100644 --- a/src/S7Tools/ViewModels/Tasks/ScheduledTasksViewModel.cs +++ b/src/S7Tools/ViewModels/Tasks/ScheduledTasksViewModel.cs @@ -7,10 +7,16 @@ namespace S7Tools.ViewModels.Tasks; /// public sealed class ScheduledTasksViewModel : ViewModelBase { + /// + /// Initializes a new instance of the class. + /// public ScheduledTasksViewModel(TaskManagerViewModel manager) { Manager = manager ?? throw new ArgumentNullException(nameof(manager)); } + /// + /// Gets or sets the Manager. + /// public TaskManagerViewModel Manager { get; } } diff --git a/src/S7Tools/ViewModels/Tasks/TaskCommandManager.cs b/src/S7Tools/ViewModels/Tasks/TaskCommandManager.cs index 6ccf642b..cd2a6406 100644 --- a/src/S7Tools/ViewModels/Tasks/TaskCommandManager.cs +++ b/src/S7Tools/ViewModels/Tasks/TaskCommandManager.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Extensions; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -20,6 +20,9 @@ public class TaskCommandManager private readonly IJobManager _jobManager; private readonly IDialogService _dialogService; + /// + /// Initializes a new instance of the class. + /// public TaskCommandManager( ILogger logger, ITaskScheduler taskScheduler, @@ -32,6 +35,9 @@ public TaskCommandManager( _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); } + /// + /// Executes the StartTaskAsync operation. + /// public async Task StartTaskAsync(TaskExecution task) { ArgumentNullException.ThrowIfNull(task); @@ -63,6 +69,9 @@ public async Task StartTaskAsync(TaskExecution task) } } + /// + /// Executes the StopTaskAsync operation. + /// public async Task StopTaskAsync(TaskExecution task) { ArgumentNullException.ThrowIfNull(task); @@ -103,6 +112,9 @@ public async Task StopTaskAsync(TaskExecution task) } } + /// + /// Executes the PauseTaskAsync operation. + /// public async Task PauseTaskAsync(TaskExecution task) { ArgumentNullException.ThrowIfNull(task); @@ -133,6 +145,9 @@ public async Task PauseTaskAsync(TaskExecution task) } } + /// + /// Executes the ResumeTaskAsync operation. + /// public async Task ResumeTaskAsync(TaskExecution task) { ArgumentNullException.ThrowIfNull(task); @@ -163,6 +178,9 @@ public async Task ResumeTaskAsync(TaskExecution task) } } + /// + /// Executes the RestartTaskAsync operation. + /// public async Task RestartTaskAsync(TaskExecution task) { ArgumentNullException.ThrowIfNull(task); @@ -212,6 +230,9 @@ public async Task RestartTaskAsync(TaskExecution task) } } + /// + /// Executes the DeleteTaskAsync operation. + /// public async Task DeleteTaskAsync(TaskExecution task) { ArgumentNullException.ThrowIfNull(task); @@ -251,6 +272,9 @@ public async Task DeleteTaskAsync(TaskExecution task) } } + /// + /// Executes the ScheduleTaskAsync operation. + /// public async Task ScheduleTaskAsync(TaskExecution task) { ArgumentNullException.ThrowIfNull(task); @@ -300,6 +324,9 @@ public async Task ScheduleTaskAsync(TaskExecution task) } } + /// + /// Executes the CreateTaskAsync operation. + /// public async Task CreateTaskAsync() { try @@ -332,6 +359,9 @@ public async Task CreateTaskAsync() } } + /// + /// Executes the ClearFinishedTasksAsync operation. + /// public async Task ClearFinishedTasksAsync() { bool confirmed = await _dialogService.ShowConfirmationAsync( @@ -385,11 +415,26 @@ public async Task ClearFinishedTasksAsync() } } +/// +/// Represents the struct. +/// public readonly record struct CommandResult { + /// + /// Gets or sets the IsSuccess. + /// public bool IsSuccess { get; } + /// + /// Gets or sets the IsCancelled. + /// public bool IsCancelled { get; } + /// + /// Gets or sets the Message. + /// public string Message { get; } + /// + /// Gets or sets the Data. + /// public object? Data { get; } private CommandResult(bool isSuccess, bool isCancelled, string message, object? data = null) @@ -400,7 +445,16 @@ private CommandResult(bool isSuccess, bool isCancelled, string message, object? Data = data; } + /// + /// Executes the Success operation. + /// public static CommandResult Success(string message, object? data = null) => new(true, false, message, data); + /// + /// Executes the Failure operation. + /// public static CommandResult Failure(string message) => new(false, false, message); + /// + /// Executes the Cancelled operation. + /// public static CommandResult Cancelled() => new(false, true, string.Empty); } diff --git a/src/S7Tools/ViewModels/Tasks/TaskCreatorViewModel.cs b/src/S7Tools/ViewModels/Tasks/TaskCreatorViewModel.cs index 22d1107d..d3e7beb2 100644 --- a/src/S7Tools/ViewModels/Tasks/TaskCreatorViewModel.cs +++ b/src/S7Tools/ViewModels/Tasks/TaskCreatorViewModel.cs @@ -7,10 +7,16 @@ namespace S7Tools.ViewModels.Tasks; /// public sealed class TaskCreatorViewModel : ViewModelBase { + /// + /// Initializes a new instance of the class. + /// public TaskCreatorViewModel(TaskManagerViewModel manager) { Manager = manager ?? throw new ArgumentNullException(nameof(manager)); } + /// + /// Gets or sets the Manager. + /// public TaskManagerViewModel Manager { get; } } diff --git a/src/S7Tools/ViewModels/Tasks/TaskDetailsViewModel.cs b/src/S7Tools/ViewModels/Tasks/TaskDetailsViewModel.cs index 89449370..0e560402 100644 --- a/src/S7Tools/ViewModels/Tasks/TaskDetailsViewModel.cs +++ b/src/S7Tools/ViewModels/Tasks/TaskDetailsViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.Concurrent; using System.Collections.ObjectModel; @@ -15,9 +16,10 @@ using S7Tools.Core.Models; using S7Tools.Core.Models.Configuration; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Core.Validation; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; using S7Tools.Services; using S7Tools.Services.Interfaces; using S7Tools.Services.Jobs; @@ -33,7 +35,7 @@ public class TaskDetailsViewModel : ViewModelBase, IDisposable private readonly ILogger _logger; private readonly ISocatService _socatService; private readonly IPowerSupplyService _powerSupplyService; - private readonly IEnhancedBootloaderService _bootloaderService; + private readonly IBootloaderService _bootloaderService; private readonly IUIThreadService _uiThreadService; private readonly IJobManager _jobManager; private readonly IPowerSupplyProfileService _powerSupplyProfileService; @@ -77,13 +79,12 @@ public class TaskDetailsViewModel : ViewModelBase, IDisposable /// Serial port profile service for accessing serial port profiles. /// Socat profile service for accessing socat profiles. /// Job profile set factory for creating profile sets. - /// The centralized task log service. /// The clipboard service. public TaskDetailsViewModel( ILogger logger, ISocatService socatService, IPowerSupplyService powerSupplyService, - IEnhancedBootloaderService bootloaderService, + IBootloaderService bootloaderService, IUIThreadService uiThreadService, IJobManager jobManager, IPowerSupplyProfileService powerSupplyProfileService, @@ -1170,4 +1171,4 @@ protected virtual void Dispose(bool disposing) } #endregion -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Tasks/TaskManagerViewModel.cs b/src/S7Tools/ViewModels/Tasks/TaskManagerViewModel.cs index 059615c9..69a40672 100644 --- a/src/S7Tools/ViewModels/Tasks/TaskManagerViewModel.cs +++ b/src/S7Tools/ViewModels/Tasks/TaskManagerViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.ObjectModel; using System.Collections.Specialized; @@ -9,7 +10,7 @@ using Microsoft.Extensions.Logging; using ReactiveUI; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Resources; using S7Tools.Services.Interfaces; @@ -1079,4 +1080,4 @@ internal enum TaskDisplayState /// Tasks that have completed, failed, or been cancelled. /// Finished -} +} \ No newline at end of file diff --git a/src/S7Tools/ViewModels/Tasks/TaskStatisticsViewModel.cs b/src/S7Tools/ViewModels/Tasks/TaskStatisticsViewModel.cs index ddc8cea9..48d88cda 100644 --- a/src/S7Tools/ViewModels/Tasks/TaskStatisticsViewModel.cs +++ b/src/S7Tools/ViewModels/Tasks/TaskStatisticsViewModel.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Collections.Generic; using System.Linq; @@ -98,4 +99,4 @@ public void Update( int activeResourceCount = activeList.SelectMany(t => t.LockedResources).Distinct().Count(); ResourceUtilization = $"{activeResourceCount} resources in use"; } -} +} \ No newline at end of file diff --git a/src/S7Tools/Views/Dialogs/InputDialog.axaml.cs b/src/S7Tools/Views/Dialogs/InputDialog.axaml.cs index fb428be6..e803cb15 100644 --- a/src/S7Tools/Views/Dialogs/InputDialog.axaml.cs +++ b/src/S7Tools/Views/Dialogs/InputDialog.axaml.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using Avalonia.Input; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; using S7Tools.ViewModels.Dialogs; namespace S7Tools.Views.Dialogs; diff --git a/src/S7Tools/Views/Dialogs/ProfileEditDialog.axaml.cs b/src/S7Tools/Views/Dialogs/ProfileEditDialog.axaml.cs index 1d8759d0..fd811f5f 100644 --- a/src/S7Tools/Views/Dialogs/ProfileEditDialog.axaml.cs +++ b/src/S7Tools/Views/Dialogs/ProfileEditDialog.axaml.cs @@ -1,3 +1,4 @@ +using S7Tools.ViewModels.Base; using System; using System.Reactive; using System.Reactive.Linq; @@ -6,6 +7,7 @@ using Avalonia.Input; using Avalonia.Interactivity; using S7Tools.Models; +using S7Tools.ViewModels.Dialogs.Models; using S7Tools.ViewModels; using S7Tools.ViewModels.Profiles; using S7Tools.Views.Profiles; @@ -345,4 +347,4 @@ private static bool IsProfileValid(ViewModelBase profileViewModel) return true; } -} +} \ No newline at end of file diff --git a/src/S7Tools/Views/Pages/MemoryDumpSidebarView.axaml.cs b/src/S7Tools/Views/Pages/MemoryDumpSidebarView.axaml.cs index c0fc5d58..17b47a79 100644 --- a/src/S7Tools/Views/Pages/MemoryDumpSidebarView.axaml.cs +++ b/src/S7Tools/Views/Pages/MemoryDumpSidebarView.axaml.cs @@ -11,7 +11,7 @@ public MemoryDumpSidebarView() private void OnTreeViewDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) { - if (sender is TreeView treeView && + if (sender is TreeView treeView && treeView.SelectedItem is S7Tools.ViewModels.Pages.FileTreeItemViewModel fileItem && DataContext is S7Tools.ViewModels.Pages.MemoryDumpViewerViewModel mainVm) { diff --git a/src/S7Tools/Views/Settings/GeneralSettingsView.axaml b/src/S7Tools/Views/Settings/GeneralSettingsView.axaml index 3686f515..58fc62bb 100644 --- a/src/S7Tools/Views/Settings/GeneralSettingsView.axaml +++ b/src/S7Tools/Views/Settings/GeneralSettingsView.axaml @@ -127,6 +127,119 @@ FontSize="11" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/S7Tools.Core.Tests/Exceptions/DialogParentNotFoundExceptionTests.cs b/tests/S7Tools.Core.Tests/Exceptions/DialogParentNotFoundExceptionTests.cs index f99dac5e..bcad7db3 100644 --- a/tests/S7Tools.Core.Tests/Exceptions/DialogParentNotFoundExceptionTests.cs +++ b/tests/S7Tools.Core.Tests/Exceptions/DialogParentNotFoundExceptionTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using S7Tools.Core.Exceptions; namespace S7Tools.Core.Tests.Exceptions; @@ -14,8 +15,8 @@ public void Constructor_Default_CreatesExceptionWithDefaultMessage() var exception = new DialogParentNotFoundException(); // Assert - Assert.NotNull(exception); - Assert.NotNull(exception.Message); + exception.Should().NotBeNull(); + exception.Message.Should().NotBeNull(); Assert.IsAssignableFrom(exception); } @@ -29,8 +30,8 @@ public void Constructor_WithMessage_CreatesExceptionWithSpecifiedMessage() var exception = new DialogParentNotFoundException(expectedMessage); // Assert - Assert.NotNull(exception); - Assert.Equal(expectedMessage, exception.Message); + exception.Should().NotBeNull(); + exception.Message.Should().Be(expectedMessage); Assert.IsAssignableFrom(exception); } @@ -45,9 +46,9 @@ public void Constructor_WithMessageAndInnerException_CreatesExceptionWithBoth() var exception = new DialogParentNotFoundException(expectedMessage, innerException); // Assert - Assert.NotNull(exception); - Assert.Equal(expectedMessage, exception.Message); - Assert.Same(innerException, exception.InnerException); + exception.Should().NotBeNull(); + exception.Message.Should().Be(expectedMessage); + exception.InnerException.Should().BeSameAs(innerException); Assert.IsAssignableFrom(exception); } @@ -58,7 +59,7 @@ public void Exception_InheritsFromS7ToolsException() var exception = new DialogParentNotFoundException(); // Assert - Assert.IsType(exception); + exception.Should().BeOfType().Subject; Assert.IsAssignableFrom(exception); Assert.IsAssignableFrom(exception); } @@ -81,8 +82,8 @@ public void Exception_CanBeThrownAndCaught() } // Assert - Assert.NotNull(caughtException); - Assert.Equal(expectedMessage, caughtException.Message); + caughtException.Should().NotBeNull(); + caughtException.Message.Should().Be(expectedMessage); } [Fact] @@ -103,8 +104,8 @@ public void Exception_CanBeCaughtAsS7ToolsException() } // Assert - Assert.NotNull(caughtException); - Assert.IsType(caughtException); - Assert.Equal(expectedMessage, caughtException.Message); + caughtException.Should().NotBeNull(); + caughtException.Should().BeOfType().Subject; + caughtException.Message.Should().Be(expectedMessage); } } diff --git a/tests/S7Tools.Core.Tests/Exceptions/ExceptionTests.cs b/tests/S7Tools.Core.Tests/Exceptions/ExceptionTests.cs index d9f15b36..7d334398 100644 --- a/tests/S7Tools.Core.Tests/Exceptions/ExceptionTests.cs +++ b/tests/S7Tools.Core.Tests/Exceptions/ExceptionTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System; using System.Collections.Generic; using System.Linq; @@ -21,7 +22,7 @@ public void S7ToolsException_DefaultConstructor_CreatesException() var exception = new S7ToolsException(); // Assert - Assert.NotNull(exception); + exception.Should().NotBeNull(); Assert.IsAssignableFrom(exception); } @@ -35,7 +36,7 @@ public void S7ToolsException_WithMessage_SetsMessage() var exception = new S7ToolsException(Message); // Assert - Assert.Equal(Message, exception.Message); + exception.Message.Should().Be(Message); } [Fact] @@ -49,8 +50,8 @@ public void S7ToolsException_WithInnerException_SetsInnerException() var exception = new S7ToolsException(Message, innerException); // Assert - Assert.Equal(Message, exception.Message); - Assert.Same(innerException, exception.InnerException); + exception.Message.Should().Be(Message); + exception.InnerException.Should().BeSameAs(innerException); } #endregion @@ -68,8 +69,8 @@ public void ProfileException_WithProfileId_SetsProfileId() var exception = new ProfileException(Message, ProfileId); // Assert - Assert.Equal(Message, exception.Message); - Assert.Equal(ProfileId, exception.ProfileId); + exception.Message.Should().Be(Message); + exception.ProfileId.Should().Be(ProfileId); } [Fact] @@ -84,9 +85,9 @@ public void ProfileException_WithProfileIdAndName_SetsBothProperties() var exception = new ProfileException(Message, ProfileId, ProfileName); // Assert - Assert.Equal(Message, exception.Message); - Assert.Equal(ProfileId, exception.ProfileId); - Assert.Equal(ProfileName, exception.ProfileName); + exception.Message.Should().Be(Message); + exception.ProfileId.Should().Be(ProfileId); + exception.ProfileName.Should().Be(ProfileName); } [Fact] @@ -115,7 +116,7 @@ public void ProfileNotFoundException_WithProfileId_FormatsMessage() // Assert Assert.Contains("123", exception.Message); Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(ProfileId, exception.ProfileId); + exception.ProfileId.Should().Be(ProfileId); } [Fact] @@ -132,8 +133,8 @@ public void ProfileNotFoundException_WithProfileIdAndName_FormatsMessageWithBoth Assert.Contains("123", exception.Message); Assert.Contains("MyProfile", exception.Message); Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(ProfileId, exception.ProfileId); - Assert.Equal(ProfileName, exception.ProfileName); + exception.ProfileId.Should().Be(ProfileId); + exception.ProfileName.Should().Be(ProfileName); } [Fact] @@ -162,8 +163,8 @@ public void DuplicateProfileNameException_WithProfileName_FormatsMessage() // Assert Assert.Contains(ProfileName, exception.Message); Assert.Contains("already exists", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(ProfileName, exception.DuplicateName); - Assert.Equal(ProfileName, exception.ProfileName); + exception.DuplicateName.Should().Be(ProfileName); + exception.ProfileName.Should().Be(ProfileName); } [Fact] @@ -193,7 +194,7 @@ public void DefaultProfileDeletionException_WithProfileId_FormatsMessage() Assert.Contains("1", exception.Message); Assert.Contains("default", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("cannot delete", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(ProfileId, exception.ProfileId); + exception.ProfileId.Should().Be(ProfileId); } [Fact] @@ -210,8 +211,8 @@ public void DefaultProfileDeletionException_WithProfileIdAndName_FormatsMessageW Assert.Contains("1", exception.Message); Assert.Contains("DefaultProfile", exception.Message); Assert.Contains("default", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(ProfileId, exception.ProfileId); - Assert.Equal(ProfileName, exception.ProfileName); + exception.ProfileId.Should().Be(ProfileId); + exception.ProfileName.Should().Be(ProfileName); } [Fact] @@ -241,7 +242,7 @@ public void ReadOnlyProfileModificationException_WithProfileId_FormatsMessage() Assert.Contains("99", exception.Message); Assert.Contains("read-only", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("cannot modify", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(profileId, exception.ProfileId); + exception.ProfileId.Should().Be(profileId); } [Fact] @@ -258,8 +259,8 @@ public void ReadOnlyProfileModificationException_WithProfileIdAndName_FormatsMes Assert.Contains("99", exception.Message); Assert.Contains("SystemProfile", exception.Message); Assert.Contains("read-only", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(profileId, exception.ProfileId); - Assert.Equal(profileName, exception.ProfileName); + exception.ProfileId.Should().Be(profileId); + exception.ProfileName.Should().Be(profileName); } [Fact] @@ -288,9 +289,9 @@ public void ConnectionException_WithConnectionDetails_SetsProperties() var exception = new ConnectionException(message, target, type); // Assert - Assert.Equal(message, exception.Message); - Assert.Equal(target, exception.ConnectionTarget); - Assert.Equal(type, exception.ConnectionType); + exception.Message.Should().Be(message); + exception.ConnectionTarget.Should().Be(target); + exception.ConnectionType.Should().Be(type); } [Fact] @@ -317,9 +318,9 @@ public void ValidationException_WithSingleError_SetsMessage() var exception = new ValidationException(error); // Assert - Assert.Equal(error, exception.Message); - Assert.Single(exception.ValidationErrors); - Assert.Equal(error, exception.ValidationErrors[0]); + exception.Message.Should().Be(error); + exception.ValidationErrors.Should().ContainSingle(); + exception.ValidationErrors[0].Should().Be(error); } [Fact] @@ -333,8 +334,8 @@ public void ValidationException_WithMultipleErrors_FormatsMessage() // Assert Assert.Contains("3 error(s)", exception.Message); - Assert.Equal(3, exception.ValidationErrors.Count); - Assert.Equal(errors, exception.ValidationErrors); + exception.ValidationErrors.Count.Should().Be(3); + exception.ValidationErrors.Should().Be(errors); } [Fact] @@ -350,7 +351,7 @@ public void ValidationException_WithPropertyName_FormatsMessageWithProperty() // Assert Assert.Contains(propertyName, exception.Message); Assert.Contains(error, exception.Message); - Assert.Equal(propertyName, exception.PropertyName); + exception.PropertyName.Should().Be(propertyName); } [Fact] @@ -364,7 +365,7 @@ public void ValidationException_WithEmptyErrors_UsesDefaultMessage() // Assert Assert.Contains("Validation failed", exception.Message); - Assert.Empty(exception.ValidationErrors); + exception.ValidationErrors.Should().BeEmpty(); } [Fact] @@ -392,8 +393,8 @@ public void ConfigurationException_WithSettingName_SetsProperty() var exception = new ConfigurationException(message, settingName); // Assert - Assert.Equal(message, exception.Message); - Assert.Equal(settingName, exception.SettingName); + exception.Message.Should().Be(message); + exception.SettingName.Should().Be(settingName); } [Fact] diff --git a/tests/S7Tools.Core.Tests/Models/JobTests.cs b/tests/S7Tools.Core.Tests/Models/JobTests.cs index 10804872..cf19963b 100644 --- a/tests/S7Tools.Core.Tests/Models/JobTests.cs +++ b/tests/S7Tools.Core.Tests/Models/JobTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; using Xunit; @@ -55,20 +56,20 @@ public void Job_Creation_Should_Set_Timestamps_And_Default_State() DateTime afterCreate = DateTime.UtcNow; // Assert - Assert.Equal(1, job.Id); - Assert.Equal("Test Job", job.Name); - Assert.Equal("Test job description", job.Description); - Assert.NotNull(job.ProfileSet); - Assert.Equal("/tmp/dumps", job.OutputPath); - Assert.Equal(JobState.Created, job.State); + job.Id.Should().Be(1); + job.Name.Should().Be("Test Job"); + job.Description.Should().Be("Test job description"); + job.ProfileSet.Should().NotBeNull(); + job.OutputPath.Should().Be("/tmp/dumps"); + job.State.Should().Be(JobState.Created); Assert.InRange(job.CreatedAt, beforeCreate, afterCreate); Assert.InRange(job.ModifiedAt, beforeCreate, afterCreate); - Assert.Null(job.QueuedAt); - Assert.Null(job.StartedAt); - Assert.Null(job.CompletedAt); - Assert.Equal(0.0, job.Progress); - Assert.Equal(string.Empty, job.CurrentOperation); - Assert.Null(job.ErrorMessage); + job.QueuedAt.Should().BeNull(); + job.StartedAt.Should().BeNull(); + job.CompletedAt.Should().BeNull(); + job.Progress.Should().Be(0.0); + job.CurrentOperation.Should().Be(string.Empty); + job.ErrorMessage.Should().BeNull(); } [Fact] @@ -114,10 +115,10 @@ public void Job_Clone_Should_Create_Independent_Instance() Assert.NotEqual(original.CurrentOperation, modifiedClone.CurrentOperation); // Original unchanged - Assert.Equal(1, original.Id); - Assert.Equal("Original Job", original.Name); - Assert.Equal(50.0, original.Progress); - Assert.Equal("Installing stager", original.CurrentOperation); + original.Id.Should().Be(1); + original.Name.Should().Be("Original Job"); + original.Progress.Should().Be(50.0); + original.CurrentOperation.Should().Be("Installing stager"); } private static JobProfileSet CreateTestProfileSet() diff --git a/tests/S7Tools.Core.Tests/Models/PayloadSetProfileTests.cs b/tests/S7Tools.Core.Tests/Models/PayloadSetProfileTests.cs index ce59b199..a0866469 100644 --- a/tests/S7Tools.Core.Tests/Models/PayloadSetProfileTests.cs +++ b/tests/S7Tools.Core.Tests/Models/PayloadSetProfileTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using S7Tools.Core.Models.Jobs; using Xunit; @@ -15,7 +16,7 @@ public void PayloadSetProfile_Creation_Should_Accept_Valid_BasePath() var profile = new PayloadSetProfile { BasePath = "/tmp/payloads" }; // Assert - Assert.Equal("/tmp/payloads", profile.BasePath); + profile.BasePath.Should().Be("/tmp/payloads"); } [Theory] @@ -27,6 +28,6 @@ public void PayloadSetProfile_Creation_Should_Accept_Empty_BasePath(string baseP var profile = new PayloadSetProfile { BasePath = basePath }; // Assert - Record construction doesn't validate, validation happens in service layer - Assert.Equal(basePath, profile.BasePath); + profile.BasePath.Should().Be(basePath); } } diff --git a/tests/S7Tools.Core.Tests/Models/Validators/PlcAddressValidatorTests.cs b/tests/S7Tools.Core.Tests/Models/Validators/PlcAddressValidatorTests.cs index 3f669db2..04d90c65 100644 --- a/tests/S7Tools.Core.Tests/Models/Validators/PlcAddressValidatorTests.cs +++ b/tests/S7Tools.Core.Tests/Models/Validators/PlcAddressValidatorTests.cs @@ -1,4 +1,5 @@ -using S7Tools.Core.Models.Validators; +using FluentAssertions; +using S7Tools.Core.Validation.Validators; using S7Tools.Core.Models.ValueObjects; using S7Tools.Core.Validation; using Xunit; @@ -28,11 +29,11 @@ public void Validate_ValidAndInvalidAddresses_ReturnsExpectedResult(string addre if (result.IsSuccess) { ValidationResult validation = _validator.Validate(result.Value); - Assert.Equal(expectedValid, validation.IsValid); + validation.IsValid.Should().Be(expectedValid); } else { - Assert.False(expectedValid); // Si no se puede crear, debe ser inválido + expectedValid.Should().BeFalse(); // Si no se puede crear, debe ser inválido } } } diff --git a/tests/S7Tools.Core.Tests/Resources/InMemoryResourceManagerTests.cs b/tests/S7Tools.Core.Tests/Resources/InMemoryResourceManagerTests.cs index a7a8bc3f..8469a3b2 100644 --- a/tests/S7Tools.Core.Tests/Resources/InMemoryResourceManagerTests.cs +++ b/tests/S7Tools.Core.Tests/Resources/InMemoryResourceManagerTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System.Globalization; using S7Tools.Core.Resources; using Xunit; @@ -11,14 +12,14 @@ public void AddOrUpdate_And_GetString_Works_For_DefaultCulture() { var manager = new InMemoryResourceManager(); manager.AddOrUpdate("Hello", "Hola"); - Assert.Equal("Hola", manager.GetString("Hello")); + manager.GetString("Hello").Should().Be("Hola"); } [Fact] public void GetString_ReturnsKey_IfNotFound() { var manager = new InMemoryResourceManager(); - Assert.Equal("MissingKey", manager.GetString("MissingKey")); + manager.GetString("MissingKey").Should().Be("MissingKey"); } [Fact] @@ -38,7 +39,7 @@ public void HasResource_ReturnsTrue_IfExists() { var manager = new InMemoryResourceManager(); manager.AddOrUpdate("Key", "Valor"); - Assert.True(manager.HasResource("Key")); + manager.HasResource("Key").Should().BeTrue(); } [Fact] @@ -72,6 +73,6 @@ public void SetCurrentCulture_Changes_Culture() manager.AddOrUpdate("Hello", "Hello", en); manager.AddOrUpdate("Hello", "Hola", es); manager.SetCurrentCulture(es); - Assert.Equal("Hola", manager.GetString("Hello")); + manager.GetString("Hello").Should().Be("Hola"); } } diff --git a/tests/S7Tools.Core.Tests/Settings/ApplicationSettingsServiceTests.cs b/tests/S7Tools.Core.Tests/Settings/ApplicationSettingsServiceTests.cs index 8d733038..f2385fd0 100644 --- a/tests/S7Tools.Core.Tests/Settings/ApplicationSettingsServiceTests.cs +++ b/tests/S7Tools.Core.Tests/Settings/ApplicationSettingsServiceTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System; using System.Collections.Generic; using System.IO; @@ -8,9 +9,8 @@ using Microsoft.Extensions.Options; using Moq; using S7Tools.Core.Interfaces.Services; -using S7Tools.Core.Models.Configuration; -using S7Tools.Core.Models.Configuration.StrongSettings; using S7Tools.Services; +using S7Tools.Core.Models.Configuration.StrongSettings; using Xunit; namespace S7Tools.Core.Tests.Settings @@ -42,6 +42,33 @@ public Task UpdateAsync(Func applyChanges) } } + /// + /// Tracks which write path was used (Update vs UpdateAsync) for verification tests. + /// + private class TrackingWritableOptions : IWritableOptions where T : class, new() + { + public T CurrentValue { get; private set; } = new T(); + public T Value => CurrentValue; + public bool UpdateSyncCalled { get; set; } + public bool UpdateAsyncCalled { get; private set; } + + public T Get(string? name) => CurrentValue; + public IDisposable? OnChange(Action listener) => null; + + public void Update(Action applyChanges) + { + UpdateSyncCalled = true; + applyChanges(CurrentValue); + } + + public Task UpdateAsync(Func applyChanges) + { + UpdateAsyncCalled = true; + applyChanges(CurrentValue).GetAwaiter().GetResult(); + return Task.CompletedTask; + } + } + private ApplicationSettingsService CreateTestService() { var loggerMock = new Mock>(); @@ -50,67 +77,107 @@ private ApplicationSettingsService CreateTestService() } [Fact] - public void GetSetting_WithNoUserSettings_ReturnsDefaultSettings() + public void Current_WithNoUserSettings_ReturnsDefaultSettings() { // Arrange ApplicationSettingsService service = CreateTestService(); - // Act & Assert - // These defaults match the AppSettings default constructor values - Assert.Equal("Information", service.GetSetting("logging.level")); - Assert.True(service.GetSetting("logging.enableFileLogging")); - Assert.Equal("System", service.GetSetting("ui.theme")); + // Act & Assert - defaults match AppSettings default constructor values + service.Current.Logging.Level.Should().Be("Information"); + service.Current.Logging.EnableFileLogging.Should().BeTrue(); + service.Current.Ui.Theme.Should().Be("System"); } [Fact] - public async Task SetSettingAsync_UserSettingOverridesDefault_CorrectHierarchy() + public async Task UpdateSettingsAsync_UserSettingOverridesDefault_CorrectHierarchy() { // Arrange ApplicationSettingsService service = CreateTestService(); - // Act - Set user setting to override default - await service.SetSettingAsync("logging.level", "Debug"); - await service.SetSettingAsync("ui.theme", "Dark"); + // Act - update settings via strongly-typed action + await service.UpdateSettingsAsync(s => + { + s.Logging.Level = "Debug"; + s.Ui.Theme = "Dark"; + }); - // Assert - User settings override defaults - Assert.Equal("Debug", service.GetSetting("logging.level")); - Assert.Equal("Dark", service.GetSetting("ui.theme")); + // Assert - settings reflect the update + service.Current.Logging.Level.Should().Be("Debug"); + service.Current.Ui.Theme.Should().Be("Dark"); } [Fact] - public async Task ResetSettingAsync_UserSettingReset_RevertsToDefault() + public async Task ResetAllSettingsAsync_UserSettingReset_RevertsToDefault() { // Arrange ApplicationSettingsService service = CreateTestService(); - await service.SetSettingAsync("logging.level", "Debug"); + await service.UpdateSettingsAsync(s => s.Logging.Level = "Debug"); - // Verify user override is active - Assert.Equal("Debug", service.GetSetting("logging.level")); + // Verify update was applied + service.Current.Logging.Level.Should().Be("Debug"); - // Act - Reset to default - await service.ResetSettingAsync("logging.level"); + // Act - reset to defaults + await service.ResetAllSettingsAsync(); - // Assert - Reverted to default - Assert.Equal("Information", service.GetSetting("logging.level")); + // Assert - reverted to default + service.Current.Logging.Level.Should().Be("Information"); } [Fact] - public async Task SettingsChanged_EventFired_WhenSettingChanged() + public async Task SettingsChanged_EventFired_WhenSettingsUpdated() { // Arrange ApplicationSettingsService service = CreateTestService(); - S7Tools.Core.Interfaces.Services.SettingsChangedEventArgs? eventArgs = null; + SettingsChangedEventArgs? eventArgs = null; service.SettingsChanged += (sender, args) => eventArgs = args; // Act - await service.SetSettingAsync("ui.theme", "Dark"); + await service.UpdateSettingsAsync(s => s.Ui.Theme = "Dark"); + + // Assert + // The strongly-typed API fires SettingsChanged with IsUserSetting=true for any UpdateSettingsAsync call. + // Individual key/value change info is no longer tracked; instead, callers read Current directly for the new values. + eventArgs.Should().NotBeNull(); + eventArgs.IsUserSetting.Should().BeTrue(); + // Verify the actual value change is accessible via Current + service.Current.Ui.Theme.Should().Be("Dark"); + } + + [Fact] + public async Task UpdateSettingsAsync_DelegatesToUpdateAsync_NotSyncUpdate() + { + // Arrange + var trackingOptions = new TrackingWritableOptions(); + var loggerMock = new Mock>(); + var service = new ApplicationSettingsService(loggerMock.Object, trackingOptions); + + // Act + await service.UpdateSettingsAsync(s => s.Logging.Level = "Debug"); + + // Assert – only the async path must have been called + Assert.True(trackingOptions.UpdateAsyncCalled, "UpdateSettingsAsync must delegate to UpdateAsync."); + Assert.False(trackingOptions.UpdateSyncCalled, "UpdateSettingsAsync must not call the synchronous Update method."); + } + + [Fact] + public async Task ResetAllSettingsAsync_DelegatesToUpdateAsync_NotSyncUpdate() + { + // Arrange + var trackingOptions = new TrackingWritableOptions(); + var loggerMock = new Mock>(); + var service = new ApplicationSettingsService(loggerMock.Object, trackingOptions); + await service.UpdateSettingsAsync(s => s.Logging.Level = "Debug"); + + // Verify arrange step used UpdateAsync + Assert.True(trackingOptions.UpdateAsyncCalled, "Arrange: UpdateSettingsAsync must have called UpdateAsync."); + trackingOptions.UpdateSyncCalled = false; + + // Act + await service.ResetAllSettingsAsync(); // Assert - Assert.NotNull(eventArgs); - Assert.Equal("ui.theme", eventArgs.Key); - Assert.Equal("Dark", eventArgs.NewValue); - Assert.True(eventArgs.IsUserSetting); + Assert.False(trackingOptions.UpdateSyncCalled, "ResetAllSettingsAsync must not call the synchronous Update method."); } } } diff --git a/tests/S7Tools.Core.Tests/Tasking/JobSchedulerTests.cs b/tests/S7Tools.Core.Tests/Tasking/JobSchedulerTests.cs index aba5c1b4..b4fdf8a0 100644 --- a/tests/S7Tools.Core.Tests/Tasking/JobSchedulerTests.cs +++ b/tests/S7Tools.Core.Tests/Tasking/JobSchedulerTests.cs @@ -3,7 +3,8 @@ using Moq; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Services; using S7Tools.Services.Tasking; namespace S7Tools.Core.Tests.Tasking; diff --git a/tests/S7Tools.Core.Tests/Tasking/SchedulerParallelExecutionTests.cs b/tests/S7Tools.Core.Tests/Tasking/SchedulerParallelExecutionTests.cs index 1f9ec139..2c40f01f 100644 --- a/tests/S7Tools.Core.Tests/Tasking/SchedulerParallelExecutionTests.cs +++ b/tests/S7Tools.Core.Tests/Tasking/SchedulerParallelExecutionTests.cs @@ -3,7 +3,8 @@ using Moq; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Services; using S7Tools.Services.Tasking; namespace S7Tools.Core.Tests.Tasking; diff --git a/tests/S7Tools.Core.Tests/Validation/ValidationServiceIntegrationTests.cs b/tests/S7Tools.Core.Tests/Validation/ValidationServiceIntegrationTests.cs index fcb2c168..a9e53df8 100644 --- a/tests/S7Tools.Core.Tests/Validation/ValidationServiceIntegrationTests.cs +++ b/tests/S7Tools.Core.Tests/Validation/ValidationServiceIntegrationTests.cs @@ -1,4 +1,5 @@ -using S7Tools.Core.Models.Validators; +using FluentAssertions; +using S7Tools.Core.Validation.Validators; using S7Tools.Core.Models.ValueObjects; using S7Tools.Core.Validation; using Xunit; @@ -14,16 +15,16 @@ public void RegisterValidator_And_Validate_Works_For_PlcAddress() service.RegisterValidator(new PlcAddressValidator()); Result valid = PlcAddress.Create("DB1.DBX0.0"); Result invalid = PlcAddress.Create("M0.9"); // bit offset fuera de rango - Assert.True(valid.IsSuccess); - Assert.True(service.Validate(valid.Value).IsValid); + valid.IsSuccess.Should().BeTrue(); + service.Validate(valid.Value).IsValid.Should().BeTrue(); // Si la creación falla, es correcto porque el value object ya valida el rango if (invalid.IsSuccess) { - Assert.False(service.Validate(invalid.Value).IsValid); + service.Validate(invalid.Value).IsValid.Should().BeFalse(); } else { - Assert.False(invalid.IsSuccess); // El value object filtra la entrada inválida + invalid.IsSuccess.Should().BeFalse(); // El value object filtra la entrada inválida } } @@ -33,10 +34,10 @@ public void UnregisterValidator_Removes_Validator() var service = new ValidationService(); service.RegisterValidator(new PlcAddressValidator()); Result valid = PlcAddress.Create("DB1.DBX0.0"); - Assert.True(valid.IsSuccess); - Assert.True(service.Validate(valid.Value).IsValid); - Assert.True(service.UnregisterValidator()); + valid.IsSuccess.Should().BeTrue(); + service.Validate(valid.Value).IsValid.Should().BeTrue(); + service.UnregisterValidator().Should().BeTrue(); // Sin validador, siempre es válido - Assert.True(service.Validate(valid.Value).IsValid); + service.Validate(valid.Value).IsValid.Should().BeTrue(); } } diff --git a/tests/S7Tools.Infrastructure.Logging.Tests/Core/Storage/LogDataStoreTests.cs b/tests/S7Tools.Infrastructure.Logging.Tests/Core/Storage/LogDataStoreTests.cs index 4f2034a7..47917d72 100644 --- a/tests/S7Tools.Infrastructure.Logging.Tests/Core/Storage/LogDataStoreTests.cs +++ b/tests/S7Tools.Infrastructure.Logging.Tests/Core/Storage/LogDataStoreTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System.Collections.Specialized; using System.ComponentModel; using Microsoft.Extensions.Logging; @@ -125,8 +126,8 @@ public void AddEntry_ShouldRaiseCollectionChangedEvent() collectionChangedArgs.Should().NotBeNull(); collectionChangedArgs!.Action.Should().Be(NotifyCollectionChangedAction.Add); var newItems = collectionChangedArgs.NewItems as System.Collections.IList; - Assert.NotNull(newItems); - Assert.True(newItems.Contains(logEntry)); + newItems.Should().NotBeNull(); + newItems.Contains(logEntry).Should().BeTrue(); } [Fact] diff --git a/tests/S7Tools.Infrastructure.Logging.Tests/Providers/Microsoft/DataStoreLoggerProviderTests.cs b/tests/S7Tools.Infrastructure.Logging.Tests/Providers/Microsoft/DataStoreLoggerProviderTests.cs index 742bfae5..7006337c 100644 --- a/tests/S7Tools.Infrastructure.Logging.Tests/Providers/Microsoft/DataStoreLoggerProviderTests.cs +++ b/tests/S7Tools.Infrastructure.Logging.Tests/Providers/Microsoft/DataStoreLoggerProviderTests.cs @@ -1,7 +1,8 @@ using System; using Microsoft.Extensions.Logging; using Moq; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Services; using S7Tools.Infrastructure.Logging.Core.Configuration; using S7Tools.Infrastructure.Logging.Providers.Microsoft; using FluentAssertions; diff --git a/tests/S7Tools.Tests/Converters/DateTimeToStringConverterTests.cs b/tests/S7Tools.Tests/Converters/DateTimeToStringConverterTests.cs index 2363544d..daa1963f 100644 --- a/tests/S7Tools.Tests/Converters/DateTimeToStringConverterTests.cs +++ b/tests/S7Tools.Tests/Converters/DateTimeToStringConverterTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System; using System.Globalization; using S7Tools.Converters; @@ -19,7 +20,7 @@ public void Convert_WithDateTime_ReturnsFormattedString() object? result = converter.Convert(dateTime, typeof(string), format, CultureInfo.InvariantCulture); // Assert - Assert.Equal("2025-10-22 14:30", result); + result.Should().Be("2025-10-22 14:30"); } [Fact] @@ -36,7 +37,7 @@ public void Convert_WithDateTimeOffset_ReturnsFormattedString() object? result = converter.Convert(dateTimeOffset, typeof(string), format, CultureInfo.InvariantCulture); // Assert - Assert.Equal("2025-10-22 14:30", result); + result.Should().Be("2025-10-22 14:30"); } [Fact] @@ -49,7 +50,7 @@ public void Convert_WithNull_ReturnsEmptyString() object? result = converter.Convert(null, typeof(string), "yyyy-MM-dd", CultureInfo.InvariantCulture); // Assert - Assert.Equal(string.Empty, result); + result.Should().Be(string.Empty); } [Fact] @@ -64,7 +65,7 @@ public void Convert_WithoutParameter_UsesDefaultFormat() // Assert // Default format is "yyyy-MM-dd HH:mm:ss.fff" - Assert.Equal("2025-10-22 14:30:45.000", result); + result.Should().Be("2025-10-22 14:30:45.000"); } [Fact] @@ -93,7 +94,7 @@ public void Convert_WithNonDateTimeValue_ReturnsStringRepresentation() object? result = converter.Convert(value, typeof(string), null, CultureInfo.InvariantCulture); // Assert - Assert.Equal("Some string value", result); + result.Should().Be("Some string value"); } [Fact] diff --git a/tests/S7Tools.Tests/Converters/ObjectConvertersTests.cs b/tests/S7Tools.Tests/Converters/ObjectConvertersTests.cs index bedcdf69..82858dbd 100644 --- a/tests/S7Tools.Tests/Converters/ObjectConvertersTests.cs +++ b/tests/S7Tools.Tests/Converters/ObjectConvertersTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System.Globalization; using Avalonia.Media; using Microsoft.Extensions.Logging; @@ -19,7 +20,7 @@ public void IsNotNull_WithNonNullValue_ReturnsTrue() object? result = converter.Convert(value, typeof(bool), (object?)null, CultureInfo.InvariantCulture); // Assert - Assert.True((bool)result!); + ((bool)result!).Should().BeTrue(); } [Fact] @@ -32,7 +33,7 @@ public void IsNotNull_WithNullValue_ReturnsFalse() object? result = converter.Convert(null, typeof(bool), (object?)null, CultureInfo.InvariantCulture); // Assert - Assert.False((bool)result!); + ((bool)result!).Should().BeFalse(); } [Fact] diff --git a/tests/S7Tools.Tests/Converters/ObjectToAllPropertiesConverterTests.cs b/tests/S7Tools.Tests/Converters/ObjectToAllPropertiesConverterTests.cs index 767d1aab..a6a86735 100644 --- a/tests/S7Tools.Tests/Converters/ObjectToAllPropertiesConverterTests.cs +++ b/tests/S7Tools.Tests/Converters/ObjectToAllPropertiesConverterTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System.Collections.ObjectModel; using System.ComponentModel; using System.ComponentModel.DataAnnotations; @@ -29,9 +30,9 @@ public void Convert_WithNullValue_ReturnsEmptyCollection() object? result = _converter.Convert(null, typeof(ObservableCollection), null, CultureInfo.InvariantCulture); // Assert - Assert.NotNull(result); + result.Should().NotBeNull(); ObservableCollection collection = Assert.IsType>(result); - Assert.Empty(collection); + collection.Should().BeEmpty(); } [Fact(DisplayName = "Convert shows all properties including Browsable(false)")] @@ -48,11 +49,11 @@ public void Convert_WithBrowsableFalseProperties_ShowsAllProperties() object? result = _converter.Convert(testObject, typeof(ObservableCollection), null, CultureInfo.InvariantCulture); // Assert - Assert.NotNull(result); + result.Should().NotBeNull(); ObservableCollection collection = Assert.IsType>(result); // Should show both properties (unlike ObjectToPropertiesConverter which would hide HiddenProperty) - Assert.Equal(2, collection.Count); + collection.Count.Should().Be(2); Assert.Contains(collection, p => p.Label.Contains("Visible Property")); Assert.Contains(collection, p => p.Label.Contains("Hidden Property")); } @@ -70,11 +71,11 @@ public void Convert_WithDisplayNameAttribute_UsesCustomLabel() object? result = _converter.Convert(testObject, typeof(ObservableCollection), null, CultureInfo.InvariantCulture); // Assert - Assert.NotNull(result); + result.Should().NotBeNull(); ObservableCollection collection = Assert.IsType>(result); - Assert.Single(collection); - Assert.Equal("Custom Display Name", collection[0].Label); - Assert.Equal("Test Value", collection[0].Value); + collection.Should().ContainSingle(); + collection[0].Label.Should().Be("Custom Display Name"); + collection[0].Value.Should().Be("Test Value"); } private class TestObjectWithBrowsableFalse diff --git a/tests/S7Tools.Tests/Converters/ObjectToPropertiesConverterTests.cs b/tests/S7Tools.Tests/Converters/ObjectToPropertiesConverterTests.cs index 5694625f..dca8344a 100644 --- a/tests/S7Tools.Tests/Converters/ObjectToPropertiesConverterTests.cs +++ b/tests/S7Tools.Tests/Converters/ObjectToPropertiesConverterTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System.Collections.ObjectModel; using System.ComponentModel; using System.ComponentModel.DataAnnotations; @@ -25,9 +26,9 @@ public void Convert_WithNullValue_ReturnsEmptyCollection() object? result = _converter.Convert(null, typeof(ObservableCollection), null, CultureInfo.InvariantCulture); // Assert - Assert.NotNull(result); + result.Should().NotBeNull(); ObservableCollection collection = Assert.IsType>(result); - Assert.Empty(collection); + collection.Should().BeEmpty(); } [Fact(DisplayName = "Convert with simple object returns properties")] @@ -44,15 +45,15 @@ public void Convert_WithSimpleObject_ReturnsProperties() object? result = _converter.Convert(testObject, typeof(ObservableCollection), null, CultureInfo.InvariantCulture); // Assert - Assert.NotNull(result); + result.Should().NotBeNull(); ObservableCollection collection = Assert.IsType>(result); - Assert.Equal(2, collection.Count); + collection.Count.Should().Be(2); PropertyDisplayItem nameItem = collection.First(p => p.Label.Contains("Name")); - Assert.Equal("Test", nameItem.Value); + nameItem.Value.Should().Be("Test"); PropertyDisplayItem valueItem = collection.First(p => p.Label.Contains("Value")); - Assert.Equal("42", valueItem.Value); + valueItem.Value.Should().Be("42"); } [Fact(DisplayName = "Convert respects Browsable(false) attribute")] @@ -69,11 +70,11 @@ public void Convert_WithBrowsableFalseAttribute_HidesProperty() object? result = _converter.Convert(testObject, typeof(ObservableCollection), null, CultureInfo.InvariantCulture); // Assert - Assert.NotNull(result); + result.Should().NotBeNull(); ObservableCollection collection = Assert.IsType>(result); - Assert.Single(collection); - Assert.Equal("Visible Property", collection[0].Label); - Assert.Equal("Visible", collection[0].Value); + collection.Should().ContainSingle(); + collection[0].Label.Should().Be("Visible Property"); + collection[0].Value.Should().Be("Visible"); } [Fact(DisplayName = "Convert respects Display Name attribute")] @@ -90,15 +91,15 @@ public void Convert_WithDisplayNameAttribute_UsesCustomLabel() object? result = _converter.Convert(testObject, typeof(ObservableCollection), null, CultureInfo.InvariantCulture); // Assert - Assert.NotNull(result); + result.Should().NotBeNull(); ObservableCollection collection = Assert.IsType>(result); - Assert.Equal(2, collection.Count); + collection.Count.Should().Be(2); PropertyDisplayItem portItem = collection.First(p => p.Label == "TCP Port"); - Assert.Equal("1234", portItem.Value); + portItem.Value.Should().Be("1234"); PropertyDisplayItem hostItem = collection.First(p => p.Label == "Host Address"); - Assert.Equal("192.168.1.1", hostItem.Value); + hostItem.Value.Should().Be("192.168.1.1"); } [Fact(DisplayName = "Convert respects Display Order attribute")] @@ -116,18 +117,18 @@ public void Convert_WithDisplayOrderAttribute_OrdersPropertiesCorrectly() object? result = _converter.Convert(testObject, typeof(ObservableCollection), null, CultureInfo.InvariantCulture); // Assert - Assert.NotNull(result); + result.Should().NotBeNull(); ObservableCollection collection = Assert.IsType>(result); - Assert.Equal(3, collection.Count); + collection.Count.Should().Be(3); - Assert.Equal("First Property", collection[0].Label); - Assert.Equal("A", collection[0].Value); + collection[0].Label.Should().Be("First Property"); + collection[0].Value.Should().Be("A"); - Assert.Equal("Second Property", collection[1].Label); - Assert.Equal("B", collection[1].Value); + collection[1].Label.Should().Be("Second Property"); + collection[1].Value.Should().Be("B"); - Assert.Equal("Third Property", collection[2].Label); - Assert.Equal("C", collection[2].Value); + collection[2].Label.Should().Be("Third Property"); + collection[2].Value.Should().Be("C"); } [Fact(DisplayName = "Convert formats boolean values correctly")] @@ -144,15 +145,15 @@ public void Convert_WithBooleanValue_FormatsCorrectly() object? result = _converter.Convert(testObject, typeof(ObservableCollection), null, CultureInfo.InvariantCulture); // Assert - Assert.NotNull(result); + result.Should().NotBeNull(); ObservableCollection collection = Assert.IsType>(result); - Assert.Equal(2, collection.Count); + collection.Count.Should().Be(2); PropertyDisplayItem enabledItem = collection.First(p => p.Label.Contains("Enabled")); - Assert.Equal("True", enabledItem.Value); + enabledItem.Value.Should().Be("True"); PropertyDisplayItem disabledItem = collection.First(p => p.Label.Contains("Disabled")); - Assert.Equal("False", disabledItem.Value); + disabledItem.Value.Should().Be("False"); } [Fact(DisplayName = "ConvertBack returns AvaloniaProperty.UnsetValue")] @@ -162,7 +163,7 @@ public void ConvertBack_ReturnsUnsetValue() object? result = _converter.ConvertBack(null, typeof(object), null, CultureInfo.InvariantCulture); // Assert - Assert.Equal(Avalonia.AvaloniaProperty.UnsetValue, result); + result.Should().Be(Avalonia.AvaloniaProperty.UnsetValue); } [Fact(DisplayName = "Convert with same type twice uses cached reflection results")] @@ -181,8 +182,8 @@ public void Convert_WithSameTypeTwice_UsesCachedResults() ObservableCollection secondCollection = Assert.IsType>(secondResult); // Assert - Both conversions produce correct results with same structure - Assert.Equal(2, firstCollection.Count); - Assert.Equal(2, secondCollection.Count); + firstCollection.Count.Should().Be(2); + secondCollection.Count.Should().Be(2); // Verify first object values Assert.Contains(firstCollection, p => p.Label.Contains("Name") && p.Value == "First"); @@ -195,7 +196,7 @@ public void Convert_WithSameTypeTwice_UsesCachedResults() // Verify labels are consistent (proving cache is being used with ConditionalWeakTable) var firstLabels = firstCollection.Select(p => p.Label).ToList(); var secondLabels = secondCollection.Select(p => p.Label).ToList(); - Assert.Equal(firstLabels, secondLabels); // Ensures order and content are the same + secondLabels.Should().Equal(firstLabels); // Ensures order and content are the same // For a stronger cache proof, assert that the string instances are the same. // This is a good indicator that they came from the same cached PropertyMetadata. @@ -203,7 +204,7 @@ public void Convert_WithSameTypeTwice_UsesCachedResults() // but during normal operation, it maintains the cache for active types. for (int i = 0; i < firstCollection.Count; i++) { - Assert.Same(firstCollection[i].Label, secondCollection[i].Label); + secondCollection[i].Label.Should().BeSameAs(firstCollection[i].Label); } } @@ -222,18 +223,18 @@ public void Convert_CachesPropertyOrderCorrectly() ObservableCollection secondCollection = Assert.IsType>(secondResult); // Assert - Order is consistent across both conversions - Assert.Equal(3, firstCollection.Count); - Assert.Equal(3, secondCollection.Count); + firstCollection.Count.Should().Be(3); + secondCollection.Count.Should().Be(3); // First conversion order - Assert.Equal("First Property", firstCollection[0].Label); - Assert.Equal("Second Property", firstCollection[1].Label); - Assert.Equal("Third Property", firstCollection[2].Label); + firstCollection[0].Label.Should().Be("First Property"); + firstCollection[1].Label.Should().Be("Second Property"); + firstCollection[2].Label.Should().Be("Third Property"); // Second conversion order (should match first due to cache) - Assert.Equal("First Property", secondCollection[0].Label); - Assert.Equal("Second Property", secondCollection[1].Label); - Assert.Equal("Third Property", secondCollection[2].Label); + secondCollection[0].Label.Should().Be("First Property"); + secondCollection[1].Label.Should().Be("Second Property"); + secondCollection[2].Label.Should().Be("Third Property"); } #region Test Classes diff --git a/tests/S7Tools.Tests/Services/Bootloader/BootloaderServiceTests.cs b/tests/S7Tools.Tests/Services/Bootloader/BootloaderServiceTests.cs index f3e9afa7..52bf3ed4 100644 --- a/tests/S7Tools.Tests/Services/Bootloader/BootloaderServiceTests.cs +++ b/tests/S7Tools.Tests/Services/Bootloader/BootloaderServiceTests.cs @@ -4,7 +4,8 @@ using NSubstitute; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Services; using S7Tools.Services.Bootloader; namespace S7Tools.Tests.Services.Bootloader; diff --git a/tests/S7Tools.Tests/Services/Bootloader/OptimizationVerificationTests.cs b/tests/S7Tools.Tests/Services/Bootloader/OptimizationVerificationTests.cs index 660424be..9ea07236 100644 --- a/tests/S7Tools.Tests/Services/Bootloader/OptimizationVerificationTests.cs +++ b/tests/S7Tools.Tests/Services/Bootloader/OptimizationVerificationTests.cs @@ -1,7 +1,9 @@ +using FluentAssertions; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -9,7 +11,8 @@ using NSubstitute; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Services; using S7Tools.Services.Bootloader; using Xunit; @@ -17,34 +20,50 @@ namespace S7Tools.Tests.Services.Bootloader; public class OptimizationVerificationTests { - private class TestBootloaderService : BaseBootloaderService + private BootloaderService CreateService(ITimeProvider timeProvider) { - public TestBootloaderService(ITimeProvider timeProvider) : base(timeProvider) { } - - public new Task> PerformDumpProcessAsync( - IPlcClient client, - JobProfileSet profiles, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, - ILogger logger, - double startPercent, - double weight, - CancellationToken cancellationToken) - { - return base.PerformDumpProcessAsync(client, profiles, progress, logger, startPercent, weight, cancellationToken); - } + return new BootloaderService( + NullLogger.Instance, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + (profiles) => Substitute.For(), + Substitute.For(), + timeProvider, + null + ); + } - public new Task PerformDumpProcessStreamingAsync( - IPlcClient client, - JobProfileSet profiles, - IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, - ILogger logger, - double startPercent, - double weight, - Guid? taskId, - CancellationToken cancellationToken) - { - return base.PerformDumpProcessStreamingAsync(client, profiles, progress, logger, startPercent, weight, taskId, cancellationToken); - } + private async Task> InvokePerformDumpProcessAsync( + BootloaderService service, + IPlcClient client, + JobProfileSet profiles, + IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, + ILogger logger, + double startPercent, + double weight, + CancellationToken cancellationToken) + { + MethodInfo? method = typeof(BootloaderService).GetMethod("PerformDumpProcessAsync", BindingFlags.NonPublic | BindingFlags.Instance); + var task = (Task>)method!.Invoke(service, new object[] { client, profiles, progress, logger, startPercent, weight, cancellationToken })!; + return await task; + } + + private async Task InvokePerformDumpProcessStreamingAsync( + BootloaderService service, + IPlcClient client, + JobProfileSet profiles, + IProgress<(string stage, double percent, long? bytesRead, long? totalBytes)> progress, + ILogger logger, + double startPercent, + double weight, + Guid? taskId, + CancellationToken cancellationToken) + { + MethodInfo? method = typeof(BootloaderService).GetMethod("PerformDumpProcessStreamingAsync", BindingFlags.NonPublic | BindingFlags.Instance); + var task = (Task)method!.Invoke(service, new object[] { client, profiles, progress, logger, startPercent, weight, taskId, cancellationToken })!; + return await task; } [Fact] @@ -53,7 +72,7 @@ public async Task PerformDumpProcessAsync_Segmented_ReturnsCorrectData() // Arrange var timeProvider = Substitute.For(); timeProvider.GetUtcNow().Returns(DateTime.UtcNow); - var service = new TestBootloaderService(timeProvider); + var service = CreateService(timeProvider); var client = Substitute.For(); var segments = new List @@ -86,12 +105,12 @@ public async Task PerformDumpProcessAsync_Segmented_ReturnsCorrectData() var progress = Substitute.For>(); // Act - var result = await service.PerformDumpProcessAsync(client, profiles, progress, NullLogger.Instance, 0, 100, CancellationToken.None); + var result = await InvokePerformDumpProcessAsync(service, client, profiles, progress, NullLogger.Instance, 0, 100, CancellationToken.None); // Assert - Assert.Single(result); - Assert.Equal(30, result[0].Length); - Assert.Equal(data1.Concat(data2), result[0]); + result.Should().ContainSingle(); + result[0].Length.Should().Be(30); + result[0].Should().BeEquivalentTo(data1.Concat(data2)); } [Fact] @@ -102,7 +121,7 @@ public async Task PerformDumpProcessStreamingAsync_Segmented_WritesCorrectFiles( DateTime now = new DateTime(2026, 1, 1, 12, 0, 0); timeProvider.GetUtcNow().Returns(now); timeProvider.GetLocalNow().Returns(now); - var service = new TestBootloaderService(timeProvider); + var service = CreateService(timeProvider); var client = Substitute.For(); string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); @@ -146,13 +165,13 @@ public async Task PerformDumpProcessStreamingAsync_Segmented_WritesCorrectFiles( var progress = Substitute.For>(); // Act - var result = await service.PerformDumpProcessStreamingAsync(client, profiles, progress, NullLogger.Instance, 0, 100, null, CancellationToken.None); + var result = await InvokePerformDumpProcessStreamingAsync(service, client, profiles, progress, NullLogger.Instance, 0, 100, null, CancellationToken.None); // Assert - Assert.Single(result.SavedFiles); + result.SavedFiles.Should().ContainSingle(); byte[] writtenData = File.ReadAllBytes(result.SavedFiles[0]); - Assert.Equal(30, writtenData.Length); - Assert.Equal(data1.Concat(data2), writtenData); + writtenData.Length.Should().Be(30); + writtenData.Should().BeEquivalentTo(data1.Concat(data2)); } finally { diff --git a/tests/S7Tools.Tests/Services/DialogServiceTests.cs b/tests/S7Tools.Tests/Services/DialogServiceTests.cs index 7908a6b2..d7674bea 100644 --- a/tests/S7Tools.Tests/Services/DialogServiceTests.cs +++ b/tests/S7Tools.Tests/Services/DialogServiceTests.cs @@ -2,7 +2,9 @@ using System.Reactive.Linq; using ReactiveUI; using S7Tools.Models; +using S7Tools.Core.Interfaces.Services; using S7Tools.Services; +using S7Tools.ViewModels.Dialogs.Models; namespace S7Tools.Tests.Services; diff --git a/tests/S7Tools.Tests/Services/GreetingServiceTests.cs b/tests/S7Tools.Tests/Services/GreetingServiceTests.cs index 73561eb8..ac958670 100644 --- a/tests/S7Tools.Tests/Services/GreetingServiceTests.cs +++ b/tests/S7Tools.Tests/Services/GreetingServiceTests.cs @@ -1,4 +1,4 @@ -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Services; using S7Tools.Services.Interfaces; using S7Tools.Resources.Strings; diff --git a/tests/S7Tools.Tests/Services/Hex/BinarySearchServiceTests.cs b/tests/S7Tools.Tests/Services/Hex/BinarySearchServiceTests.cs index f8e95645..cd531ab3 100644 --- a/tests/S7Tools.Tests/Services/Hex/BinarySearchServiceTests.cs +++ b/tests/S7Tools.Tests/Services/Hex/BinarySearchServiceTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -62,10 +63,10 @@ public async Task FindNextAsync_SimpleMatch_ReturnsCorrectOffset() var pattern = new byte[] { 0xAA, 0xBB }; var result1 = await service.FindNextAsync(doc, pattern, 0); - Assert.Equal(2, result1); + result1.Should().Be(2); var result2 = await service.FindNextAsync(doc, pattern, 3); - Assert.Equal(5, result2); + result2.Should().Be(5); } [Fact] @@ -78,7 +79,7 @@ public async Task FindAllAsync_NoMatch_ReturnsEmpty() var results = await service.FindAllAsync(doc, pattern); - Assert.Empty(results); + results.Should().BeEmpty(); } [Fact] @@ -91,7 +92,7 @@ public async Task FindAllAsync_PatternLargerThanDoc_ReturnsEmpty() var results = await service.FindAllAsync(doc, pattern); - Assert.Empty(results); + results.Should().BeEmpty(); } } } diff --git a/tests/S7Tools.Tests/Services/Jobs/JobPersistenceTests.cs b/tests/S7Tools.Tests/Services/Jobs/JobPersistenceTests.cs index c815943d..833f3ff0 100644 --- a/tests/S7Tools.Tests/Services/Jobs/JobPersistenceTests.cs +++ b/tests/S7Tools.Tests/Services/Jobs/JobPersistenceTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System.Text.Json; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; @@ -64,21 +65,21 @@ public void Job_Profile_Persistence_Should_Save_And_Load_Correctly() Job? loadedJob = JsonSerializer.Deserialize(json); // Assert - Assert.NotNull(loadedJob); - Assert.Equal(originalJob.Id, loadedJob.Id); - Assert.Equal(originalJob.Name, loadedJob.Name); - Assert.Equal(originalJob.Description, loadedJob.Description); - Assert.Equal(originalJob.OutputPath, loadedJob.OutputPath); - Assert.Equal(originalJob.State, loadedJob.State); - Assert.Equal(originalJob.Progress, loadedJob.Progress); + loadedJob.Should().NotBeNull(); + loadedJob.Id.Should().Be(originalJob.Id); + loadedJob.Name.Should().Be(originalJob.Name); + loadedJob.Description.Should().Be(originalJob.Description); + loadedJob.OutputPath.Should().Be(originalJob.OutputPath); + loadedJob.State.Should().Be(originalJob.State); + loadedJob.Progress.Should().Be(originalJob.Progress); // ProfileSet comparison - Assert.NotNull(loadedJob.ProfileSet); - Assert.Equal(originalJob.ProfileSet.Serial.Device, loadedJob.ProfileSet.Serial.Device); - Assert.Equal(originalJob.ProfileSet.Socat.Port, loadedJob.ProfileSet.Socat.Port); - Assert.Equal(originalJob.ProfileSet.Power.Host, loadedJob.ProfileSet.Power.Host); - Assert.Equal(originalJob.ProfileSet.Memory.Start, loadedJob.ProfileSet.Memory.Start); - Assert.Equal(originalJob.ProfileSet.Payloads.BasePath, loadedJob.ProfileSet.Payloads.BasePath); + loadedJob.ProfileSet.Should().NotBeNull(); + loadedJob.ProfileSet.Serial.Device.Should().Be(originalJob.ProfileSet.Serial.Device); + loadedJob.ProfileSet.Socat.Port.Should().Be(originalJob.ProfileSet.Socat.Port); + loadedJob.ProfileSet.Power.Host.Should().Be(originalJob.ProfileSet.Power.Host); + loadedJob.ProfileSet.Memory.Start.Should().Be(originalJob.ProfileSet.Memory.Start); + loadedJob.ProfileSet.Payloads.BasePath.Should().Be(originalJob.ProfileSet.Payloads.BasePath); } private static JobProfileSet CreateTestProfileSet() diff --git a/tests/S7Tools.Tests/Services/Jobs/JobProfilePropagationTests.cs b/tests/S7Tools.Tests/Services/Jobs/JobProfilePropagationTests.cs index a25ce20c..22abf7cb 100644 --- a/tests/S7Tools.Tests/Services/Jobs/JobProfilePropagationTests.cs +++ b/tests/S7Tools.Tests/Services/Jobs/JobProfilePropagationTests.cs @@ -1,10 +1,11 @@ +using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using S7Tools.Core.Constants; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Services; using S7Tools.Services.Jobs; @@ -84,9 +85,9 @@ public async Task CreateExecutionJobAsync_ShouldPreserveCustomPowerTimings_FromJ Job executionJob = await _jobManager.CreateExecutionJobAsync(jobProfile); // Assert - Verify that the JobProfileSet contains the custom timing values - Assert.NotNull(executionJob.ProfileSet); - Assert.Equal(customPowerOnTimeMs, executionJob.ProfileSet.PowerOnTimeMs); - Assert.Equal(customPowerOffDelayMs, executionJob.ProfileSet.PowerOffDelayMs); + executionJob.ProfileSet.Should().NotBeNull(); + executionJob.ProfileSet.PowerOnTimeMs.Should().Be(customPowerOnTimeMs); + executionJob.ProfileSet.PowerOffDelayMs.Should().Be(customPowerOffDelayMs); } [Fact] @@ -114,11 +115,11 @@ public async Task CreateExecutionJobAsync_ShouldPreserveSerialConfiguration_From Job executionJob = await _jobManager.CreateExecutionJobAsync(jobProfile); // Assert - Verify serial configuration is from the profile, not defaults - Assert.NotNull(executionJob.ProfileSet.Serial.Configuration); - Assert.Equal(115200, executionJob.ProfileSet.Serial.Configuration.BaudRate); - Assert.Equal(ParityMode.None, executionJob.ProfileSet.Serial.Configuration.Parity); - Assert.Equal(8, executionJob.ProfileSet.Serial.Configuration.CharacterSize); - Assert.Equal(StopBits.One, executionJob.ProfileSet.Serial.Configuration.StopBits); + executionJob.ProfileSet.Serial.Configuration.Should().NotBeNull(); + executionJob.ProfileSet.Serial.Configuration.BaudRate.Should().Be(115200); + executionJob.ProfileSet.Serial.Configuration.Parity.Should().Be(ParityMode.None); + executionJob.ProfileSet.Serial.Configuration.CharacterSize.Should().Be(8); + executionJob.ProfileSet.Serial.Configuration.StopBits.Should().Be(StopBits.One); } [Fact] @@ -144,10 +145,10 @@ public async Task CreateExecutionJobAsync_ShouldPreserveSocatConfiguration_FromP Job executionJob = await _jobManager.CreateExecutionJobAsync(jobProfile); // Assert - Verify socat configuration is from the profile - Assert.NotNull(executionJob.ProfileSet.Socat.Configuration); - Assert.Equal(8080, executionJob.ProfileSet.Socat.Configuration.TcpPort); - Assert.True(executionJob.ProfileSet.Socat.Configuration.EnableFork); - Assert.True(executionJob.ProfileSet.Socat.Configuration.EnableReuseAddr); + executionJob.ProfileSet.Socat.Configuration.Should().NotBeNull(); + executionJob.ProfileSet.Socat.Configuration.TcpPort.Should().Be(8080); + executionJob.ProfileSet.Socat.Configuration.EnableFork.Should().BeTrue(); + executionJob.ProfileSet.Socat.Configuration.EnableReuseAddr.Should().BeTrue(); } [Fact] @@ -173,13 +174,13 @@ public async Task CreateExecutionJobAsync_ShouldPreservePowerConfiguration_FromP Job executionJob = await _jobManager.CreateExecutionJobAsync(jobProfile); // Assert - Verify power configuration is from the profile - Assert.NotNull(executionJob.ProfileSet.Power.Configuration); + executionJob.ProfileSet.Power.Configuration.Should().NotBeNull(); var modbusTcp = executionJob.ProfileSet.Power.Configuration as ModbusTcpConfiguration; - Assert.NotNull(modbusTcp); - Assert.Equal("192.168.1.100", modbusTcp.Host); - Assert.Equal(502, modbusTcp.Port); - Assert.Equal((ushort)1, modbusTcp.DeviceId); - Assert.Equal((ushort)0, modbusTcp.OnOffCoil); + modbusTcp.Should().NotBeNull(); + modbusTcp.Host.Should().Be("192.168.1.100"); + modbusTcp.Port.Should().Be(502); + modbusTcp.DeviceId.Should().Be((byte)1); + modbusTcp.OnOffCoil.Should().Be((ushort)0); } [Fact] @@ -206,8 +207,8 @@ public async Task CreateExecutionJobAsync_WithDefaultValues_ShouldNotOverwriteWi Job executionJob = await _jobManager.CreateExecutionJobAsync(jobProfile); // Assert - Verify that default values are preserved - Assert.Equal(5000, executionJob.ProfileSet.PowerOnTimeMs); - Assert.Equal(2000, executionJob.ProfileSet.PowerOffDelayMs); + executionJob.ProfileSet.PowerOnTimeMs.Should().Be(5000); + executionJob.ProfileSet.PowerOffDelayMs.Should().Be(2000); } [Fact] @@ -238,8 +239,8 @@ public async Task CreateExecutionJobAsync_ShouldNotModifyOriginalJobProfile() _ = await _jobManager.CreateExecutionJobAsync(jobProfile); // Assert - Verify original profile is unchanged - Assert.Equal(originalPowerOnTime, jobProfile.PowerOnTimeMs); - Assert.Equal(originalPowerOffDelay, jobProfile.PowerOffDelayMs); + jobProfile.PowerOnTimeMs.Should().Be(originalPowerOnTime); + jobProfile.PowerOffDelayMs.Should().Be(originalPowerOffDelay); } public void Dispose() diff --git a/tests/S7Tools.Tests/Services/PlcDataServiceTests.cs b/tests/S7Tools.Tests/Services/PlcDataServiceTests.cs index 8bcb924a..88ca92b5 100644 --- a/tests/S7Tools.Tests/Services/PlcDataServiceTests.cs +++ b/tests/S7Tools.Tests/Services/PlcDataServiceTests.cs @@ -1,8 +1,9 @@ +using FluentAssertions; using Microsoft.Extensions.Logging; using Moq; using S7Tools.Core.Models; using S7Tools.Core.Models.ValueObjects; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Services; using Xunit; @@ -26,8 +27,8 @@ public void PlcDataService_CanBeInstantiated() var service = new PlcDataService(_mockLogger.Object); // Assert - Assert.NotNull(service); - Assert.Equal(ConnectionState.Disconnected, service.State); + service.Should().NotBeNull(); + service.State.Should().Be(ConnectionState.Disconnected); } [Fact] @@ -40,7 +41,7 @@ public async Task ReadTagAsync_WhenNotConnected_ReturnsFailure() Result result = await _service.ReadTagAsync(address); // Assert - Assert.True(result.IsFailure); + result.IsFailure.Should().BeTrue(); Assert.Contains("Not connected", result.Error); } @@ -57,8 +58,8 @@ public async Task ReadTagAsync_WhenConnected_ReturnsSuccess() Result result = await _service.ReadTagAsync(address); // Assert - Assert.True(result.IsSuccess); - Assert.NotNull(result.Value); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); Assert.Contains("Tag_", result.Value.Name); } @@ -72,8 +73,8 @@ public async Task ConnectAsync_WithValidConfig_ReturnsSuccess() Result result = await _service.ConnectAsync(config); // Assert - Assert.True(result.IsSuccess); - Assert.Equal(ConnectionState.Connected, _service.State); + result.IsSuccess.Should().BeTrue(); + _service.State.Should().Be(ConnectionState.Connected); } [Fact] @@ -87,8 +88,8 @@ public async Task DisconnectAsync_WhenConnected_ReturnsSuccess() Result result = await _service.DisconnectAsync(); // Assert - Assert.True(result.IsSuccess); - Assert.Equal(ConnectionState.Disconnected, _service.State); + result.IsSuccess.Should().BeTrue(); + _service.State.Should().Be(ConnectionState.Disconnected); } [Fact] @@ -101,7 +102,7 @@ public async Task TestConnectionAsync_WithValidConfig_ReturnsSuccess() Result result = await _service.TestConnectionAsync(config); // Assert - Assert.True(result.IsSuccess); + result.IsSuccess.Should().BeTrue(); } [Fact] @@ -115,8 +116,8 @@ public async Task GetPlcInfoAsync_WhenConnected_ReturnsPlcInfo() Result result = await _service.GetPlcInfoAsync(); // Assert - Assert.True(result.IsSuccess); - Assert.NotNull(result.Value); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); Assert.Contains("CPU", result.Value.CpuType); } @@ -125,17 +126,17 @@ public async Task AddTagAsync_AddsTagToManagedTags() { // Arrange Result tagResult = Tag.Create("TestTag", "DB1.DBX0.0", true); - Assert.True(tagResult.IsSuccess); + tagResult.IsSuccess.Should().BeTrue(); Tag? tag = tagResult.Value; // Act Result result = await _service.AddTagAsync(tag!); // Assert - Assert.True(result.IsSuccess); + result.IsSuccess.Should().BeTrue(); Result> allTagsResult = await _service.GetAllTagsAsync(); - Assert.True(allTagsResult.IsSuccess); + allTagsResult.IsSuccess.Should().BeTrue(); Assert.Contains(allTagsResult.Value!, t => t.Name == "TestTag"); } @@ -149,7 +150,7 @@ public async Task WriteTagAsync_WhenNotConnected_ReturnsFailure() Result result = await _service.WriteTagAsync(address, true); // Assert - Assert.True(result.IsFailure); + result.IsFailure.Should().BeTrue(); Assert.Contains("Not connected", result.Error); } @@ -166,7 +167,7 @@ public async Task WriteTagAsync_WhenConnected_ReturnsSuccess() Result result = await _service.WriteTagAsync(address, true); // Assert - Assert.True(result.IsSuccess); + result.IsSuccess.Should().BeTrue(); } [Fact] diff --git a/tests/S7Tools.Tests/Services/SerialPort/SerialPortConfigurationServiceTests.cs b/tests/S7Tools.Tests/Services/SerialPort/SerialPortConfigurationServiceTests.cs index c8fe8a53..b0363c37 100644 --- a/tests/S7Tools.Tests/Services/SerialPort/SerialPortConfigurationServiceTests.cs +++ b/tests/S7Tools.Tests/Services/SerialPort/SerialPortConfigurationServiceTests.cs @@ -1,11 +1,13 @@ +using FluentAssertions; using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; -using S7Tools.Core.Services.Shell; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Core.Interfaces.Shell; +using S7Tools.Services; using S7Tools.Services.SerialPort; using Xunit; @@ -68,9 +70,9 @@ public async Task ReadPortConfigurationAsync_ValidOutput_ParsesCorrectly() var result = await _service.ReadPortConfigurationAsync(portPath); // Assert - Assert.NotNull(result); - Assert.Equal(115200, result.BaudRate); - Assert.Equal(8, result.CharacterSize); + result.Should().NotBeNull(); + result.BaudRate.Should().Be(115200); + result.CharacterSize.Should().Be(8); _shellExecutorMock.Verify(e => e.ExecuteCommandWithTimeoutAsync(It.Is(c => c.Contains("-a")), It.IsAny(), It.IsAny()), Times.Once); } @@ -88,7 +90,7 @@ public async Task ApplyConfigurationAsync_Success_ReturnsTrue() bool success = await _service.ApplyConfigurationAsync(portPath, config); // Assert - Assert.True(success); + success.Should().BeTrue(); _shellExecutorMock.Verify(e => e.ExecuteCommandWithTimeoutAsync(It.Is(c => c.Contains("stty")), It.IsAny(), It.IsAny()), Times.Once); } @@ -106,7 +108,7 @@ public async Task ApplyConfigurationAsync_Failure_ReturnsFalse() bool success = await _service.ApplyConfigurationAsync(portPath, config); // Assert - Assert.False(success); + success.Should().BeFalse(); } [Fact] @@ -116,8 +118,8 @@ public void ValidateSttyCommand_ValidCommand_ReturnsValid() var result = _service.ValidateSttyCommand("stty -F /dev/ttyUSB0 115200"); // Assert - Assert.True(result.IsValid); - Assert.Empty(result.Errors); + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); } [Fact] @@ -127,7 +129,7 @@ public void ValidateSttyCommand_DangerousCommand_ReturnsInvalid() var result = _service.ValidateSttyCommand("stty -F /dev/ttyUSB0; rm -rf /"); // Assert - Assert.False(result.IsValid); + result.IsValid.Should().BeFalse(); Assert.NotEmpty(result.Errors); } } diff --git a/tests/S7Tools.Tests/Services/SerialPort/SerialPortDiscoveryServiceTests.cs b/tests/S7Tools.Tests/Services/SerialPort/SerialPortDiscoveryServiceTests.cs index a80b0d6b..fb03b636 100644 --- a/tests/S7Tools.Tests/Services/SerialPort/SerialPortDiscoveryServiceTests.cs +++ b/tests/S7Tools.Tests/Services/SerialPort/SerialPortDiscoveryServiceTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System; using System.Collections.Generic; using System.IO; @@ -7,8 +8,9 @@ using Microsoft.Extensions.Logging; using Moq; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; -using S7Tools.Core.Services.Shell; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Core.Interfaces.Shell; +using S7Tools.Services; using S7Tools.Services.SerialPort; using Xunit; @@ -42,11 +44,11 @@ public SerialPortDiscoveryServiceTests() [Fact] public void GetPortType_KnownPatterns_ReturnsCorrectType() { - Assert.Equal(SerialPortType.Usb, _service.GetPortType("/dev/ttyUSB0")); - Assert.Equal(SerialPortType.Acm, _service.GetPortType("/dev/ttyACM0")); - Assert.Equal(SerialPortType.Standard, _service.GetPortType("/dev/ttyS0")); - Assert.Equal(SerialPortType.Virtual, _service.GetPortType("/dev/pts/1")); - Assert.Equal(SerialPortType.Unknown, _service.GetPortType("/dev/unknown")); + _service.GetPortType("/dev/ttyUSB0").Should().Be(SerialPortType.Usb); + _service.GetPortType("/dev/ttyACM0").Should().Be(SerialPortType.Acm); + _service.GetPortType("/dev/ttyS0").Should().Be(SerialPortType.Standard); + _service.GetPortType("/dev/pts/1").Should().Be(SerialPortType.Virtual); + _service.GetPortType("/dev/unknown").Should().Be(SerialPortType.Unknown); } [Fact] @@ -56,7 +58,7 @@ public async Task IsPortAccessibleAsync_FileDoesNotExist_ReturnsFalse() bool result = await _service.IsPortAccessibleAsync("/non/existent/port"); // Assert - Assert.False(result); + result.Should().BeFalse(); } [Fact] @@ -77,11 +79,11 @@ public async Task GetPortInfoAsync_ValidPort_ReturnsPopulatedInfo() var info = await _service.GetPortInfoAsync(portPath, 1000); // Assert - Assert.NotNull(info); - Assert.Equal(portPath, info.PortPath); - Assert.Equal(SerialPortType.Usb, info.PortType); - Assert.True(info.IsAccessible); - Assert.False(info.IsInUse); + info.Should().NotBeNull(); + info.PortPath.Should().Be(portPath); + info.PortType.Should().Be(SerialPortType.Usb); + info.IsAccessible.Should().BeTrue(); + info.IsInUse.Should().BeFalse(); } public void Dispose() diff --git a/tests/S7Tools.Tests/Services/SerialPort/SerialPortSecurityTests.cs b/tests/S7Tools.Tests/Services/SerialPort/SerialPortSecurityTests.cs index 74dca741..7a26d7e2 100644 --- a/tests/S7Tools.Tests/Services/SerialPort/SerialPortSecurityTests.cs +++ b/tests/S7Tools.Tests/Services/SerialPort/SerialPortSecurityTests.cs @@ -4,8 +4,9 @@ using Microsoft.Extensions.Logging; using Moq; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; -using S7Tools.Core.Services.Shell; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Core.Interfaces.Shell; +using S7Tools.Services; using S7Tools.Services.SerialPort; using Xunit; using System.Collections.Generic; diff --git a/tests/S7Tools.Tests/Services/Shell/ShellCommandExecutorTests.cs b/tests/S7Tools.Tests/Services/Shell/ShellCommandExecutorTests.cs index c4a5bb14..15559e5f 100644 --- a/tests/S7Tools.Tests/Services/Shell/ShellCommandExecutorTests.cs +++ b/tests/S7Tools.Tests/Services/Shell/ShellCommandExecutorTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System; using System.Threading; using System.Threading.Tasks; @@ -26,8 +27,8 @@ public async Task ExecuteCommandAsync_ValidCommand_ReturnsSuccess() var result = await _executor.ExecuteCommandAsync("echo 'hello world'"); // Assert - Assert.True(result.Success); - Assert.Equal(0, result.ExitCode); + result.Success.Should().BeTrue(); + result.ExitCode.Should().Be(0); Assert.Contains("hello world", result.Output); } @@ -38,7 +39,7 @@ public async Task ExecuteCommandAsync_InvalidCommand_ReturnsFailure() var result = await _executor.ExecuteCommandAsync("nonexistent_command_12345"); // Assert - Assert.False(result.Success); + result.Success.Should().BeFalse(); Assert.NotEqual(0, result.ExitCode); } @@ -49,8 +50,8 @@ public async Task ExecuteCommandWithTimeoutAsync_FastCommand_CompletesSuccessful var result = await _executor.ExecuteCommandWithTimeoutAsync("sleep 0.1", 1000); // Assert - Assert.True(result.Success); - Assert.Equal(0, result.ExitCode); + result.Success.Should().BeTrue(); + result.ExitCode.Should().Be(0); } [Fact] @@ -60,7 +61,7 @@ public async Task ExecuteCommandWithTimeoutAsync_SlowCommand_TimesOut() var result = await _executor.ExecuteCommandWithTimeoutAsync("sleep 2", 500); // Assert - Assert.False(result.Success); + result.Success.Should().BeFalse(); Assert.Contains("timed out", result.Error.ToLower()); } @@ -81,7 +82,7 @@ public async Task GetChildProcessesAsync_ParentWithChildren_ReturnsChildPids() int currentPid = System.Diagnostics.Process.GetCurrentProcess().Id; var children = await _executor.GetChildProcessesAsync(currentPid); - Assert.NotNull(children); + children.Should().NotBeNull(); cts.Cancel(); } diff --git a/tests/S7Tools.Tests/ViewModels/Jobs/JobInfoDisplayViewModelTests.cs b/tests/S7Tools.Tests/ViewModels/Jobs/JobInfoDisplayViewModelTests.cs index 2abacc9e..5d655999 100644 --- a/tests/S7Tools.Tests/ViewModels/Jobs/JobInfoDisplayViewModelTests.cs +++ b/tests/S7Tools.Tests/ViewModels/Jobs/JobInfoDisplayViewModelTests.cs @@ -8,8 +8,9 @@ using Moq; using S7Tools.Core.Models; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Services; +using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Controls; using S7Tools.ViewModels.Jobs; using S7Tools.ViewModels.Profiles; diff --git a/tests/S7Tools.Tests/ViewModels/Jobs/JobWizardMemoryRegionStepViewModelTests.cs b/tests/S7Tools.Tests/ViewModels/Jobs/JobWizardMemoryRegionStepViewModelTests.cs index d92a36cb..1f0b3269 100644 --- a/tests/S7Tools.Tests/ViewModels/Jobs/JobWizardMemoryRegionStepViewModelTests.cs +++ b/tests/S7Tools.Tests/ViewModels/Jobs/JobWizardMemoryRegionStepViewModelTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using System; using System.Collections.ObjectModel; using System.Linq; @@ -7,7 +8,8 @@ using NSubstitute; using ReactiveUI; using S7Tools.Core.Models; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Services; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Jobs; using Xunit; @@ -76,11 +78,11 @@ public void Constructor_WithValidParameters_ShouldInitializeSuccessfully() JobWizardMemoryRegionStepViewModel viewModel = CreateViewModel(); // Assert - Assert.NotNull(viewModel); - Assert.NotNull(viewModel.AvailableProfiles); - Assert.NotNull(viewModel.SelectedSegments); - Assert.False(viewModel.IsBusy); - Assert.False(viewModel.IsStepValid); + viewModel.Should().NotBeNull(); + viewModel.AvailableProfiles.Should().NotBeNull(); + viewModel.SelectedSegments.Should().NotBeNull(); + viewModel.IsBusy.Should().BeFalse(); + viewModel.IsStepValid.Should().BeFalse(); // Status may be set during profile loading, so we don't assert it's empty } @@ -132,8 +134,8 @@ public void SelectedProfile_WhenSet_ShouldRaisePropertyChanged() viewModel.SelectedProfile = profile; // Assert - Assert.True(propertyChanged); - Assert.Equal(profile, viewModel.SelectedProfile); + propertyChanged.Should().BeTrue(); + viewModel.SelectedProfile.Should().Be(profile); } [Fact] @@ -143,7 +145,7 @@ public void ProfileSummary_WithNoProfile_ShouldReturnNoProfileMessage() JobWizardMemoryRegionStepViewModel viewModel = CreateViewModel(); // Act & Assert - Assert.Equal("No profile selected", viewModel.ProfileSummary); + viewModel.ProfileSummary.Should().Be("No profile selected"); } [Fact] @@ -167,7 +169,7 @@ public void SelectedSegmentCount_WithNoProfile_ShouldReturnZero() JobWizardMemoryRegionStepViewModel viewModel = CreateViewModel(); // Act & Assert - Assert.Equal(0, viewModel.SelectedSegmentCount); + viewModel.SelectedSegmentCount.Should().Be(0); } [Fact] @@ -181,7 +183,7 @@ public void SelectedSegmentCount_WithProfile_ShouldReturnCorrectCount() viewModel.SelectedProfile = profile; // Assert - Assert.Equal(1, viewModel.SelectedSegmentCount); // Only .bss is selected in sample profile + viewModel.SelectedSegmentCount.Should().Be(1); // Only .bss is selected in sample profile } [Fact] @@ -191,7 +193,7 @@ public void TotalSelectedSize_WithNoProfile_ShouldReturnZero() JobWizardMemoryRegionStepViewModel viewModel = CreateViewModel(); // Act & Assert - Assert.Equal(0, viewModel.TotalSelectedSize); + viewModel.TotalSelectedSize.Should().Be(0); } [Fact] @@ -205,7 +207,7 @@ public void TotalSelectedSize_WithProfile_ShouldReturnCorrectSize() viewModel.SelectedProfile = profile; // Assert - Assert.Equal(16 * 1024, viewModel.TotalSelectedSize); // .bss segment size + viewModel.TotalSelectedSize.Should().Be(16 * 1024); // .bss segment size } [Fact] @@ -220,7 +222,7 @@ public void TotalSelectedSizeFormatted_WithSmallSize_ShouldReturnBytesFormat() viewModel.SelectedProfile = profile; // Assert - Assert.Equal("512 bytes", viewModel.TotalSelectedSizeFormatted); + viewModel.TotalSelectedSizeFormatted.Should().Be("512 bytes"); } [Fact] @@ -247,7 +249,7 @@ public void IsStepValid_WithNoProfile_ShouldReturnFalse() JobWizardMemoryRegionStepViewModel viewModel = CreateViewModel(); // Act & Assert - Assert.False(viewModel.IsStepValid); + viewModel.IsStepValid.Should().BeFalse(); } [Fact] @@ -261,7 +263,7 @@ public void IsStepValid_WithProfileAndSelectedSegments_ShouldReturnTrue() viewModel.SelectedProfile = profile; // Assert - Assert.True(viewModel.IsStepValid); + viewModel.IsStepValid.Should().BeTrue(); } [Fact] @@ -271,7 +273,7 @@ public void ValidationMessage_WithNoProfile_ShouldReturnSelectProfileMessage() JobWizardMemoryRegionStepViewModel viewModel = CreateViewModel(); // Act & Assert - Assert.Equal("Please select a memory region profile", viewModel.ValidationMessage); + viewModel.ValidationMessage.Should().Be("Please select a memory region profile"); } [Fact] @@ -303,7 +305,7 @@ public void GetSelectedProfileId_WithNoProfile_ShouldReturnNull() int? result = viewModel.GetSelectedProfileId(); // Assert - Assert.Null(result); + result.Should().BeNull(); } [Fact] @@ -318,7 +320,7 @@ public void GetSelectedProfileId_WithProfile_ShouldReturnProfileId() int? result = viewModel.GetSelectedProfileId(); // Assert - Assert.Equal(42, result); + result.Should().Be(42); } [Fact] @@ -334,8 +336,8 @@ public void SetSelectedProfileId_WithNullId_ShouldClearSelection() bool result = viewModel.SetSelectedProfileId(null); // Assert - Assert.True(result); - Assert.Null(viewModel.SelectedProfile); + result.Should().BeTrue(); + viewModel.SelectedProfile.Should().BeNull(); } [Fact] @@ -350,8 +352,8 @@ public void SetSelectedProfileId_WithValidId_ShouldSelectProfile() bool result = viewModel.SetSelectedProfileId(42); // Assert - Assert.True(result); - Assert.Equal(profile, viewModel.SelectedProfile); + result.Should().BeTrue(); + viewModel.SelectedProfile.Should().Be(profile); } [Fact] @@ -366,8 +368,8 @@ public void SetSelectedProfileId_WithInvalidId_ShouldReturnFalse() bool result = viewModel.SetSelectedProfileId(99); // Assert - Assert.False(result); - Assert.Null(viewModel.SelectedProfile); + result.Should().BeFalse(); + viewModel.SelectedProfile.Should().BeNull(); } [Fact] @@ -380,7 +382,7 @@ public void ValidateStep_WithNoProfile_ShouldReturnError() List errors = viewModel.ValidateStep(); // Assert - Assert.Single(errors); + errors.Should().ContainSingle(); Assert.Contains("Memory region profile must be selected", errors); } @@ -416,7 +418,7 @@ public void ValidateStep_WithValidProfileAndSelectedSegments_ShouldReturnNoError List errors = viewModel.ValidateStep(); // Assert - Assert.Empty(errors); + errors.Should().BeEmpty(); } #endregion @@ -435,8 +437,8 @@ public void IsStepValid_ShouldUpdateWhenProfileChanges() viewModel.SelectedProfile = profile; // Assert - Assert.False(initialValid); - Assert.True(viewModel.IsStepValid); + initialValid.Should().BeFalse(); + viewModel.IsStepValid.Should().BeTrue(); } [Fact] @@ -450,8 +452,8 @@ public void SelectedSegments_ShouldUpdateWhenProfileChanges() viewModel.SelectedProfile = profile; // Assert - Assert.Single(viewModel.SelectedSegments); - Assert.Equal(".bss", viewModel.SelectedSegments.First().Name); + viewModel.SelectedSegments.Should().ContainSingle(); + viewModel.SelectedSegments.First().Name.Should().Be(".bss"); } #endregion diff --git a/tests/S7Tools.Tests/ViewModels/Jobs/JobWizardViewModelTests.cs b/tests/S7Tools.Tests/ViewModels/Jobs/JobWizardViewModelTests.cs index 82752127..cc0d9941 100644 --- a/tests/S7Tools.Tests/ViewModels/Jobs/JobWizardViewModelTests.cs +++ b/tests/S7Tools.Tests/ViewModels/Jobs/JobWizardViewModelTests.cs @@ -5,9 +5,11 @@ using Microsoft.Extensions.Logging; using Moq; using ReactiveUI; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Services; using S7Tools.Core.Models; +using S7Tools.Core.Models.Configuration.StrongSettings; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Controls; using S7Tools.ViewModels.Jobs; @@ -31,6 +33,7 @@ public sealed class JobWizardViewModelTests : IDisposable private readonly Mock _mockViewModelFactory; private readonly Mock _mockSerialPortService; private readonly Mock> _mockScannerLogger; + private readonly Mock _mockSettingsService; public JobWizardViewModelTests() { @@ -45,11 +48,16 @@ public JobWizardViewModelTests() _mockViewModelFactory = new Mock(); _mockSerialPortService = new Mock(); _mockScannerLogger = new Mock>(); + _mockSettingsService = new Mock(); var _mockUIRefreshService = new Mock(); + // Setup mock settings service + _mockSettingsService.Setup(s => s.Current).Returns(new AppSettings()); + // Create a real instance for SerialPortDiscoveryViewModel (can't mock concrete class) var realSerialScanner = new SerialPortDiscoveryViewModel( _mockSerialPortService.Object, + _mockSettingsService.Object, _mockUIThreadService.Object, _mockUIRefreshService.Object, _mockScannerLogger.Object); diff --git a/tests/S7Tools.Tests/ViewModels/SettingsManagementViewModelTests.cs b/tests/S7Tools.Tests/ViewModels/SettingsManagementViewModelTests.cs index da488582..aae8d65d 100644 --- a/tests/S7Tools.Tests/ViewModels/SettingsManagementViewModelTests.cs +++ b/tests/S7Tools.Tests/ViewModels/SettingsManagementViewModelTests.cs @@ -1,16 +1,16 @@ +using FluentAssertions; using Microsoft.Extensions.Logging; using Moq; using S7Tools.Core.Interfaces.Services; -using S7Tools.Core.Models.Configuration; +using S7Tools.Services; +using S7Tools.Core.Models.Configuration.StrongSettings; using S7Tools.ViewModels.Layout; using Xunit; namespace S7Tools.Tests.ViewModels; /// -/// Tests for the SettingsManagementViewModel. -/// NOTE: This test file uses the old ISettingsService which has been removed. -/// These tests are disabled pending update to use IApplicationSettingsService. +/// Tests for the SettingsManagementViewModel using the strongly-typed IApplicationSettingsService. /// public class SettingsManagementViewModelTests { @@ -22,15 +22,11 @@ public SettingsManagementViewModelTests() _mockLogger = new Mock>(); _mockSettingsService = new Mock(); - // Setup mock to return default values for settings - _mockSettingsService.Setup(s => s.GetSetting(It.IsAny(), It.IsAny())) - .Returns((string key, string defaultValue) => defaultValue); - _mockSettingsService.Setup(s => s.GetSetting(It.IsAny(), It.IsAny())) - .Returns((string key, bool defaultValue) => defaultValue); - _mockSettingsService.Setup(s => s.GetSetting(It.IsAny(), It.IsAny())) - .Returns((string key, int defaultValue) => defaultValue); - _mockSettingsService.Setup(s => s.LoadSettingsAsync()) - .ReturnsAsync(ApplicationSettings.CreateDefault()); + // Setup mock to return default AppSettings + _mockSettingsService.Setup(s => s.Current).Returns(new AppSettings()); + _mockSettingsService.Setup(s => s.LoadSettingsAsync()).Returns(Task.CompletedTask); + _mockSettingsService.Setup(s => s.UpdateSettingsAsync(It.IsAny>())) + .Returns(Task.CompletedTask); } [Fact] @@ -40,10 +36,10 @@ public void Constructor_WithValidDependencies_CreatesInstance() var viewModel = new SettingsManagementViewModel(_mockLogger.Object, _mockSettingsService.Object); // Assert - Assert.NotNull(viewModel); - Assert.NotNull(viewModel.SaveSettingsCommand); - Assert.NotNull(viewModel.LoadSettingsCommand); - Assert.NotNull(viewModel.ResetSettingsCommand); + viewModel.Should().NotBeNull(); + viewModel.SaveSettingsCommand.Should().NotBeNull(); + viewModel.LoadSettingsCommand.Should().NotBeNull(); + viewModel.ResetSettingsCommand.Should().NotBeNull(); } [Fact] @@ -79,9 +75,9 @@ public void Properties_CanBeSetAndRetrieved() viewModel.AutoScrollLogs = false; // Assert - Assert.Equal("/test/path", viewModel.DefaultLogPath); - Assert.Equal("/export/path", viewModel.ExportPath); - Assert.Equal("Debug", viewModel.MinimumLogLevel); - Assert.False(viewModel.AutoScrollLogs); + viewModel.DefaultLogPath.Should().Be("/test/path"); + viewModel.ExportPath.Should().Be("/export/path"); + viewModel.MinimumLogLevel.Should().Be("Debug"); + viewModel.AutoScrollLogs.Should().BeFalse(); } } diff --git a/tests/S7Tools.Tests/ViewModels/TaskManagerViewModelTests.cs b/tests/S7Tools.Tests/ViewModels/TaskManagerViewModelTests.cs index ac657e9c..c8005c06 100644 --- a/tests/S7Tools.Tests/ViewModels/TaskManagerViewModelTests.cs +++ b/tests/S7Tools.Tests/ViewModels/TaskManagerViewModelTests.cs @@ -1,8 +1,10 @@ +using FluentAssertions; using System; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; +using S7Tools.Services; using S7Tools.Services.Interfaces; using S7Tools.Services.Jobs; using S7Tools.ViewModels.Tasks; @@ -22,7 +24,7 @@ private static Mock CreateMockTaskDetailsViewModel() new Mock>().Object, new Mock().Object, new Mock().Object, - new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, @@ -65,8 +67,8 @@ public void T088_TaskManagerViewModel_Should_Use_UIThreadService_For_Updates() var viewModel = CreateViewModel(); // Assert - Verify ViewModel was constructed with UIThreadService - Assert.NotNull(viewModel); - Assert.NotNull(viewModel.RefreshTasksCommand); + viewModel.Should().NotBeNull(); + viewModel.RefreshTasksCommand.Should().NotBeNull(); } [Fact] @@ -79,6 +81,6 @@ public void StatusMessage_Should_Be_Settable() viewModel.StatusMessage = "Test Message"; // Assert - Assert.Equal("Test Message", viewModel.StatusMessage); + viewModel.StatusMessage.Should().Be("Test Message"); } } diff --git a/tests/S7Tools.Tests/Views/Jobs/JobInfoDisplayViewTests.cs b/tests/S7Tools.Tests/Views/Jobs/JobInfoDisplayViewTests.cs index 8f95fe6f..6f951575 100644 --- a/tests/S7Tools.Tests/Views/Jobs/JobInfoDisplayViewTests.cs +++ b/tests/S7Tools.Tests/Views/Jobs/JobInfoDisplayViewTests.cs @@ -6,8 +6,9 @@ using Microsoft.Extensions.Logging; using Moq; using S7Tools.Core.Models.Jobs; -using S7Tools.Core.Services.Interfaces; +using S7Tools.Core.Interfaces.Services; using S7Tools.Services; +using S7Tools.Services.Interfaces; using S7Tools.ViewModels.Jobs; using Xunit;