Files
Robovoice/Robovoice.Stt.Dhcp/DhcpSttSource.cs
T

278 lines
7.8 KiB
C#

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<string>? 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 <session> <text> or HKMSTR:F <session> <text>
// <text> 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;
}
}