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).
377 lines
13 KiB
C#
377 lines
13 KiB
C#
using CMM.Library.Base;
|
|
using CMM.Library.Helpers;
|
|
using CMM.Library.ViewModel;
|
|
using System.Collections.Concurrent;
|
|
using System.IO;
|
|
|
|
namespace CMM.Library.Method;
|
|
|
|
/// <summary>
|
|
/// Control My Monitor Management Command
|
|
/// </summary>
|
|
public static class CMMCommand
|
|
{
|
|
static readonly string CMMTmpFolder = Path.Combine(Path.GetTempPath(), "CMM");
|
|
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
|
|
Directory.CreateDirectory(CMMTmpFolder);
|
|
await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/smonitors {CMMsMonitors}");
|
|
}
|
|
|
|
public static async Task PowerOn(string monitorSN)
|
|
{
|
|
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 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 — 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
|
|
if (exitCode > 0)
|
|
return exitCode.ToString();
|
|
|
|
// Also check stdout in case it outputs there
|
|
var value = output?.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault()?.Trim();
|
|
if (!string.IsNullOrEmpty(value) && value != "0")
|
|
return value;
|
|
|
|
// Only retry on failure
|
|
if (attempt < maxRetries)
|
|
await Task.Delay(300);
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
#region Brightness (VCP Code 10)
|
|
|
|
public static async Task SetBrightness(string monitorSN, int 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)
|
|
{
|
|
var value = await GetMonitorValue(monitorSN, "10");
|
|
return int.TryParse(value, out var result) ? result : null;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Contrast (VCP Code 12)
|
|
|
|
public static async Task SetContrast(string monitorSN, int 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)
|
|
{
|
|
var value = await GetMonitorValue(monitorSN, "12");
|
|
return int.TryParse(value, out var result) ? result : null;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Input Source (VCP Code 60)
|
|
|
|
public static async Task SetInputSource(string monitorSN, int 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)
|
|
{
|
|
var value = await GetMonitorValue(monitorSN, "60");
|
|
return int.TryParse(value, out var result) ? result : null;
|
|
}
|
|
|
|
public static async Task<List<InputSourceOption>> GetInputSourceOptions(string monitorSN)
|
|
{
|
|
var options = new List<InputSourceOption>();
|
|
var savePath = Path.Combine(CMMTmpFolder, $"{monitorSN}_vcp.tmp");
|
|
|
|
await ThrottleDdcCommand(monitorSN);
|
|
var (_, exitCode) = await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/sjson {savePath} {monitorSN}");
|
|
if (exitCode == -1) RecordTimeout(monitorSN);
|
|
|
|
if (!File.Exists(savePath)) return options;
|
|
|
|
var monitorModel = JsonHelper.JsonFormFile<IEnumerable<SMonitorModel>>(savePath);
|
|
var inputSourceVcp = monitorModel?.FirstOrDefault(m => m.VCPCode == "60");
|
|
|
|
if (inputSourceVcp?.PossibleValues != null)
|
|
{
|
|
var possibleValues = inputSourceVcp.PossibleValues
|
|
.Split(",", StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(v => int.TryParse(v.Trim(), out var val) ? val : (int?)null)
|
|
.Where(v => v.HasValue)
|
|
.Select(v => v.Value);
|
|
|
|
foreach (var value in possibleValues)
|
|
{
|
|
options.Add(new InputSourceOption(value, GetInputSourceName(value)));
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
public static string GetInputSourceName(int vcpValue)
|
|
{
|
|
return vcpValue switch
|
|
{
|
|
1 => "VGA-1",
|
|
2 => "VGA-2",
|
|
3 => "DVI-1",
|
|
4 => "DVI-2",
|
|
5 => "Composite-1",
|
|
6 => "Composite-2",
|
|
7 => "S-Video-1",
|
|
8 => "S-Video-2",
|
|
9 => "Tuner-1",
|
|
10 => "Tuner-2",
|
|
11 => "Tuner-3",
|
|
12 => "Component-1",
|
|
13 => "Component-2",
|
|
14 => "Component-3",
|
|
15 => "DisplayPort-1",
|
|
16 => "DisplayPort-2",
|
|
17 => "HDMI-1",
|
|
18 => "HDMI-2",
|
|
_ => $"Input-{vcpValue}"
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
|
|
public static async Task<string> GetMonPowerStatus(string monitorSN)
|
|
{
|
|
var status = await GetMonitorValue(monitorSN);
|
|
|
|
return status switch
|
|
{
|
|
"1" => "PowerOn",
|
|
"4" => "Sleep",
|
|
"5" => "PowerOff",
|
|
_ => string.Empty
|
|
};
|
|
}
|
|
|
|
public static async Task ScanMonitorStatus(IEnumerable<XMonitor> monitors)
|
|
{
|
|
// Sequential scan to avoid overwhelming DDC/CI bus with parallel commands
|
|
foreach (var x in monitors)
|
|
{
|
|
await ScanMonitorStatus($"{CMMTmpFolder}\\{x.SerialNumber}.tmp", x);
|
|
}
|
|
}
|
|
|
|
static async Task ScanMonitorStatus(string savePath, XMonitor mon)
|
|
{
|
|
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();
|
|
|
|
mon.Status = new ObservableRangeCollection<XMonitorStatus>(status);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 取得螢幕狀態
|
|
/// </summary>
|
|
public static IEnumerable<XMonitorStatus> ReadMonitorStatus(this IEnumerable<SMonitorModel> monitorModel)
|
|
{
|
|
foreach (var m in monitorModel)
|
|
{
|
|
yield return new XMonitorStatus
|
|
{
|
|
VCP_Code = m.VCPCode,
|
|
VCPCodeName = m.VCPCodeName,
|
|
Read_Write = m.ReadWrite,
|
|
CurrentValue = TryGetInt(m.CurrentValue),
|
|
MaximumValue = TryGetInt(m.MaximumValue),
|
|
PossibleValues = TryGetArrStr(m.PossibleValues),
|
|
};
|
|
}
|
|
|
|
IEnumerable<int> TryGetArrStr(string str)
|
|
{
|
|
return str.Split(",", StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(x => TryGetInt(x))
|
|
.Where(x => x != null)
|
|
.Select(x => (int)x)
|
|
.ToList();
|
|
}
|
|
|
|
int? TryGetInt(string str)
|
|
{
|
|
return int.TryParse(str, out var value)
|
|
? value
|
|
: null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 取得螢幕清單
|
|
/// </summary>
|
|
public static async Task<IEnumerable<XMonitor>> ReadMonitorsData()
|
|
{
|
|
var monitors = new List<XMonitor>();
|
|
|
|
if (!File.Exists(CMMsMonitors)) return monitors;
|
|
|
|
// Read with UTF-16 LE encoding (ControlMyMonitor outputs UTF-16)
|
|
var rawBytes = await File.ReadAllBytesAsync(CMMsMonitors);
|
|
var content = System.Text.Encoding.Unicode.GetString(rawBytes);
|
|
|
|
XMonitor? mon = null;
|
|
foreach (var line in content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var colonIdx = line.IndexOf(':');
|
|
if (colonIdx < 0) continue;
|
|
|
|
var key = line.Substring(0, colonIdx).Trim();
|
|
var val = line.Substring(colonIdx + 1).Trim().Trim('"');
|
|
|
|
if (key.Contains("Monitor Device Name"))
|
|
{
|
|
mon = new XMonitor { MonitorDeviceName = val };
|
|
}
|
|
else if (mon != null && key.Contains("Monitor Name"))
|
|
{
|
|
mon.MonitorName = val;
|
|
}
|
|
else if (mon != null && key.Contains("Serial Number"))
|
|
{
|
|
mon.SerialNumber = val;
|
|
}
|
|
else if (mon != null && key.Contains("Adapter Name"))
|
|
{
|
|
mon.AdapterName = val;
|
|
}
|
|
else if (mon != null && key.Contains("Monitor ID"))
|
|
{
|
|
mon.MonitorID = val;
|
|
if (!string.IsNullOrEmpty(mon.SerialNumber))
|
|
monitors.Add(mon);
|
|
mon = null;
|
|
}
|
|
}
|
|
|
|
return monitors;
|
|
}
|
|
|
|
}
|