Compare commits

...

4 Commits

Author SHA1 Message Date
mute 33e289d596 Add per-channel independent limit controls
- Separate Lim A / Lim B trackbars (0-200, default 30 each)
- Separate Scale A / Scale B checkboxes (clamp vs scale per channel)
- State.SetLimitA/SetLimitB with independent LimitModeA/LimitModeB
- ConsumeTick applies per-channel limit and mode
2026-08-08 11:26:44 +00:00
mute 325022d27c Fix Send gauges showing intensity, zero when idle
- Send gauges now show waveform intensity (0-100) not strength ceiling
- State.LastIntensityA/B tracks average intensity per tick
- Zero when no pattern/stream active (was 25 due to IntensityOff 101 sentinel)
- Recv gauges unchanged (device-reported strength 0-200)
2026-08-08 11:18:01 +00:00
mute 84eba7d094 Bump version to 0.1.6, surface in form title 2026-08-08 11:07:15 +00:00
mute 612385af7f Add Send/Recv gauges, fix tick loop to consume without BLE
- Four gauges: Send A/B (commanded) and Recv A/B (device-reported)
- State.LastSentA/B tracks what would be sent after limiting
- ConsumeTick always runs; only SendB0 gated on IsConnected
- Stream queues drain correctly in dev mode (no box)
- Tray icon hot state works without BLE connected
- Removed strength text label, widened form to 420x360
2026-08-08 11:03:44 +00:00
4 changed files with 270 additions and 74 deletions
+109
View File
@@ -0,0 +1,109 @@
using System.Drawing.Drawing2D;
namespace Substation;
public class HeatMap : Panel
{
record Tick(byte[] Freq, byte[] Intensity);
readonly List<Tick> _history = new();
readonly int _maxTicks;
public HeatMap(int maxTicks = 60)
{
_maxTicks = maxTicks;
DoubleBuffered = true;
BackColor = Color.FromArgb(20, 20, 22);
}
public void AddTick(byte[] freq, byte[] intensity, bool active)
{
if (_history.Count >= _maxTicks)
_history.RemoveAt(0);
if (active)
_history.Add(new Tick(freq, intensity));
else
_history.Add(new Tick(Array.Empty<byte>(), Array.Empty<byte>()));
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.None;
int w = Width;
int h = Height;
int colW = Math.Max(1, w / _maxTicks);
// Draw axis labels
using var font = new Font(FontFamily.GenericMonospace, 7);
using var textBrush = new SolidBrush(Color.FromArgb(100, 100, 100));
g.DrawString("100Hz", font, textBrush, 2, 2);
g.DrawString("1Hz", font, textBrush, 2, h - 14);
int plotX = 36;
int plotW = w - plotX - 4;
int plotH = h - 4;
colW = Math.Max(1, plotW / _maxTicks);
for (int i = 0; i < _history.Count; i++)
{
var tick = _history[i];
int xPos = plotX + i * colW;
if (tick.Freq.Length == 0)
{
// Inactive — dark cell
using var dark = new SolidBrush(Color.FromArgb(25, 25, 28));
g.FillRectangle(dark, xPos, 2, colW, plotH);
continue;
}
// 4 sub-ticks side by side within the column
int subW = Math.Max(1, colW / 4);
for (int s = 0; s < 4; s++)
{
int freq = tick.Freq[s]; // 10-240
int inten = tick.Intensity[s]; // 0-100
if (inten > 100) inten = 0; // 101 sentinel = off
// Map freq byte (10-240) to Y position (top=high freq, bottom=low freq)
float normFreq = (freq - 10f) / (240f - 10f);
int y = (int)(2 + plotH * (1f - normFreq));
if (y < 2) y = 2;
if (y > plotH) y = plotH;
var color = HeatColor(inten);
using var brush = new SolidBrush(color);
g.FillRectangle(brush, xPos + s * subW, y, subW, 3);
}
}
}
static Color HeatColor(int intensity)
{
// 0=black, 30=blue, 60=green, 80=gold, 100=red
if (intensity <= 0) return Color.FromArgb(20, 20, 22);
if (intensity < 30)
{
float t = intensity / 30f;
return Color.FromArgb(0, (int)(50 * t), (int)(80 + 80 * t));
}
if (intensity < 60)
{
float t = (intensity - 30) / 30f;
return Color.FromArgb((int)(60 * t), (int)(130 + 80 * t), (int)(160 - 100 * t));
}
if (intensity < 80)
{
float t = (intensity - 60) / 20f;
return Color.FromArgb((int)(60 + 180 * t), 210, (int)(60 - 30 * t));
}
float t2 = (intensity - 80) / 20f;
return Color.FromArgb(255, (int)(240 - 140 * t2), (int)(30 - 10 * t2));
}
}
+121 -61
View File
@@ -14,7 +14,6 @@ public class MainForm : Form
readonly NotifyIcon _tray;
readonly Label _lblBle;
readonly Label _lblWs;
readonly Label _lblStrength;
readonly Label _lblDeviceName;
readonly TextBox _txtDeviceName;
readonly Button _btnConnect;
@@ -25,13 +24,20 @@ public class MainForm : Form
bool _closingFromTray;
readonly ProgressBar _gaugeA;
readonly ProgressBar _gaugeB;
readonly Label _lblGaugeA;
readonly Label _lblGaugeB;
readonly TrackBar _limitBar;
readonly Label _lblLimit;
readonly CheckBox _chkScale;
readonly HeatMap _heatA;
readonly HeatMap _heatB;
readonly ProgressBar _gaugeRecvA;
readonly ProgressBar _gaugeRecvB;
readonly Label _lblSendA;
readonly Label _lblSendB;
readonly Label _lblRecvA;
readonly Label _lblRecvB;
readonly TrackBar _limitBarA;
readonly TrackBar _limitBarB;
readonly Label _lblLimitA;
readonly Label _lblLimitB;
readonly CheckBox _chkScaleA;
readonly CheckBox _chkScaleB;
readonly Icon _iconNeutral;
readonly Icon _iconActive;
@@ -51,8 +57,8 @@ public class MainForm : Form
_server = server;
_loopCts = new CancellationTokenSource();
Text = "Substation";
ClientSize = new Size(360, 340);
Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}";
ClientSize = new Size(420, 410);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
@@ -62,7 +68,7 @@ public class MainForm : Form
{
Text = "BLE: searching...",
Location = new Point(16, 16),
Size = new Size(328, 20),
Size = new Size(388, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
@@ -70,34 +76,27 @@ public class MainForm : Form
{
Text = "WS: idle",
Location = new Point(16, 40),
Size = new Size(328, 20),
Size = new Size(388, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_lblStrength = new Label
{
Text = "Strength: A=0 B=0",
Location = new Point(16, 64),
Size = new Size(328, 20)
};
_lblDeviceName = new Label
{
Text = "Device:",
Location = new Point(16, 90),
Location = new Point(16, 66),
Size = new Size(48, 20)
};
_txtDeviceName = new TextBox
{
Text = "47L121000",
Location = new Point(68, 88),
Location = new Point(68, 64),
Size = new Size(160, 20)
};
_btnConnect = new Button
{
Text = "Connect",
Location = new Point(236, 88),
Location = new Point(296, 64),
Size = new Size(108, 24)
};
_btnConnect.Click += OnConnect;
@@ -105,7 +104,7 @@ public class MainForm : Form
_btnTest = new Button
{
Text = "Test",
Location = new Point(16, 120),
Location = new Point(16, 96),
Size = new Size(100, 32)
};
_btnTest.Click += OnTest;
@@ -113,7 +112,7 @@ public class MainForm : Form
_btnMusic = new Button
{
Text = "Music",
Location = new Point(130, 120),
Location = new Point(130, 96),
Size = new Size(100, 32)
};
_btnMusic.Click += OnMusic;
@@ -121,7 +120,7 @@ public class MainForm : Form
_btnStop = new Button
{
Text = "Stop All",
Location = new Point(244, 120),
Location = new Point(244, 96),
Size = new Size(100, 32)
};
_btnStop.Click += OnStop;
@@ -129,70 +128,120 @@ public class MainForm : Form
_lblTrack = new Label
{
Text = "",
Location = new Point(16, 158),
Size = new Size(328, 16),
Location = new Point(16, 134),
Size = new Size(388, 16),
ForeColor = Color.DimGray
};
_lblGaugeA = new Label
_lblSendA = new Label
{
Text = "A",
Location = new Point(16, 180),
Size = new Size(16, 20),
Text = "Send A",
Location = new Point(16, 160),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_gaugeA = new ProgressBar
_heatA = new HeatMap(60)
{
Location = new Point(40, 180),
Size = new Size(304, 20),
Location = new Point(72, 158),
Size = new Size(332, 22)
};
_lblSendB = new Label
{
Text = "Send B",
Location = new Point(16, 188),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_heatB = new HeatMap(60)
{
Location = new Point(72, 186),
Size = new Size(332, 22)
};
_lblRecvA = new Label
{
Text = "Recv A",
Location = new Point(16, 216),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_gaugeRecvA = new ProgressBar
{
Location = new Point(72, 216),
Size = new Size(332, 20),
Minimum = 0,
Maximum = 200,
Style = ProgressBarStyle.Continuous
};
_lblGaugeB = new Label
_lblRecvB = new Label
{
Text = "B",
Location = new Point(16, 208),
Size = new Size(16, 20),
Text = "Recv B",
Location = new Point(16, 242),
Size = new Size(48, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_gaugeB = new ProgressBar
_gaugeRecvB = new ProgressBar
{
Location = new Point(40, 208),
Size = new Size(304, 20),
Location = new Point(72, 242),
Size = new Size(332, 20),
Minimum = 0,
Maximum = 200,
Style = ProgressBarStyle.Continuous
};
_lblLimit = new Label
_lblLimitA = new Label
{
Text = "Limit: 30",
Location = new Point(16, 248),
Size = new Size(64, 20),
Text = "Lim A: 30",
Location = new Point(16, 274),
Size = new Size(56, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_limitBar = new TrackBar
_limitBarA = new TrackBar
{
Location = new Point(80, 244),
Size = new Size(180, 45),
Location = new Point(72, 270),
Size = new Size(250, 45),
Minimum = 0,
Maximum = 200,
TickFrequency = 50,
Value = 30
};
_limitBar.ValueChanged += OnLimitChanged;
_chkScale = new CheckBox
_limitBarA.ValueChanged += OnLimitChanged;
_chkScaleA = new CheckBox
{
Text = "Scale",
Location = new Point(268, 248),
Location = new Point(328, 274),
Size = new Size(76, 24),
Checked = false
};
_chkScale.CheckedChanged += OnLimitChanged;
_chkScaleA.CheckedChanged += OnLimitChanged;
_lblLimitB = new Label
{
Text = "Lim B: 30",
Location = new Point(16, 312),
Size = new Size(56, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_limitBarB = new TrackBar
{
Location = new Point(72, 308),
Size = new Size(250, 45),
Minimum = 0,
Maximum = 200,
TickFrequency = 50,
Value = 30
};
_limitBarB.ValueChanged += OnLimitChanged;
_chkScaleB = new CheckBox
{
Text = "Scale",
Location = new Point(328, 312),
Size = new Size(76, 24),
Checked = false
};
_chkScaleB.CheckedChanged += OnLimitChanged;
OnLimitChanged(null, EventArgs.Empty);
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblStrength, _lblDeviceName, _txtDeviceName, _btnConnect, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblGaugeA, _gaugeA, _lblGaugeB, _gaugeB, _lblLimit, _limitBar, _chkScale });
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _chkScaleA, _lblLimitB, _limitBarB, _chkScaleB });
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
_iconNeutral = CreateVoltageIcon(Color.Gray);
@@ -242,9 +291,9 @@ public class MainForm : Form
{
try
{
var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick();
if (_device.IsConnected)
{
var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick();
var frame = B0.Build(0, modeA, modeB, valA, valB, freqA, intA, freqB, intB);
await _device.SendB0(frame);
}
@@ -331,9 +380,16 @@ public class MainForm : Form
_lblBle.Text = _device.IsConnected
? $"BLE: connected ({_device.DeviceName})"
: "BLE: disconnected";
_lblStrength.Text = $"Strength: A={_device.StrengthA} B={_device.StrengthB}";
_gaugeA.Value = Math.Clamp(_device.StrengthA, 0, 200);
_gaugeB.Value = Math.Clamp(_device.StrengthB, 0, 200);
_heatA.AddTick(
_state.LastFreqA ?? Array.Empty<byte>(),
_state.LastIntA ?? Array.Empty<byte>(),
_state.LastActiveA);
_heatB.AddTick(
_state.LastFreqB ?? Array.Empty<byte>(),
_state.LastIntB ?? Array.Empty<byte>(),
_state.LastActiveB);
_gaugeRecvA.Value = Math.Clamp(_device.StrengthA, 0, 200);
_gaugeRecvB.Value = Math.Clamp(_device.StrengthB, 0, 200);
_btnTest.Enabled = !_server.HasClient && !IsTestRunning;
_btnMusic.Enabled = !_server.HasClient && !_isMusicRunning;
_btnStop.Enabled = true;
@@ -416,10 +472,14 @@ public class MainForm : Form
void OnLimitChanged(object? sender, EventArgs e)
{
var max = _limitBar.Value;
var mode = _chkScale.Checked ? LimitMode.Scale : LimitMode.Clamp;
_state.SetLimit(max, mode);
_lblLimit.Text = $"Limit: {max}";
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);
_lblLimitA.Text = $"Lim A: {maxA}";
_lblLimitB.Text = $"Lim B: {maxB}";
}
string _musicFilePath = "";
+37 -13
View File
@@ -16,17 +16,32 @@ public class State
public readonly ConcurrentQueue<WaveFrame> StreamB = new();
public int ActualA, ActualB;
public int LastSentA, LastSentB;
public int LastIntensityA, LastIntensityB;
public byte[]? LastFreqA, LastIntA, LastFreqB, LastIntB;
public bool LastActiveA, LastActiveB;
public int MaxStrength = 200;
public LimitMode LimitMode = LimitMode.Clamp;
public int MaxA = 200;
public int MaxB = 200;
public LimitMode LimitModeA = LimitMode.Clamp;
public LimitMode LimitModeB = LimitMode.Clamp;
public void SetLimit(int max, LimitMode mode)
public void SetLimitA(int max, LimitMode mode)
{
lock (Lock)
{
MaxStrength = Math.Clamp(max, 0, 200);
LimitMode = mode;
MaxA = Math.Clamp(max, 0, 200);
LimitModeA = mode;
DirtyA = true;
}
}
public void SetLimitB(int max, LimitMode mode)
{
lock (Lock)
{
MaxB = Math.Clamp(max, 0, 200);
LimitModeB = mode;
DirtyB = true;
}
}
@@ -91,20 +106,29 @@ public class State
var modeA = DirtyA ? StrengthMode.Abs : StrengthMode.None;
var modeB = DirtyB ? StrengthMode.Abs : StrengthMode.None;
var limit = MaxStrength;
var limMode = LimitMode;
byte ApplyLimit(int desired) =>
limMode == LimitMode.Scale
? (byte)(desired * limit / 200)
: (byte)Math.Clamp(desired, 0, limit);
var valA = ApplyLimit(DesiredA);
var valB = ApplyLimit(DesiredB);
byte ApplyLimit(int desired, int max, LimitMode mode) =>
mode == LimitMode.Scale
? (byte)(desired * max / 200)
: (byte)Math.Clamp(desired, 0, max);
var valA = ApplyLimit(DesiredA, MaxA, LimitModeA);
var valB = ApplyLimit(DesiredB, MaxB, LimitModeB);
LastSentA = valA;
LastSentB = valB;
DirtyA = false;
DirtyB = false;
bool hasA = !StreamA.IsEmpty || (LoopFreqA != null && LoopIntA != null);
bool hasB = !StreamB.IsEmpty || (LoopFreqB != null && LoopIntB != null);
var (freqA, intA) = PopWave(StreamA, LoopFreqA, LoopIntA);
var (freqB, intB) = PopWave(StreamB, LoopFreqB, LoopIntB);
LastActiveA = hasA;
LastActiveB = hasB;
LastFreqA = freqA; LastIntA = intA;
LastFreqB = freqB; LastIntB = intB;
LastIntensityA = hasA ? (intA[0] + intA[1] + intA[2] + intA[3]) / 4 : 0;
LastIntensityB = hasB ? (intB[0] + intB[1] + intB[2] + intB[3]) / 4 : 0;
return (modeA, valA, modeB, valB, freqA, intA, freqB, intB);
}
}
+3
View File
@@ -11,6 +11,9 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>Substation</AssemblyName>
<RootNamespace>Substation</RootNamespace>
<Version>0.1.6</Version>
<AssemblyVersion>0.1.6</AssemblyVersion>
<FileVersion>0.1.6</FileVersion>
</PropertyGroup>
<ItemGroup>