2
0

refactor(ci): rename DellMonitorControl to MonitorControl
All checks were successful
Build / build (push) Successful in 9h0m26s
Build and Release / build (push) Successful in 8h0m15s

Rename project from DellMonitorControl to MonitorControl to reflect broader monitor compatibility beyond Dell hardware. Update all references in solution file, workflow, and project paths.

Add DDC/CI rate limiting and throttling logic to prevent command failures:
- Minimum 50ms interval between commands to same monitor
- 8-second grace period after system resume before sending commands
- 3-second cooldown after timeout to allow monitor recovery
- Global semaphore to prevent command collisions

Replace old icon with new generic monitor icon (ico and png formats).
This commit is contained in:
2026-01-29 18:14:58 -05:00
parent 3c6cc15281
commit 404e6f42da
24 changed files with 262 additions and 53 deletions

View File

@@ -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;
/// <summary>
/// <summary>
/// Control My Monitor Management Command
/// </summary>
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<string, DateTime> _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<string, DateTime> _timeoutCooldowns = new();
private const int TimeoutCooldownMs = 3000;
/// <summary>
/// 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.
/// </summary>
public static void NotifySystemResumed()
{
_resumeTime = DateTime.UtcNow;
}
/// <summary>
/// Waits for the post-resume grace period to expire, respects timeout cooldowns,
/// and enforces per-monitor command spacing.
/// </summary>
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();
}
}
/// <summary>
/// Records that a command to this monitor timed out, activating a cooldown period.
/// </summary>
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<string> 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<int?> 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<int?> 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<int?> GetInputSource(string monitorSN)
@@ -110,7 +199,9 @@ public static class CMMCommand
var options = new List<InputSourceOption>();
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<XMonitor> 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<IEnumerable<SMonitorModel>>(savePath);
var status = monitorModel.ReadMonitorStatus();