Files
Substation/MainForm.cs
T

891 lines
27 KiB
C#
Raw Normal View History

using System.ComponentModel;
using System.Drawing.Drawing2D;
2026-08-08 19:20:46 +00:00
using NAudio.CoreAudioApi;
namespace Substation;
public class MainForm : Form
{
readonly CoyoteDevice _device;
readonly State _state;
readonly Server _server;
readonly NotifyIcon _tray;
readonly Label _lblBle;
readonly Label _lblWs;
readonly Label _lblDeviceName;
readonly TextBox _txtDeviceName;
readonly Button _btnConnect;
readonly Button _btnStop;
readonly System.Windows.Forms.Timer _statusTimer;
readonly CancellationTokenSource _loopCts;
2026-08-08 11:26:44 +00:00
readonly HeatMap _heatA;
readonly HeatMap _heatB;
readonly ProgressBar _gaugeRecvA;
readonly ProgressBar _gaugeRecvB;
readonly Label _lblSendA;
readonly Label _lblSendB;
readonly Label _lblRecvA;
readonly Label _lblRecvB;
2026-08-08 11:26:44 +00:00
readonly TrackBar _limitBarA;
readonly TrackBar _limitBarB;
readonly TrackBar _ampBarA;
readonly TrackBar _ampBarB;
2026-08-08 11:26:44 +00:00
readonly Label _lblLimitA;
readonly Label _lblLimitB;
readonly Label _lblLimitValA;
readonly Label _lblLimitValB;
readonly Label _lblAmpA;
readonly Label _lblAmpB;
readonly Label _lblAmpValA;
readonly Label _lblAmpValB;
2026-08-08 11:26:44 +00:00
readonly CheckBox _chkScaleA;
readonly CheckBox _chkScaleB;
readonly CheckBox _chkSwap;
readonly Icon _iconNeutral;
readonly Icon _iconActive;
readonly Icon _iconHot;
string _trayState = "";
2026-08-08 19:20:46 +00:00
readonly Button _btnLive;
2026-08-08 19:44:25 +00:00
readonly Button _btnPattern;
readonly Button _btnRandom;
readonly Label _lblTrack;
2026-08-08 19:20:46 +00:00
readonly Label _lblAudioDev;
readonly ComboBox _cboAudioDev;
bool _isLiveRunning;
2026-08-08 19:44:25 +00:00
bool _isPatternRunning;
bool _isRandomRunning;
2026-08-08 19:20:46 +00:00
LiveCapture? _liveCapture;
public MainForm(CoyoteDevice device, State state, Server server)
{
_device = device;
_state = state;
_server = server;
_loopCts = new CancellationTokenSource();
Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}";
ClientSize = new Size(420, 520);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
ShowInTaskbar = true;
_lblBle = new Label
{
Text = "BLE: searching...",
Location = new Point(16, 16),
Size = new Size(388, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_lblWs = new Label
{
Text = "WS: idle",
Location = new Point(16, 40),
Size = new Size(388, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_lblDeviceName = new Label
{
Text = "Device:",
Location = new Point(16, 66),
Size = new Size(48, 20)
};
_txtDeviceName = new TextBox
{
Text = "47L121000",
Location = new Point(68, 64),
Size = new Size(160, 20)
};
_btnConnect = new Button
{
Text = "Connect",
Location = new Point(236, 64),
Size = new Size(80, 24)
};
_btnConnect.Click += OnConnect;
_chkSwap = new CheckBox
{
Text = "Swap",
Location = new Point(328, 66),
Size = new Size(56, 20),
Checked = false
};
_chkSwap.CheckedChanged += OnSwapChanged;
2026-08-08 19:20:46 +00:00
_btnLive = new Button
{
Text = "Live",
Location = new Point(16, 96),
Size = new Size(80, 32)
2026-08-08 19:20:46 +00:00
};
_btnLive.Click += OnLive;
2026-08-08 19:44:25 +00:00
_btnPattern = new Button
{
Text = "Pattern",
Location = new Point(104, 96),
Size = new Size(80, 32)
2026-08-08 19:44:25 +00:00
};
_btnPattern.Click += OnPattern;
_btnRandom = new Button
{
Text = "Random",
Location = new Point(192, 96),
Size = new Size(80, 32)
};
_btnRandom.Click += OnRandom;
_btnStop = new Button
{
Text = "Stop All",
Location = new Point(324, 96),
Size = new Size(80, 32)
};
_btnStop.Click += OnStop;
2026-08-08 19:20:46 +00:00
_lblAudioDev = new Label
{
Text = "Audio:",
Location = new Point(16, 134),
Size = new Size(40, 20)
};
_cboAudioDev = new ComboBox
{
DropDownStyle = ComboBoxStyle.DropDownList,
Location = new Point(60, 132),
Size = new Size(344, 22)
};
PopulateAudioDevices();
_lblTrack = new Label
{
Text = "",
2026-08-08 19:20:46 +00:00
Location = new Point(16, 158),
Size = new Size(388, 16),
ForeColor = Color.DimGray
};
_lblSendA = new Label
{
Text = "Send A",
2026-08-08 19:20:46 +00:00
Location = new Point(16, 184),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
2026-08-08 11:26:44 +00:00
_heatA = new HeatMap(60)
{
2026-08-08 19:20:46 +00:00
Location = new Point(72, 182),
2026-08-08 11:26:44 +00:00
Size = new Size(332, 22)
};
_lblSendB = new Label
{
Text = "Send B",
2026-08-08 19:20:46 +00:00
Location = new Point(16, 212),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
2026-08-08 11:26:44 +00:00
_heatB = new HeatMap(60)
{
2026-08-08 19:20:46 +00:00
Location = new Point(72, 210),
2026-08-08 11:26:44 +00:00
Size = new Size(332, 22)
};
_lblRecvA = new Label
{
Text = "Recv A",
2026-08-08 19:20:46 +00:00
Location = new Point(16, 240),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_gaugeRecvA = new ProgressBar
{
2026-08-08 19:20:46 +00:00
Location = new Point(72, 240),
Size = new Size(332, 20),
Minimum = 0,
Maximum = 200,
Style = ProgressBarStyle.Continuous
};
_lblRecvB = new Label
{
Text = "Recv B",
2026-08-08 19:20:46 +00:00
Location = new Point(16, 266),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_gaugeRecvB = new ProgressBar
{
2026-08-08 19:20:46 +00:00
Location = new Point(72, 266),
Size = new Size(332, 20),
Minimum = 0,
Maximum = 200,
Style = ProgressBarStyle.Continuous
};
2026-08-08 11:26:44 +00:00
_lblLimitA = new Label
{
Text = "Lim A",
2026-08-08 19:20:46 +00:00
Location = new Point(16, 298),
Size = new Size(40, 20),
2026-08-08 11:26:44 +00:00
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_limitBarA = new TrackBar
{
2026-08-08 19:20:46 +00:00
Location = new Point(60, 294),
Size = new Size(220, 45),
2026-08-08 11:26:44 +00:00
Minimum = 0,
Maximum = 200,
TickFrequency = 50,
Value = 30
};
_limitBarA.ValueChanged += OnLimitChanged;
FixTrackbarKeys(_limitBarA);
_lblLimitValA = new Label
{
Text = "30",
2026-08-08 19:20:46 +00:00
Location = new Point(286, 298),
Size = new Size(32, 20),
TextAlign = ContentAlignment.MiddleRight
};
2026-08-08 11:26:44 +00:00
_chkScaleA = new CheckBox
{
Text = "Scale",
2026-08-08 19:20:46 +00:00
Location = new Point(320, 298),
2026-08-08 11:26:44 +00:00
Size = new Size(76, 24),
Checked = false
};
_chkScaleA.CheckedChanged += OnLimitChanged;
_lblLimitB = new Label
{
Text = "Lim B",
2026-08-08 19:20:46 +00:00
Location = new Point(16, 346),
Size = new Size(40, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
2026-08-08 11:26:44 +00:00
_limitBarB = new TrackBar
{
2026-08-08 19:20:46 +00:00
Location = new Point(60, 342),
Size = new Size(220, 45),
Minimum = 0,
Maximum = 200,
TickFrequency = 50,
Value = 30
};
2026-08-08 11:26:44 +00:00
_limitBarB.ValueChanged += OnLimitChanged;
FixTrackbarKeys(_limitBarB);
_lblLimitValB = new Label
{
Text = "30",
2026-08-08 19:20:46 +00:00
Location = new Point(286, 346),
Size = new Size(32, 20),
TextAlign = ContentAlignment.MiddleRight
};
2026-08-08 11:26:44 +00:00
_chkScaleB = new CheckBox
{
Text = "Scale",
2026-08-08 19:20:46 +00:00
Location = new Point(320, 346),
Size = new Size(76, 24),
Checked = false
};
2026-08-08 11:26:44 +00:00
_chkScaleB.CheckedChanged += OnLimitChanged;
OnLimitChanged(null, EventArgs.Empty);
_lblAmpA = new Label
{
Text = "Amp A",
Location = new Point(16, 394),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_ampBarA = new TrackBar
{
Location = new Point(68, 390),
Size = new Size(212, 45),
Minimum = 0,
Maximum = 100,
TickFrequency = 25,
Value = 0
};
_ampBarA.ValueChanged += OnAmpChanged;
FixTrackbarKeys(_ampBarA);
_lblAmpValA = new Label
{
Text = "0",
Location = new Point(286, 394),
Size = new Size(32, 20),
TextAlign = ContentAlignment.MiddleRight
};
_lblAmpB = new Label
{
Text = "Amp B",
Location = new Point(16, 442),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_ampBarB = new TrackBar
{
Location = new Point(68, 438),
Size = new Size(212, 45),
Minimum = 0,
Maximum = 100,
TickFrequency = 25,
Value = 0
};
_ampBarB.ValueChanged += OnAmpChanged;
FixTrackbarKeys(_ampBarB);
_lblAmpValB = new Label
{
Text = "0",
Location = new Point(286, 442),
Size = new Size(32, 20),
TextAlign = ContentAlignment.MiddleRight
};
OnAmpChanged(null, EventArgs.Empty);
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnLive, _btnPattern, _btnRandom, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB, _lblAmpA, _ampBarA, _lblAmpValA, _lblAmpB, _ampBarB, _lblAmpValB });
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
_iconNeutral = CreateVoltageIcon(Color.Gray);
_iconActive = CreateVoltageIcon(Color.Gold);
_iconHot = CreateVoltageIcon(Color.OrangeRed);
Icon = _iconActive;
_tray = new NotifyIcon
{
Icon = _iconActive,
Visible = true,
Text = "Substation"
};
_tray.ContextMenuStrip = new ContextMenuStrip();
_tray.ContextMenuStrip.Items.Add("Show", null, (_, _) => ShowFromTray());
_tray.ContextMenuStrip.Items.Add("-");
_tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) => ExitFromTray());
_tray.DoubleClick += (_, _) => ShowFromTray();
_statusTimer = new System.Windows.Forms.Timer { Interval = 1000 };
_statusTimer.Tick += OnStatusTick;
_statusTimer.Start();
Load += OnLoad;
FormClosing += OnFormClosing;
Resize += OnResize;
_server.ClientChanged += OnWsClientChanged;
_device.ConnectionChanged += OnDeviceConnectionChanged;
_device.StrengthChanged += OnDeviceStrengthChanged;
}
async void OnLoad(object? sender, EventArgs e)
{
_ = _server.RunAsync(CancellationToken.None);
_ = Task.Run(() => TickLoop(_loopCts.Token));
try
{
await _device.ConnectAsync(_txtDeviceName.Text);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[BLE] auto-connect failed: {ex.Message}");
}
}
async Task TickLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick();
if (_device.IsConnected)
{
var frame = B0.Build(0, modeA, modeB, valA, valB, freqA, intA, freqB, intB);
await _device.SendB0(frame);
}
if (!IsDisposed)
{
BeginInvoke(() =>
{
double aScale = _state.LimitScaleA;
double bScale = _state.LimitScaleB;
var scaledIntA = new byte[4];
var scaledIntB = new byte[4];
for (int i = 0; i < 4; i++)
{
scaledIntA[i] = (byte)Math.Round(intA[i] * aScale);
scaledIntB[i] = (byte)Math.Round(intB[i] * bScale);
}
_heatA.AddTick(freqA, scaledIntA, _state.LastActiveA);
_heatB.AddTick(freqB, scaledIntB, _state.LastActiveB);
});
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[tick] error: {ex.Message}");
}
try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; }
}
}
void ShowFromTray()
{
Show();
WindowState = FormWindowState.Normal;
Activate();
}
void ExitFromTray()
{
_tray.Visible = false;
Application.Exit();
}
void OnResize(object? sender, EventArgs e)
{
if (WindowState == FormWindowState.Minimized)
Hide();
}
void OnFormClosing(object? sender, FormClosingEventArgs e)
{
_tray.Visible = false;
_statusTimer.Stop();
_loopCts.Cancel();
_server.Stop();
}
void OnConnect(object? sender, EventArgs e)
{
if (_device.IsConnected) return;
_btnConnect.Enabled = false;
_lblBle.Text = "BLE: connecting...";
Task.Run(async () =>
{
try
{
await _device.ConnectAsync(_txtDeviceName.Text);
}
catch (Exception ex)
{
BeginInvoke(() => _lblBle.Text = $"BLE: error - {ex.Message}");
}
finally
{
BeginInvoke(() => _btnConnect.Enabled = true);
}
});
}
void OnWsClientChanged(bool connected)
{
if (IsDisposed) return;
BeginInvoke(() =>
{
_lblWs.Text = connected ? "WS: client connected" : "WS: idle";
RefreshButtonStates();
UpdateTrayIcon();
});
}
void OnDeviceConnectionChanged(bool connected)
{
if (IsDisposed) return;
BeginInvoke(() =>
{
_lblBle.Text = connected
? $"BLE: connected ({_device.DeviceName})"
: "BLE: disconnected";
_btnConnect.Enabled = !connected;
RefreshButtonStates();
});
}
void OnDeviceStrengthChanged(int a, int b)
{
if (IsDisposed) return;
BeginInvoke(() =>
{
if (_state.SwapChannels) (a, b) = (b, a);
_gaugeRecvA.Value = Math.Clamp(a, 0, 200);
_gaugeRecvB.Value = Math.Clamp(b, 0, 200);
});
}
void RefreshButtonStates()
{
bool busy = _isLiveRunning || _isPatternRunning || _isRandomRunning;
2026-08-08 19:44:25 +00:00
_btnLive.Enabled = !_server.HasClient && !busy;
_btnPattern.Enabled = !_server.HasClient && !busy;
_btnRandom.Enabled = !_server.HasClient && !busy;
_btnStop.Enabled = true;
}
void OnStatusTick(object? sender, EventArgs e)
{
if (IsDisposed) return;
if (_isLiveRunning)
2026-08-08 19:20:46 +00:00
{
_lblTrack.Text = "Live capture";
}
2026-08-08 19:44:25 +00:00
else if (_isPatternRunning)
{
_lblTrack.Text = $"Pattern: {_patternName}";
}
else if (_isRandomRunning)
{
_lblTrack.Text = "Random noise";
}
UpdateTrayIcon();
}
void OnStop(object? sender, EventArgs e)
{
2026-08-08 19:20:46 +00:00
StopLive();
2026-08-08 19:44:25 +00:00
StopPattern();
StopRandom();
_state.SetStrength('A', 0);
_state.SetStrength('B', 0);
_state.Stop('A');
_state.Stop('B');
}
void OnLimitChanged(object? sender, EventArgs e)
{
2026-08-08 11:26:44 +00:00
var maxA = _limitBarA.Value;
var maxB = _limitBarB.Value;
var modeA = _chkScaleA.Checked ? LimitMode.Scale : LimitMode.Clamp;
var modeB = _chkScaleB.Checked ? LimitMode.Scale : LimitMode.Clamp;
_state.SetLimitA(maxA, modeA);
_state.SetLimitB(maxB, modeB);
_lblLimitValA.Text = $"{maxA}";
_lblLimitValB.Text = $"{maxB}";
}
void OnAmpChanged(object? sender, EventArgs e)
{
var ampA = 1.0 + _ampBarA.Value / 100.0 * 9.0;
var ampB = 1.0 + _ampBarB.Value / 100.0 * 9.0;
_state.SetAmpA(ampA);
_state.SetAmpB(ampB);
_lblAmpValA.Text = $"{_ampBarA.Value}";
_lblAmpValB.Text = $"{_ampBarB.Value}";
}
void OnSwapChanged(object? sender, EventArgs e)
{
_state.SwapChannels = _chkSwap.Checked;
}
2026-08-08 19:20:46 +00:00
void PopulateAudioDevices()
{
using var enumerator = new MMDeviceEnumerator();
var defaultDev = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Console);
var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active);
_cboAudioDev.Items.Clear();
int defaultIdx = 0;
for (int i = 0; i < devices.Count; i++)
{
_cboAudioDev.Items.Add(devices[i]);
if (devices[i].ID == defaultDev.ID)
defaultIdx = i;
}
_cboAudioDev.SelectedIndex = defaultIdx;
}
void OnLive(object? sender, EventArgs e)
{
if (_isLiveRunning || _server.HasClient) return;
if (_cboAudioDev.SelectedItem is not MMDevice dev) return;
_isLiveRunning = true;
_btnLive.Enabled = false;
_lblTrack.Text = "Starting live capture...";
_state.SetStrength('A', 60);
_state.SetStrength('B', 60);
_state.Stop('A');
_state.Stop('B');
_liveCapture = new LiveCapture(_state, dev);
_liveCapture.Stopped += () => BeginInvoke(StopLive);
_liveCapture.Start();
_lblTrack.Text = "Live capture";
RefreshButtonStates();
}
void StopLive()
{
if (_liveCapture != null)
{
try { _liveCapture.Stop(); } catch { }
try { _liveCapture.Dispose(); } catch { }
_liveCapture = null;
}
if (_isLiveRunning)
{
_isLiveRunning = false;
if (!IsDisposed)
{
_lblTrack.Text = "";
RefreshButtonStates();
}
}
}
2026-08-08 19:44:25 +00:00
string _patternName = "";
CancellationTokenSource? _patternCts;
void OnPattern(object? sender, EventArgs e)
{
if (_isPatternRunning || _server.HasClient) return;
var url = ShowInputDialog("Paste xToys pattern URL:", "Pattern Import");
if (string.IsNullOrWhiteSpace(url)) return;
_isPatternRunning = true;
_btnPattern.Enabled = false;
_lblTrack.Text = "Loading pattern...";
_patternCts = new CancellationTokenSource();
Task.Run(() => RunPatternAsync(url, _patternCts.Token));
}
async Task RunPatternAsync(string url, CancellationToken ct)
{
XToysPattern? pattern = null;
try
{
pattern = await XToysPatternImporter.ImportAsync(url);
}
catch (Exception ex)
{
BeginInvoke(() =>
{
_lblTrack.Text = $"Pattern failed: {ex.Message?.Split('\n')[0]}";
_isPatternRunning = false;
RefreshButtonStates();
});
return;
}
_patternName = pattern.Name;
_state.SetStrength('A', 60);
_state.SetStrength('B', 60);
_state.Stop('A');
_state.Stop('B');
BeginInvoke(() => _lblTrack.Text = $"Pattern: {_patternName}");
// Continuously enqueue pattern frames at 100ms per frame
int idx = 0;
while (!ct.IsCancellationRequested && _isPatternRunning)
{
var frameA = pattern.ChannelA[idx];
var frameB = pattern.ChannelB[idx];
_state.EnqueueStream('A', new[] { frameA });
_state.EnqueueStream('B', new[] { frameB });
idx = (idx + 1) % pattern.ChannelA.Count;
try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; }
}
}
void StopPattern()
{
_patternCts?.Cancel();
_patternCts = null;
if (_isPatternRunning)
{
_isPatternRunning = false;
if (!IsDisposed)
{
_lblTrack.Text = "";
RefreshButtonStates();
}
}
}
CancellationTokenSource? _randomCts;
void OnRandom(object? sender, EventArgs e)
{
if (_isRandomRunning || _server.HasClient) return;
_isRandomRunning = true;
_btnRandom.Enabled = false;
_lblTrack.Text = "Random noise";
_state.SetStrength('A', 60);
_state.SetStrength('B', 60);
_state.Stop('A');
_state.Stop('B');
_randomCts = new CancellationTokenSource();
Task.Run(() => RunRandomAsync(_randomCts.Token));
}
async Task RunRandomAsync(CancellationToken ct)
{
// Pink noise via Voss-McCartney algorithm (1/f spectrum)
// Separate state per channel, B inverted for counterphase
var rng = new Random();
int octaves = 8;
var rowsA = new double[octaves];
var rowsB = new double[octaves];
double pinkA = 0, pinkB = 0;
while (!ct.IsCancellationRequested && _isRandomRunning)
{
// Channel A
int updateA = rng.Next(octaves);
rowsA[updateA] = rng.NextDouble() * 2 - 1;
pinkA = 0;
for (int i = 0; i < octaves; i++) pinkA += rowsA[i];
pinkA /= octaves;
// Channel B (independent state, inverted for counterphase)
int updateB = rng.Next(octaves);
rowsB[updateB] = rng.NextDouble() * 2 - 1;
pinkB = 0;
for (int i = 0; i < octaves; i++) pinkB += rowsB[i];
pinkB /= octaves;
pinkB = -pinkB; // counterphase
// Map pink noise (-1..1) to intensity (0..80, capped for comfort)
int intA = (int)Math.Clamp((pinkA + 1) * 40, 0, 80);
int intB = (int)Math.Clamp((pinkB + 1) * 40, 0, 80);
// Random frequency: map pink noise to period (10-1000ms)
int freqMsA = (int)Math.Clamp((pinkA + 1) * 500, 10, 1000);
int freqMsB = (int)Math.Clamp((pinkB + 1) * 500, 10, 1000);
var frameA = new WaveFrame(
Freq.Compress4(new[] { freqMsA, freqMsA, freqMsA, freqMsA }),
Intensity.Clamp4(new[] { intA, intA, intA, intA }));
var frameB = new WaveFrame(
Freq.Compress4(new[] { freqMsB, freqMsB, freqMsB, freqMsB }),
Intensity.Clamp4(new[] { intB, intB, intB, intB }));
_state.EnqueueStream('A', new[] { frameA });
_state.EnqueueStream('B', new[] { frameB });
try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; }
}
}
void StopRandom()
{
_randomCts?.Cancel();
_randomCts = null;
if (_isRandomRunning)
{
_isRandomRunning = false;
if (!IsDisposed)
{
_lblTrack.Text = "";
RefreshButtonStates();
}
}
}
2026-08-08 19:44:25 +00:00
string ShowInputDialog(string prompt, string title)
{
using var dlg = new Form
{
Text = title,
FormBorderStyle = FormBorderStyle.FixedDialog,
ClientSize = new Size(380, 100),
StartPosition = FormStartPosition.CenterParent,
MaximizeBox = false,
MinimizeBox = false
};
var lbl = new Label { Text = prompt, Location = new Point(12, 12), Size = new Size(356, 20) };
var txt = new TextBox { Location = new Point(12, 36), Size = new Size(356, 22) };
var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, Location = new Point(200, 66), Size = new Size(80, 24) };
var cancel = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Location = new Point(288, 66), Size = new Size(80, 24) };
dlg.Controls.AddRange(new Control[] { lbl, txt, ok, cancel });
dlg.AcceptButton = ok;
dlg.CancelButton = cancel;
return dlg.ShowDialog(this) == DialogResult.OK ? txt.Text.Trim() : "";
}
void UpdateTrayIcon()
{
var newState = _state.IsSignaling ? "hot"
: _server.HasClient ? "active"
: "neutral";
if (newState == _trayState) return;
_trayState = newState;
_tray.Icon = newState switch
{
"hot" => _iconHot,
"active" => _iconActive,
_ => _iconNeutral
};
}
static void FixTrackbarKeys(TrackBar tb)
{
tb.KeyDown += (s, e) =>
{
if (s is not TrackBar t) return;
int step = e.KeyCode == Keys.Up || e.KeyCode == Keys.Down ? 1
: e.KeyCode == Keys.PageUp || e.KeyCode == Keys.PageDown ? 10
: 0;
if (step == 0) return;
if (e.KeyCode is Keys.Up or Keys.PageUp or Keys.Home)
t.Value = Math.Min(t.Maximum, t.Value + (e.KeyCode == Keys.Home ? t.Maximum : step));
else
t.Value = Math.Max(t.Minimum, t.Value - (e.KeyCode == Keys.End ? t.Maximum : step));
e.Handled = true;
};
}
static Icon CreateVoltageIcon(Color boltColor)
{
const int sz = 32;
using var bmp = new Bitmap(sz, sz);
using var g = Graphics.FromImage(bmp);
g.SmoothingMode = SmoothingMode.AntiAlias;
// Dark panel background with rounded corners.
using var bg = new SolidBrush(Color.FromArgb(45, 45, 48));
using var path = new GraphicsPath();
int r = 6, pad = 1;
var rect = new Rectangle(pad, pad, sz - 2 * pad, sz - 2 * pad);
path.AddArc(rect.X, rect.Y, r, r, 180, 90);
path.AddArc(rect.Right - r, rect.Y, r, r, 270, 90);
path.AddArc(rect.Right - r, rect.Bottom - r, r, r, 0, 90);
path.AddArc(rect.X, rect.Bottom - r, r, r, 90, 90);
path.CloseFigure();
g.FillPath(bg, path);
// Generic voltage symbol: a bold lightning bolt in the given colour.
var pts = new Point[]
{
new(21, 4), new(10, 18), new(16, 18),
new(13, 28), new(24, 13), new(17, 13)
};
using var bolt = new SolidBrush(boltColor);
g.FillPolygon(bolt, pts);
return Icon.FromHandle(bmp.GetHicon());
}
}