2026-08-08 10:46:25 +00:00
|
|
|
using System.ComponentModel;
|
|
|
|
|
using System.Drawing.Drawing2D;
|
|
|
|
|
using System.Diagnostics;
|
2026-08-08 19:20:46 +00:00
|
|
|
using NAudio.CoreAudioApi;
|
2026-08-08 10:46:25 +00:00
|
|
|
using NAudio.Wave;
|
|
|
|
|
|
|
|
|
|
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 _btnTest;
|
|
|
|
|
readonly Button _btnStop;
|
|
|
|
|
readonly System.Windows.Forms.Timer _statusTimer;
|
|
|
|
|
readonly CancellationTokenSource _loopCts;
|
|
|
|
|
|
|
|
|
|
bool _closingFromTray;
|
|
|
|
|
|
2026-08-08 11:26:44 +00:00
|
|
|
readonly HeatMap _heatA;
|
|
|
|
|
readonly HeatMap _heatB;
|
2026-08-08 11:03:44 +00:00
|
|
|
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 Label _lblLimitA;
|
|
|
|
|
readonly Label _lblLimitB;
|
2026-08-08 11:58:37 +00:00
|
|
|
readonly Label _lblLimitValA;
|
|
|
|
|
readonly Label _lblLimitValB;
|
2026-08-08 11:26:44 +00:00
|
|
|
readonly CheckBox _chkScaleA;
|
|
|
|
|
readonly CheckBox _chkScaleB;
|
2026-08-08 11:58:37 +00:00
|
|
|
readonly CheckBox _chkSwap;
|
2026-08-08 10:46:25 +00:00
|
|
|
|
|
|
|
|
readonly Icon _iconNeutral;
|
|
|
|
|
readonly Icon _iconActive;
|
|
|
|
|
readonly Icon _iconHot;
|
|
|
|
|
string _trayState = "";
|
|
|
|
|
|
|
|
|
|
readonly Button _btnMusic;
|
2026-08-08 19:20:46 +00:00
|
|
|
readonly Button _btnLive;
|
2026-08-08 19:44:25 +00:00
|
|
|
readonly Button _btnPattern;
|
2026-08-08 10:46:25 +00:00
|
|
|
readonly Label _lblTrack;
|
2026-08-08 19:20:46 +00:00
|
|
|
readonly Label _lblAudioDev;
|
|
|
|
|
readonly ComboBox _cboAudioDev;
|
2026-08-08 10:46:25 +00:00
|
|
|
bool _isMusicRunning;
|
2026-08-08 19:20:46 +00:00
|
|
|
bool _isLiveRunning;
|
2026-08-08 19:44:25 +00:00
|
|
|
bool _isPatternRunning;
|
2026-08-08 10:46:25 +00:00
|
|
|
WaveOutEvent? _audioOut;
|
|
|
|
|
Stopwatch? _musicStopwatch;
|
2026-08-08 19:20:46 +00:00
|
|
|
LiveCapture? _liveCapture;
|
2026-08-08 10:46:25 +00:00
|
|
|
|
|
|
|
|
public MainForm(CoyoteDevice device, State state, Server server)
|
|
|
|
|
{
|
|
|
|
|
_device = device;
|
|
|
|
|
_state = state;
|
|
|
|
|
_server = server;
|
|
|
|
|
_loopCts = new CancellationTokenSource();
|
|
|
|
|
|
2026-08-08 11:07:15 +00:00
|
|
|
Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}";
|
2026-08-08 19:20:46 +00:00
|
|
|
ClientSize = new Size(420, 450);
|
2026-08-08 10:46:25 +00:00
|
|
|
FormBorderStyle = FormBorderStyle.FixedSingle;
|
|
|
|
|
MaximizeBox = false;
|
|
|
|
|
StartPosition = FormStartPosition.CenterScreen;
|
|
|
|
|
ShowInTaskbar = true;
|
|
|
|
|
|
|
|
|
|
_lblBle = new Label
|
|
|
|
|
{
|
|
|
|
|
Text = "BLE: searching...",
|
|
|
|
|
Location = new Point(16, 16),
|
2026-08-08 11:03:44 +00:00
|
|
|
Size = new Size(388, 20),
|
2026-08-08 10:46:25 +00:00
|
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_lblWs = new Label
|
|
|
|
|
{
|
|
|
|
|
Text = "WS: idle",
|
|
|
|
|
Location = new Point(16, 40),
|
2026-08-08 11:03:44 +00:00
|
|
|
Size = new Size(388, 20),
|
2026-08-08 10:46:25 +00:00
|
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_lblDeviceName = new Label
|
|
|
|
|
{
|
|
|
|
|
Text = "Device:",
|
2026-08-08 11:03:44 +00:00
|
|
|
Location = new Point(16, 66),
|
2026-08-08 10:46:25 +00:00
|
|
|
Size = new Size(48, 20)
|
|
|
|
|
};
|
|
|
|
|
_txtDeviceName = new TextBox
|
|
|
|
|
{
|
|
|
|
|
Text = "47L121000",
|
2026-08-08 11:03:44 +00:00
|
|
|
Location = new Point(68, 64),
|
2026-08-08 10:46:25 +00:00
|
|
|
Size = new Size(160, 20)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_btnConnect = new Button
|
|
|
|
|
{
|
|
|
|
|
Text = "Connect",
|
2026-08-08 11:58:37 +00:00
|
|
|
Location = new Point(236, 64),
|
|
|
|
|
Size = new Size(80, 24)
|
2026-08-08 10:46:25 +00:00
|
|
|
};
|
|
|
|
|
_btnConnect.Click += OnConnect;
|
|
|
|
|
|
2026-08-08 11:58:37 +00:00
|
|
|
_chkSwap = new CheckBox
|
|
|
|
|
{
|
|
|
|
|
Text = "Swap",
|
|
|
|
|
Location = new Point(328, 66),
|
|
|
|
|
Size = new Size(56, 20),
|
|
|
|
|
Checked = false
|
|
|
|
|
};
|
|
|
|
|
_chkSwap.CheckedChanged += OnSwapChanged;
|
|
|
|
|
|
2026-08-08 10:46:25 +00:00
|
|
|
_btnTest = new Button
|
|
|
|
|
{
|
|
|
|
|
Text = "Test",
|
2026-08-08 11:03:44 +00:00
|
|
|
Location = new Point(16, 96),
|
2026-08-08 19:44:25 +00:00
|
|
|
Size = new Size(68, 32)
|
2026-08-08 10:46:25 +00:00
|
|
|
};
|
|
|
|
|
_btnTest.Click += OnTest;
|
|
|
|
|
|
|
|
|
|
_btnMusic = new Button
|
|
|
|
|
{
|
|
|
|
|
Text = "Music",
|
2026-08-08 19:44:25 +00:00
|
|
|
Location = new Point(92, 96),
|
|
|
|
|
Size = new Size(68, 32)
|
2026-08-08 10:46:25 +00:00
|
|
|
};
|
|
|
|
|
_btnMusic.Click += OnMusic;
|
|
|
|
|
|
2026-08-08 19:20:46 +00:00
|
|
|
_btnLive = new Button
|
|
|
|
|
{
|
|
|
|
|
Text = "Live",
|
2026-08-08 19:44:25 +00:00
|
|
|
Location = new Point(168, 96),
|
|
|
|
|
Size = new Size(68, 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(244, 96),
|
|
|
|
|
Size = new Size(68, 32)
|
|
|
|
|
};
|
|
|
|
|
_btnPattern.Click += OnPattern;
|
|
|
|
|
|
2026-08-08 10:46:25 +00:00
|
|
|
_btnStop = new Button
|
|
|
|
|
{
|
|
|
|
|
Text = "Stop All",
|
2026-08-08 19:44:25 +00:00
|
|
|
Location = new Point(320, 96),
|
|
|
|
|
Size = new Size(76, 32)
|
2026-08-08 10:46:25 +00:00
|
|
|
};
|
|
|
|
|
_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();
|
|
|
|
|
|
2026-08-08 10:46:25 +00:00
|
|
|
_lblTrack = new Label
|
|
|
|
|
{
|
|
|
|
|
Text = "",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(16, 158),
|
2026-08-08 11:03:44 +00:00
|
|
|
Size = new Size(388, 16),
|
2026-08-08 10:46:25 +00:00
|
|
|
ForeColor = Color.DimGray
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-08 11:03:44 +00:00
|
|
|
_lblSendA = new Label
|
|
|
|
|
{
|
|
|
|
|
Text = "Send A",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(16, 184),
|
2026-08-08 11:03:44 +00:00
|
|
|
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 11:03:44 +00:00
|
|
|
{
|
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)
|
2026-08-08 11:03:44 +00:00
|
|
|
};
|
|
|
|
|
_lblSendB = new Label
|
|
|
|
|
{
|
|
|
|
|
Text = "Send B",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(16, 212),
|
2026-08-08 11:03:44 +00:00
|
|
|
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 11:03:44 +00:00
|
|
|
{
|
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)
|
2026-08-08 11:03:44 +00:00
|
|
|
};
|
|
|
|
|
_lblRecvA = new Label
|
2026-08-08 10:46:25 +00:00
|
|
|
{
|
2026-08-08 11:03:44 +00:00
|
|
|
Text = "Recv A",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(16, 240),
|
2026-08-08 11:03:44 +00:00
|
|
|
Size = new Size(48, 20),
|
2026-08-08 10:46:25 +00:00
|
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
|
|
|
};
|
2026-08-08 11:03:44 +00:00
|
|
|
_gaugeRecvA = new ProgressBar
|
2026-08-08 10:46:25 +00:00
|
|
|
{
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(72, 240),
|
2026-08-08 11:03:44 +00:00
|
|
|
Size = new Size(332, 20),
|
2026-08-08 10:46:25 +00:00
|
|
|
Minimum = 0,
|
|
|
|
|
Maximum = 200,
|
|
|
|
|
Style = ProgressBarStyle.Continuous
|
|
|
|
|
};
|
2026-08-08 11:03:44 +00:00
|
|
|
_lblRecvB = new Label
|
2026-08-08 10:46:25 +00:00
|
|
|
{
|
2026-08-08 11:03:44 +00:00
|
|
|
Text = "Recv B",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(16, 266),
|
2026-08-08 11:03:44 +00:00
|
|
|
Size = new Size(48, 20),
|
2026-08-08 10:46:25 +00:00
|
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
|
|
|
};
|
2026-08-08 11:03:44 +00:00
|
|
|
_gaugeRecvB = new ProgressBar
|
2026-08-08 10:46:25 +00:00
|
|
|
{
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(72, 266),
|
2026-08-08 11:03:44 +00:00
|
|
|
Size = new Size(332, 20),
|
2026-08-08 10:46:25 +00:00
|
|
|
Minimum = 0,
|
|
|
|
|
Maximum = 200,
|
|
|
|
|
Style = ProgressBarStyle.Continuous
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-08 11:26:44 +00:00
|
|
|
_lblLimitA = new Label
|
|
|
|
|
{
|
2026-08-08 11:58:37 +00:00
|
|
|
Text = "Lim A",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(16, 298),
|
2026-08-08 11:58:37 +00:00
|
|
|
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),
|
2026-08-08 11:58:37 +00:00
|
|
|
Size = new Size(220, 45),
|
2026-08-08 11:26:44 +00:00
|
|
|
Minimum = 0,
|
|
|
|
|
Maximum = 200,
|
|
|
|
|
TickFrequency = 50,
|
|
|
|
|
Value = 30
|
|
|
|
|
};
|
|
|
|
|
_limitBarA.ValueChanged += OnLimitChanged;
|
2026-08-08 11:58:37 +00:00
|
|
|
FixTrackbarKeys(_limitBarA);
|
|
|
|
|
_lblLimitValA = new Label
|
|
|
|
|
{
|
|
|
|
|
Text = "30",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(286, 298),
|
2026-08-08 11:58:37 +00:00
|
|
|
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
|
2026-08-08 10:46:25 +00:00
|
|
|
{
|
2026-08-08 11:58:37 +00:00
|
|
|
Text = "Lim B",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(16, 346),
|
2026-08-08 11:58:37 +00:00
|
|
|
Size = new Size(40, 20),
|
2026-08-08 10:46:25 +00:00
|
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
|
|
|
};
|
2026-08-08 11:26:44 +00:00
|
|
|
_limitBarB = new TrackBar
|
2026-08-08 10:46:25 +00:00
|
|
|
{
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(60, 342),
|
2026-08-08 11:58:37 +00:00
|
|
|
Size = new Size(220, 45),
|
2026-08-08 10:46:25 +00:00
|
|
|
Minimum = 0,
|
|
|
|
|
Maximum = 200,
|
|
|
|
|
TickFrequency = 50,
|
|
|
|
|
Value = 30
|
|
|
|
|
};
|
2026-08-08 11:26:44 +00:00
|
|
|
_limitBarB.ValueChanged += OnLimitChanged;
|
2026-08-08 11:58:37 +00:00
|
|
|
FixTrackbarKeys(_limitBarB);
|
|
|
|
|
_lblLimitValB = new Label
|
|
|
|
|
{
|
|
|
|
|
Text = "30",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(286, 346),
|
2026-08-08 11:58:37 +00:00
|
|
|
Size = new Size(32, 20),
|
|
|
|
|
TextAlign = ContentAlignment.MiddleRight
|
|
|
|
|
};
|
2026-08-08 11:26:44 +00:00
|
|
|
_chkScaleB = new CheckBox
|
2026-08-08 10:46:25 +00:00
|
|
|
{
|
|
|
|
|
Text = "Scale",
|
2026-08-08 19:20:46 +00:00
|
|
|
Location = new Point(320, 346),
|
2026-08-08 10:46:25 +00:00
|
|
|
Size = new Size(76, 24),
|
|
|
|
|
Checked = false
|
|
|
|
|
};
|
2026-08-08 11:26:44 +00:00
|
|
|
_chkScaleB.CheckedChanged += OnLimitChanged;
|
2026-08-08 10:55:30 +00:00
|
|
|
OnLimitChanged(null, EventArgs.Empty);
|
2026-08-08 10:46:25 +00:00
|
|
|
|
2026-08-08 19:44:25 +00:00
|
|
|
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnTest, _btnMusic, _btnLive, _btnPattern, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB });
|
2026-08-08 10:46:25 +00:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
2026-08-08 11:38:05 +00:00
|
|
|
_statusTimer = new System.Windows.Forms.Timer { Interval = 1000 };
|
2026-08-08 10:46:25 +00:00
|
|
|
_statusTimer.Tick += OnStatusTick;
|
|
|
|
|
_statusTimer.Start();
|
|
|
|
|
|
|
|
|
|
Load += OnLoad;
|
|
|
|
|
FormClosing += OnFormClosing;
|
|
|
|
|
Resize += OnResize;
|
|
|
|
|
|
|
|
|
|
_server.ClientChanged += OnWsClientChanged;
|
2026-08-08 11:38:05 +00:00
|
|
|
_device.ConnectionChanged += OnDeviceConnectionChanged;
|
|
|
|
|
_device.StrengthChanged += OnDeviceStrengthChanged;
|
2026-08-08 10:46:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
{
|
2026-08-08 11:03:44 +00:00
|
|
|
var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick();
|
2026-08-08 10:46:25 +00:00
|
|
|
if (_device.IsConnected)
|
|
|
|
|
{
|
|
|
|
|
var frame = B0.Build(0, modeA, modeB, valA, valB, freqA, intA, freqB, intB);
|
|
|
|
|
await _device.SendB0(frame);
|
|
|
|
|
}
|
2026-08-08 11:38:05 +00:00
|
|
|
if (!IsDisposed)
|
|
|
|
|
{
|
|
|
|
|
BeginInvoke(() =>
|
|
|
|
|
{
|
|
|
|
|
_heatA.AddTick(freqA, intA, _state.LastActiveA);
|
|
|
|
|
_heatB.AddTick(freqB, intB, _state.LastActiveB);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-08-08 10:46:25 +00:00
|
|
|
}
|
|
|
|
|
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()
|
|
|
|
|
{
|
|
|
|
|
_closingFromTray = true;
|
|
|
|
|
_tray.Visible = false;
|
|
|
|
|
Application.Exit();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OnResize(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (WindowState == FormWindowState.Minimized)
|
|
|
|
|
Hide();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OnFormClosing(object? sender, FormClosingEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (e.CloseReason == CloseReason.UserClosing && !_closingFromTray)
|
|
|
|
|
{
|
|
|
|
|
e.Cancel = true;
|
|
|
|
|
Hide();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_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";
|
2026-08-08 11:38:05 +00:00
|
|
|
RefreshButtonStates();
|
2026-08-08 10:46:25 +00:00
|
|
|
UpdateTrayIcon();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 11:38:05 +00:00
|
|
|
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)
|
2026-08-08 10:46:25 +00:00
|
|
|
{
|
|
|
|
|
if (IsDisposed) return;
|
2026-08-08 11:38:05 +00:00
|
|
|
BeginInvoke(() =>
|
|
|
|
|
{
|
2026-08-08 11:58:37 +00:00
|
|
|
if (_state.SwapChannels) (a, b) = (b, a);
|
2026-08-08 11:38:05 +00:00
|
|
|
_gaugeRecvA.Value = Math.Clamp(a, 0, 200);
|
|
|
|
|
_gaugeRecvB.Value = Math.Clamp(b, 0, 200);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RefreshButtonStates()
|
|
|
|
|
{
|
2026-08-08 19:44:25 +00:00
|
|
|
bool busy = IsTestRunning || _isMusicRunning || _isLiveRunning || _isPatternRunning;
|
|
|
|
|
_btnTest.Enabled = !_server.HasClient && !busy;
|
|
|
|
|
_btnMusic.Enabled = !_server.HasClient && !busy;
|
|
|
|
|
_btnLive.Enabled = !_server.HasClient && !busy;
|
|
|
|
|
_btnPattern.Enabled = !_server.HasClient && !busy;
|
2026-08-08 10:46:25 +00:00
|
|
|
_btnStop.Enabled = true;
|
2026-08-08 11:38:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OnStatusTick(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (IsDisposed) return;
|
2026-08-08 10:46:25 +00:00
|
|
|
|
|
|
|
|
if (_isMusicRunning && _musicStopwatch != null)
|
|
|
|
|
{
|
|
|
|
|
var elapsed = _musicStopwatch.Elapsed;
|
|
|
|
|
_lblTrack.Text = $"Playing {elapsed:mm\\:ss} / {_musicTotalTime:mm\\:ss} {Path.GetFileName(_musicFilePath)}";
|
|
|
|
|
}
|
2026-08-08 19:20:46 +00:00
|
|
|
else if (_isLiveRunning)
|
|
|
|
|
{
|
|
|
|
|
_lblTrack.Text = "Live capture";
|
|
|
|
|
}
|
2026-08-08 19:44:25 +00:00
|
|
|
else if (_isPatternRunning)
|
|
|
|
|
{
|
|
|
|
|
_lblTrack.Text = $"Pattern: {_patternName}";
|
|
|
|
|
}
|
2026-08-08 10:46:25 +00:00
|
|
|
|
|
|
|
|
UpdateTrayIcon();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool IsTestRunning;
|
|
|
|
|
|
|
|
|
|
void OnTest(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (IsTestRunning || _server.HasClient) return;
|
|
|
|
|
IsTestRunning = true;
|
|
|
|
|
_btnTest.Enabled = false;
|
|
|
|
|
|
|
|
|
|
Task.Run(() => RunTestAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task RunTestAsync()
|
|
|
|
|
{
|
|
|
|
|
// Short feel-good pattern over both channels (~3s), gentle swell, A leads B.
|
|
|
|
|
const int testStrength = 25;
|
|
|
|
|
|
|
|
|
|
_state.SetStrength('A', testStrength);
|
|
|
|
|
_state.SetStrength('B', testStrength);
|
|
|
|
|
|
|
|
|
|
// 30 frames x 100ms = 3 seconds. Deep-ish 7Hz pulse with breathing envelope.
|
|
|
|
|
var framesA = new List<WaveFrame>();
|
|
|
|
|
var framesB = new List<WaveFrame>();
|
|
|
|
|
for (int i = 0; i < 30; i++)
|
|
|
|
|
{
|
|
|
|
|
double t = (double)i / 30;
|
|
|
|
|
// Swell 0 -> 70 -> 0 over the run
|
|
|
|
|
double env = Math.Sin(Math.PI * t) * 70;
|
|
|
|
|
int intA = (int)Math.Round(env);
|
|
|
|
|
int intB = (int)Math.Round(env * 0.7); // B slightly softer
|
|
|
|
|
|
|
|
|
|
int freqMs = 150; // ~7Hz deep
|
|
|
|
|
framesA.Add(new WaveFrame(
|
|
|
|
|
Freq.Compress4(new[] { freqMs, freqMs, freqMs, freqMs }),
|
|
|
|
|
Intensity.Clamp4(new[] { intA, intA, intA, intA })));
|
|
|
|
|
framesB.Add(new WaveFrame(
|
|
|
|
|
Freq.Compress4(new[] { freqMs, freqMs, freqMs, freqMs }),
|
|
|
|
|
Intensity.Clamp4(new[] { intB, intB, intB, intB })));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_state.Stop('A'); _state.Stop('B');
|
|
|
|
|
_state.EnqueueStream('A', framesA);
|
|
|
|
|
_state.EnqueueStream('B', framesB);
|
|
|
|
|
|
|
|
|
|
// Wait for the stream to be consumed (~3s) plus margin, then silence.
|
|
|
|
|
await Task.Delay(3300);
|
|
|
|
|
|
|
|
|
|
_state.SetStrength('A', 0);
|
|
|
|
|
_state.SetStrength('B', 0);
|
|
|
|
|
_state.Stop('A');
|
|
|
|
|
_state.Stop('B');
|
|
|
|
|
|
|
|
|
|
BeginInvoke(() =>
|
|
|
|
|
{
|
|
|
|
|
IsTestRunning = false;
|
2026-08-08 11:38:05 +00:00
|
|
|
RefreshButtonStates();
|
2026-08-08 10:46:25 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OnStop(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
StopMusic();
|
2026-08-08 19:20:46 +00:00
|
|
|
StopLive();
|
2026-08-08 19:44:25 +00:00
|
|
|
StopPattern();
|
2026-08-08 10:46:25 +00:00
|
|
|
_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);
|
2026-08-08 11:58:37 +00:00
|
|
|
_lblLimitValA.Text = $"{maxA}";
|
|
|
|
|
_lblLimitValB.Text = $"{maxB}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OnSwapChanged(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
_state.SwapChannels = _chkSwap.Checked;
|
2026-08-08 10:46:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
string _musicFilePath = "";
|
|
|
|
|
TimeSpan _musicTotalTime;
|
|
|
|
|
|
|
|
|
|
void OnMusic(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (_isMusicRunning || _server.HasClient) return;
|
|
|
|
|
|
|
|
|
|
using var dlg = new OpenFileDialog
|
|
|
|
|
{
|
|
|
|
|
Filter = "MP3 files (*.mp3)|*.mp3|All files (*.*)|*.*",
|
|
|
|
|
Title = "Select music to drive e-stim"
|
|
|
|
|
};
|
|
|
|
|
if (dlg.ShowDialog() != DialogResult.OK) return;
|
|
|
|
|
|
|
|
|
|
_musicFilePath = dlg.FileName;
|
|
|
|
|
_musicTotalTime = TimeSpan.Zero;
|
|
|
|
|
_isMusicRunning = true;
|
|
|
|
|
_btnMusic.Enabled = false;
|
|
|
|
|
_btnTest.Enabled = false;
|
|
|
|
|
_lblTrack.Text = $"Analyzing... {Path.GetFileName(_musicFilePath)}";
|
|
|
|
|
|
|
|
|
|
var filePath = _musicFilePath;
|
|
|
|
|
Task.Run(() => RunMusicAsync(filePath));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task RunMusicAsync(string filePath)
|
|
|
|
|
{
|
|
|
|
|
MusicPattern? pattern = null;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var progress = new Progress<int>(pct =>
|
|
|
|
|
{
|
|
|
|
|
if (!IsDisposed)
|
|
|
|
|
_lblTrack.Text = $"Analyzing... {pct}% {Path.GetFileName(filePath)}";
|
|
|
|
|
});
|
|
|
|
|
pattern = await Task.Run(() => MusicAnalyzer.Analyze(filePath, (IProgress<int>)progress));
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
BeginInvoke(() =>
|
|
|
|
|
{
|
|
|
|
|
_lblTrack.Text = $"Analysis failed: {ex.Message}";
|
|
|
|
|
_isMusicRunning = false;
|
2026-08-08 11:38:05 +00:00
|
|
|
RefreshButtonStates();
|
2026-08-08 10:46:25 +00:00
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_musicTotalTime = pattern.Duration;
|
|
|
|
|
|
|
|
|
|
// Set a moderate strength ceiling for music
|
|
|
|
|
_state.SetStrength('A', 60);
|
|
|
|
|
_state.SetStrength('B', 60);
|
|
|
|
|
|
|
|
|
|
// Clear any existing patterns and enqueue all music frames
|
|
|
|
|
_state.Stop('A');
|
|
|
|
|
_state.Stop('B');
|
|
|
|
|
_state.EnqueueStream('A', pattern.ChannelA);
|
|
|
|
|
_state.EnqueueStream('B', pattern.ChannelB);
|
|
|
|
|
|
|
|
|
|
// Start audio playback + stopwatch simultaneously
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var reader = new AudioFileReader(filePath);
|
|
|
|
|
_audioOut = new WaveOutEvent();
|
|
|
|
|
_audioOut.Init(reader);
|
|
|
|
|
_audioOut.PlaybackStopped += (_, _) => BeginInvoke(StopMusic);
|
|
|
|
|
_musicStopwatch = Stopwatch.StartNew();
|
|
|
|
|
_audioOut.Play();
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Console.Error.WriteLine($"[music] playback failed: {ex.Message}");
|
|
|
|
|
_musicStopwatch = Stopwatch.StartNew();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
BeginInvoke(() => _lblTrack.Text = $"Playing 00:00 / {_musicTotalTime:mm\\:ss} {Path.GetFileName(filePath)}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void StopMusic()
|
|
|
|
|
{
|
|
|
|
|
if (_audioOut != null)
|
|
|
|
|
{
|
|
|
|
|
try { _audioOut.Stop(); } catch { }
|
|
|
|
|
try { _audioOut.Dispose(); } catch { }
|
|
|
|
|
_audioOut = null;
|
|
|
|
|
}
|
|
|
|
|
_musicStopwatch = null;
|
|
|
|
|
|
|
|
|
|
if (_isMusicRunning)
|
|
|
|
|
{
|
|
|
|
|
_isMusicRunning = false;
|
|
|
|
|
if (!IsDisposed)
|
|
|
|
|
{
|
|
|
|
|
_lblTrack.Text = "";
|
2026-08-08 11:38:05 +00:00
|
|
|
RefreshButtonStates();
|
2026-08-08 10:46:25 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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() : "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 10:46:25 +00:00
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 11:58:37 +00:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 10:46:25 +00:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|