44421a51e3
- 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
335 lines
10 KiB
C#
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);
|
|
}
|