2026-08-12 00:29:54 +00:00
|
|
|
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;
|
2026-08-12 10:30:11 +00:00
|
|
|
private uint _session;
|
2026-08-12 00:29:54 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-08-12 10:30:11 +00:00
|
|
|
_session++;
|
2026-08-12 00:29:54 +00:00
|
|
|
_nonce = 0;
|
2026-08-12 10:30:11 +00:00
|
|
|
Log?.Invoke($"STT: session {_session} started");
|
2026-08-12 00:29:54 +00:00
|
|
|
SendNop();
|
|
|
|
|
_nopTask = NopLoopAsync(_cts.Token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void SendOff()
|
|
|
|
|
{
|
|
|
|
|
StopNop();
|
2026-08-12 10:30:11 +00:00
|
|
|
SendControl($"HKMSTR:OFF {_session} {_nonce}");
|
2026-08-12 00:29:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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++;
|
2026-08-12 10:30:11 +00:00
|
|
|
SendControl($"HKMSTR {_session} {_nonce}");
|
2026-08-12 00:29:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-08-12 10:30:11 +00:00
|
|
|
TranscriptMessage? message = ParseReply(text);
|
|
|
|
|
if (message is null)
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
TranscriptReceived?.Invoke(this, new TranscriptEventArgs
|
2026-08-12 00:29:54 +00:00
|
|
|
{
|
2026-08-12 10:30:11 +00:00
|
|
|
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))
|
2026-08-12 00:29:54 +00:00
|
|
|
{
|
2026-08-12 10:30:11 +00:00
|
|
|
if (sessionOnly != _session)
|
|
|
|
|
return null;
|
|
|
|
|
return new TranscriptMessage(type, string.Empty);
|
2026-08-12 00:29:54 +00:00
|
|
|
}
|
2026-08-12 10:30:11 +00:00
|
|
|
return null;
|
2026-08-12 00:29:54 +00:00
|
|
|
}
|
2026-08-12 10:30:11 +00:00
|
|
|
|
|
|
|
|
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);
|
2026-08-12 00:29:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|