diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 45eb60c..baa09ac 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -24,7 +24,7 @@ jobs: Write-Host "Setting version to: $version" # Update csproj (preserve UTF-8 BOM encoding) - $csprojPath = "DellMonitorControl/DellMonitorControl.csproj" + $csprojPath = "MonitorControl/MonitorControl.csproj" $content = [System.IO.File]::ReadAllText($csprojPath) $content = $content -replace '.*', "$version" $content = $content -replace '.*', "$version.0" @@ -32,7 +32,7 @@ jobs: [System.IO.File]::WriteAllText($csprojPath, $content, [System.Text.UTF8Encoding]::new($true)) # Update ISS - $issPath = "DellMonitorControl/MonitorControl.iss" + $issPath = "MonitorControl/MonitorControl.iss" $issContent = [System.IO.File]::ReadAllText($issPath) $issContent = $issContent -replace '#define MyAppVersion ".*"', "#define MyAppVersion `"$version`"" [System.IO.File]::WriteAllText($issPath, $issContent, [System.Text.UTF8Encoding]::new($true)) @@ -48,21 +48,21 @@ jobs: run: | echo "Current directory:" cd - echo "DellMonitorControl contents:" - dir DellMonitorControl - echo "DellMonitorControl\bin contents:" - dir DellMonitorControl\bin - echo "DellMonitorControl\bin\Release contents:" - dir DellMonitorControl\bin\Release - echo "DellMonitorControl\bin\Release\net9.0-windows contents:" - dir DellMonitorControl\bin\Release\net9.0-windows + echo "MonitorControl contents:" + dir MonitorControl + echo "MonitorControl\bin contents:" + dir MonitorControl\bin + echo "MonitorControl\bin\Release contents:" + dir MonitorControl\bin\Release + echo "MonitorControl\bin\Release\net9.0-windows contents:" + dir MonitorControl\bin\Release\net9.0-windows - name: Build Installer shell: cmd run: | echo Copying to short path to avoid path length issues... mkdir C:\build 2>nul - xcopy /E /I /Y DellMonitorControl C:\build\app + xcopy /E /I /Y MonitorControl C:\build\app echo. echo Working from C:\build\app cd /d C:\build\app @@ -86,7 +86,7 @@ jobs: shell: powershell run: | $version = "${{ gitea.ref_name }}".TrimStart("v") - $buildDir = "DellMonitorControl\bin\Release\net9.0-windows" + $buildDir = "MonitorControl\bin\Release\net9.0-windows" $zipName = "MonitorControl-Portable-$version.zip" Write-Host "Creating portable zip: $zipName" @@ -115,7 +115,7 @@ jobs: $body = @{ tag_name = $tag name = "Monitor Control $tag" - body = "## Monitor Control $tag`n`n### Installation`n- **Installer**: Download and run the setup exe`n- **Portable**: Download the zip, extract anywhere, and run DellMonitorControl.exe`n`n### Features`n- System tray monitor control`n- Brightness/Contrast adjustment`n- Input source switching`n- Quick-switch toolbar`n`n### Requirements`n- Windows 10/11`n- .NET 9.0 Runtime" + body = "## Monitor Control $tag`n`n### Installation`n- **Installer**: Download and run the setup exe`n- **Portable**: Download the zip, extract anywhere, and run MonitorControl.exe`n`n### Features`n- System tray monitor control`n- Brightness/Contrast adjustment`n- Input source switching`n- Quick-switch toolbar`n`n### Requirements`n- Windows 10/11`n- .NET 9.0 Runtime" } | ConvertTo-Json $release = Invoke-RestMethod -Uri "$baseUrl/repos/$repo/releases" -Method Post -Headers @{"Authorization"="token $env:GITEA_TOKEN"; "Content-Type"="application/json"} -Body $body diff --git a/ControlMyMonitorManagement.sln b/ControlMyMonitorManagement.sln index 822f1a8..4467b03 100644 --- a/ControlMyMonitorManagement.sln +++ b/ControlMyMonitorManagement.sln @@ -11,7 +11,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Language", "Language\Langua EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tester", "Tester\Tester.csproj", "{0D34DD73-3282-40EB-8F59-DF190944BF12}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DellMonitorControl", "DellMonitorControl\DellMonitorControl.csproj", "{64E96610-D431-40B9-A00B-55CE195B4B58}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MonitorControl", "MonitorControl\MonitorControl.csproj", "{64E96610-D431-40B9-A00B-55CE195B4B58}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CMMService", "CMMService\CMMService.csproj", "{FEA2B019-32BC-4704-939F-1CD26F373F55}" EndProject diff --git a/DellMonitorControl/MonitorIcon.ico b/DellMonitorControl/MonitorIcon.ico deleted file mode 100644 index 232686e..0000000 Binary files a/DellMonitorControl/MonitorIcon.ico and /dev/null differ diff --git a/Library/Method/CMMCommand.cs b/Library/Method/CMMCommand.cs index 02d8764..a03db71 100644 --- a/Library/Method/CMMCommand.cs +++ b/Library/Method/CMMCommand.cs @@ -1,11 +1,12 @@ using CMM.Library.Base; using CMM.Library.Helpers; using CMM.Library.ViewModel; +using System.Collections.Concurrent; using System.IO; namespace CMM.Library.Method; -/// +/// /// Control My Monitor Management Command /// public static class CMMCommand @@ -14,6 +15,78 @@ public static class CMMCommand static readonly string CMMexe = Path.Combine(AppContext.BaseDirectory, "ControlMyMonitor.exe"); static readonly string CMMsMonitors = Path.Combine(CMMTmpFolder, "smonitors.tmp"); + // DDC/CI rate limiting: minimum time between commands to the same monitor + private static readonly ConcurrentDictionary _lastCommandTime = new(); + private static readonly SemaphoreSlim _globalDdcLock = new(1, 1); + private const int MinCommandIntervalMs = 50; + + // Post-resume grace period: prevents DDC/CI commands from hitting monitors still waking up + private static DateTime _resumeTime = DateTime.MinValue; + private const int ResumeGracePeriodMs = 8000; + + // Timeout cooldown: when a command times out, the monitor is likely struggling. + // Back off for this long before sending another command to that monitor. + private static readonly ConcurrentDictionary _timeoutCooldowns = new(); + private const int TimeoutCooldownMs = 3000; + + /// + /// Call this when the system resumes from sleep/hibernate to activate the grace period. + /// Monitors need several seconds after resume before their scalers are ready for DDC/CI. + /// + public static void NotifySystemResumed() + { + _resumeTime = DateTime.UtcNow; + } + + /// + /// Waits for the post-resume grace period to expire, respects timeout cooldowns, + /// and enforces per-monitor command spacing. + /// + private static async Task ThrottleDdcCommand(string monitorSN) + { + // Wait for post-resume grace period + var elapsed = (DateTime.UtcNow - _resumeTime).TotalMilliseconds; + if (elapsed < ResumeGracePeriodMs) + { + var waitMs = (int)(ResumeGracePeriodMs - elapsed); + await Task.Delay(waitMs); + } + + // Wait for timeout cooldown — if this monitor recently timed out, give it time to recover + if (_timeoutCooldowns.TryGetValue(monitorSN, out var cooldownUntil)) + { + var remaining = (cooldownUntil - DateTime.UtcNow).TotalMilliseconds; + if (remaining > 0) + await Task.Delay((int)remaining); + _timeoutCooldowns.TryRemove(monitorSN, out _); + } + + // Enforce minimum interval between commands to the same monitor + await _globalDdcLock.WaitAsync(); + try + { + if (_lastCommandTime.TryGetValue(monitorSN, out var lastTime)) + { + var sinceLastMs = (DateTime.UtcNow - lastTime).TotalMilliseconds; + if (sinceLastMs < MinCommandIntervalMs) + await Task.Delay((int)(MinCommandIntervalMs - sinceLastMs)); + } + _lastCommandTime[monitorSN] = DateTime.UtcNow; + } + finally + { + _globalDdcLock.Release(); + } + } + + /// + /// Records that a command to this monitor timed out, activating a cooldown period. + /// + private static void RecordTimeout(string monitorSN) + { + _timeoutCooldowns[monitorSN] = DateTime.UtcNow.AddMilliseconds(TimeoutCooldownMs); + } + public static async Task ScanMonitor() { // Ensure temp folder exists for output files @@ -23,26 +96,36 @@ public static class CMMCommand public static async Task PowerOn(string monitorSN) { - await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} D6 1"); + await ThrottleDdcCommand(monitorSN); + var (_, exitCode) = await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} D6 1"); + if (exitCode == -1) RecordTimeout(monitorSN); } public static async Task Sleep(string monitorSN) { - await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} D6 4"); + await ThrottleDdcCommand(monitorSN); + var (_, exitCode) = await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} D6 4"); + if (exitCode == -1) RecordTimeout(monitorSN); } private static async Task GetMonitorValue(string monitorSN, string vcpCode = "D6", int maxRetries = 2) { for (int attempt = 0; attempt <= maxRetries; attempt++) { + await ThrottleDdcCommand(monitorSN); + // Execute directly without batch file wrapper var (output, exitCode) = await ConsoleHelper.ExecuteExeAsync( CMMexe, $"/GetValue {monitorSN} {vcpCode}"); - // Timeout + // Timeout — monitor is unresponsive, activate cooldown and stop retrying. + // Continuing to send commands to a timed-out monitor risks scaler lockout. if (exitCode == -1) + { + RecordTimeout(monitorSN); return string.Empty; + } // ControlMyMonitor returns the value as the exit code // Exit code > 0 means success with that value @@ -66,7 +149,9 @@ public static class CMMCommand public static async Task SetBrightness(string monitorSN, int value) { - await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} 10 {value}"); + await ThrottleDdcCommand(monitorSN); + var (_, exitCode) = await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} 10 {value}"); + if (exitCode == -1) RecordTimeout(monitorSN); } public static async Task GetBrightness(string monitorSN) @@ -81,7 +166,9 @@ public static class CMMCommand public static async Task SetContrast(string monitorSN, int value) { - await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} 12 {value}"); + await ThrottleDdcCommand(monitorSN); + var (_, exitCode) = await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} 12 {value}"); + if (exitCode == -1) RecordTimeout(monitorSN); } public static async Task GetContrast(string monitorSN) @@ -96,7 +183,9 @@ public static class CMMCommand public static async Task SetInputSource(string monitorSN, int value) { - await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} 60 {value}"); + await ThrottleDdcCommand(monitorSN); + var (_, exitCode) = await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} 60 {value}"); + if (exitCode == -1) RecordTimeout(monitorSN); } public static async Task GetInputSource(string monitorSN) @@ -110,7 +199,9 @@ public static class CMMCommand var options = new List(); var savePath = Path.Combine(CMMTmpFolder, $"{monitorSN}_vcp.tmp"); - await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/sjson {savePath} {monitorSN}"); + await ThrottleDdcCommand(monitorSN); + var (_, exitCode) = await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/sjson {savePath} {monitorSN}"); + if (exitCode == -1) RecordTimeout(monitorSN); if (!File.Exists(savePath)) return options; @@ -177,17 +268,19 @@ public static class CMMCommand public static async Task ScanMonitorStatus(IEnumerable monitors) { - var taskList = monitors.Select(x => + // Sequential scan to avoid overwhelming DDC/CI bus with parallel commands + foreach (var x in monitors) { - return ScanMonitorStatus($"{CMMTmpFolder}\\{x.SerialNumber}.tmp", x); - }); - - await Task.WhenAll(taskList); + await ScanMonitorStatus($"{CMMTmpFolder}\\{x.SerialNumber}.tmp", x); + } } static async Task ScanMonitorStatus(string savePath, XMonitor mon) { - await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/sjson {savePath} {mon.MonitorID}"); + var sn = mon.SerialNumber ?? mon.MonitorID ?? "unknown"; + await ThrottleDdcCommand(sn); + var (_, exitCode) = await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/sjson {savePath} {mon.MonitorID}"); + if (exitCode == -1) RecordTimeout(sn); var monitorModel = JsonHelper.JsonFormFile>(savePath); var status = monitorModel.ReadMonitorStatus(); diff --git a/DellMonitorControl/App.xaml b/MonitorControl/App.xaml similarity index 83% rename from DellMonitorControl/App.xaml rename to MonitorControl/App.xaml index cf6ba55..1d9126a 100644 --- a/DellMonitorControl/App.xaml +++ b/MonitorControl/App.xaml @@ -1,8 +1,8 @@ - _availablePorts; private readonly List _portRows = new(); + private CancellationTokenSource? _detectCts; public bool ConfigChanged { get; private set; } @@ -205,13 +207,35 @@ public partial class ConfigWindow : Window private async void DetectButton_Click(object sender, RoutedEventArgs e) { - btnDetect.IsEnabled = false; - btnDetect.Content = "..."; + // If detection is already running, cancel it + if (_detectCts != null) + { + _detectCts.Cancel(); + return; + } + + // Warn the user about what this does + var confirm = MessageBox.Show( + "Port detection will temporarily switch your monitor's input source to probe for available ports. " + + "Your screen may go dark briefly during each probe.\n\n" + + "This is safe for most monitors, but avoid running it repeatedly in a short time.\n\n" + + "You can click the Cancel button to stop detection at any time.\n\n" + + "Continue?", + "Port Detection", MessageBoxButton.YesNo, MessageBoxImage.Warning); + + if (confirm != MessageBoxResult.Yes) + return; + + _detectCts = new CancellationTokenSource(); + var ct = _detectCts.Token; + + btnDetect.Content = "Cancel"; try { - // Common VCP 60 values: 15=DP1, 16=DP2, 17=HDMI1, 18=HDMI2, 3=DVI1, 4=DVI2, 1=VGA1, 2=VGA2 - var commonPorts = new[] { 15, 16, 17, 18, 3, 4, 1, 2 }; + // Only probe modern ports (DP/HDMI) by default — legacy ports (VGA/DVI) are rarely used + // and probing them on monitors without those connectors wastes time and stresses the scaler + var commonPorts = new[] { 15, 16, 17, 18 }; var detectedPorts = new List(); // Get current input so we can restore it @@ -219,6 +243,8 @@ public partial class ConfigWindow : Window foreach (var vcpValue in commonPorts) { + ct.ThrowIfCancellationRequested(); + // Skip ports we already know about if (_availablePorts.Any(p => p.Value == vcpValue)) continue; @@ -227,7 +253,10 @@ public partial class ConfigWindow : Window { // Try to set the input - if it succeeds, the port exists await CMMCommand.SetInputSource(_serialNumber, vcpValue); - await Task.Delay(500); // Give monitor time to respond + + // Give the monitor's scaler time to attempt signal lock on the new input. + // 3 seconds is the minimum safe settle time for most scalers. + await Task.Delay(3000, ct); // Check if the input actually changed var newInput = await CMMCommand.GetInputSource(_serialNumber); @@ -236,21 +265,26 @@ public partial class ConfigWindow : Window detectedPorts.Add(vcpValue); } } + catch (OperationCanceledException) { throw; } catch { - // Port doesn't exist or isn't supported + // Port doesn't exist or isn't supported — continue to next } + + // Extra settle time between probes to let the scaler fully recover + try { await Task.Delay(1000, ct); } + catch (OperationCanceledException) { throw; } } - // Restore original input if we have one + // Restore original input — give it extra time to settle after all the switching if (currentInput.HasValue) { await CMMCommand.SetInputSource(_serialNumber, currentInput.Value); + await Task.Delay(2000); } if (detectedPorts.Count > 0) { - // Add detected ports to the available list and config foreach (var vcpValue in detectedPorts) { var name = CMMCommand.GetInputSourceName(vcpValue); @@ -258,7 +292,6 @@ public partial class ConfigWindow : Window MonitorConfigManager.AddDiscoveredPort(_serialNumber, _monitorName, vcpValue, name); } - // Reload to show new ports MonitorConfigManager.ClearCache(); LoadPortConfiguration(); @@ -272,6 +305,21 @@ public partial class ConfigWindow : Window MessageBoxButton.OK, MessageBoxImage.Information); } } + catch (OperationCanceledException) + { + // User cancelled — try to restore original input before exiting + try + { + var currentInput = await CMMCommand.GetInputSource(_serialNumber); + // Only restore if we can still talk to the monitor + if (currentInput.HasValue) + await CMMCommand.SetInputSource(_serialNumber, currentInput.Value); + } + catch { } + + MessageBox.Show("Port detection was cancelled.", "Detection Cancelled", + MessageBoxButton.OK, MessageBoxImage.Information); + } catch (Exception ex) { MessageBox.Show($"Detection failed: {ex.Message}", "Error", @@ -279,8 +327,9 @@ public partial class ConfigWindow : Window } finally { + _detectCts?.Dispose(); + _detectCts = null; btnDetect.Content = "Detect"; - btnDetect.IsEnabled = true; } } diff --git a/DellMonitorControl/ControlPanel.xaml b/MonitorControl/ControlPanel.xaml similarity index 92% rename from DellMonitorControl/ControlPanel.xaml rename to MonitorControl/ControlPanel.xaml index e9c4317..bd89825 100644 --- a/DellMonitorControl/ControlPanel.xaml +++ b/MonitorControl/ControlPanel.xaml @@ -1,4 +1,4 @@ - /// Interaction logic for ControlPanel.xaml diff --git a/DellMonitorControl/DebugLogger.cs b/MonitorControl/DebugLogger.cs similarity index 98% rename from DellMonitorControl/DebugLogger.cs rename to MonitorControl/DebugLogger.cs index cf2f070..d464dee 100644 --- a/DellMonitorControl/DebugLogger.cs +++ b/MonitorControl/DebugLogger.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Text; -namespace DellMonitorControl; +namespace MonitorControl; public static class DebugLogger { diff --git a/DellMonitorControl/Images/icons.xaml b/MonitorControl/Images/icons.xaml similarity index 100% rename from DellMonitorControl/Images/icons.xaml rename to MonitorControl/Images/icons.xaml diff --git a/DellMonitorControl/MainWindow.xaml b/MonitorControl/MainWindow.xaml similarity index 99% rename from DellMonitorControl/MainWindow.xaml rename to MonitorControl/MainWindow.xaml index 9bdd6ac..a226f30 100644 --- a/DellMonitorControl/MainWindow.xaml +++ b/MonitorControl/MainWindow.xaml @@ -1,4 +1,4 @@ - + { + timer.Stop(); + _displayChangeReloadPending = false; + + if (IsVisible) + { + DebugLogger.Log("Reloading monitors after display change"); + await LoadMonitors(); + } + }; + timer.Start(); + } + + return IntPtr.Zero; + } + public void ShowNearTray() { DebugLogger.Log("ShowNearTray called"); @@ -119,7 +158,7 @@ public partial class MainWindow : Window { Title = "About Monitor Control", Width = 300, - Height = 180, + Height = 240, WindowStartupLocation = WindowStartupLocation.CenterScreen, ResizeMode = ResizeMode.NoResize, WindowStyle = WindowStyle.None, @@ -138,6 +177,16 @@ public partial class MainWindow : Window var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + var logo = new System.Windows.Controls.Image + { + Source = new System.Windows.Media.Imaging.BitmapImage(new Uri("pack://application:,,,/MonitorIcon.png")), + Width = 48, + Height = 48, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 0, 0, 10) + }; + stack.Children.Add(logo); + stack.Children.Add(new TextBlock { Text = "Monitor Control", diff --git a/DellMonitorControl/DellMonitorControl.csproj b/MonitorControl/MonitorControl.csproj similarity index 90% rename from DellMonitorControl/DellMonitorControl.csproj rename to MonitorControl/MonitorControl.csproj index 76c7305..9b7dbcc 100644 --- a/DellMonitorControl/DellMonitorControl.csproj +++ b/MonitorControl/MonitorControl.csproj @@ -4,8 +4,8 @@ WinExe net9.0-windows enable - DellMonitorControl - DellMonitorControl + MonitorControl + MonitorControl ControlMyMonitorManagement true MarketAlly @@ -22,10 +22,12 @@ + + diff --git a/DellMonitorControl/MonitorControl.iss b/MonitorControl/MonitorControl.iss similarity index 97% rename from DellMonitorControl/MonitorControl.iss rename to MonitorControl/MonitorControl.iss index bb28c58..d5b779a 100644 --- a/DellMonitorControl/MonitorControl.iss +++ b/MonitorControl/MonitorControl.iss @@ -1,7 +1,7 @@ #define MyAppName "Monitor Control" #define MyAppVersion "1.1.5" #define MyAppPublisher "MarketAlly" -#define MyAppExeName "DellMonitorControl.exe" +#define MyAppExeName "MonitorControl.exe" #define MyAppIcon "MonitorIcon.ico" #define SourcePath "bin\Release\net9.0-windows" diff --git a/MonitorControl/MonitorIcon.ico b/MonitorControl/MonitorIcon.ico new file mode 100644 index 0000000..078b0f0 Binary files /dev/null and b/MonitorControl/MonitorIcon.ico differ diff --git a/MonitorControl/MonitorIcon.png b/MonitorControl/MonitorIcon.png new file mode 100644 index 0000000..07ee8c3 Binary files /dev/null and b/MonitorControl/MonitorIcon.png differ diff --git a/DellMonitorControl/Style/Btn.xaml b/MonitorControl/Style/Btn.xaml similarity index 100% rename from DellMonitorControl/Style/Btn.xaml rename to MonitorControl/Style/Btn.xaml diff --git a/DellMonitorControl/Style/Color.xaml b/MonitorControl/Style/Color.xaml similarity index 100% rename from DellMonitorControl/Style/Color.xaml rename to MonitorControl/Style/Color.xaml diff --git a/DellMonitorControl/Style/Main.xaml b/MonitorControl/Style/Main.xaml similarity index 100% rename from DellMonitorControl/Style/Main.xaml rename to MonitorControl/Style/Main.xaml diff --git a/DellMonitorControl/Style/Slider.xaml b/MonitorControl/Style/Slider.xaml similarity index 100% rename from DellMonitorControl/Style/Slider.xaml rename to MonitorControl/Style/Slider.xaml diff --git a/DellMonitorControl/UpdateChecker.cs b/MonitorControl/UpdateChecker.cs similarity index 99% rename from DellMonitorControl/UpdateChecker.cs rename to MonitorControl/UpdateChecker.cs index c71a773..579c46f 100644 --- a/DellMonitorControl/UpdateChecker.cs +++ b/MonitorControl/UpdateChecker.cs @@ -5,7 +5,7 @@ using System.Reflection; using System.Text.Json; using System.Threading.Tasks; -namespace DellMonitorControl; +namespace MonitorControl; public class UpdateChecker {