diff --git a/src/Log4YM.Contracts/Events/LogEvents.cs b/src/Log4YM.Contracts/Events/LogEvents.cs index cad375d..5b69b0c 100644 --- a/src/Log4YM.Contracts/Events/LogEvents.cs +++ b/src/Log4YM.Contracts/Events/LogEvents.cs @@ -899,3 +899,78 @@ public record ActivateChannelTunerGeniusCommand( string DeviceSerial, int Channel // 1 or 2 ); + +// ===== UDP Provider Events ===== + +/// +/// UDP provider status changed +/// +public record UdpProviderStatusChangedEvent( + string ProviderId, + string ProviderName, + bool IsRunning, + bool IsListening, + int? ListeningPort, + bool? IsMulticastEnabled, + string? ErrorMessage = null +); + +/// +/// WSJT-X status update received +/// +public record WsjtxStatusReceivedEvent( + string Id, + long DialFrequency, + string Mode, + string DxCall, + string DxGrid, + string DeCall, + string DeGrid, + bool TxEnabled, + bool Transmitting, + bool Decoding, + int RxDf, + int TxDf, + string SubMode, + int FreqTolerance, + double TrPeriod, + string Report, + string TxMessage +); + +/// +/// WSJT-X decode message received +/// +public record WsjtxDecodeReceivedEvent( + string Id, + bool IsNew, + DateTime Time, + int Snr, + double DeltaTime, + int DeltaFrequency, + string Mode, + string Message, + bool LowConfidence, + bool OffAir +); + +/// +/// WSJT-X QSO logged event +/// +public record WsjtxQsoLoggedEvent( + string Id, + string DxCall, + string DxGrid, + long TxFrequency, + string Mode, + string ReportSent, + string ReportReceived, + string TxPower, + DateTime TimeOn, + DateTime TimeOff, + string MyCall, + string MyGrid, + string? Comments = null, + string? ExchangeSent = null, + string? ExchangeReceived = null +); diff --git a/src/Log4YM.Contracts/Models/Settings.cs b/src/Log4YM.Contracts/Models/Settings.cs index 0198ceb..317fca4 100644 --- a/src/Log4YM.Contracts/Models/Settings.cs +++ b/src/Log4YM.Contracts/Models/Settings.cs @@ -36,6 +36,9 @@ public class UserSettings [BsonElement("ai")] public AiSettings Ai { get; set; } = new(); + [BsonElement("udpProviders")] + public UdpProviderSettings UdpProviders { get; set; } = new(); + [BsonElement("layoutJson")] public string? LayoutJson { get; set; } diff --git a/src/Log4YM.Contracts/Models/UdpProviderSettings.cs b/src/Log4YM.Contracts/Models/UdpProviderSettings.cs new file mode 100644 index 0000000..3de4c71 --- /dev/null +++ b/src/Log4YM.Contracts/Models/UdpProviderSettings.cs @@ -0,0 +1,71 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace Log4YM.Contracts.Models; + +/// +/// Settings for UDP-based integrations (WSJT-X, JTDX, MSHV, GridTracker, Hamlib, etc.) +/// +public class UdpProviderSettings +{ + [BsonElement("providers")] + public List Providers { get; set; } = new() + { + // WSJT-X/JTDX/MSHV (compatible protocol) + new UdpProviderConfig + { + Id = "wsjtx", + Name = "WSJT-X / JTDX / MSHV", + Enabled = false, + Port = 2237, + SupportsMulticast = true, + MulticastEnabled = false, + MulticastAddress = "224.0.0.73", + MulticastTtl = 1, + ForwardingEnabled = false, + ForwardingAddresses = new List(), + Description = "Digital mode applications (FT8, FT4, JT65, etc.)" + } + }; +} + +/// +/// Configuration for a single UDP provider +/// +public class UdpProviderConfig +{ + [BsonElement("id")] + public string Id { get; set; } = string.Empty; + + [BsonElement("name")] + public string Name { get; set; } = string.Empty; + + [BsonElement("enabled")] + public bool Enabled { get; set; } + + [BsonElement("port")] + public int Port { get; set; } + + [BsonElement("supportsMulticast")] + public bool SupportsMulticast { get; set; } + + [BsonElement("multicastEnabled")] + public bool MulticastEnabled { get; set; } + + [BsonElement("multicastAddress")] + public string MulticastAddress { get; set; } = "224.0.0.73"; + + [BsonElement("multicastTtl")] + public int MulticastTtl { get; set; } = 1; + + [BsonElement("forwardingEnabled")] + public bool ForwardingEnabled { get; set; } + + [BsonElement("forwardingAddresses")] + public List ForwardingAddresses { get; set; } = new(); + + [BsonElement("description")] + public string Description { get; set; } = string.Empty; + + [BsonElement("customSettings")] + public Dictionary CustomSettings { get; set; } = new(); +} diff --git a/src/Log4YM.Server/Hubs/LogHub.cs b/src/Log4YM.Server/Hubs/LogHub.cs index 79bfd73..d78ec86 100644 --- a/src/Log4YM.Server/Hubs/LogHub.cs +++ b/src/Log4YM.Server/Hubs/LogHub.cs @@ -69,6 +69,12 @@ public interface ILogHubClient // RBN events Task OnRbnSpot(RbnSpot spot); + + // UDP Provider events + Task OnUdpProviderStatusChanged(UdpProviderStatusChangedEvent evt); + Task OnWsjtxStatusReceived(WsjtxStatusReceivedEvent evt); + Task OnWsjtxDecodeReceived(WsjtxDecodeReceivedEvent evt); + Task OnWsjtxQsoLogged(WsjtxQsoLoggedEvent evt); } public class LogHub : Hub diff --git a/src/Log4YM.Server/Program.cs b/src/Log4YM.Server/Program.cs index 140e7e6..1974c0a 100644 --- a/src/Log4YM.Server/Program.cs +++ b/src/Log4YM.Server/Program.cs @@ -206,6 +206,10 @@ // Register CW Keyer service builder.Services.AddSingleton(); +// Register UDP Provider services +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + var app = builder.Build(); // Configure middleware diff --git a/src/Log4YM.Server/Services/UdpProviders/WsjtxUdpService.cs b/src/Log4YM.Server/Services/UdpProviders/WsjtxUdpService.cs new file mode 100644 index 0000000..772ec1a --- /dev/null +++ b/src/Log4YM.Server/Services/UdpProviders/WsjtxUdpService.cs @@ -0,0 +1,508 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.AspNetCore.SignalR; +using Log4YM.Contracts.Events; +using Log4YM.Contracts.Models; +using Log4YM.Server.Core.Events; +using Log4YM.Server.Hubs; + +namespace Log4YM.Server.Services.UdpProviders; + +/// +/// WSJT-X/JTDX/MSHV UDP Protocol Service +/// Supports receiving status, decode, and QSO logged messages from digital mode applications +/// Compatible with WSJT-X protocol specification (magic: 0xadbccbda) +/// +public class WsjtxUdpService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IHubContext _hubContext; + private readonly EventBus _eventBus; + private readonly SettingsService _settingsService; + + private UdpClient? _udpClient; + private UdpProviderConfig? _config; + private CancellationTokenSource? _listenerCts; + + private const uint MagicNumber = 0xadbccbda; + private const int SchemaVersion = 3; // Using schema version 3 (Qt 5.4) + + // Message type constants + private const uint MessageTypeHeartbeat = 0; + private const uint MessageTypeStatus = 1; + private const uint MessageTypeDecode = 2; + private const uint MessageTypeClear = 3; + private const uint MessageTypeReply = 4; + private const uint MessageTypeQsoLogged = 5; + private const uint MessageTypeClose = 6; + private const uint MessageTypeReplay = 7; + private const uint MessageTypeHaltTx = 8; + private const uint MessageTypeFreeText = 9; + private const uint MessageTypeWsprDecode = 10; + private const uint MessageTypeLocation = 11; + private const uint MessageTypeLoggedAdif = 12; + private const uint MessageTypeHighlightCallsign = 13; + + public WsjtxUdpService( + ILogger logger, + IHubContext hubContext, + EventBus eventBus, + SettingsService settingsService) + { + _logger = logger; + _hubContext = hubContext; + _eventBus = eventBus; + _settingsService = settingsService; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("WSJT-X UDP service starting..."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await LoadConfigurationAsync(); + + if (_config?.Enabled == true) + { + await StartListenerAsync(stoppingToken); + } + else + { + await StopListenerAsync(); + } + + // Check for configuration changes every 5 seconds + await Task.Delay(5000, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in WSJT-X UDP service"); + await Task.Delay(5000, stoppingToken); + } + } + + await StopListenerAsync(); + _logger.LogInformation("WSJT-X UDP service stopped"); + } + + private async Task LoadConfigurationAsync() + { + var settings = await _settingsService.GetSettingsAsync(); + var newConfig = settings.UdpProviders.Providers.FirstOrDefault(p => p.Id == "wsjtx"); + + // Check if configuration changed + if (newConfig != null && !ConfigEquals(_config, newConfig)) + { + _logger.LogInformation("WSJT-X configuration changed, reloading..."); + _config = newConfig; + + // Restart listener if already running + if (_udpClient != null) + { + await StopListenerAsync(); + } + } + } + + private bool ConfigEquals(UdpProviderConfig? a, UdpProviderConfig? b) + { + if (a == null || b == null) return a == b; + return a.Enabled == b.Enabled && + a.Port == b.Port && + a.MulticastEnabled == b.MulticastEnabled && + a.MulticastAddress == b.MulticastAddress; + } + + private async Task StartListenerAsync(CancellationToken ct) + { + if (_udpClient != null || _config == null) return; + + try + { + _listenerCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _udpClient = new UdpClient(); + _udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + + if (_config.MulticastEnabled && !string.IsNullOrEmpty(_config.MulticastAddress)) + { + // Multicast mode + _udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, _config.Port)); + var multicastAddress = IPAddress.Parse(_config.MulticastAddress); + _udpClient.JoinMulticastGroup(multicastAddress, _config.MulticastTtl); + + _logger.LogInformation( + "WSJT-X listening on multicast {Address}:{Port} (TTL: {Ttl})", + _config.MulticastAddress, _config.Port, _config.MulticastTtl); + } + else + { + // Unicast mode + _udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, _config.Port)); + _logger.LogInformation("WSJT-X listening on UDP port {Port}", _config.Port); + } + + await PublishStatusEvent(true, null); + + // Start listener task + _ = Task.Run(async () => await RunListenerAsync(_listenerCts.Token), _listenerCts.Token); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start WSJT-X UDP listener"); + await PublishStatusEvent(false, ex.Message); + await StopListenerAsync(); + } + } + + private async Task StopListenerAsync() + { + if (_udpClient == null) return; + + try + { + _listenerCts?.Cancel(); + _udpClient?.Close(); + _udpClient?.Dispose(); + _udpClient = null; + _listenerCts?.Dispose(); + _listenerCts = null; + + await PublishStatusEvent(false, null); + _logger.LogInformation("WSJT-X UDP listener stopped"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping WSJT-X UDP listener"); + } + } + + private async Task RunListenerAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && _udpClient != null) + { + try + { + var result = await _udpClient.ReceiveAsync(ct); + await ProcessDatagramAsync(result.Buffer, result.RemoteEndPoint); + + // Forward datagram if enabled + if (_config?.ForwardingEnabled == true && _config.ForwardingAddresses.Any()) + { + await ForwardDatagramAsync(result.Buffer); + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error receiving WSJT-X UDP datagram"); + await Task.Delay(1000, ct); + } + } + } + + private async Task ProcessDatagramAsync(byte[] data, IPEndPoint sender) + { + if (data.Length < 12) return; // Minimum header size + + using var stream = new MemoryStream(data); + using var reader = new BinaryReader(stream); + + // Read header + var magic = ReadUInt32BigEndian(reader); + if (magic != MagicNumber) + { + _logger.LogDebug("Invalid WSJT-X magic number: 0x{Magic:X8}", magic); + return; + } + + var schema = ReadUInt32BigEndian(reader); + var messageType = ReadUInt32BigEndian(reader); + + _logger.LogDebug("WSJT-X message: type={Type}, schema={Schema}", messageType, schema); + + try + { + switch (messageType) + { + case MessageTypeStatus: + await HandleStatusMessage(reader); + break; + + case MessageTypeDecode: + await HandleDecodeMessage(reader); + break; + + case MessageTypeQsoLogged: + await HandleQsoLoggedMessage(reader); + break; + + case MessageTypeLoggedAdif: + await HandleLoggedAdifMessage(reader); + break; + + default: + _logger.LogDebug("Unhandled WSJT-X message type: {Type}", messageType); + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing WSJT-X message type {Type}", messageType); + } + } + + private async Task HandleStatusMessage(BinaryReader reader) + { + var id = ReadQString(reader); + var dialFrequency = ReadUInt64BigEndian(reader); + var mode = ReadQString(reader); + var dxCall = ReadQString(reader); + var report = ReadQString(reader); + var txMode = ReadQString(reader); + var txEnabled = reader.ReadBoolean(); + var transmitting = reader.ReadBoolean(); + var decoding = reader.ReadBoolean(); + var rxDf = ReadUInt32BigEndian(reader); + var txDf = ReadUInt32BigEndian(reader); + var deCall = ReadQString(reader); + var deGrid = ReadQString(reader); + var dxGrid = ReadQString(reader); + var txWatchdog = reader.ReadBoolean(); + var subMode = ReadQString(reader); + var fastMode = reader.ReadBoolean(); + var specialOpMode = reader.ReadByte(); + var freqTolerance = ReadUInt32BigEndian(reader); + var trPeriod = ReadUInt32BigEndian(reader); + var confName = ReadQString(reader); + var txMessage = ReadQString(reader); + + var evt = new WsjtxStatusReceivedEvent( + Id: id, + DialFrequency: (long)dialFrequency, + Mode: mode, + DxCall: dxCall, + DxGrid: dxGrid, + DeCall: deCall, + DeGrid: deGrid, + TxEnabled: txEnabled, + Transmitting: transmitting, + Decoding: decoding, + RxDf: (int)rxDf, + TxDf: (int)txDf, + SubMode: subMode, + FreqTolerance: (int)freqTolerance, + TrPeriod: trPeriod, + Report: report, + TxMessage: txMessage + ); + + _eventBus.Publish(evt); + await _hubContext.Clients.All.OnWsjtxStatusReceived(evt); + } + + private async Task HandleDecodeMessage(BinaryReader reader) + { + var id = ReadQString(reader); + var isNew = reader.ReadBoolean(); + var time = ReadQTime(reader); + var snr = ReadInt32BigEndian(reader); + var deltaTime = ReadDoubleBigEndian(reader); + var deltaFrequency = ReadUInt32BigEndian(reader); + var mode = ReadQString(reader); + var message = ReadQString(reader); + var lowConfidence = reader.ReadBoolean(); + var offAir = reader.ReadBoolean(); + + var evt = new WsjtxDecodeReceivedEvent( + Id: id, + IsNew: isNew, + Time: time, + Snr: snr, + DeltaTime: deltaTime, + DeltaFrequency: (int)deltaFrequency, + Mode: mode, + Message: message, + LowConfidence: lowConfidence, + OffAir: offAir + ); + + _eventBus.Publish(evt); + await _hubContext.Clients.All.OnWsjtxDecodeReceived(evt); + } + + private async Task HandleQsoLoggedMessage(BinaryReader reader) + { + var id = ReadQString(reader); + var timeOff = ReadQDateTime(reader); + var dxCall = ReadQString(reader); + var dxGrid = ReadQString(reader); + var txFrequency = ReadUInt64BigEndian(reader); + var mode = ReadQString(reader); + var reportSent = ReadQString(reader); + var reportReceived = ReadQString(reader); + var txPower = ReadQString(reader); + var comments = ReadQString(reader); + var name = ReadQString(reader); + var timeOn = ReadQDateTime(reader); + var operatorCall = ReadQString(reader); + var myCall = ReadQString(reader); + var myGrid = ReadQString(reader); + var exchangeSent = ReadQString(reader); + var exchangeReceived = ReadQString(reader); + var propMode = ReadQString(reader); + + var evt = new WsjtxQsoLoggedEvent( + Id: id, + DxCall: dxCall, + DxGrid: dxGrid, + TxFrequency: (long)txFrequency, + Mode: mode, + ReportSent: reportSent, + ReportReceived: reportReceived, + TxPower: txPower, + TimeOn: timeOn, + TimeOff: timeOff, + MyCall: myCall, + MyGrid: myGrid, + Comments: string.IsNullOrEmpty(comments) ? null : comments, + ExchangeSent: string.IsNullOrEmpty(exchangeSent) ? null : exchangeSent, + ExchangeReceived: string.IsNullOrEmpty(exchangeReceived) ? null : exchangeReceived + ); + + _eventBus.Publish(evt); + await _hubContext.Clients.All.OnWsjtxQsoLogged(evt); + } + + private async Task HandleLoggedAdifMessage(BinaryReader reader) + { + var id = ReadQString(reader); + var adifText = ReadQString(reader); + + _logger.LogInformation("WSJT-X ADIF QSO logged from {Id}: {Length} bytes", id, adifText.Length); + + // TODO: Parse ADIF and emit QSO event + // For now, just log it + } + + private async Task ForwardDatagramAsync(byte[] data) + { + if (_config?.ForwardingAddresses == null) return; + + foreach (var addressStr in _config.ForwardingAddresses) + { + try + { + var parts = addressStr.Split(':', 2); + if (parts.Length != 2) continue; + + var host = parts[0]; + var port = int.Parse(parts[1]); + + using var forwardClient = new UdpClient(); + await forwardClient.SendAsync(data, data.Length, host, port); + + _logger.LogDebug("Forwarded WSJT-X datagram to {Address}", addressStr); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error forwarding WSJT-X datagram to {Address}", addressStr); + } + } + } + + private async Task PublishStatusEvent(bool isRunning, string? errorMessage) + { + var evt = new UdpProviderStatusChangedEvent( + ProviderId: "wsjtx", + ProviderName: "WSJT-X / JTDX / MSHV", + IsRunning: isRunning, + IsListening: isRunning, + ListeningPort: _config?.Port, + IsMulticastEnabled: _config?.MulticastEnabled, + ErrorMessage: errorMessage + ); + + _eventBus.Publish(evt); + await _hubContext.Clients.All.OnUdpProviderStatusChanged(evt); + } + + // Binary reading helpers (WSJT-X uses big-endian) + + private static uint ReadUInt32BigEndian(BinaryReader reader) + { + var bytes = reader.ReadBytes(4); + if (BitConverter.IsLittleEndian) Array.Reverse(bytes); + return BitConverter.ToUInt32(bytes, 0); + } + + private static ulong ReadUInt64BigEndian(BinaryReader reader) + { + var bytes = reader.ReadBytes(8); + if (BitConverter.IsLittleEndian) Array.Reverse(bytes); + return BitConverter.ToUInt64(bytes, 0); + } + + private static int ReadInt32BigEndian(BinaryReader reader) + { + var bytes = reader.ReadBytes(4); + if (BitConverter.IsLittleEndian) Array.Reverse(bytes); + return BitConverter.ToInt32(bytes, 0); + } + + private static double ReadDoubleBigEndian(BinaryReader reader) + { + var bytes = reader.ReadBytes(8); + if (BitConverter.IsLittleEndian) Array.Reverse(bytes); + return BitConverter.ToDouble(bytes, 0); + } + + private static string ReadQString(BinaryReader reader) + { + var length = ReadUInt32BigEndian(reader); + if (length == 0xFFFFFFFF) return string.Empty; // Null string in Qt + if (length == 0) return string.Empty; + + var bytes = reader.ReadBytes((int)length); + return Encoding.UTF8.GetString(bytes); + } + + private static DateTime ReadQDateTime(BinaryReader reader) + { + var julianDay = ReadUInt64BigEndian(reader); + var msecsSinceMidnight = ReadUInt32BigEndian(reader); + var timeSpec = reader.ReadByte(); + + // Qt Julian Day starts from November 24, 4714 BCE + // Convert to .NET DateTime + var baseDate = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var days = (long)julianDay - 2440588; // Convert Qt Julian Day to Unix epoch days + var dt = baseDate.AddDays(days).AddMilliseconds(msecsSinceMidnight); + + return dt; + } + + private static DateTime ReadQTime(BinaryReader reader) + { + var msecsSinceMidnight = ReadUInt32BigEndian(reader); + var today = DateTime.UtcNow.Date; + return today.AddMilliseconds(msecsSinceMidnight); + } + + public override void Dispose() + { + StopListenerAsync().GetAwaiter().GetResult(); + base.Dispose(); + } +} diff --git a/src/Log4YM.Web/src/components/SettingsPanel.tsx b/src/Log4YM.Web/src/components/SettingsPanel.tsx index f5ef5de..273e525 100644 --- a/src/Log4YM.Web/src/components/SettingsPanel.tsx +++ b/src/Log4YM.Web/src/components/SettingsPanel.tsx @@ -91,6 +91,12 @@ const SETTINGS_SECTIONS: { id: SettingsSection; name: string; icon: React.ReactN icon: , description: 'LLM API settings for talk points', }, + { + id: 'udp', + name: 'UDP Integrations', + icon: , + description: 'WSJT-X, JTDX, MSHV, and other UDP apps', + }, { id: 'about', name: 'About', diff --git a/src/Log4YM.Web/src/store/settingsStore.ts b/src/Log4YM.Web/src/store/settingsStore.ts index b8b3300..b42a9c3 100644 --- a/src/Log4YM.Web/src/store/settingsStore.ts +++ b/src/Log4YM.Web/src/store/settingsStore.ts @@ -122,6 +122,25 @@ export interface AiSettings { includeSpotComments: boolean; } +export interface UdpProviderConfig { + id: string; + name: string; + enabled: boolean; + port: number; + supportsMulticast: boolean; + multicastEnabled: boolean; + multicastAddress: string; + multicastTtl: number; + forwardingEnabled: boolean; + forwardingAddresses: string[]; + description: string; + customSettings: Record; +} + +export interface UdpProviderSettings { + providers: UdpProviderConfig[]; +} + export interface Settings { station: StationSettings; qrz: QrzSettings; @@ -132,9 +151,10 @@ export interface Settings { cluster: ClusterSettings; header: HeaderSettings; ai: AiSettings; + udpProviders: UdpProviderSettings; } -export type SettingsSection = 'station' | 'qrz' | 'rotator' | 'database' | 'appearance' | 'map' | 'header' | 'ai' | 'about'; +export type SettingsSection = 'station' | 'qrz' | 'rotator' | 'database' | 'appearance' | 'map' | 'header' | 'ai' | 'udp' | 'about'; interface SettingsState { // Settings data @@ -166,6 +186,8 @@ interface SettingsState { updateClusterConnection: (connectionId: string, connection: Partial) => void; updateHeaderSettings: (header: Partial) => void; updateAiSettings: (ai: Partial) => void; + updateUdpProviderSettings: (udpProviders: Partial) => void; + updateUdpProvider: (providerId: string, provider: Partial) => void; addClusterConnection: () => void; removeClusterConnection: (connectionId: string) => void; @@ -267,6 +289,24 @@ const defaultSettings: Settings = { includeQsoHistory: true, includeSpotComments: false, }, + udpProviders: { + providers: [ + { + id: 'wsjtx', + name: 'WSJT-X / JTDX / MSHV', + enabled: false, + port: 2237, + supportsMulticast: true, + multicastEnabled: false, + multicastAddress: '224.0.0.73', + multicastTtl: 1, + forwardingEnabled: false, + forwardingAddresses: [], + description: 'Digital mode applications (FT8, FT4, JT65, etc.)', + customSettings: {}, + }, + ], + }, }; // Settings are stored in MongoDB only - no localStorage persistence @@ -380,6 +420,30 @@ export const useSettingsStore = create()((set, get) => ({ isDirty: true, })), + // UDP Provider settings + updateUdpProviderSettings: (udpProviders) => + set((state) => ({ + settings: { + ...state.settings, + udpProviders: { ...state.settings.udpProviders, ...udpProviders }, + }, + isDirty: true, + })), + + updateUdpProvider: (providerId, provider) => + set((state) => { + const updatedProviders = state.settings.udpProviders.providers.map((p) => + p.id === providerId ? { ...p, ...provider } : p + ); + return { + settings: { + ...state.settings, + udpProviders: { ...state.settings.udpProviders, providers: updatedProviders }, + }, + isDirty: true, + }; + }), + // Cluster settings updateClusterSettings: (cluster) => set((state) => ({ @@ -515,6 +579,7 @@ export const useSettingsStore = create()((set, get) => ({ cluster: { ...defaultSettings.cluster, ...settings.cluster }, header: { ...defaultSettings.header, ...settings.header }, ai: { ...defaultSettings.ai, ...settings.ai }, + udpProviders: { ...defaultSettings.udpProviders, ...settings.udpProviders }, }; set({ settings: mergedSettings, isDirty: false, isLoaded: true }); } else {