using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text; using Robovoice.Core; namespace Robovoice.Stt.Dhcp; public sealed class DhcpSttSource : ISttSource { private const string Magic = "HKMSTR"; private const int DhcpClientPort = 68; private const int DhcpServerPort = 67; private static readonly TimeSpan NopInterval = TimeSpan.FromMilliseconds(50); private Socket? _sock; private CancellationTokenSource? _cts; private Task? _receiveTask; private Task? _nopTask; private EndPoint _broadcastEp = new IPEndPoint(IPAddress.Broadcast, DhcpServerPort); private uint _nonce; private uint _session; private bool _disposed; public string InterfaceIp { get; set; } = string.Empty; public Action? Log { get; set; } public event TranscriptEventHandler? TranscriptReceived; public Task StartAsync(CancellationToken ct = default) { ObjectDisposedException.ThrowIf(_disposed, this); if (_cts is not null) return Task.CompletedTask; string ip = string.IsNullOrEmpty(InterfaceIp) ? AutoDetectInterfaceIp() ?? "0.0.0.0" : InterfaceIp; _sock = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); _sock.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _sock.EnableBroadcast = true; _sock.Bind(new IPEndPoint(IPAddress.Parse(ip), DhcpClientPort)); Log?.Invoke($"STT: bound to {ip}:{DhcpClientPort}, broadcasting to :{DhcpServerPort}"); _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _receiveTask = ReceiveLoopAsync(_cts.Token); return Task.CompletedTask; } public async Task StopAsync(CancellationToken ct = default) { StopNop(); if (_cts is not null) _cts.Cancel(); _sock?.Dispose(); _sock = null; if (_receiveTask is not null) { try { await _receiveTask.WaitAsync(ct); } catch { } _receiveTask = null; } _cts?.Dispose(); _cts = null; } public void SendOn() { if (_cts is null) return; _session++; _nonce = 0; Log?.Invoke($"STT: session {_session} started"); SendNop(); _nopTask = NopLoopAsync(_cts.Token); } public void SendOff() { StopNop(); SendControl($"HKMSTR:OFF {_session} {_nonce}"); } private void StopNop() { if (_nopTask is not null) { try { _nopTask.Wait(2000); } catch { } _nopTask = null; } } private async Task NopLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { await Task.Delay(NopInterval, ct); } catch (OperationCanceledException) { break; } SendNop(); } } private void SendNop() { _nonce++; SendControl($"HKMSTR {_session} {_nonce}"); } private void SendControl(string message) { if (_sock is null) return; try { byte[] payload = Encoding.UTF8.GetBytes(message + "\n"); _sock.SendTo(payload, _broadcastEp); } catch (Exception ex) { Log?.Invoke($"STT: send failed: {ex.Message}"); } } private async Task ReceiveLoopAsync(CancellationToken ct) { byte[] buffer = new byte[4096]; EndPoint fromEp = new IPEndPoint(IPAddress.Any, 0); while (!ct.IsCancellationRequested) { int received; try { if (!_sock!.Poll(500_000, SelectMode.SelectRead)) continue; received = _sock.ReceiveFrom(buffer, ref fromEp); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { break; } catch (Exception ex) { Log?.Invoke($"STT: receive error: {ex.Message}"); continue; } string text = Encoding.UTF8.GetString(buffer, 0, received).TrimEnd('\n', '\r'); if (!text.StartsWith(Magic)) continue; TranscriptMessage? message = ParseReply(text); if (message is null) continue; TranscriptReceived?.Invoke(this, new TranscriptEventArgs { Message = message, }); } } private TranscriptMessage? ParseReply(string text) { // Format: HKMSTR:P or HKMSTR:F // may be empty. string prefix; TranscriptType type; if (text.StartsWith("HKMSTR:P ")) { prefix = "HKMSTR:P "; type = TranscriptType.Partial; } else if (text.StartsWith("HKMSTR:F ")) { prefix = "HKMSTR:F "; type = TranscriptType.Final; } else { return null; } string rest = text[prefix.Length..]; int spaceIndex = rest.IndexOf(' '); if (spaceIndex < 0) { if (uint.TryParse(rest, out uint sessionOnly)) { if (sessionOnly != _session) return null; return new TranscriptMessage(type, string.Empty); } return null; } string sessionStr = rest[..spaceIndex]; if (!uint.TryParse(sessionStr, out uint session)) return null; if (session != _session) { Log?.Invoke($"STT: dropping stale reply (session {session} != current {_session})"); return null; } string transcript = rest[(spaceIndex + 1)..]; return new TranscriptMessage(type, transcript); } public static List<(string Ip, string Name)> GetAvailableInterfaces() { var result = new List<(string, string)>(); foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { if (nic.OperationalStatus != OperationalStatus.Up) continue; if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; string desc = nic.Description; if (desc.Contains("WireGuard", StringComparison.OrdinalIgnoreCase)) continue; foreach (var addr in nic.GetIPProperties().UnicastAddresses) { if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue; string ip = addr.Address.ToString(); if (ip.StartsWith("192.168.") || ip.StartsWith("10.") || ip.StartsWith("172.16.") || ip.StartsWith("172.17.") || ip.StartsWith("172.18.") || ip.StartsWith("172.19.") || ip.StartsWith("172.20.") || ip.StartsWith("172.21.") || ip.StartsWith("172.22.") || ip.StartsWith("172.23.") || ip.StartsWith("172.24.") || ip.StartsWith("172.25.") || ip.StartsWith("172.26.") || ip.StartsWith("172.27.") || ip.StartsWith("172.28.") || ip.StartsWith("172.29.") || ip.StartsWith("172.30.") || ip.StartsWith("172.31.")) { result.Add((ip, $"{nic.Name} ({ip})")); } } } return result; } private static string? AutoDetectInterfaceIp() { foreach (var (ip, _) in GetAvailableInterfaces()) return ip; return null; } public async ValueTask DisposeAsync() { if (_disposed) return; await StopAsync(); _disposed = true; } }