Implement Substation: BLE-WS bridge with music-reactive e-stim
- Renamed from CoyoteBridge to Substation (namespace, classes, UI) - BLE connection via WinRT with graceful disconnect handling - WebSocket server (127.0.0.1:8765) with single-client, push events for BLE connect/disconnect transitions - Operator strength limiter (clamp/scale modes, default 30) - Tray icon with 3 states: neutral/active/hot (runtime-drawn voltage glyph) - A/B strength gauges, hide-on-minimise to tray - Music mode: MP3 decode + FFT analysis (NAudio + FftSharp), rhythm→ch A, melody→ch B, pre-analyzed with synced audio playback - Test/Stop All work without BLE connected (dev mode) - WS device-driving commands error when BLE not connected; ping/status/ connect always work - TreatWarningsAsErrors, LangVersion=latest - .gitignore, README with full API docs + attribution
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
## .NET build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
## User-specific files
|
||||
*.user
|
||||
*.suo
|
||||
*.userprefs
|
||||
*.vs/
|
||||
*.DotSettings.user
|
||||
|
||||
## NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
**/[Pp]ackages/*
|
||||
!*.build.props
|
||||
!*.build.targets
|
||||
|
||||
## Visual Studio / Rider / VS Code
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sln.docstates
|
||||
|
||||
## OS files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
|
||||
## Logs / temp
|
||||
*.log
|
||||
tmp/
|
||||
*.tmp
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using Windows.Devices.Bluetooth;
|
||||
using Windows.Devices.Bluetooth.GenericAttributeProfile;
|
||||
using Windows.Devices.Enumeration;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Substation;
|
||||
|
||||
public class CoyoteDevice : IDisposable
|
||||
{
|
||||
static readonly Guid ServiceUuid = Guid.Parse("0000180c-0000-1000-8000-00805f9b34fb");
|
||||
static readonly Guid WriteCharUuid = Guid.Parse("0000150a-0000-1000-8000-00805f9b34fb");
|
||||
static readonly Guid NotifyCharUuid= Guid.Parse("0000150b-0000-1000-8000-00805f9b34fb");
|
||||
|
||||
BluetoothLEDevice? _device;
|
||||
GattCharacteristic? _write;
|
||||
GattCharacteristic? _notify;
|
||||
|
||||
public bool IsConnected => _write != null;
|
||||
public int StrengthA { get; private set; }
|
||||
public int StrengthB { get; private set; }
|
||||
public string DeviceName { get; private set; } = "";
|
||||
|
||||
public event Action<int, int>? StrengthChanged;
|
||||
public event Action<bool>? ConnectionChanged;
|
||||
|
||||
public async Task ConnectAsync(string nameFilter = "47L121000")
|
||||
{
|
||||
var selector = BluetoothLEDevice.GetDeviceSelectorFromDeviceName(nameFilter);
|
||||
var devices = await DeviceInformation.FindAllAsync(selector);
|
||||
|
||||
if (devices.Count == 0)
|
||||
{
|
||||
selector = BluetoothLEDevice.GetDeviceSelectorFromPairingState(false);
|
||||
devices = await DeviceInformation.FindAllAsync(selector);
|
||||
var match = devices.FirstOrDefault(d =>
|
||||
d.Name.Contains("47L121000", StringComparison.OrdinalIgnoreCase) ||
|
||||
d.Name.Contains("DG-LAB", StringComparison.OrdinalIgnoreCase) ||
|
||||
d.Name.Contains("Coyote", StringComparison.OrdinalIgnoreCase));
|
||||
if (match == null)
|
||||
throw new InvalidOperationException(
|
||||
$"Coyote 3.0 not found (filter='{nameFilter}'). Ensure it's powered on and BLE is enabled.");
|
||||
// fallback path
|
||||
var fallback = await BluetoothLEDevice.FromIdAsync(match.Id);
|
||||
await ConnectDeviceAsync(fallback, match.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var dev = await BluetoothLEDevice.FromIdAsync(devices[0].Id);
|
||||
await ConnectDeviceAsync(dev, devices[0].Name);
|
||||
}
|
||||
|
||||
async Task ConnectDeviceAsync(BluetoothLEDevice dev, string name)
|
||||
{
|
||||
_device = dev;
|
||||
DeviceName = name;
|
||||
_device.ConnectionStatusChanged += OnConnectionStatusChanged;
|
||||
|
||||
var svcResult = await dev.GetGattServicesForUuidAsync(ServiceUuid, BluetoothCacheMode.Uncached);
|
||||
if (svcResult.Status != GattCommunicationStatus.Success || svcResult.Services.Count == 0)
|
||||
throw new InvalidOperationException($"GATT service {ServiceUuid} not found on {name}.");
|
||||
|
||||
var svc = svcResult.Services[0];
|
||||
|
||||
var charResult = await svc.GetCharacteristicsAsync();
|
||||
if (charResult.Status != GattCommunicationStatus.Success)
|
||||
throw new InvalidOperationException($"Failed to enumerate characteristics on {name}.");
|
||||
|
||||
_write = charResult.Characteristics.FirstOrDefault(c => c.Uuid == WriteCharUuid)
|
||||
?? throw new InvalidOperationException("Write characteristic 0x150A not found.");
|
||||
_notify = charResult.Characteristics.FirstOrDefault(c => c.Uuid == NotifyCharUuid)
|
||||
?? throw new InvalidOperationException("Notify characteristic 0x150B not found.");
|
||||
|
||||
_notify.ValueChanged += OnNotify;
|
||||
var subStatus = await _notify.WriteClientCharacteristicConfigurationDescriptorAsync(
|
||||
GattClientCharacteristicConfigurationDescriptorValue.Notify);
|
||||
if (subStatus != GattCommunicationStatus.Success)
|
||||
throw new InvalidOperationException("Failed to subscribe to 0x150B notifications.");
|
||||
|
||||
await SendBF(200, 200, 128, 128, 128, 128);
|
||||
ConnectionChanged?.Invoke(true);
|
||||
}
|
||||
|
||||
void OnConnectionStatusChanged(BluetoothLEDevice sender, object args)
|
||||
{
|
||||
if (sender.ConnectionStatus == Windows.Devices.Bluetooth.BluetoothConnectionStatus.Disconnected)
|
||||
{
|
||||
_write = null;
|
||||
_notify = null;
|
||||
ConnectionChanged?.Invoke(false);
|
||||
}
|
||||
}
|
||||
|
||||
void OnNotify(GattCharacteristic sender, GattValueChangedEventArgs args)
|
||||
{
|
||||
var reader = DataReader.FromBuffer(args.CharacteristicValue);
|
||||
if (reader.UnconsumedBufferLength < 1) return;
|
||||
|
||||
var head = reader.ReadByte();
|
||||
if (head == 0xB1 && reader.UnconsumedBufferLength >= 3)
|
||||
{
|
||||
reader.ReadByte();
|
||||
StrengthA = reader.ReadByte();
|
||||
StrengthB = reader.ReadByte();
|
||||
StrengthChanged?.Invoke(StrengthA, StrengthB);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendB0(byte[] frame)
|
||||
{
|
||||
if (_write == null) return;
|
||||
var writer = new DataWriter();
|
||||
writer.WriteBytes(frame);
|
||||
var status = await _write.WriteValueAsync(writer.DetachBuffer(), GattWriteOption.WriteWithoutResponse);
|
||||
if (status != GattCommunicationStatus.Success)
|
||||
Console.Error.WriteLine($"[BLE] B0 write failed: {status}");
|
||||
}
|
||||
|
||||
public async Task SendBF(byte capA, byte capB, byte freqBalA, byte freqBalB, byte intBalA, byte intBalB)
|
||||
{
|
||||
if (_write == null) return;
|
||||
var frame = BF.Build(capA, capB, freqBalA, freqBalB, intBalA, intBalB);
|
||||
var writer = new DataWriter();
|
||||
writer.WriteBytes(frame);
|
||||
var status = await _write.WriteValueAsync(writer.DetachBuffer(), GattWriteOption.WriteWithoutResponse);
|
||||
if (status != GattCommunicationStatus.Success)
|
||||
Console.Error.WriteLine($"[BLE] BF write failed: {status}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_notify != null)
|
||||
_notify.ValueChanged -= OnNotify;
|
||||
_device?.Dispose();
|
||||
}
|
||||
}
|
||||
+571
@@ -0,0 +1,571 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Diagnostics;
|
||||
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 _lblStrength;
|
||||
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;
|
||||
|
||||
readonly ProgressBar _gaugeA;
|
||||
readonly ProgressBar _gaugeB;
|
||||
readonly Label _lblGaugeA;
|
||||
readonly Label _lblGaugeB;
|
||||
readonly TrackBar _limitBar;
|
||||
readonly Label _lblLimit;
|
||||
readonly CheckBox _chkScale;
|
||||
|
||||
readonly Icon _iconNeutral;
|
||||
readonly Icon _iconActive;
|
||||
readonly Icon _iconHot;
|
||||
string _trayState = "";
|
||||
|
||||
readonly Button _btnMusic;
|
||||
readonly Label _lblTrack;
|
||||
bool _isMusicRunning;
|
||||
WaveOutEvent? _audioOut;
|
||||
Stopwatch? _musicStopwatch;
|
||||
|
||||
public MainForm(CoyoteDevice device, State state, Server server)
|
||||
{
|
||||
_device = device;
|
||||
_state = state;
|
||||
_server = server;
|
||||
_loopCts = new CancellationTokenSource();
|
||||
|
||||
Text = "Substation";
|
||||
ClientSize = new Size(360, 340);
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
ShowInTaskbar = true;
|
||||
|
||||
_lblBle = new Label
|
||||
{
|
||||
Text = "BLE: searching...",
|
||||
Location = new Point(16, 16),
|
||||
Size = new Size(328, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
|
||||
_lblWs = new Label
|
||||
{
|
||||
Text = "WS: idle",
|
||||
Location = new Point(16, 40),
|
||||
Size = new Size(328, 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),
|
||||
Size = new Size(48, 20)
|
||||
};
|
||||
_txtDeviceName = new TextBox
|
||||
{
|
||||
Text = "47L121000",
|
||||
Location = new Point(68, 88),
|
||||
Size = new Size(160, 20)
|
||||
};
|
||||
|
||||
_btnConnect = new Button
|
||||
{
|
||||
Text = "Connect",
|
||||
Location = new Point(236, 88),
|
||||
Size = new Size(108, 24)
|
||||
};
|
||||
_btnConnect.Click += OnConnect;
|
||||
|
||||
_btnTest = new Button
|
||||
{
|
||||
Text = "Test",
|
||||
Location = new Point(16, 120),
|
||||
Size = new Size(100, 32)
|
||||
};
|
||||
_btnTest.Click += OnTest;
|
||||
|
||||
_btnMusic = new Button
|
||||
{
|
||||
Text = "Music",
|
||||
Location = new Point(130, 120),
|
||||
Size = new Size(100, 32)
|
||||
};
|
||||
_btnMusic.Click += OnMusic;
|
||||
|
||||
_btnStop = new Button
|
||||
{
|
||||
Text = "Stop All",
|
||||
Location = new Point(244, 120),
|
||||
Size = new Size(100, 32)
|
||||
};
|
||||
_btnStop.Click += OnStop;
|
||||
|
||||
_lblTrack = new Label
|
||||
{
|
||||
Text = "",
|
||||
Location = new Point(16, 158),
|
||||
Size = new Size(328, 16),
|
||||
ForeColor = Color.DimGray
|
||||
};
|
||||
|
||||
_lblGaugeA = new Label
|
||||
{
|
||||
Text = "A",
|
||||
Location = new Point(16, 180),
|
||||
Size = new Size(16, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_gaugeA = new ProgressBar
|
||||
{
|
||||
Location = new Point(40, 180),
|
||||
Size = new Size(304, 20),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
Style = ProgressBarStyle.Continuous
|
||||
};
|
||||
_lblGaugeB = new Label
|
||||
{
|
||||
Text = "B",
|
||||
Location = new Point(16, 208),
|
||||
Size = new Size(16, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_gaugeB = new ProgressBar
|
||||
{
|
||||
Location = new Point(40, 208),
|
||||
Size = new Size(304, 20),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
Style = ProgressBarStyle.Continuous
|
||||
};
|
||||
|
||||
_lblLimit = new Label
|
||||
{
|
||||
Text = "Limit: 30",
|
||||
Location = new Point(16, 248),
|
||||
Size = new Size(64, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_limitBar = new TrackBar
|
||||
{
|
||||
Location = new Point(80, 244),
|
||||
Size = new Size(180, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
TickFrequency = 50,
|
||||
Value = 30
|
||||
};
|
||||
_limitBar.ValueChanged += OnLimitChanged;
|
||||
OnLimitChanged(null, EventArgs.Empty);
|
||||
_chkScale = new CheckBox
|
||||
{
|
||||
Text = "Scale",
|
||||
Location = new Point(268, 248),
|
||||
Size = new Size(76, 24),
|
||||
Checked = false
|
||||
};
|
||||
_chkScale.CheckedChanged += OnLimitChanged;
|
||||
|
||||
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblStrength, _lblDeviceName, _txtDeviceName, _btnConnect, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblGaugeA, _gaugeA, _lblGaugeB, _gaugeB, _lblLimit, _limitBar, _chkScale });
|
||||
|
||||
// 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 = 250 };
|
||||
_statusTimer.Tick += OnStatusTick;
|
||||
_statusTimer.Start();
|
||||
|
||||
Load += OnLoad;
|
||||
FormClosing += OnFormClosing;
|
||||
Resize += OnResize;
|
||||
|
||||
_server.ClientChanged += OnWsClientChanged;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
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";
|
||||
_btnTest.Enabled = !connected && !IsTestRunning;
|
||||
_btnMusic.Enabled = !connected && !_isMusicRunning;
|
||||
UpdateTrayIcon();
|
||||
});
|
||||
}
|
||||
|
||||
void OnStatusTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
_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);
|
||||
_btnTest.Enabled = !_server.HasClient && !IsTestRunning;
|
||||
_btnMusic.Enabled = !_server.HasClient && !_isMusicRunning;
|
||||
_btnStop.Enabled = true;
|
||||
|
||||
if (_isMusicRunning && _musicStopwatch != null)
|
||||
{
|
||||
var elapsed = _musicStopwatch.Elapsed;
|
||||
_lblTrack.Text = $"Playing {elapsed:mm\\:ss} / {_musicTotalTime:mm\\:ss} {Path.GetFileName(_musicFilePath)}";
|
||||
}
|
||||
|
||||
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;
|
||||
_btnTest.Enabled = !_server.HasClient;
|
||||
});
|
||||
}
|
||||
|
||||
void OnStop(object? sender, EventArgs e)
|
||||
{
|
||||
StopMusic();
|
||||
_state.SetStrength('A', 0);
|
||||
_state.SetStrength('B', 0);
|
||||
_state.Stop('A');
|
||||
_state.Stop('B');
|
||||
}
|
||||
|
||||
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}";
|
||||
}
|
||||
|
||||
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;
|
||||
_btnMusic.Enabled = !_server.HasClient;
|
||||
_btnTest.Enabled = !_server.HasClient && !IsTestRunning;
|
||||
});
|
||||
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 = "";
|
||||
_btnMusic.Enabled = !_server.HasClient;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Numerics;
|
||||
using FftSharp;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace Substation;
|
||||
|
||||
public record MusicPattern(List<WaveFrame> ChannelA, List<WaveFrame> ChannelB, TimeSpan Duration);
|
||||
|
||||
public static class MusicAnalyzer
|
||||
{
|
||||
const int SampleRate = 44100;
|
||||
const int WindowSize = 2048;
|
||||
const int HopSize = 1024;
|
||||
const double TickDuration = 0.1; // 100ms per e-stim frame
|
||||
|
||||
// Frequency band boundaries (Hz)
|
||||
const double RhythmLow = 20, RhythmHigh = 250;
|
||||
const double MelodyLow = 300, MelodyHigh = 4000;
|
||||
|
||||
public static MusicPattern Analyze(string mp3Path, IProgress<int>? progress = null)
|
||||
{
|
||||
var samples = DecodeToMono(mp3Path);
|
||||
var duration = TimeSpan.FromSeconds((double)samples.Length / SampleRate);
|
||||
|
||||
var tickCount = (int)Math.Ceiling((double)samples.Length / SampleRate / TickDuration);
|
||||
var channelA = new List<WaveFrame>(tickCount);
|
||||
var channelB = new List<WaveFrame>(tickCount);
|
||||
|
||||
var window = new FftSharp.Windows.Hanning();
|
||||
var buffer = new double[WindowSize];
|
||||
int fftsPerTick = (int)Math.Round(TickDuration * SampleRate / HopSize);
|
||||
|
||||
double[]? prevRhythmMag = null;
|
||||
double maxFlux = 0;
|
||||
double maxMelodyEnergy = 0;
|
||||
|
||||
// First pass: collect per-tick features
|
||||
var tickFeatures = new List<TickFeature>(tickCount);
|
||||
int pos = 0;
|
||||
int fftIndex = 0;
|
||||
|
||||
while (pos + WindowSize <= samples.Length)
|
||||
{
|
||||
for (int i = 0; i < WindowSize; i++)
|
||||
buffer[i] = samples[pos + i];
|
||||
|
||||
window.ApplyInPlace(buffer);
|
||||
var spectrum = FFT.Forward(buffer);
|
||||
var mag = FFT.Magnitude(spectrum);
|
||||
|
||||
var (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq) =
|
||||
ExtractFeatures(mag, prevRhythmMag);
|
||||
|
||||
if (rhythmFlux > maxFlux) maxFlux = rhythmFlux;
|
||||
if (melodyEnergy > maxMelodyEnergy) maxMelodyEnergy = melodyEnergy;
|
||||
|
||||
prevRhythmMag = mag;
|
||||
|
||||
int tickIdx = fftIndex / Math.Max(1, fftsPerTick);
|
||||
while (tickFeatures.Count <= tickIdx)
|
||||
tickFeatures.Add(new TickFeature());
|
||||
|
||||
var tf = tickFeatures[tickIdx];
|
||||
tf.RhythmFlux = Math.Max(tf.RhythmFlux, rhythmFlux);
|
||||
tf.MelodyEnergy += melodyEnergy;
|
||||
tf.MelodyFreqSamples.Add(melodyFreq);
|
||||
tf.MelodyCount++;
|
||||
|
||||
pos += HopSize;
|
||||
fftIndex++;
|
||||
|
||||
if (progress != null && fftIndex % 50 == 0)
|
||||
{
|
||||
var pct = (int)((double)pos / samples.Length * 100);
|
||||
progress.Report(pct);
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(100);
|
||||
|
||||
// Second pass: normalize and build WaveFrames
|
||||
const int rhythmFreqMs = 150; // ~7Hz deep pulse
|
||||
|
||||
foreach (var tf in tickFeatures)
|
||||
{
|
||||
// Channel A: rhythm onset
|
||||
double normalizedFlux = maxFlux > 0 ? tf.RhythmFlux / maxFlux : 0;
|
||||
int onsetIntensity = (int)Math.Round(normalizedFlux * 100);
|
||||
// Sub-tick attack/decay shape
|
||||
var intA = new[]
|
||||
{
|
||||
(byte)Math.Clamp(onsetIntensity, 0, 100),
|
||||
(byte)Math.Clamp(onsetIntensity * 6 / 10, 0, 100),
|
||||
(byte)Math.Clamp(onsetIntensity * 3 / 10, 0, 100),
|
||||
(byte)0
|
||||
};
|
||||
var freqA = Freq.Compress4(new[] { rhythmFreqMs, rhythmFreqMs, rhythmFreqMs, rhythmFreqMs });
|
||||
channelA.Add(new WaveFrame(freqA, intA));
|
||||
|
||||
// Channel B: melody
|
||||
double avgEnergy = tf.MelodyCount > 0 ? tf.MelodyEnergy / tf.MelodyCount : 0;
|
||||
double normalizedEnergy = maxMelodyEnergy > 0 ? avgEnergy / maxMelodyEnergy : 0;
|
||||
int melodyIntensity = (int)Math.Round(normalizedEnergy * 80); // cap at 80 for comfort
|
||||
melodyIntensity = Math.Clamp(melodyIntensity, 0, 100);
|
||||
|
||||
// Weighted average dominant frequency
|
||||
double weightedFreq = 0;
|
||||
double totalWeight = 0;
|
||||
foreach (var f in tf.MelodyFreqSamples)
|
||||
{
|
||||
weightedFreq += f * f; // weight by energy (freq already squared mag)
|
||||
totalWeight += f;
|
||||
}
|
||||
double avgMelodyHz = totalWeight > 0 ? weightedFreq / totalWeight : 500;
|
||||
int estimsMs = MapPitchToPeriod(avgMelodyHz);
|
||||
|
||||
var intB = new[]
|
||||
{
|
||||
(byte)melodyIntensity, (byte)melodyIntensity,
|
||||
(byte)melodyIntensity, (byte)melodyIntensity
|
||||
};
|
||||
var freqB = Freq.Compress4(new[] { estimsMs, estimsMs, estimsMs, estimsMs });
|
||||
channelB.Add(new WaveFrame(freqB, intB));
|
||||
}
|
||||
|
||||
return new MusicPattern(channelA, channelB, duration);
|
||||
}
|
||||
|
||||
static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq) ExtractFeatures(
|
||||
double[] magnitude, double[]? prevRhythmMag)
|
||||
{
|
||||
double binWidth = (double)SampleRate / WindowSize;
|
||||
int rhythmLoBin = (int)(RhythmLow / binWidth);
|
||||
int rhythmHiBin = (int)(RhythmHigh / binWidth);
|
||||
int melodyLoBin = (int)(MelodyLow / binWidth);
|
||||
int melodyHiBin = (int)(MelodyHigh / binWidth);
|
||||
|
||||
// Rhythm band energy
|
||||
double rhythmEnergy = 0;
|
||||
for (int i = rhythmLoBin; i <= rhythmHiBin && i < magnitude.Length; i++)
|
||||
rhythmEnergy += magnitude[i] * magnitude[i];
|
||||
rhythmEnergy = Math.Sqrt(rhythmEnergy / (rhythmHiBin - rhythmLoBin + 1));
|
||||
|
||||
// Spectral flux (positive change in rhythm band)
|
||||
double rhythmFlux = 0;
|
||||
if (prevRhythmMag != null)
|
||||
{
|
||||
for (int i = rhythmLoBin; i <= rhythmHiBin && i < magnitude.Length; i++)
|
||||
{
|
||||
double diff = magnitude[i] - prevRhythmMag[i];
|
||||
if (diff > 0) rhythmFlux += diff;
|
||||
}
|
||||
}
|
||||
|
||||
// Melody band: energy + dominant frequency (spectral peak)
|
||||
double melodyEnergy = 0;
|
||||
double peakMag = 0;
|
||||
int peakBin = melodyLoBin;
|
||||
for (int i = melodyLoBin; i <= melodyHiBin && i < magnitude.Length; i++)
|
||||
{
|
||||
double m = magnitude[i];
|
||||
melodyEnergy += m * m;
|
||||
if (m > peakMag)
|
||||
{
|
||||
peakMag = m;
|
||||
peakBin = i;
|
||||
}
|
||||
}
|
||||
melodyEnergy = Math.Sqrt(melodyEnergy / (melodyHiBin - melodyLoBin + 1));
|
||||
double melodyFreq = peakBin * binWidth;
|
||||
|
||||
return (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq);
|
||||
}
|
||||
|
||||
static int MapPitchToPeriod(double hz)
|
||||
{
|
||||
// Map melody frequency (300-4000Hz) to e-stim period (10-1000ms)
|
||||
// Logarithmic mapping: low notes → deep, high notes → buzzy
|
||||
double logFreq = Math.Log(Math.Clamp(hz, MelodyLow, MelodyHigh));
|
||||
double logMin = Math.Log(MelodyLow);
|
||||
double logMax = Math.Log(MelodyHigh);
|
||||
double t = (logFreq - logMin) / (logMax - logMin); // 0..1
|
||||
// Invert: high freq → short period (buzzy), low freq → long period (deep)
|
||||
int ms = (int)Math.Round(1000 - t * 990); // 1000ms..10ms
|
||||
return Math.Clamp(ms, 10, 1000);
|
||||
}
|
||||
|
||||
static double[] DecodeToMono(string mp3Path)
|
||||
{
|
||||
using var reader = new Mp3FileReader(mp3Path);
|
||||
var format = new WaveFormat(SampleRate, 16, 1);
|
||||
using var resampler = new MediaFoundationResampler(reader, format);
|
||||
resampler.ResamplerQuality = 60;
|
||||
|
||||
var sampleList = new List<float>();
|
||||
var buffer = new byte[SampleRate * 2]; // 1s worth of 16-bit mono
|
||||
int read;
|
||||
while ((read = resampler.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
for (int i = 0; i < read; i += 2)
|
||||
{
|
||||
short sample = (short)(buffer[i] | (buffer[i + 1] << 8));
|
||||
sampleList.Add(sample / 32768f);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new double[sampleList.Count];
|
||||
for (int i = 0; i < sampleList.Count; i++)
|
||||
result[i] = sampleList[i];
|
||||
return result;
|
||||
}
|
||||
|
||||
class TickFeature
|
||||
{
|
||||
public double RhythmFlux;
|
||||
public double MelodyEnergy;
|
||||
public double MelodyCount;
|
||||
public readonly List<double> MelodyFreqSamples = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# Coyote 3.0 — Research Notes
|
||||
|
||||
## Device
|
||||
|
||||
**DG-Lab Coyote 3.0** — biphasic pulse generator, 2 independent channels (A/B).
|
||||
- BLE name: `47L121000`
|
||||
- Service UUID: `0x180C`
|
||||
- Write characteristic: `0x150A` (commands in)
|
||||
- Notify characteristic: `0x150B` (responses out)
|
||||
- Base UUID: `0000XXXX-0000-1000-8000-00805f9b34fb`
|
||||
- Strength range: 0–200 per channel (amplitude ceiling)
|
||||
- Waveform frequency byte: 10–240 (compressed from 10–1000ms)
|
||||
- Waveform intensity byte: 0–100 (pulse width, relative)
|
||||
- Output window: 25ms; commands carry 4 ticks = 100ms of output
|
||||
|
||||
## How waveforms work
|
||||
|
||||
The box outputs **biphasic pulses**. A waveform is a time-series of two parameters:
|
||||
|
||||
1. **Frequency** = pulse repetition period (ms). Low ms = high Hz = buzzy/tingly. High ms = low Hz = deep/thumpy.
|
||||
- 10ms ≈ 100Hz, 50ms ≈ 20Hz, 100ms ≈ 10Hz, 1000ms ≈ 1Hz
|
||||
2. **Intensity** = pulse width (0–100, relative). Wider pulse = stronger feel. The intensity envelope is what makes a pattern pleasurable vs. sharp — slow swells/fades, not square edges.
|
||||
|
||||
**Strength** (0–200) is the hard amplitude ceiling per channel, set separately. Do expression in the 0–100 intensity envelope; keep strength modest.
|
||||
|
||||
### Frequency compression (input ms → device byte 10–240)
|
||||
|
||||
```
|
||||
10–100 → identity
|
||||
101–600 → (input - 100) / 5 + 100
|
||||
601–1000 → (input - 600) / 10 + 200
|
||||
```
|
||||
|
||||
### B0 frame (20 bytes, sent every 100ms)
|
||||
|
||||
| Byte(s) | Field |
|
||||
|---------|-------|
|
||||
| 0 | `0xB0` head |
|
||||
| 1 | seq (high 4) + strength mode (low 4): 00=none, 01=add, 10=sub, 11=abs |
|
||||
| 2 | Channel A strength (0–200) |
|
||||
| 3 | Channel B strength (0–200) |
|
||||
| 4–7 | Ch A frequency ×4 (10–240) |
|
||||
| 8–11 | Ch A intensity ×4 (0–100) |
|
||||
| 12–15 | Ch B frequency ×4 |
|
||||
| 16–19 | Ch B intensity ×4 |
|
||||
|
||||
Invalid value in any channel's 4-tuple → device drops all 4 for that channel. To disable a channel, send intensity `101` in one slot.
|
||||
|
||||
### BF frame (7 bytes, soft caps + balance)
|
||||
|
||||
| Byte(s) | Field |
|
||||
|---------|-------|
|
||||
| 0 | `0xBF` head |
|
||||
| 1–2 | Ch A/B strength soft cap (0–200, persisted) |
|
||||
| 3–4 | Ch A/B frequency balance (0–255, 128=neutral; higher = stronger low-freq impact) |
|
||||
| 5–6 | Ch A/B intensity balance (0–255, 128=neutral; higher = stronger low-freq stimulation) |
|
||||
|
||||
⚠️ BF takes effect immediately with no response. Must re-send after every reconnect.
|
||||
|
||||
### B1 notification (from 0x150B)
|
||||
|
||||
| Byte(s) | Field |
|
||||
|---------|-------|
|
||||
| 0 | `0xB1` head |
|
||||
| 1 | sequence number (matches the B0 that caused the change, 0 if wheel) |
|
||||
| 2 | Ch A actual strength |
|
||||
| 3 | Ch B actual strength |
|
||||
|
||||
## Connectivity topologies
|
||||
|
||||
The last mile is **always BLE**. Everything upstream is an adapter.
|
||||
|
||||
1. **Direct BLE** — any BLE host writes B0/BF directly. No app, no internet.
|
||||
- Web Bluetooth (Chrome/Edge) → xToys.app, OpenDGLab-Connect
|
||||
- Python `bleak`, C# WinRT, Node `noble`
|
||||
2. **Phone-app bridge (DG-Lab Socket mode)** — phone app pairs to box via BLE AND runs a WebSocket endpoint. Third-party terminal connects to that WS (LAN or internet) and sends commands the app forwards over BLE.
|
||||
- `PyDGLab-WS` (Python, 81★) implements both client and server sides
|
||||
- QR code in app encodes WS URL + clientId for binding
|
||||
3. **OpenDGLab OpenProtocol** — protobuf protocol for OPClient ↔ device-host. Richer; meant for game/VR integrations (HL2, R.E.P.O. mods).
|
||||
|
||||
## Browser Bluetooth
|
||||
|
||||
- **Web Bluetooth API** (`navigator.bluetooth.requestDevice`) — Chrome, Edge, Opera, Brave, Android Chrome. xToys and OpenDGLab-Connect use this.
|
||||
- **Firefox**: not supported, Mozilla refuses over fingerprinting concerns. No flag. Use app-bridge + WS instead.
|
||||
- **Safari/iOS**: not supported.
|
||||
|
||||
## Key repos
|
||||
|
||||
| Repo | What |
|
||||
|------|------|
|
||||
| `dungeonlab-open/dglab-bluetooth-protocol` (638★) | **Official** BLE protocol docs + example waveform data (V2/V3) |
|
||||
| `OpenDGLab/OpenDGLab-WaveGen` (8★) | Web GUI waveform editor, exports pattern strings. Live: opendglab.github.io/OpenDGLab-WaveGen |
|
||||
| `OpenDGLab/OpenDGLab-OpenProtocol` (16★) | OpenProtocol spec (protobuf) |
|
||||
| `OpenDGLab/OpenDGLab-Core` (45★, Kotlin) | Reference implementation of BLE protocol |
|
||||
| `OpenDGLab/OpenDGLab-Connect` (19★, JS) | Web client using Web Bluetooth |
|
||||
| `OpenDGLab/OpenDGLab-Desktop` (58★, C++) | Desktop client |
|
||||
| `Ljzd-PRO/PyDGLab-WS` (81★, Python) | App-socket bridge library, async, well-maintained. Docs: pydglab-ws.readthedocs.io |
|
||||
| `Kruziikloksu/simple-custom-dg-lab-server` (Python) | Custom relay server, supports app-exported waveforms |
|
||||
| `huzpsb/DGLAB4J` (Java) | Coyote v3 socket protocol, Java impl |
|
||||
| `EcstasyEngineer/coyote-mcp` (JS) | MCP server for Coyote via app socket (LAN) |
|
||||
| `AngelcoMilk/DGLabPunish` (C#) | R.E.P.O. game mod with continuous waveforms |
|
||||
|
||||
## Where to find feel-good patterns
|
||||
|
||||
1. **Official example data**: `dungeonlab-open/dglab-bluetooth-protocol` — added V2/V3 波形示例数据 on 2024/10/28, under `coyote/v2` and `coyote/v3` directories
|
||||
2. **Built-in named patterns**: app presets (`Flick`, `Click`, …). Enumerate via `GETWAVELIST` (OpenProtocol) or select by name (PyDGLab-WS)
|
||||
3. **OpenDGLab-WaveGen**: visual editor that exports pattern strings importable in code
|
||||
4. **Community**: DG-Lab Discord/subreddit, nonebot plugin repos, game mod repos share pattern strings
|
||||
|
||||
## Pattern generation shapes (sensation over shock)
|
||||
|
||||
- **Slow breathing**: intensity = `50 + 40*sin(2π t / 8s)`, freq fixed ~150ms (~7Hz deep). 8s period.
|
||||
- **Teasing ramp**: intensity 0→80 over 5s, hold 1s, drop to 5, repeat. Freq alternating 100ms/250ms.
|
||||
- **Flutter**: freq 20ms (50Hz), intensity 10–30 quick pulses. Buzzy/tingly, low strength.
|
||||
|
||||
## Substation app (built this session)
|
||||
|
||||
Location: this directory
|
||||
- .NET 8 console app, targets `net8.0-windows10.0.19041.0`
|
||||
- Direct BLE via WinRT (`BluetoothLEDevice`, `GattCharacteristic`)
|
||||
- WebSocket server on `127.0.0.1:8765` via `HttpListener`
|
||||
- 100ms tick loop builds B0 from shared state and writes to BLE
|
||||
- JSON commands: `connect`, `status`, `strength`, `wave`, `stream`, `stop`, `config`, `disconnect`, `ping`
|
||||
- Designed to be driven by a browser userscript talking to `ws://127.0.0.1:8765`
|
||||
|
||||
```
|
||||
web game / userscript ──WS──> Substation ──BLE──> Coyote 3.0
|
||||
127.0.0.1:8765 B0/BF frames
|
||||
```
|
||||
|
||||
Build: `dotnet run -c Release`
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
namespace Substation;
|
||||
|
||||
static class Program
|
||||
{
|
||||
public const int Port = 8765;
|
||||
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
using var device = new CoyoteDevice();
|
||||
var state = new State();
|
||||
var server = new Server(device, state, Port);
|
||||
|
||||
device.StrengthChanged += (a, b) =>
|
||||
{
|
||||
state.ActualA = a;
|
||||
state.ActualB = b;
|
||||
};
|
||||
|
||||
Application.Run(new MainForm(device, state, server));
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Substation;
|
||||
|
||||
public static class Freq
|
||||
{
|
||||
public static byte Compress(int ms)
|
||||
{
|
||||
if (ms is >= 10 and <= 100) return (byte)ms;
|
||||
if (ms is > 100 and <= 600) return (byte)((ms - 100) / 5 + 100);
|
||||
if (ms is > 600 and <= 1000) return (byte)((ms - 600) / 10 + 200);
|
||||
return 10;
|
||||
}
|
||||
|
||||
public static byte[] Compress4(int[] ms)
|
||||
{
|
||||
if (ms.Length != 4) throw new ArgumentException("freq must have exactly 4 values", nameof(ms));
|
||||
return new byte[]
|
||||
{
|
||||
Compress(ms[0]), Compress(ms[1]), Compress(ms[2]), Compress(ms[3])
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class Intensity
|
||||
{
|
||||
public static byte Clamp(int v) => (byte)Math.Clamp(v, 0, 100);
|
||||
|
||||
public static byte[] Clamp4(int[] v)
|
||||
{
|
||||
if (v.Length != 4) throw new ArgumentException("intensity must have exactly 4 values", nameof(v));
|
||||
return new byte[] { Clamp(v[0]), Clamp(v[1]), Clamp(v[2]), Clamp(v[3]) };
|
||||
}
|
||||
}
|
||||
|
||||
public enum StrengthMode : byte
|
||||
{
|
||||
None = 0b00,
|
||||
Add = 0b01,
|
||||
Sub = 0b10,
|
||||
Abs = 0b11
|
||||
}
|
||||
|
||||
public enum LimitMode
|
||||
{
|
||||
Clamp = 0,
|
||||
Scale = 1
|
||||
}
|
||||
|
||||
public static class B0
|
||||
{
|
||||
public static byte PackModes(StrengthMode a, StrengthMode b) =>
|
||||
(byte)(((int)a << 2) | (int)b);
|
||||
|
||||
public static byte[] Build(
|
||||
byte seq,
|
||||
StrengthMode modeA, StrengthMode modeB,
|
||||
byte strengthA, byte strengthB,
|
||||
byte[] freqA, byte[] intA,
|
||||
byte[] freqB, byte[] intB)
|
||||
{
|
||||
if (freqA.Length != 4 || intA.Length != 4 || freqB.Length != 4 || intB.Length != 4)
|
||||
throw new ArgumentException("wave arrays must be 4 bytes each");
|
||||
|
||||
var f = new byte[20];
|
||||
f[0] = 0xB0;
|
||||
f[1] = (byte)((seq << 4) | (PackModes(modeA, modeB) & 0x0F));
|
||||
f[2] = strengthA;
|
||||
f[3] = strengthB;
|
||||
Buffer.BlockCopy(freqA, 0, f, 4, 4);
|
||||
Buffer.BlockCopy(intA, 0, f, 8, 4);
|
||||
Buffer.BlockCopy(freqB, 0, f, 12, 4);
|
||||
Buffer.BlockCopy(intB, 0, f, 16, 4);
|
||||
return f;
|
||||
}
|
||||
|
||||
public static readonly byte[] ChannelOff =
|
||||
{ 0, 0, 0, 0 };
|
||||
|
||||
public static readonly byte[] IntensityOff =
|
||||
{ 0, 0, 0, 101 };
|
||||
}
|
||||
|
||||
public static class BF
|
||||
{
|
||||
public static byte[] Build(
|
||||
byte capA, byte capB,
|
||||
byte freqBalA, byte freqBalB,
|
||||
byte intBalA, byte intBalB)
|
||||
{
|
||||
return new byte[]
|
||||
{
|
||||
0xBF, capA, capB, freqBalA, freqBalB, intBalA, intBalB
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public record WaveFrame(byte[] Freq, byte[] Intensity);
|
||||
|
||||
public class Command
|
||||
{
|
||||
[JsonPropertyName("op")] public string Op { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("channel")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Channel { get; set; }
|
||||
|
||||
[JsonPropertyName("mode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Value { get; set; }
|
||||
|
||||
[JsonPropertyName("freq")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int[]? Freq { get; set; }
|
||||
|
||||
[JsonPropertyName("intensity")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int[]? Intensity { get; set; }
|
||||
|
||||
[JsonPropertyName("frames")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public FrameDto[]? Frames { get; set; }
|
||||
|
||||
[JsonPropertyName("softcapA")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? SoftCapA { get; set; }
|
||||
|
||||
[JsonPropertyName("softcapB")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? SoftCapB { get; set; }
|
||||
|
||||
[JsonPropertyName("freqBalA")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? FreqBalA { get; set; }
|
||||
|
||||
[JsonPropertyName("freqBalB")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? FreqBalB { get; set; }
|
||||
|
||||
[JsonPropertyName("intBalA")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? IntBalA { get; set; }
|
||||
|
||||
[JsonPropertyName("intBalB")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? IntBalB { get; set; }
|
||||
}
|
||||
|
||||
public class FrameDto
|
||||
{
|
||||
[JsonPropertyName("freq")] public int[]? Freq { get; set; }
|
||||
[JsonPropertyName("intensity")] public int[]? Intensity { get; set; }
|
||||
}
|
||||
|
||||
public class OkResponse
|
||||
{
|
||||
[JsonPropertyName("ok")] public bool Ok => true;
|
||||
[JsonPropertyName("msg")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Msg { get; set; }
|
||||
}
|
||||
|
||||
public class ErrResponse
|
||||
{
|
||||
[JsonPropertyName("ok")] public bool Ok => false;
|
||||
[JsonPropertyName("error")] public string Error { get; set; } = "";
|
||||
}
|
||||
|
||||
public class StatusResponse
|
||||
{
|
||||
[JsonPropertyName("ok")] public bool Ok => true;
|
||||
[JsonPropertyName("event")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Event { get; set; }
|
||||
[JsonPropertyName("connected")] public bool Connected { get; set; }
|
||||
[JsonPropertyName("strengthA")] public int StrengthA { get; set; }
|
||||
[JsonPropertyName("strengthB")] public int StrengthB { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,231 @@
|
||||
# Substation
|
||||
|
||||
DGLab Coyote 3.0 WS adapter desktop app
|
||||
A thin .NET 8 **WinForms** app that bridges a **DG-Lab Coyote 3.0** e-stim box over Bluetooth Low Energy to a **WebSocket API on 127.0.0.1**. Designed to be driven by a browser userscript, game mod, or any process that can speak JSON over WS. Runs in the system tray.
|
||||
|
||||
```
|
||||
web game / userscript ──WS──> Substation ──BLE──> Coyote 3.0
|
||||
127.0.0.1:8765 B0/BF frames
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10 1809+ (Bluetooth LE support)
|
||||
- .NET 8 SDK — `winget install Microsoft.DotNet.SDK.8` (or <https://dotnet.microsoft.com/download/dotnet/8.0>)
|
||||
- Bluetooth radio enabled
|
||||
- Coyote 3.0 powered on (advertises as `47L121000`)
|
||||
|
||||
## Build
|
||||
|
||||
```bat
|
||||
cd Substation
|
||||
dotnet build -c Release
|
||||
dotnet run -c Release
|
||||
```
|
||||
|
||||
Or publish a self-contained exe:
|
||||
|
||||
```bat
|
||||
dotnet publish -c Release -r win-x64 --self-contained
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
A small window with:
|
||||
- **BLE status** — connection state + device name
|
||||
- **WS status** — idle / client connected (only one client allowed at a time; additional attempts are rejected)
|
||||
- **Strength readout** — current A/B strength from the box
|
||||
- **Connect BLE** — retry connection if auto-connect failed
|
||||
- **Test** — runs a ~3s gentle swell pattern on both channels (strength 25, 7Hz deep pulse, sinusoidal envelope). Enabled only when BLE is connected AND no WS client is attached.
|
||||
- **Stop All** — zeros strength and clears waveforms on both channels (panic stop)
|
||||
|
||||
Closing the window minimizes to the tray icon (double-click to restore; right-click for Show/Exit menu).
|
||||
|
||||
## How it works
|
||||
|
||||
The app runs two concurrent loops:
|
||||
|
||||
1. **Tick loop** — every 100ms, builds a `B0` frame (20 bytes) from the current shared state and writes it to BLE characteristic `0x150A`. This is the Coyote's output window: 4 frequency + 4 intensity values per channel, each value representing 25ms of output.
|
||||
2. **WebSocket server** — listens on `127.0.0.1:PORT`, accepts JSON commands that mutate the shared state (strength, waveform patterns, stream queues). Only one WS client is served at a time; a second client receives `{"ok":false,"error":"another client is already connected"}` and is closed.
|
||||
|
||||
On BLE connect, the app sends a `BF` frame to set soft caps and balance parameters with safe defaults (caps=200, balances=128).
|
||||
|
||||
### Protocol cheat sheet
|
||||
|
||||
| Byte(s) | B0 field |
|
||||
|---------|---------------------------------------|
|
||||
| 0 | `0xB0` (command head) |
|
||||
| 1 | seq (high 4 bits) + strength mode (low 4 bits) |
|
||||
| 2 | Channel A strength value (0–200) |
|
||||
| 3 | Channel B strength value (0–200) |
|
||||
| 4–7 | Channel A waveform frequency ×4 (10–240) |
|
||||
| 8–11 | Channel A waveform intensity ×4 (0–100) |
|
||||
| 12–15 | Channel B waveform frequency ×4 |
|
||||
| 16–19 | Channel B waveform intensity ×4 |
|
||||
|
||||
Frequency input is in **milliseconds** (10–1000, where 10ms = 100Hz buzzy, 1000ms = 1Hz deep thump). The app compresses this to the device's 10–240 byte range:
|
||||
|
||||
| Input range | Compression formula |
|
||||
|--------------|------------------------------|
|
||||
| 10–100 | identity |
|
||||
| 101–600 | `(input - 100) / 5 + 100` |
|
||||
| 601–1000 | `(input - 600) / 10 + 200` |
|
||||
|
||||
## WebSocket API
|
||||
|
||||
All commands are JSON objects with an `op` field. Send one per message. Responses are JSON: `{"ok":true,"msg":"..."}` or `{"ok":false,"error":"..."}`.
|
||||
|
||||
### `connect`
|
||||
```json
|
||||
{"op":"connect"}
|
||||
```
|
||||
Scans for the Coyote and connects. Auto-attempted on startup; retry with this if it fails.
|
||||
|
||||
### `status`
|
||||
```json
|
||||
{"op":"status"}
|
||||
```
|
||||
Returns:
|
||||
```json
|
||||
{"ok":true,"connected":true,"strengthA":15,"strengthB":0}
|
||||
```
|
||||
|
||||
### `strength`
|
||||
```json
|
||||
{"op":"strength","channel":"A","value":50}
|
||||
```
|
||||
Sets channel strength (absolute, 0–200). This is the **hard ceiling** — the panic stop. Set to 0 to silence a channel immediately.
|
||||
|
||||
### `wave`
|
||||
```json
|
||||
{"op":"wave","channel":"A","freq":[100,100,100,100],"intensity":[0,20,50,80]}
|
||||
```
|
||||
Sets a **looping 4-tick pattern** for the channel. Repeats every 100ms until replaced or stopped.
|
||||
- `freq`: 4 values in ms (10–1000)
|
||||
- `intensity`: 4 values 0–100 (pulse width, relative)
|
||||
|
||||
### `stream`
|
||||
```json
|
||||
{"op":"stream","channel":"A","frames":[
|
||||
{"freq":[100,100,100,100],"intensity":[0,30,60,90]},
|
||||
{"freq":[50,50,50,50],"intensity":[90,70,40,10]}
|
||||
]}
|
||||
```
|
||||
Queues **one-shot frames** that play before resuming the loop pattern. Each frame = 100ms. Use for transitions, ramps, timed effects.
|
||||
|
||||
### `stop`
|
||||
```json
|
||||
{"op":"stop","channel":"A"}
|
||||
```
|
||||
Clears the loop pattern and stream queue for the channel. Channel goes silent.
|
||||
|
||||
### `config`
|
||||
```json
|
||||
{"op":"config","softcapA":150,"softcapB":150,"freqBalA":128,"freqBalB":128,"intBalA":128,"intBalB":128}
|
||||
```
|
||||
Sends a `BF` frame. Soft caps limit the maximum strength (persisted on device). Balance parameters adjust low/high frequency feel (0–255, 128 = neutral). All fields optional; omitted fields use defaults.
|
||||
|
||||
### `disconnect`
|
||||
```json
|
||||
{"op":"disconnect"}
|
||||
```
|
||||
Stops all waveforms on both channels. BLE connection stays alive.
|
||||
|
||||
### `ping`
|
||||
```json
|
||||
{"op":"ping"}
|
||||
```
|
||||
Returns `{"ok":true,"msg":"pong"}`. Use for keepalive.
|
||||
|
||||
## Example userscript
|
||||
|
||||
```javascript
|
||||
// ==UserScript==
|
||||
// @name Coyote Bridge Client
|
||||
// @match https://your-game.example.com/*
|
||||
// @grant none
|
||||
// ==/UserScript==
|
||||
|
||||
const ws = new WebSocket("ws://127.0.0.1:8765");
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("[coyote] connected to bridge");
|
||||
ws.send(JSON.stringify({ op: "status" }));
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
console.log("[coyote]", JSON.parse(e.data));
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.error("[coyote] WS error", e);
|
||||
|
||||
function setStrength(ch, val) {
|
||||
ws.send(JSON.stringify({ op: "strength", channel: ch, value: val }));
|
||||
}
|
||||
|
||||
function setWave(ch, freq, intensity) {
|
||||
ws.send(JSON.stringify({ op: "wave", channel: ch, freq, intensity }));
|
||||
}
|
||||
|
||||
function stop(ch) {
|
||||
ws.send(JSON.stringify({ op: "stop", channel: ch }));
|
||||
}
|
||||
|
||||
// Example: breathing pattern on channel A
|
||||
// 7Hz deep pulse (150ms), intensity swells 0->80->0 over ~8 ticks
|
||||
const breathe = [
|
||||
{ freq: [150,150,150,150], intensity: [0, 10, 20, 30] },
|
||||
{ freq: [150,150,150,150], intensity: [40, 55, 70, 80] },
|
||||
{ freq: [150,150,150,150], intensity: [80, 70, 55, 40] },
|
||||
{ freq: [150,150,150,150], intensity: [30, 20, 10, 0 ] },
|
||||
];
|
||||
|
||||
let breatheIdx = 0;
|
||||
setInterval(() => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({
|
||||
op: "stream",
|
||||
channel: "A",
|
||||
frames: [breathe[breatheIdx]]
|
||||
}));
|
||||
breatheIdx = (breatheIdx + 1) % breathe.length;
|
||||
}, 100);
|
||||
|
||||
// Set a safe strength ceiling first
|
||||
setStrength("A", 30);
|
||||
|
||||
// Panic stop
|
||||
// stop("A"); setStrength("A", 0);
|
||||
```
|
||||
|
||||
## Safety notes
|
||||
|
||||
- **Start with low strength** (10–30). The box goes to 200; that's a lot.
|
||||
- Set soft caps via `config` to limit the ceiling before experimenting.
|
||||
- The `stop` command + `strength 0` is your emergency brake.
|
||||
- Strength is the **amplitude ceiling**; waveform intensity (0–100) is the **pulse width** that creates texture within that ceiling. Keep strength modest and do expression in the intensity envelope.
|
||||
- The app uses `seq=0` (no strength ack) for simplicity. Strength changes are fire-and-forget. If you need guaranteed delivery, enhance `CoyoteDevice` to use `seq>0` and wait for `B1` responses.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
Substation/
|
||||
├── Substation.csproj — net8.0-windows, WinForms, WinRT BLE
|
||||
├── Protocol.cs — freq compression, B0/BF builders, JSON DTOs
|
||||
├── CoyoteDevice.cs — BLE connect, GATT read/write, notify handling
|
||||
├── State.cs — thread-safe shared state (strength, wave queues)
|
||||
├── Server.cs — HttpListener WebSocket server (single-client) + command dispatch
|
||||
├── MainForm.cs — WinForms window, tray icon, voltage glyph, test pattern, tick loop
|
||||
├── Program.cs — entry point, wires components, runs form
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [DG-LAB official BLE protocol](https://github.com/dungeonlab-open/dglab-bluetooth-protocol)
|
||||
- [Waveform explanation](https://github.com/dungeonlab-open/dglab-bluetooth-protocol/blob/main/coyote/README.md)
|
||||
- [PyDGLab-WS](https://github.com/Ljzd-PRO/PyDGLab-WS) — Python equivalent (app-socket path)
|
||||
- [OpenDGLab WaveGen](https://opendglab.github.io/OpenDGLab-WaveGen/) — visual waveform editor
|
||||
|
||||
---
|
||||
|
||||
Entirely vibecoded with [GLM 5.2](https://chatglm.ai).
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Substation;
|
||||
|
||||
public class Server
|
||||
{
|
||||
readonly CoyoteDevice _device;
|
||||
readonly State _state;
|
||||
readonly HttpListener _listener;
|
||||
readonly string _url;
|
||||
|
||||
int _hasClient; // 0 = no, 1 = yes (Interlocked)
|
||||
|
||||
WebSocket? _pushWs;
|
||||
readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
|
||||
public bool HasClient => Interlocked.CompareExchange(ref _hasClient, 0, 0) == 1;
|
||||
|
||||
public event Action<bool>? ClientChanged;
|
||||
|
||||
static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
public Server(CoyoteDevice device, State state, int port)
|
||||
{
|
||||
_device = device;
|
||||
_state = state;
|
||||
_url = $"http://127.0.0.1:{port}/";
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add(_url);
|
||||
_device.ConnectionChanged += OnDeviceConnectionChanged;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try { _listener.Stop(); } catch { }
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_listener.Start();
|
||||
Console.WriteLine($"[WS] listening on {_url}");
|
||||
|
||||
using var reg = ct.Register(() =>
|
||||
{
|
||||
try { _listener.Stop(); } catch { }
|
||||
});
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
HttpListenerContext ctx;
|
||||
try
|
||||
{
|
||||
ctx = await _listener.GetContextAsync();
|
||||
}
|
||||
catch (HttpListenerException) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
|
||||
if (!ctx.Request.IsWebSocketRequest)
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
ctx.Response.Close();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HasClient)
|
||||
{
|
||||
// Reject: only one client at a time.
|
||||
try
|
||||
{
|
||||
var rej = await ctx.AcceptWebSocketAsync(null);
|
||||
var msg = Encoding.UTF8.GetBytes("{\"ok\":false,\"error\":\"another client is already connected\"}");
|
||||
await rej.WebSocket.SendAsync(msg, WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
await rej.WebSocket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "busy", CancellationToken.None);
|
||||
}
|
||||
catch { }
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = HandleClientAsync(ctx, ct);
|
||||
}
|
||||
}
|
||||
|
||||
async Task HandleClientAsync(HttpListenerContext ctx, CancellationToken ct)
|
||||
{
|
||||
WebSocket ws;
|
||||
try
|
||||
{
|
||||
var wsCtx = await ctx.AcceptWebSocketAsync(null);
|
||||
ws = wsCtx.WebSocket;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WS] accept failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _hasClient, 1);
|
||||
_pushWs = ws;
|
||||
ClientChanged?.Invoke(true);
|
||||
Console.WriteLine($"[WS] client connected ({ctx.Request.RemoteEndPoint})");
|
||||
|
||||
// Push current status immediately so the client knows the BLE state
|
||||
await SendAsync(ws, BuildStatusPush(_device.IsConnected ? "hello" : null));
|
||||
|
||||
var buf = new byte[8192];
|
||||
try
|
||||
{
|
||||
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
|
||||
{
|
||||
WebSocketReceiveResult result;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
do
|
||||
{
|
||||
result = await ws.ReceiveAsync(buf, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
goto done;
|
||||
|
||||
sb.Append(Encoding.UTF8.GetString(buf, 0, result.Count));
|
||||
}
|
||||
while (!result.EndOfMessage);
|
||||
|
||||
var response = HandleCommand(sb.ToString());
|
||||
await SendAsync(ws, response);
|
||||
}
|
||||
done:;
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WS] socket error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pushWs = null;
|
||||
if (ws.State == WebSocketState.Open)
|
||||
try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); } catch { }
|
||||
ws.Dispose();
|
||||
Interlocked.Exchange(ref _hasClient, 0);
|
||||
ClientChanged?.Invoke(false);
|
||||
Console.WriteLine("[WS] client disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
async Task SendAsync(WebSocket ws, string text)
|
||||
{
|
||||
await _sendLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (ws.State == WebSocketState.Open)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
await ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
finally { _sendLock.Release(); }
|
||||
}
|
||||
|
||||
void OnDeviceConnectionChanged(bool connected)
|
||||
{
|
||||
var ws = _pushWs;
|
||||
if (ws == null) return;
|
||||
var msg = BuildStatusPush(connected ? "connected" : "disconnected");
|
||||
_ = SendAsync(ws, msg);
|
||||
}
|
||||
|
||||
string BuildStatusPush(string? eventName)
|
||||
{
|
||||
return JsonSerializer.Serialize(new StatusResponse
|
||||
{
|
||||
Event = eventName,
|
||||
Connected = _device.IsConnected,
|
||||
StrengthA = _device.StrengthA,
|
||||
StrengthB = _device.StrengthB
|
||||
}, JsonOpts);
|
||||
}
|
||||
|
||||
string HandleCommand(string json)
|
||||
{
|
||||
Command? cmd;
|
||||
try
|
||||
{
|
||||
cmd = JsonSerializer.Deserialize<Command>(json, JsonOpts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Err($"invalid JSON: {ex.Message}");
|
||||
}
|
||||
|
||||
if (cmd == null || string.IsNullOrWhiteSpace(cmd.Op))
|
||||
return Err("missing 'op' field");
|
||||
|
||||
try
|
||||
{
|
||||
return cmd.Op switch
|
||||
{
|
||||
"connect" => DoConnect(),
|
||||
"status" => DoStatus(),
|
||||
"ping" => Ok("pong"),
|
||||
_ when !_device.IsConnected => Err("device not connected"),
|
||||
"strength" => DoStrength(cmd),
|
||||
"wave" => DoWave(cmd),
|
||||
"stream" => DoStream(cmd),
|
||||
"stop" => DoStop(cmd),
|
||||
"config" => DoConfig(cmd),
|
||||
"disconnect" => DoDisconnect(),
|
||||
_ => Err($"unknown op: '{cmd.Op}'")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Err(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
string DoConnect()
|
||||
{
|
||||
if (_device.IsConnected)
|
||||
return Ok("already connected");
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _device.ConnectAsync();
|
||||
Console.WriteLine($"[BLE] connected to {_device.DeviceName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[BLE] connect failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok("connecting...");
|
||||
}
|
||||
|
||||
string DoStatus()
|
||||
{
|
||||
return JsonSerializer.Serialize(new StatusResponse
|
||||
{
|
||||
Connected = _device.IsConnected,
|
||||
StrengthA = _device.StrengthA,
|
||||
StrengthB = _device.StrengthB
|
||||
}, JsonOpts);
|
||||
}
|
||||
|
||||
string DoStrength(Command cmd)
|
||||
{
|
||||
if (cmd.Channel is not ("A" or "B"))
|
||||
return Err("channel must be 'A' or 'B'");
|
||||
if (!cmd.Value.HasValue)
|
||||
return Err("value required (0-200)");
|
||||
|
||||
var ch = cmd.Channel[0];
|
||||
_state.SetStrength(ch, cmd.Value.Value);
|
||||
return Ok($"strength {ch} -> {cmd.Value.Value}");
|
||||
}
|
||||
|
||||
string DoWave(Command cmd)
|
||||
{
|
||||
if (cmd.Channel is not ("A" or "B"))
|
||||
return Err("channel must be 'A' or 'B'");
|
||||
if (cmd.Freq == null || cmd.Intensity == null)
|
||||
return Err("freq[] and intensity[] required (4 values each)");
|
||||
|
||||
var freq = Freq.Compress4(cmd.Freq);
|
||||
var intensity = Intensity.Clamp4(cmd.Intensity);
|
||||
var ch = cmd.Channel[0];
|
||||
|
||||
_state.SetLoop(ch, freq, intensity);
|
||||
return Ok($"wave {ch} looped: freq=[{string.Join(',', freq)}] int=[{string.Join(',', intensity)}]");
|
||||
}
|
||||
|
||||
string DoStream(Command cmd)
|
||||
{
|
||||
if (cmd.Channel is not ("A" or "B"))
|
||||
return Err("channel must be 'A' or 'B'");
|
||||
if (cmd.Frames == null || cmd.Frames.Length == 0)
|
||||
return Err("frames[] required");
|
||||
|
||||
var ch = cmd.Channel[0];
|
||||
var frames = cmd.Frames.Select(f =>
|
||||
{
|
||||
if (f.Freq == null || f.Intensity == null || f.Freq.Length != 4 || f.Intensity.Length != 4)
|
||||
throw new ArgumentException("each frame needs freq[4] and intensity[4]");
|
||||
return new WaveFrame(Freq.Compress4(f.Freq), Intensity.Clamp4(f.Intensity));
|
||||
}).ToList();
|
||||
|
||||
_state.EnqueueStream(ch, frames);
|
||||
return Ok($"streamed {frames.Count} frames to {ch}");
|
||||
}
|
||||
|
||||
string DoStop(Command cmd)
|
||||
{
|
||||
if (cmd.Channel is not ("A" or "B"))
|
||||
return Err("channel must be 'A' or 'B'");
|
||||
|
||||
var ch = cmd.Channel[0];
|
||||
_state.Stop(ch);
|
||||
return Ok($"stopped {ch}");
|
||||
}
|
||||
|
||||
string DoConfig(Command cmd)
|
||||
{
|
||||
var capA = (byte)Math.Clamp(cmd.SoftCapA ?? 200, 0, 200);
|
||||
var capB = (byte)Math.Clamp(cmd.SoftCapB ?? 200, 0, 200);
|
||||
var freqBalA = (byte)Math.Clamp(cmd.FreqBalA ?? 128, 0, 255);
|
||||
var freqBalB = (byte)Math.Clamp(cmd.FreqBalB ?? 128, 0, 255);
|
||||
var intBalA = (byte)Math.Clamp(cmd.IntBalA ?? 128, 0, 255);
|
||||
var intBalB = (byte)Math.Clamp(cmd.IntBalB ?? 128, 0, 255);
|
||||
|
||||
Task.Run(() => _device.SendBF(capA, capB, freqBalA, freqBalB, intBalA, intBalB));
|
||||
return Ok($"config sent: caps={capA}/{capB} freqBal={freqBalA}/{freqBalB} intBal={intBalA}/{intBalB}");
|
||||
}
|
||||
|
||||
string DoDisconnect()
|
||||
{
|
||||
_state.Stop('A');
|
||||
_state.Stop('B');
|
||||
return Ok("waveforms stopped (BLE stays connected; restart app to fully disconnect)");
|
||||
}
|
||||
|
||||
static string Ok(string? msg = null) =>
|
||||
JsonSerializer.Serialize(new OkResponse { Msg = msg }, JsonOpts);
|
||||
|
||||
static string Err(string error) =>
|
||||
JsonSerializer.Serialize(new ErrResponse { Error = error }, JsonOpts);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Substation;
|
||||
|
||||
public class State
|
||||
{
|
||||
public readonly object Lock = new();
|
||||
|
||||
public bool DirtyA, DirtyB;
|
||||
public int DesiredA, DesiredB;
|
||||
|
||||
public byte[]? LoopFreqA, LoopIntA;
|
||||
public byte[]? LoopFreqB, LoopIntB;
|
||||
|
||||
public readonly ConcurrentQueue<WaveFrame> StreamA = new();
|
||||
public readonly ConcurrentQueue<WaveFrame> StreamB = new();
|
||||
|
||||
public int ActualA, ActualB;
|
||||
|
||||
public int MaxStrength = 200;
|
||||
public LimitMode LimitMode = LimitMode.Clamp;
|
||||
|
||||
public void SetLimit(int max, LimitMode mode)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
MaxStrength = Math.Clamp(max, 0, 200);
|
||||
LimitMode = mode;
|
||||
DirtyA = true;
|
||||
DirtyB = true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSignaling
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
bool hasPattern = LoopFreqA != null || LoopFreqB != null
|
||||
|| !StreamA.IsEmpty || !StreamB.IsEmpty;
|
||||
bool hasStrength = DesiredA > 0 || DesiredB > 0;
|
||||
return hasPattern && hasStrength;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetStrength(char ch, int value)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 200);
|
||||
if (ch == 'A') { DesiredA = clamped; DirtyA = true; }
|
||||
else { DesiredB = clamped; DirtyB = true; }
|
||||
}
|
||||
}
|
||||
|
||||
public void SetLoop(char ch, byte[] freq, byte[] intensity)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (ch == 'A') { LoopFreqA = freq; LoopIntA = intensity; }
|
||||
else { LoopFreqB = freq; LoopIntB = intensity; }
|
||||
}
|
||||
}
|
||||
|
||||
public void EnqueueStream(char ch, IEnumerable<WaveFrame> frames)
|
||||
{
|
||||
var queue = ch == 'A' ? StreamA : StreamB;
|
||||
foreach (var f in frames)
|
||||
queue.Enqueue(f);
|
||||
}
|
||||
|
||||
public void Stop(char ch)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (ch == 'A') { LoopFreqA = null; LoopIntA = null; }
|
||||
else { LoopFreqB = null; LoopIntB = null; }
|
||||
}
|
||||
var queue = ch == 'A' ? StreamA : StreamB;
|
||||
while (queue.TryDequeue(out _)) { }
|
||||
}
|
||||
|
||||
public (StrengthMode modeA, byte valA, StrengthMode modeB, byte valB,
|
||||
byte[] freqA, byte[] intA, byte[] freqB, byte[] intB)
|
||||
ConsumeTick()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
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);
|
||||
DirtyA = false;
|
||||
DirtyB = false;
|
||||
|
||||
var (freqA, intA) = PopWave(StreamA, LoopFreqA, LoopIntA);
|
||||
var (freqB, intB) = PopWave(StreamB, LoopFreqB, LoopIntB);
|
||||
|
||||
return (modeA, valA, modeB, valB, freqA, intA, freqB, intB);
|
||||
}
|
||||
}
|
||||
|
||||
static (byte[] freq, byte[] intensity) PopWave(
|
||||
ConcurrentQueue<WaveFrame> stream,
|
||||
byte[]? loopFreq, byte[]? loopInt)
|
||||
{
|
||||
if (stream.TryDequeue(out var frame))
|
||||
return (frame.Freq, frame.Intensity);
|
||||
|
||||
if (loopFreq != null && loopInt != null)
|
||||
return (loopFreq, loopInt);
|
||||
|
||||
return (B0.ChannelOff, B0.IntensityOff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AssemblyName>Substation</AssemblyName>
|
||||
<RootNamespace>Substation</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NAudio" Version="2.2.1" />
|
||||
<PackageReference Include="FftSharp" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user