Files
Substation/Server.cs
T
mute 44421a51e3 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
2026-08-08 10:46:25 +00:00

335 lines
10 KiB
C#

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);
}