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