From 689f3103d4f90c1d34c74958310f4ad285a1a46b Mon Sep 17 00:00:00 2001 From: Patrick W3AXL Date: Mon, 24 Feb 2025 14:06:08 -0500 Subject: [PATCH 01/18] initial work towards incorporating rc2-core into original rc2-daemon --- .gitmodules | 3 + console/client.js | 55 +-- daemon/Configuration.cs | 114 +++++++ daemon/LocalAudio.cs | 13 + daemon/Program.cs | 2 +- daemon/Radio.MotoSB9600.cs | 110 ++++++ daemon/Radio.cs | 436 ------------------------ daemon/{MotorolaSB9600.cs => SB9600.cs} | 429 ++++++++++++----------- daemon/config.example.yml | 111 ++++++ daemon/rc2-core | 1 + 10 files changed, 626 insertions(+), 648 deletions(-) create mode 100644 daemon/Configuration.cs create mode 100644 daemon/LocalAudio.cs create mode 100644 daemon/Radio.MotoSB9600.cs delete mode 100644 daemon/Radio.cs rename daemon/{MotorolaSB9600.cs => SB9600.cs} (83%) create mode 100644 daemon/config.example.yml create mode 160000 daemon/rc2-core diff --git a/.gitmodules b/.gitmodules index 331c6dc..3a22eed 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = daemon/SDL url = https://github.com/libsdl-org/SDL.git branch = SDL2 +[submodule "daemon/rc2-core"] + path = daemon/rc2-core + url = https://github.com/W3AXL/rc2-core diff --git a/console/client.js b/console/client.js index f50c31f..3dacb48 100644 --- a/console/client.js +++ b/console/client.js @@ -105,6 +105,9 @@ const rtcConf = { // RTT (round-trip time) parameters for RTC connection rttLimit: 0.25, rttSize: 25, + + // Periodic WebRTC latency check time (ms) + statCheckTime: 3000, // Whether to disable FEC and enable CBR (this actually causes more latency annoyingly) cbr: false @@ -1684,7 +1687,7 @@ function checkRoundTripTime(idx) { }) setTimeout(function() { checkRoundTripTime(idx) - }, 1000); + }, rtcConf.statCheckTime); }) } else { console.warn(`Peer connection closed, stopping RTT monitoring for radio ${idx}`); @@ -1871,7 +1874,7 @@ function audioMeterCallback() { return; } - // Draw stuff + // Update meters radios.forEach((radio, idx) => { // Ignore radios with no connected audio if (radios[idx].audioSrc == null) { @@ -2461,23 +2464,37 @@ function connectRadio(idx) { * @param {function} callback callback function to execute once connected */ function waitForWebSockets(sockets, callback=null) { - setTimeout( - function() { - socketsReady = 0; - sockets.forEach((socket) => { - if (socket.readyState === 1) - { - socketsReady++; - } - }) - if (socketsReady === sockets.length) - { - callback(); - } else { + // Starting variables + socketsReady = 0; + cancel = false; + // Iterate over each socket in our list + sockets.forEach((socket) => { + if (socket.readyState === WebSocket.OPEN) + { + socketsReady++; + } + // If any of our sockets closed or are closing, we cancel the wait + else if (socket.readyState == WebSocket.CLOSING || socket.readyState == WebSocket.CLOSED) + { + console.warn(`Websocket ${socket} closed, cancelling waitForWebsockets`); + cancel = true; + } + }); + // Check if we should cancel listening + if (cancel) { return; } + // Check if all sockets are ready + if (socketsReady === sockets.length) + { + callback(); + } + else + { + setTimeout( + function() { waitForWebSockets(sockets, callback); - } - }, - 5); // 5 ms timeout + }, + 5 ); + } } /** @@ -2486,7 +2503,7 @@ function waitForWebSockets(sockets, callback=null) { */ function onConnectWebsocket(idx) { //$("#navbar-status").html("Websocket connected"); - console.log(`Websocket connected for radio ${radios[idx].name}`); + console.log(`Websockets connected for radio ${radios[idx].name}`); // Query radio status console.log(`Querying radio ${radios[idx].name} status`); radios[idx].wsConn.send(JSON.stringify( diff --git a/daemon/Configuration.cs b/daemon/Configuration.cs new file mode 100644 index 0000000..fb97fa0 --- /dev/null +++ b/daemon/Configuration.cs @@ -0,0 +1,114 @@ +using moto_sb9600; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace daemon +{ + /// + /// Base daemon config + /// + public class DaemonConfig + { + /// + /// Name of this daemon + /// + public string Name = ""; + /// + /// Description of this daemon + /// + public string Desc = ""; + /// + /// Listen address the console client connects to + /// + public IPAddress ListenAddress = IPAddress.Parse("127.0.0.1"); + /// + /// Listen port the console client connects to + /// + public int ListenPort = 8801; + } + + /// + /// Valid radio control types + /// + public enum RadioControlMode + { + VOX = 0, + TRC = 1, + SB9600 = 2, + XCMP_SER = 3, + XCMP_USB = 4 + } + + /// + /// Radio control config + /// + public class ControlConfig + { + /// + /// Control mode for this daemon + /// + public RadioControlMode ControlMode = RadioControlMode.SB9600; + /// + /// Config for motorola SB9600 + /// + public MotoSb9600Config Sb9600 = new MotoSb9600Config(); + } + + /// + /// Radio audio config + /// + public class AudioConfig + { + /// + /// TX audio device for radio (speakers) + /// + public string TxDevice = ""; + /// + /// TX gain for audio (1.0 = no gain/attenuation) + /// + public float TxGain = 1.0f; + /// + /// RX audio device for radio (microphone) + /// + public string RxDevice = ""; + /// + /// RX gain for audio + /// + public float RxGain = 1.0f; + } + + public class TextLookupConfig + { + public List Zone = new List(); + + public List Channel = new List(); + } + + public class ConfigObject + { + /// + /// Daemon config + /// + public DaemonConfig Daemon = new DaemonConfig(); + /// + /// Control config + /// + public ControlConfig Control = new ControlConfig(); + /// + /// Audio config + /// + public AudioConfig Audio = new AudioConfig(); + /// + /// Text lookup configuration + /// + public TextLookupConfig TextLookups = new TextLookupConfig(); + /// + /// Softkey list + /// + public List Softkeys = new List(); + } +} diff --git a/daemon/LocalAudio.cs b/daemon/LocalAudio.cs new file mode 100644 index 0000000..9ceda33 --- /dev/null +++ b/daemon/LocalAudio.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace daemon +{ + public class LocalAudio + { + + } +} diff --git a/daemon/Program.cs b/daemon/Program.cs index f1c0eac..c95a64d 100644 --- a/daemon/Program.cs +++ b/daemon/Program.cs @@ -59,7 +59,7 @@ public class Config } // Radio object - static Radio radio = null; + static rc2_core.Radio radio = null; // Main Program Entry static async Task Main(string[] args) diff --git a/daemon/Radio.MotoSB9600.cs b/daemon/Radio.MotoSB9600.cs new file mode 100644 index 0000000..89ad660 --- /dev/null +++ b/daemon/Radio.MotoSB9600.cs @@ -0,0 +1,110 @@ +using daemon; +using FFmpeg.AutoGen; +using rc2_core; +using Serilog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace moto_sb9600 +{ + /// + /// Config object used to parse YML config for SB9600 control + /// + public class MotoSb9600Config + { + /// + /// Serial port name + /// + public string SerialPort = ""; + /// + /// SB9600 control head type + /// + public SB9600.HeadType ControlHeadType = SB9600.HeadType.W9; + /// + /// Softkey binding dictionary + /// + public Dictionary SoftkeyBindings; + } + + public class MotoSb9600Radio : rc2_core.Radio + { + private SB9600 sb9600; + + private Dictionary softkeyBindings; + + /// + /// Initialize a new Motorola SB9600 radio + /// + /// Radio name + /// Radio description + /// Whether radio is rx-only or not + /// daemon listen address + /// daemon list port + /// Serial port name for SB9600 + /// SB9600 head type + /// Whether to use the RX leds on the control head as an RX status indicator + /// list of softkeys + /// list of zone text lookups + /// list of channel text lookups + /// callback for tx audio samples + /// samplerate for tx audio + public MotoSb9600Radio( + string name, string desc, bool rxOnly, + IPAddress listenAddress, int listenPort, + string serialPortName, SB9600.HeadType headType, bool rxLeds, Dictionary softkeyBindings, + Action txAudioCallback, int txAudioSampleRate, + List softkeys, + List zoneLookups = null, List chanLookups = null + ) : base(name, desc, rxOnly, listenAddress, listenPort, softkeys, zoneLookups, chanLookups, txAudioCallback, txAudioSampleRate) + { + // Save softkey lookups + this.softkeyBindings = softkeyBindings; + // Init SB9600 + sb9600 = new SB9600(serialPortName, headType, this.softkeyBindings, this, rxLeds); + } + + /// + /// Start the base radio as well as the SB9600 services + /// + /// + public new void Start(bool reset = false) + { + base.Start(reset); + sb9600.Start(reset); + } + + /// + /// Stop the base radio as well as the SB9600 services + /// + public new void Stop() + { + base.Stop(); + sb9600.Stop(); + } + + public override bool ChangeChannel(bool down) + { + return sb9600.ChangeChannel(down); + } + + public override bool SetTransmit(bool tx) + { + return sb9600.SetTransmit(tx); + } + + public override bool PressButton(rc2_core.SoftkeyName name) + { + return sb9600.PressButton(name); + } + + public override bool ReleaseButton(rc2_core.SoftkeyName name) + { + return sb9600.ReleaseButton(name); + } + + } +} diff --git a/daemon/Radio.cs b/daemon/Radio.cs deleted file mode 100644 index dcff3e2..0000000 --- a/daemon/Radio.cs +++ /dev/null @@ -1,436 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using SIPSorceryMedia.SDL2; -using SIPSorceryMedia.Abstractions; -using SIPSorceryMedia.FFmpeg; -using Serilog; -using Newtonsoft.Json; -using MathNet.Numerics; - -namespace daemon -{ - // Valid states a radio can be in - public enum RadioState - { - Disconnected, - Connecting, - Idle, - Transmitting, - Receiving, - Error, - Disconnecting - } - - /// - /// Valid radio types to be controlled - /// - public enum RadioType - { - ListenOnly, // Generic single channel radio, RX state is controlled by VOX threshold on receive audio - CM108, // Generic single channel radio, controlled by CM108 soundcard GPIO - SB9600, // SB9600 radio controlled via serial - } - - /// - /// Valid scanning states (used for scan icons on radio cards in the client) - /// - public enum ScanState - { - NotScanning, - Scanning - } - - public enum PriorityState - { - NoPriority, - Priority1, - Priority2 - } - - public enum PowerState - { - LowPower, - MidPower, - HighPower - } - - public enum SoftkeyState - { - Off, - On, - Flashing - } - - /// - /// These are the valid softkey bindings which can be used to setup softkeys on radios which don't have them - /// - /// Pruned from the Astro25 mobile CPS help section on button bindings - public enum SoftkeyName - { - CALL, // Signalling call - CHAN, // Channel Select - CHUP, // Channel Up - CHDN, // Channel Down - DEL, // Nuisance Delete - DIR, // Talkaround/direct - EMER, // Emergency - DYNP, // Dynamic Priority - HOME, // Home - LOCK, // Trunking site lock - LPWR, // Low power - MON, // Monitor (PL defeat) - PAGE, // Signalling page - PHON, // Phone operation - RAB1, // Repeater access button 1 - RAB2, // Repeater access button 2 - RCL, // Scan recall - SCAN, // Scan mode, etc - SEC, // Secure mode - SEL, // Select - SITE, // Site alias - TCH1, // One-touch 1 - TCH2, // One-touch 2 - TCH3, // One-touch 3 - TCH4, // One-touch 4 - TGRP, // Talkgroup select - TMS, // Text messaging - TMSQ, // Quick message - ZNUP, // Zone up - ZNDN, // Zone down - ZONE, // Zone select - } - - /// - /// Softkey object to hold key text, description (for hover) and state - /// - public class Softkey - { - public SoftkeyName Name { get; set; } - public string Description { get; set; } - public SoftkeyState State { get; set; } - public ControlHeads.Button Button { get; set; } - } - - /// - /// Class for text-replacement lookup objects - /// - public class TextLookup - { - // The text string to match - public string Match { get; set; } - // The text string to replace the matched text with - public string Replacement { get; set; } - - public TextLookup(string match, string replacement) - { - Match = match; - Replacement = replacement; - } - } - - /// - /// Radio status object, contains all the possible radio states sent to the client during status updates - /// - public class RadioStatus - { - public string Name { get; set; } = ""; - public string Description { get; set; } = ""; - public string ZoneName { get; set; } = ""; - public string ChannelName { get; set; } = ""; - public RadioState State { get; set; } = RadioState.Disconnected; - public ScanState ScanState { get; set; } = ScanState.NotScanning; - public PriorityState PriorityState {get; set;} = PriorityState.NoPriority; - public PowerState PowerState {get; set;} = PowerState.LowPower; - public List Softkeys { get; set; } = new List(); - public bool Monitor { get; set; } = false; - public bool Direct {get; set;} = false; - public bool Error { get; set; } = false; - public string ErrorMsg { get; set; } = ""; - - /// - /// Encode the RadioStatus object into a JSON string for sending to the client - /// - /// - public string Encode() - { - // convert the status object to a string - return JsonConvert.SerializeObject(this, new Newtonsoft.Json.Converters.StringEnumConverter()); - } - } - - /// - /// Radio class representing a radio to be controlled by the daemon - /// - internal class Radio - { - // Radio configuration - public RadioType Type { get; set; } - public bool RxOnly { get; set; } - - // SB9600 interface - public SB9600 IntSB9600 { get; set; } - - // Radio status - public RadioStatus Status { get; set; } - - // Lookup lists for zone & channel text - public List ZoneLookups { get; set; } - public List ChanLookups { get; set; } - - public delegate void Callback(); - public Callback StatusCallback { get; set; } - - public int RecTimeout { get; set; } = 0; - - /// - /// Overload for a listen-only radio - /// - /// - /// - /// - /// - public Radio(string name, string desc, RadioType type, string zoneName, string channelName) - { - Type = type; - RxOnly = true; - // Create status and assign static names - Status = new RadioStatus(); - Status.Name = name; - Status.Description = desc; - Status.ZoneName = zoneName; - Status.ChannelName = channelName; - } - - /// - /// Overload for an SB9600 radio - /// - /// - /// - /// - /// - /// - /// - public Radio(string name, string desc, RadioType type, SB9600.HeadType head, string comPort, bool rxOnly, List zoneLookups = null, List chanLookups = null, List softkeys = null, bool rxLeds = false) - { - // Get basic info - Type = type; - RxOnly = rxOnly; - // Parse Lookups - ZoneLookups = zoneLookups; - ChanLookups = chanLookups; - // Create Interface - IntSB9600 = new SB9600(comPort, head, rxLeds); - IntSB9600.StatusCallback = RadioStatusCallback; - // Create status - Status = new RadioStatus(); - Status.Name = name; - Status.Description = desc; - Status.Softkeys = softkeys; - } - - /// - /// Start the radio - /// - /// - public void Start(bool noreset) - { - // Update the radio status to connecting - Status.State = RadioState.Connecting; - RadioStatusCallback(); - // Start runtimes depending on control type - if (Type == RadioType.SB9600) - { - IntSB9600.radioStatus = Status; - IntSB9600.Start(noreset); - } - } - - public void Stop() - { - // Stop runtimes depending on control type - if (Type == RadioType.SB9600) - { - IntSB9600.Stop(); - } - } - - /// - /// Callback function called by the interface class, which in turn calls the callback in the main program for reporting status - /// Confusing, I know - /// Basically it goes like this (for SB9600) SB9600.StatusCallback() -> Radio.RadioStatusCallback() -> DaemonWebsocket.SendRadioStatus() - /// - private void RadioStatusCallback() - { - Log.Verbose("Got radio status callback from interface"); - // Perform lookups on zone/channel names (radio-control-type agnostic) - if (ZoneLookups.Count > 0) - { - foreach (TextLookup lookup in ZoneLookups) - { - // An empty string for the match indicates we should always replace the zone name with the replacement - if (lookup.Match == "") - { - Log.Verbose("Empty lookup {replacement} found for zone name, overriding all other lookups", lookup.Replacement); - Status.ZoneName = lookup.Replacement; - break; - } - if (Status.ZoneName.Contains(lookup.Match)) - { - Log.Verbose("Found zone text {ZoneName} from {Match} in original text {Text}", lookup.Replacement, lookup.Match, Status.ZoneName); - Status.ZoneName = lookup.Replacement; - } - // On Moto W9, we also look for zone in the channel text since it's a one-liner display - if (Type == RadioType.SB9600 && IntSB9600.Head == SB9600.HeadType.W9) - { - if (Status.ChannelName.Contains(lookup.Match)) - { - Log.Verbose("Found zone text {ZoneName} from {Match} in channel text {Text} on W9 head", lookup.Replacement, lookup.Match, Status.ChannelName); - Status.ZoneName = lookup.Replacement; - } - } - } - } - if (ChanLookups.Count > 0) - { - foreach (TextLookup lookup in ChanLookups) - { - if (Status.ChannelName.Contains(lookup.Match)) - { - Log.Verbose("Found channel text {ChannelName} from {Match} in original text {Text}", lookup.Replacement, lookup.Match, Status.ChannelName); - Status.ChannelName = lookup.Replacement; - } - } - } - // Call recording start/stop callbacks which will trigger audio recording file start/stop if enabled - if (Status.State == RadioState.Transmitting) - { - Task.Delay(100).ContinueWith(t => RecTxCallback()); - } - else if (Status.State == RadioState.Receiving) - { - Task.Delay(100).ContinueWith(t => RecRxCallback()); - } - // Stop recording if we're not either of the above - else - { - if (WebRTC.RecTxInProgress || WebRTC.RecRxInProgress) - { - Task.Delay(RecTimeout).ContinueWith(t => RecStopCallback()); - } - } - // Call the next callback up - StatusCallback(); - } - - /// - /// Sets transmit state of the connected radio - /// - /// true to transmit, false to stop - /// true on success - public bool SetTransmit(bool tx) - { - if (RxOnly) - { - return false; - } - else - { - if (Type == RadioType.SB9600) - { - IntSB9600.SetTransmit(tx); - return true; - } - else - { - Log.Error("SetTransmit not defined for interface type {IntType}", Type); - return false; - } - } - } - - public bool ChangeChannel(bool down) - { - if (Type == RadioType.SB9600) - { - IntSB9600.ChangeChannel(down); - return true; - } - else - { - Log.Error("ChangeChannel not defined for interface type {IntType}", Type); - return false; - } - } - - public bool PressButton(SoftkeyName name) - { - if (Type == RadioType.SB9600) - { - IntSB9600.PressButton(name); - return true; - } - else - { - Log.Error("PressButton not defined for interface type {IntType}", Type); - return false; - } - } - - public bool ReleaseButton(SoftkeyName name) - { - if (Type == RadioType.SB9600) - { - IntSB9600.ReleaseButton(name); - return true; - } - else - { - Log.Error("ReleaseButton not defined for interface type {IntType}", Type); - return false; - } - } - - private void RecTxCallback() - { - // If we were recording RX, stop - if (WebRTC.RecRxInProgress) - { - WebRTC.RecStop(); - } - if (!WebRTC.RecTxInProgress) - { - WebRTC.RecStartTx(Status.ChannelName.Trim()); - } - } - - private void RecRxCallback() - { - // If we were recording TX, stop - if (WebRTC.RecTxInProgress) - { - WebRTC.RecStop(); - } - // Start recording RX if we're not - if (!WebRTC.RecRxInProgress) - { - WebRTC.RecStartRx(Status.ChannelName.Trim()); - } - } - - private void RecStopCallback() - { - // Only stop recording if we have to - if (WebRTC.RecTxInProgress && Status.State != RadioState.Transmitting) - { - WebRTC.RecStop(); - } - if (WebRTC.RecRxInProgress && Status.State != RadioState.Receiving) - { - WebRTC.RecStop(); - } - } - } -} diff --git a/daemon/MotorolaSB9600.cs b/daemon/SB9600.cs similarity index 83% rename from daemon/MotorolaSB9600.cs rename to daemon/SB9600.cs index 87fa255..a69fa3e 100644 --- a/daemon/MotorolaSB9600.cs +++ b/daemon/SB9600.cs @@ -1,26 +1,16 @@ -using System; +using netcore_cli; +using Serilog; +using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO.Ports; using System.Linq; using System.Text; using System.Threading.Tasks; -using System.IO.Ports; -using FFmpeg.AutoGen; -using System.Collections.Concurrent; -using System.Threading.Tasks.Dataflow; -using Serilog; -using Microsoft.VisualBasic; -using Serilog.Debugging; -using Org.BouncyCastle.Utilities; -using WebSocketSharp; -using Org.BouncyCastle.Crypto.Digests; -using netcore_cli; -using System.ComponentModel.DataAnnotations; -using System.Data.SqlTypes; -using System.Reflection.Emit; -using Org.BouncyCastle.Math.EC.Rfc7748; -using NAudio.Utils; - -namespace daemon +using daemon; +using rc2_core; + +namespace moto_sb9600 { public static class ControlHeads { @@ -34,9 +24,9 @@ public enum IndicatorStates : byte public class Indicator { - public byte Code {get; set;} - public string Name {get; set;} - public IndicatorStates State {get; set;} + public byte Code { get; set; } + public string Name { get; set; } + public IndicatorStates State { get; set; } public Indicator(byte code, string name, IndicatorStates state) { @@ -48,8 +38,8 @@ public Indicator(byte code, string name, IndicatorStates state) public class Button { - public byte Code {get; set;} - public string Name {get; set;} + public byte Code { get; set; } + public string Name { get; set; } public Button(byte code, string name) { @@ -224,31 +214,55 @@ public static Indicator GetIndicator(SB9600.HeadType head, byte code) public class SB9600 { - // Class Variables + /// + /// Serial port RX byte buffer size (in bytes) + /// + private static int RX_BUFFER_SIZE = 512; + + /// + /// Serial port for SB9600 communication + /// public SerialPort Port { get; set; } - private byte[] rxBuffer = new byte[512]; + /// + /// RX buffer for serial bytes + /// + private byte[] rxBuffer = new byte[RX_BUFFER_SIZE]; - // Cancellation token for serial listener task + /// + /// Cancellation token objects for serial listener task + /// private CancellationTokenSource ts; private CancellationToken ct; - // Flags for SB9600 + /// + /// Flag indicating if we're in SBEP mode + /// private bool inSbep = false; - // Queue for TX messages to send in infinite loop when free + /// + /// Queue for TX messages to send in serial task + /// private ConcurrentQueue msgQueue = new ConcurrentQueue(); - // Delayed Message List + /// + /// Queue for delayed messages (messages to send after a specific timeout) + /// private List delayedMessages = new List(); - // Head type - public HeadType Head { get; set; } + /// + /// Control Head Type + /// + public HeadType ControlHead { get; set; } - // Delegate callback to indicate new received status + /// + /// + /// public delegate void Callback(); public Callback StatusCallback { get; set; } + private Dictionary softkeyMappings; + private bool newStatus = false; private bool noReset = false; @@ -258,16 +272,16 @@ public class SB9600 /// /// Reference back to Radio state object for status updates /// - public RadioStatus radioStatus { get; set; } + private MotoSb9600Radio radio; private string displayText1 { get; set; } = ""; private string displayText2 { get; set; } = ""; - + public enum HeadType { - W9, - M3, - O5 + W9 = 0, + M3 = 1, + O5 = 2 } private static readonly byte[] sb9600CrcTable = @@ -292,45 +306,45 @@ public enum HeadType private enum SB9600Addresses : byte { - BROADCAST = 0x00, - RADIO = 0x01, - DSP = 0x02, - MPL = 0x03, - INTOPTIONS = 0x04, - FRONTPANEL = 0x05, - REARPANEL = 0x06, - EXTPANEL = 0x07, - SIREN_PA = 0x08, - SECURENET = 0x09, - EMGCY_STAT = 0x0A, + BROADCAST = 0x00, + RADIO = 0x01, + DSP = 0x02, + MPL = 0x03, + INTOPTIONS = 0x04, + FRONTPANEL = 0x05, + REARPANEL = 0x06, + EXTPANEL = 0x07, + SIREN_PA = 0x08, + SECURENET = 0x09, + EMGCY_STAT = 0x0A, MSG_SELCALL = 0x0B, - MDC600CALL = 0x0C, - MVS = 0x0D, - PHONE = 0x0E, - DTMF = 0x0F, - TRNK_SYS = 0x10, - TRNK_OPT = 0x11, - VRS = 0x12, - SP_RPT = 0x13, - SINGLETONE = 0x14, + MDC600CALL = 0x0C, + MVS = 0x0D, + PHONE = 0x0E, + DTMF = 0x0F, + TRNK_SYS = 0x10, + TRNK_OPT = 0x11, + VRS = 0x12, + SP_RPT = 0x13, + SINGLETONE = 0x14, VEHICLE_LOC = 0x16, - KDT_TERM = 0x17, - TRNK_DESK = 0x18, - METROCOM = 0x19, - CTRL_HOST = 0x1A, + KDT_TERM = 0x17, + TRNK_DESK = 0x18, + METROCOM = 0x19, + CTRL_HOST = 0x1A, VEHICLE_ADP = 0x1B } private enum SB9600Opcodes : byte { // Broadcasts - EPREQ = 0x06, // Expanded protocol request (enter SBEP) + EPREQ = 0x06, // Expanded protocol request (enter SBEP) SETBUT = 0x0A, // Set button RADRDY = 0x15, // Radio Ready OPTSTS = 0x16, // Option Status Value RADKEY = 0x19, // Radio Key - RXAUD = 0x1A, // RX audio routing - TXAUD = 0x1B, // TX audio routing + RXAUD = 0x1A, // RX audio routing + TXAUD = 0x1B, // TX audio routing AUDMUT = 0x1D, // Audio muting SQLDET = 0x1E, // Squelch detect ACTMDU = 0x1F, // Active mode update @@ -396,7 +410,7 @@ public bool Decode(byte[] data) Data[0] = data[1]; Data[1] = data[2]; Opcode = data[3]; - + // Verify CRC if (data[4] != CalcCrc()) { @@ -686,20 +700,14 @@ public DelayedMessage(long execTime, SBEPMsg msg) } } - public SB9600(SerialPort port, HeadType _head, bool rxLeds = false) - { - Port = port; - Port.BaudRate = 9600; - Head = _head; - RxLeds = rxLeds; - } - - public SB9600(string portName, HeadType _head, bool rxLeds = false) + public SB9600(string portName, HeadType controlHead, Dictionary softkeyMappings, MotoSb9600Radio radio, bool rxLeds = false) { Port = new SerialPort(portName); Port.BaudRate = 9600; - Head = _head; + ControlHead = controlHead; RxLeds = rxLeds; + this.softkeyMappings = softkeyMappings; + this.radio = radio; } public void Start(bool noreset) @@ -707,7 +715,7 @@ public void Start(bool noreset) noReset = noreset; // Check if serial port exists first Log.Verbose("Available serial ports:"); - foreach( var name in SerialPort.GetPortNames()) + foreach (var name in SerialPort.GetPortNames()) { Log.Verbose(name); } @@ -768,7 +776,8 @@ private bool Reset() private bool sendSb9600(SB9600Msg msg, int attempts = 3) { // Wait for busy to drop - while (getBusy()) { + while (getBusy()) + { Log.Debug("Waiting for BUSY to drop"); } // Grab busy @@ -861,7 +870,8 @@ private bool processSB9600(byte[] msgBytes) inSbep = true; Log.Verbose("Entering SBEP at 9600 baud"); } - } else + } + else { Log.Warning("Got EPREQ command for unknon protocol {EPREQProtocol}", protocol); } @@ -880,12 +890,12 @@ private bool processSB9600(byte[] msgBytes) case 0x01: if (command == 0x01) { - radioStatus.Monitor = true; + radio.Status.Monitor = true; Log.Information("Radio monitor on"); } else { - radioStatus.Monitor = false; + radio.Status.Monitor = false; Log.Information("Radio monitor off"); } newStatus = true; @@ -894,15 +904,15 @@ private bool processSB9600(byte[] msgBytes) case 0x03: if (command == 0x01) { - if (radioStatus.State != RadioState.Transmitting) + if (radio.Status.State != RadioState.Transmitting) { - radioStatus.State = RadioState.Transmitting; + radio.Status.State = RadioState.Transmitting; newStatus = true; Log.Information("Radio now transmitting"); } - else if (radioStatus.State != RadioState.Receiving && radioStatus.State != RadioState.Idle) + else if (radio.Status.State != RadioState.Receiving && radio.Status.State != RadioState.Idle) { - radioStatus.State = RadioState.Idle; + radio.Status.State = RadioState.Idle; newStatus = true; Log.Information("Radio no longer transmitting"); } @@ -931,7 +941,7 @@ private bool processSB9600(byte[] msgBytes) /// /// /// - + /// /// Handle any unknown codes with a warning /// @@ -954,9 +964,9 @@ private bool processSB9600(byte[] msgBytes) /// case (byte)SB9600Opcodes.RADRDY: // If we're not already in an idle state, we are now - if (radioStatus.State != RadioState.Idle) + if (radio.Status.State != RadioState.Idle) { - radioStatus.State = RadioState.Idle; + radio.Status.State = RadioState.Idle; newStatus = true; } Log.Debug("Got RADRDY opcode. Data: {RadRdyData:X4}", msg.Data); @@ -967,19 +977,19 @@ private bool processSB9600(byte[] msgBytes) case (byte)SB9600Opcodes.RADKEY: if (msg.Data[1] == 0x01) { - if (radioStatus.State != RadioState.Transmitting) + if (radio.Status.State != RadioState.Transmitting) { Log.Information("Radio now transmitting"); - radioStatus.State = RadioState.Transmitting; + radio.Status.State = RadioState.Transmitting; newStatus = true; } } else { - if (radioStatus.State != RadioState.Receiving && radioStatus.State != RadioState.Idle) + if (radio.Status.State != RadioState.Receiving && radio.Status.State != RadioState.Idle) { Log.Information("Radio no longer transmitting"); - radioStatus.State = RadioState.Idle; + radio.Status.State = RadioState.Idle; newStatus = true; } } @@ -1008,18 +1018,18 @@ private bool processSB9600(byte[] msgBytes) // Channel Idle if (msg.Data[1] == 0x00) { - if (radioStatus.State != RadioState.Idle && radioStatus.State != RadioState.Transmitting) + if (radio.Status.State != RadioState.Idle && radio.Status.State != RadioState.Transmitting) { - radioStatus.State = RadioState.Idle; + radio.Status.State = RadioState.Idle; newStatus = true; } } // Channel RX else if (msg.Data[1] == 0x03) { - if (radioStatus.State != RadioState.Receiving && radioStatus.State != RadioState.Transmitting) + if (radio.Status.State != RadioState.Receiving && radio.Status.State != RadioState.Transmitting) { - radioStatus.State = RadioState.Receiving; + radio.Status.State = RadioState.Receiving; newStatus = true; } } @@ -1090,11 +1100,11 @@ private bool processSB9600(byte[] msgBytes) /// case (byte)SB9600Opcodes.BUTCTL: // Lookup the button - string buttonName = ControlHeads.GetButton(Head, msg.Data[0]); + string buttonName = ControlHeads.GetButton(ControlHead, msg.Data[0]); // Ignore knobs for now if (buttonName == null) { - Log.Warning("Unhandled button code {ButtonCode:X2} for control head {ControlHead}!", msg.Data[0], Head); + Log.Warning("Unhandled button code {ButtonCode:X2} for control head {ControlHead}!", msg.Data[0], ControlHead); } else if (buttonName.Contains("knob")) { } else @@ -1164,13 +1174,13 @@ private int processSBEP(byte[] msgBytes) byte chars = msg.Data[2]; byte srow = msg.Data[3]; byte scol = msg.Data[4]; - Log.Verbose("Got {Head} display update ({chars} chars) for row/col {StartingRow}/{StartingCol}", Head, chars, srow, scol); + Log.Verbose("Got {Head} display update ({chars} chars) for row/col {StartingRow}/{StartingCol}", ControlHead, chars, srow, scol); // Extract display characters string text; // We do this because GetString goes out of range on single-character strings if (chars == 1) { - text = Encoding.ASCII.GetString(new[]{msg.Data[5]}); + text = Encoding.ASCII.GetString(new[] { msg.Data[5] }); } else { @@ -1178,26 +1188,26 @@ private int processSBEP(byte[] msgBytes) } Log.Verbose("Got text string ({StringLen}) from SBEP: {String}", chars, text); // Update head parameters depending on head type - switch (Head) + switch (ControlHead) { /// /// W9 is a single-line display /// so we extract both zone & channel text lookups from one display line /// case HeadType.W9: - string newDisplay = displayText1[..scol] + text + displayText1[Math.Min((scol + chars),displayText1.Length)..]; + string newDisplay = displayText1[..scol] + text + displayText1[Math.Min((scol + chars), displayText1.Length)..]; Log.Verbose("Got new display text: {NewDisplayText}", newDisplay); if (newDisplay != displayText1) { // Update our display text displayText1 = newDisplay; // By default we use the full display as the radio's channel text - radioStatus.ChannelName = displayText1; + radio.Status.ChannelName = displayText1; // Flag that we've got a new status - if (radioStatus.ChannelName != "") - Log.Information("Got new channel name: {ChanText}", radioStatus.ChannelName); - if (radioStatus.ZoneName != "") - Log.Information("Got new zone name: {ZoneText}", radioStatus.ZoneName); + if (radio.Status.ChannelName != "") + Log.Information("Got new channel name: {ChanText}", radio.Status.ChannelName); + if (radio.Status.ZoneName != "") + Log.Information("Got new zone name: {ZoneText}", radio.Status.ZoneName); newStatus = true; } break; @@ -1217,8 +1227,8 @@ private int processSBEP(byte[] msgBytes) // Verify that the new text is not an ignored string if (ControlHeads.M3.IgnoredStrings.IndexOf(displayText1) == -1) { - radioStatus.ZoneName = displayText1; - Log.Verbose("Got new zone text for radio: {ZoneName}", radioStatus.ZoneName); + radio.Status.ZoneName = displayText1; + Log.Verbose("Got new zone text for radio: {ZoneName}", radio.Status.ZoneName); // Set flag newStatus = true; } @@ -1234,8 +1244,8 @@ private int processSBEP(byte[] msgBytes) // Verify that it's not an ignored string if (ControlHeads.M3.IgnoredStrings.IndexOf(displayText2) == -1) { - radioStatus.ChannelName = displayText2; - Log.Verbose("Got new channel text for radio: {ChannelName}", radioStatus.ChannelName); + radio.Status.ChannelName = displayText2; + Log.Verbose("Got new channel text for radio: {ChannelName}", radio.Status.ChannelName); // Set flag newStatus = true; } @@ -1243,10 +1253,10 @@ private int processSBEP(byte[] msgBytes) if (newStatus) { // Flag that we've got a new status - if (radioStatus.ChannelName != "") - Log.Information("Got new channel name: {ChanText}", radioStatus.ChannelName); - if (radioStatus.ZoneName != "") - Log.Information("Got new zone name: {ZoneText}", radioStatus.ZoneName); + if (radio.Status.ChannelName != "") + Log.Information("Got new channel name: {ChanText}", radio.Status.ChannelName); + if (radio.Status.ZoneName != "") + Log.Information("Got new zone name: {ZoneText}", radio.Status.ZoneName); } break; } @@ -1265,12 +1275,12 @@ private int processSBEP(byte[] msgBytes) Log.Debug("Got SBEP indicator update"); int count = msg.Data[0]; Log.Verbose("Got {count} indicator update(s)", count); - byte[] codes = msg.Data[1..(1+count)]; + byte[] codes = msg.Data[1..(1 + count)]; Log.Verbose("Codes: {codes}", codes); - byte[] states = msg.Data[(1+count)..(1+(2*count))]; + byte[] states = msg.Data[(1 + count)..(1 + (2 * count))]; Log.Verbose("States: {states}", states); // Iterate through each indicator and get its state and name - for (int i=0; i k.Name == mappedKey.Name)) { - // Scan softkey maps to scan state - case SoftkeyName.SCAN: - Log.Debug("Got new scan state from indicator {ind}", indicator.Name); - if (indicator.State == ControlHeads.IndicatorStates.ON) - radioStatus.ScanState = ScanState.Scanning; - else if (indicator.State == ControlHeads.IndicatorStates.OFF) - radioStatus.ScanState = ScanState.NotScanning; - break; - case SoftkeyName.LPWR: - Log.Debug("Got new low power state from indicator {ind}", indicator.Name); - if (indicator.State == ControlHeads.IndicatorStates.ON) - radioStatus.PowerState = PowerState.LowPower; - else if (indicator.State == ControlHeads.IndicatorStates.OFF) - radioStatus.PowerState = PowerState.HighPower; - break; - case SoftkeyName.DIR: - Log.Debug("Got new direct state from indicator {ind}", indicator.Name); - if (indicator.State == ControlHeads.IndicatorStates.ON) - radioStatus.Direct = true; - else - radioStatus.Direct = false; - break; + if (indicator.State == ControlHeads.IndicatorStates.ON) + softkey.State = SoftkeyState.On; + else if (indicator.State == ControlHeads.IndicatorStates.FLASHING_1 || indicator.State == ControlHeads.IndicatorStates.FLASHING_2) + softkey.State = SoftkeyState.Flashing; + else + softkey.State = SoftkeyState.Off; } } + // Update non-softkey radio states (SCAN, MON, etc) based on softkey name + switch (mappedKey.Name) + { + // Scan softkey maps to scan state + case SoftkeyName.SCAN: + Log.Debug("Got new scan state from indicator {ind}", indicator.Name); + if (indicator.State == ControlHeads.IndicatorStates.ON) + radio.Status.ScanState = ScanState.Scanning; + else if (indicator.State == ControlHeads.IndicatorStates.OFF) + radio.Status.ScanState = ScanState.NotScanning; + break; + case SoftkeyName.LPWR: + Log.Debug("Got new low power state from indicator {ind}", indicator.Name); + if (indicator.State == ControlHeads.IndicatorStates.ON) + radio.Status.PowerState = PowerState.LowPower; + else if (indicator.State == ControlHeads.IndicatorStates.OFF) + radio.Status.PowerState = PowerState.HighPower; + break; + case SoftkeyName.DIR: + Log.Debug("Got new direct state from indicator {ind}", indicator.Name); + if (indicator.State == ControlHeads.IndicatorStates.ON) + radio.Status.Direct = true; + else + radio.Status.Direct = false; + break; + } } } } @@ -1504,7 +1519,7 @@ private void processData(byte[] data) /// private void serialLoop(object _token) { - var token = (CancellationToken) _token; + var token = (CancellationToken)_token; // Open the serial port. Log.Verbose("Opening port..."); @@ -1575,7 +1590,7 @@ private void serialLoop(object _token) inSbep = false; Log.Verbose("Exiting SBEP"); } - + // Next, handle SB9600 else { @@ -1648,9 +1663,39 @@ private void serialLoop(object _token) { Log.Error(ex, "Got exception in SB9600 thread"); Stop(); - WebRTC.Stop("SB9600 thread encountered an error!"); + //radio.WebRTC.Stop("SB9600 thread encountered an error!"); + radio.Stop(); + } + } + } + + /// + /// Get an SB9600 button opcode from a softkey name using the softkey mapping + /// + /// Softkey to lookup + /// byte opcode for the SB9600 button based on the control head + /// + private byte getButtonCodeFromSoftkeyMapping(Softkey softkey) + { + // Identify button name based on softkey name & mapping + if (softkeyMappings.ContainsValue(softkey)) + { + // Get button name from softkey + string buttonName = softkeyMappings.First(mapping => mapping.Value == softkey).Key; + // Get button code from button name based on head type + switch (ControlHead) + { + case HeadType.W9: + return ControlHeads.W9.Buttons[buttonName]; + case HeadType.M3: + return ControlHeads.M3.Buttons[buttonName]; + case HeadType.O5: + // O5 is special since we only have softkeys + // TODO: this + break; } } + throw new ArgumentException($"Softkey {softkey.Name} is not mapped in softkey list!"); } public void SendButton(byte code, byte value) @@ -1684,7 +1729,7 @@ public void ToggleButton(byte code) public bool SetTransmit(bool tx) { byte btnVal = (byte)(tx ? 0x01 : 0x00); - switch (Head) + switch (ControlHead) { case HeadType.W9: SendButton(ControlHeads.W9.Buttons["ptt"], btnVal); @@ -1696,7 +1741,7 @@ public bool SetTransmit(bool tx) //SendButton(ControlHeads.O5.Buttons["ptt"], 0x01); break; default: - Log.Error("Transmit not defined for headtype {Head}", Head); + Log.Error("Transmit not defined for headtype {Head}", ControlHead); return false; } return true; @@ -1709,24 +1754,24 @@ public bool SetTransmit(bool tx) /// public bool ChangeChannel(bool down) { - switch (Head) + switch (ControlHead) { case HeadType.W9: string btn = down ? "btn_mode_down" : "btn_mode_up"; ToggleButton(ControlHeads.W9.Buttons[btn]); break; case HeadType.M3: - // M3 channel up/down is defined by programming + // M3 channel up/down is defined by programming, so we first idenfity the softkey name and then find it in the mapping list SoftkeyName name = down ? SoftkeyName.CHDN : SoftkeyName.CHUP; - Softkey key = radioStatus.Softkeys.Find(s => s.Name == name); - ToggleButton(key.Button.Code); + Softkey key = radio.Status.Softkeys.Find(s => s.Name == name); + ToggleButton(getButtonCodeFromSoftkeyMapping(key)); break; case HeadType.O5: byte steps = (byte)(down ? 0xFF : 0x01); //SendButton(ControlHeads.O5.Buttons["knob_chan"], steps); break; default: - Log.Error("ChangeChannel not defined for headtype {Head}", Head); + Log.Error("ChangeChannel not defined for headtype {Head}", ControlHead); return false; } return true; @@ -1735,18 +1780,18 @@ public bool ChangeChannel(bool down) public bool PressButton(SoftkeyName name) { // Find the true button name based on the mapping - Softkey key = radioStatus.Softkeys.Find(s => s.Name == name); + Softkey key = radio.Status.Softkeys.Find(s => s.Name == name); // Send the button command for the mapped button - SendButton(key.Button.Code, 0x01); + SendButton(getButtonCodeFromSoftkeyMapping(key), 0x01); return true; } - + public bool ReleaseButton(SoftkeyName name) { // Find the true button name based on the mapping - Softkey key = radioStatus.Softkeys.Find(s => s.Name == name); + Softkey key = radio.Status.Softkeys.Find(s => s.Name == name); // Send the button command for the mapped button - SendButton(key.Button.Code, 0x00); + SendButton(getButtonCodeFromSoftkeyMapping(key), 0x00); return true; } } diff --git a/daemon/config.example.yml b/daemon/config.example.yml new file mode 100644 index 0000000..e671207 --- /dev/null +++ b/daemon/config.example.yml @@ -0,0 +1,111 @@ +# +# RadioConsole2 Daemon Config File +# + +# Base radio configuration parameters +daemon: + # Name of this radio daemon (shown in console radio card header) + name: "Radio" + # Description of this radio daemon (shown on hover over name in console) + desc: "RadioConsole2 Radio Daemon" + # Listen address for this radio daemon + listenAddress: 0.0.0.0 + # Listen port + listenPort: 8801 + +# Radio control configuration +control: + # Control Mode + # + # 0 - VOX (control of TX/RX states is based on audio levels only) [Not Yet Implemented] + # 1 - TRC (Tone remote control based on EIA tone signalling) [Not Yet Implemented] + # 2 - SB9600 (emulation of Motorola Astro W-series and MCS2000 model-3 control heads over SB9600) + # 3 - XCMP Serial (control of Motorola XTL radios via serial) + # 4 - XCMP USB (control of Motorola XPR and APX radios via USB) + controlMode: 2 + + # SB9600 configuration + sb9600: + # Serial port name (COMx on Windows, /dev/ttyX on linux) + serialPort: "/dev/ttyS0" + # Control head type + # 0 - Astro W9 head (Astro spectra or XTL5000) + # 1 - MCS2000 M3 Head + # 2 - XTL O5/M5 head (XTL2500 or 5000) + controlHeadType: 0 + # Softkey button binding (maps SB9600 buttons to configured softkeys below) + softkeyBindings: + # Each list entry is in the format [ sb9600 button name, softkey name ] + # valid SB9600 buttons can be found in the configuration documentation + - [ "btn_top_1", "MON" ] + - [ "btn_top_2", "LPWR" ] + - [ "btn_top_3", "SCAN" ] + - [ "btn_top_4", "DIR" ] + - [ "btn_top_5", "SEC" ] + - [ "btn_top_6", "" ] + - [ "btn_kp_1", "CALL" ] + - [ "btn_kp_2", "PAGE" ] + - [ "btn_kp_3", "TGRP" ] + - [ "btn_kp_4", "" ] + - [ "btn_kp_5", "" ] + - [ "btn_kp_6", "" ] + - [ "btn_kp_7", "" ] + - [ "btn_kp_8", "" ] + - [ "btn_kp_9", "" ] + - [ "btn_kp_*", "RCL" ] + - [ "btn_kp_0", "" ] + - [ "btn_kp_#", "DEL" ] + - [ "btn_home", "HOME" ] + - [ "btn_sel", "SEL" ] + +# Audio settings for radio TX/RX audio +# Run `daemon list-audio` to get valid names for tx/rx devices +audio: + # TX audio device (speaker) + txDevice: "C-Media USB Headphone Set, USB Audio" + # TX audio (linear value, 1.0 = no gain/attenuation) + txGain: 1.0 + # RX audio device (microphone) + rxDevice: "C-Media USB Headphone Set, USB Audio" + # RX audio gain + rxGain: 1.0 + # Settings for audio recording + recording: + # Whether audio recording is enabled + enabled: false + # Path for recordings: + recPath: . + +# Text lookups for Zone/Channel text replacement with single-line displays +textLookups: + # Zone text lookups + zone: + # Lookups consist of multiple list entries as follows: + - match: "Z1" # Text matching "Z1" will replace the radio zone name + replace: "Zone 1" # with the text "Zone 1" + # Here's a second zone lookup entry + - match: "Z2" + replace: "Zone 2" + # Channel text lookups + channel: + # Same thing for channel text + - match: "CHAN1" + replace: "Channel 1" + # And a second + - match: "CHAN2" + replace: "Channel 2" + +# List of softkeys shown for this radio in the console client +softkeys: + - MON + - DEL + - LPWR + - SCAN + - DIR + - HOME + - CALL + - PAGE + - TGRP + - SEC + - RCL + - SEL \ No newline at end of file diff --git a/daemon/rc2-core b/daemon/rc2-core new file mode 160000 index 0000000..8c20aef --- /dev/null +++ b/daemon/rc2-core @@ -0,0 +1 @@ +Subproject commit 8c20aeff6b894882e67b011d8b1adb289cba02e1 From ed2d68d125e8b7a05ee0577d47ff6bdb47c4487a Mon Sep 17 00:00:00 2001 From: W3AXL <29879554+W3AXL@users.noreply.github.com> Date: Mon, 24 Feb 2025 19:19:25 -0500 Subject: [PATCH 02/18] trying to get rc2-core to properly submodule --- .gitmodules | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitmodules b/.gitmodules index 3a22eed..a028157 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,4 @@ [submodule "daemon/rc2-core"] path = daemon/rc2-core url = https://github.com/W3AXL/rc2-core + branch = main From 29f534bb39aa9973d078275e57d6eb297ff7a4af Mon Sep 17 00:00:00 2001 From: W3AXL <29879554+W3AXL@users.noreply.github.com> Date: Mon, 24 Feb 2025 19:24:46 -0500 Subject: [PATCH 03/18] updated both submodules --- daemon/SDL | 2 +- daemon/rc2-core | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/daemon/SDL b/daemon/SDL index 41bf6b5..24693ac 160000 --- a/daemon/SDL +++ b/daemon/SDL @@ -1 +1 @@ -Subproject commit 41bf6b5a51d868061c767affbceea2d8735dcccd +Subproject commit 24693ac2857e614a3f3c84f8bbdca17b3423dd4c diff --git a/daemon/rc2-core b/daemon/rc2-core index 8c20aef..2135d6d 160000 --- a/daemon/rc2-core +++ b/daemon/rc2-core @@ -1 +1 @@ -Subproject commit 8c20aeff6b894882e67b011d8b1adb289cba02e1 +Subproject commit 2135d6decb8181fc6b88d508bd1083729197a670 From 3d321445c7802b2e734a56c839409f334b62e849 Mon Sep 17 00:00:00 2001 From: W3AXL <29879554+W3AXL@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:44:58 -0500 Subject: [PATCH 04/18] radio control via SB9600 working, local audio isn't yet --- daemon/Audio.cs | 40 ---- daemon/ClientProtocol.cs | 154 --------------- daemon/Configuration.cs | 14 +- daemon/LocalAudio.cs | 121 +++++++++++- daemon/Program.cs | 304 +++++++++-------------------- daemon/Radio.MotoSB9600.cs | 18 +- daemon/SB9600.cs | 390 ++++++++++++++++++++++--------------- daemon/WebRTC.cs | 333 ------------------------------- daemon/config.example.yml | 52 ++--- daemon/daemon.csproj | 1 + daemon/rc2-core | 2 +- 11 files changed, 484 insertions(+), 945 deletions(-) delete mode 100644 daemon/Audio.cs delete mode 100644 daemon/ClientProtocol.cs delete mode 100644 daemon/WebRTC.cs diff --git a/daemon/Audio.cs b/daemon/Audio.cs deleted file mode 100644 index f26db41..0000000 --- a/daemon/Audio.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using MathNet.Numerics.Statistics; -using SIPSorceryMedia.Abstractions; -using SIPSorceryMedia.FFmpeg; -using SIPSorceryMedia.SDL2; - -namespace daemon -{ - internal class Audio - { - public static bool IsSDLInit = false; - /// - /// Inits SDL2 if necessary - /// - public static void InitSDL() - { - if (!IsSDLInit) - { - SDL2Helper.InitSDL(); - IsSDLInit = true; - } - } - - public static bool CheckInputExists(string inputName) - { - InitSDL(); - if (SDL2Helper.GetAudioRecordingDevices().Contains(inputName)) { return true; } else { return false; } - } - - public static bool CheckOutputExists(string outputName) - { - InitSDL(); - if (SDL2Helper.GetAudioPlaybackDevices().Contains(outputName)) { return true; } else { return false; } - } - } -} diff --git a/daemon/ClientProtocol.cs b/daemon/ClientProtocol.cs deleted file mode 100644 index 48289c2..0000000 --- a/daemon/ClientProtocol.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Text; -using System.Threading.Tasks; -using Serilog; -using SIPSorcery.Net; -using SIPSorcery.Media; -using SIPSorceryMedia.SDL2; -using WebSocketSharp.Server; -using SIPSorceryMedia.Abstractions; -using WebSocketSharp; -using netcore_cli; -using Newtonsoft.Json; -using MathNet.Numerics.Statistics; - -namespace daemon -{ - internal class DaemonWebsocket - { - public static WebSocketServer Wss { get; set; } - - public static Radio radio { get; set; } - - public static void StartWsServer() - { - Log.Information("Starting websocket server on {IP}:{Port}", Daemon.Config.DaemonIP, Daemon.Config.DaemonPort); - Wss = new WebSocketServer(Daemon.Config.DaemonIP, Daemon.Config.DaemonPort); // May need to set up SSL later - // Set up the WebRTC handler - Wss.AddWebSocketService("/rtc", (peer) => peer.CreatePeerConnection = WebRTC.CreatePeerConnection); - // Set up the regular message handler - Wss.AddWebSocketService("/"); - // Keeps the thing alive - Wss.KeepClean = false; - // Start the service - Wss.Start(); - } - - public static void StopWsServer() - { - Wss.Stop(); - } - - public static void SendRadioStatus() - { - string statusJson = radio.Status.Encode(); - Log.Debug("Sending radio status via websocket"); - Log.Verbose(statusJson); - SendClientMessage("{\"status\": " + statusJson + " }"); - } - - public static void SendClientMessage(string msg) - { - Wss.WebSocketServices["/"].Sessions.Broadcast(msg); - } - - public static void SendAck(String cmd = "") - { - Wss.WebSocketServices["/"].Sessions.Broadcast($"{{\"ack\": \"{cmd}\"}}"); - } - - public static void SendNack(String cmd = "") - { - Wss.WebSocketServices["/"].Sessions.Broadcast($"{{\"nack\": \"{cmd}\"}}"); - } - } - - internal class ClientProtocol : WebSocketBehavior - { - protected override void OnMessage(MessageEventArgs e) - { - var msg = e.Data; - Serilog.Log.Verbose("Got client message from websocket: {WSMessage}", msg); - dynamic jsonObj = JsonConvert.DeserializeObject(msg); - // Handle commands - if (jsonObj.ContainsKey("radio")) - { - // Radio Status Query - if (jsonObj.radio.command == "query") - { - DaemonWebsocket.SendRadioStatus(); - } - // Radio Start Transmit Command - else if (jsonObj.radio.command == "startTx") - { - if (DaemonWebsocket.radio.SetTransmit(true)) - DaemonWebsocket.SendAck("startTx"); - else - DaemonWebsocket.SendNack("startTx"); - } - // Radio Stop Transmit Command - else if (jsonObj.radio.command == "stopTx") - { - if (DaemonWebsocket.radio.SetTransmit(false)) - DaemonWebsocket.SendAck("stopTx"); - else - DaemonWebsocket.SendNack("stopTx"); - } - // Channel Up/Down - else if (jsonObj.radio.command == "chanUp") - { - if (DaemonWebsocket.radio.ChangeChannel(false)) - DaemonWebsocket.SendAck("chanUp"); - else - DaemonWebsocket.SendNack("chanUp"); - } - else if (jsonObj.radio.command == "chanDn") - { - if (DaemonWebsocket.radio.ChangeChannel(true)) - DaemonWebsocket.SendAck("chanDn"); - else - DaemonWebsocket.SendNack("chanDn"); - } - // Button press/release - else if (jsonObj.radio.command == "buttonPress") - { - if (DaemonWebsocket.radio.PressButton((SoftkeyName)Enum.Parse(typeof(SoftkeyName),(string)jsonObj.radio.options))) - DaemonWebsocket.SendAck("buttonPress"); - else - DaemonWebsocket.SendNack("buttonPress"); - } - else if (jsonObj.radio.command == "buttonRelease") - { - if (DaemonWebsocket.radio.ReleaseButton((SoftkeyName)Enum.Parse(typeof(SoftkeyName),(string)jsonObj.radio.options))) - DaemonWebsocket.SendAck("buttonRelease"); - else - DaemonWebsocket.SendNack("buttonRelease"); - } - // Reset - else if (jsonObj.radio.command == "reset") - { - Serilog.Log.Information("Resetting and restarting radio interface"); - // Stop - DaemonWebsocket.radio.Stop(); - // Restart with reset - DaemonWebsocket.radio.Start(false); - } - } - } - - protected override void OnClose(CloseEventArgs e) - { - Serilog.Log.Warning("Websocket connection closed: {args}", e.Reason); - WebRTC.Stop("Websocket closed"); - //DaemonWebsocket.radio.Stop(); - } - - protected override void OnError(WebSocketSharp.ErrorEventArgs e) - { - Serilog.Log.Error("Websocket encountered an error! {error}", e.Message); - } - } -} diff --git a/daemon/Configuration.cs b/daemon/Configuration.cs index fb97fa0..0366ae1 100644 --- a/daemon/Configuration.cs +++ b/daemon/Configuration.cs @@ -53,6 +53,10 @@ public class ControlConfig /// public RadioControlMode ControlMode = RadioControlMode.SB9600; /// + /// Whether the radio is RX only (TX disabled) + /// + public bool RxOnly = false; + /// /// Config for motorola SB9600 /// public MotoSb9600Config Sb9600 = new MotoSb9600Config(); @@ -68,17 +72,9 @@ public class AudioConfig /// public string TxDevice = ""; /// - /// TX gain for audio (1.0 = no gain/attenuation) - /// - public float TxGain = 1.0f; - /// /// RX audio device for radio (microphone) /// public string RxDevice = ""; - /// - /// RX gain for audio - /// - public float RxGain = 1.0f; } public class TextLookupConfig @@ -109,6 +105,6 @@ public class ConfigObject /// /// Softkey list /// - public List Softkeys = new List(); + public List Softkeys = new List(); } } diff --git a/daemon/LocalAudio.cs b/daemon/LocalAudio.cs index 9ceda33..70514d0 100644 --- a/daemon/LocalAudio.cs +++ b/daemon/LocalAudio.cs @@ -3,11 +3,130 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using MathNet.Numerics.Statistics; +using Serilog; +using SIPSorcery.Media; +using SIPSorceryMedia.Abstractions; +using SIPSorceryMedia.FFmpeg; +using SIPSorceryMedia.SDL2; namespace daemon { - public class LocalAudio + internal class Audio { + public static bool IsSDLInit = false; + /// + /// Inits SDL2 if necessary + /// + public static void InitSDL() + { + if (!IsSDLInit) + { + SDL2Helper.InitSDL(); + IsSDLInit = true; + } + } + public static bool CheckInputExists(string inputName) + { + InitSDL(); + if (SDL2Helper.GetAudioRecordingDevices().Contains(inputName)) { return true; } else { return false; } + } + + public static bool CheckOutputExists(string outputName) + { + InitSDL(); + if (SDL2Helper.GetAudioPlaybackDevices().Contains(outputName)) { return true; } else { return false; } + } + } + + /// + /// Local audio class which assists with sending & receiving audio from local SDL2 audio devices to WebRTC endpoints + /// + internal class LocalAudio + { + // RX Audio Objects + private SDL2AudioSource rxSource; + private AudioEncoder rxEncoder; + + // TX Audio Objects + private SDL2AudioEndPoint txEndpoint; + private AudioEncoder txEncoder; + + // Radio to obtain statuses from + private rc2_core.Radio radio; + + // RX audio callback action + public Action RxSampleCallback; + + public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool rxOnly = false) + { + // Store radio + this.radio = radio; + + // Init SDL2 + SDL2Helper.InitSDL(); + + Log.Information("Creating SDL2 local audio devices:"); + + // Setup RX audio devices + rxEncoder = new AudioEncoder(); + rxSource = new SDL2AudioSource(rxDevice, rxEncoder); + rxSource.OnAudioSourceError += (e) => { + Log.Error("Got RX audio error: {error}", e); + }; + // Setup RX sample callback + rxSource.OnAudioSourceRawSample += (AudioSamplingRatesEnum sampleRate, uint durationMs, short[] samples) => { + RxSampleCallback(samples, (uint)sampleRate); + }; + Log.Information(" RX: {rxDevice}", rxDevice); + + // Setup TX audio devices if we aren't rx-only + if (!rxOnly) { + txEncoder = new AudioEncoder(); + txEndpoint = new SDL2AudioEndPoint(txDevice, txEncoder); + txEndpoint.OnAudioSinkError += (e) => { + Log.Error("Got RX audio error: {error}", e); + }; + } + Log.Information(" TX: {txDevice}", txDevice); + } + + public async Task Start() + { + await rxSource.StartAudio(); + if (txEndpoint != null) + { + await txEndpoint.StartAudioSink(); + } + Log.Debug("Audio devices started"); + } + + public async Task Stop() + { + await rxSource.CloseAudio(); + if (txEndpoint != null) + { + await txEndpoint.CloseAudioSink(); + } + // De-init SDL2 + SDL2Helper.QuitSDL(); + Log.Debug("Audio devices stopped"); + } + + public void TxAudioCallback(short[] pcm16Samples) + { + // Ignore if we're not transmitting + if (radio.Status.State != rc2_core.RadioState.Transmitting) + { + return; + } + + // Convert the short[] samples into byte[] samples + byte[] pcm16Bytes = new byte[pcm16Samples.Length * 2]; + Buffer.BlockCopy(pcm16Samples, 0, pcm16Bytes, 0, pcm16Samples.Length * 2); + // Send TX samples to the TX audio device + txEndpoint.GotAudioSample(pcm16Bytes); + } } } diff --git a/daemon/Program.cs b/daemon/Program.cs index c95a64d..629075d 100644 --- a/daemon/Program.cs +++ b/daemon/Program.cs @@ -22,8 +22,8 @@ using Serilog.Events; using Serilog.Sinks.File; -using Tomlyn; -using Tomlyn.Model; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; using SIPSorcery.Net; using SIPSorceryMedia.Abstractions; @@ -35,6 +35,8 @@ using System.Runtime; using DirectShowLib; using MathNet.Numerics; +using rc2_core; +using moto_sb9600; namespace netcore_cli { @@ -43,20 +45,11 @@ internal class Daemon // Log Level Switch static LoggingLevelSwitch loggerSwitch = new LoggingLevelSwitch(); - private static bool shutdown = false; - - // Global Config Variables for the Daemon - public class Config - { - public static string DaemonName { get; set; } - public static string DaemonDesc { get; set; } - public static IPAddress DaemonIP { get; set; } - public static int DaemonPort { get; set; } - public static string TxAudioDevice { get; set; } - public static int TxAudioDeviceIdx { get; set; } - public static string RxAudioDevice { get; set; } - public static int RxAudioDeviceIdx { get; set; } - } + // Config Object (read in from config.yml) + static ConfigObject Config; + + // Local audio object + static LocalAudio localAudio; // Radio object static rc2_core.Radio radio = null; @@ -101,11 +94,10 @@ static async Task Main(string[] args) cmdRoot.AddCommand(cmdGetAudio); // Define arguments - var optConfigFile = new Option(new[] { "--config", "-c" }, "TOML daemon config file"); + var optConfigFile = new Option(new[] { "--config", "-c" }, "YAML daemon config file"); var optDebug = new Option(new[] { "--debug", "-d" }, "enable debug logging"); var optVerbose = new Option(new[] { "--verbose", "-v" }, "enable verbose logging (lots of prints)"); var optNoReset = new Option(new[] { "--no-reset", "-nr" }, "don't reset radio on startup"); - var optCodec = new Option(new[] { "--codec" }, "(debug) codec to use for WebRTC audio, default is G722"); var optLogging = new Option(new[] { "--log", "-l" }, "log console output to file"); // Add arguments @@ -113,11 +105,10 @@ static async Task Main(string[] args) cmdRoot.AddOption(optVerbose); cmdRoot.AddOption(optDebug); cmdRoot.AddOption(optNoReset); - cmdRoot.AddOption(optCodec); cmdRoot.AddOption(optLogging); // Main Runtime Handler - cmdRoot.SetHandler((context) => + cmdRoot.SetHandler(async (context) => { // Make sure a config file was specified if (context.ParseResult.GetValueForOption(optConfigFile) == null) @@ -131,21 +122,21 @@ static async Task Main(string[] args) bool debug = context.ParseResult.GetValueForOption(optDebug); bool verbose = context.ParseResult.GetValueForOption(optVerbose); bool noreset = context.ParseResult.GetValueForOption(optNoReset); - string codec = context.ParseResult.GetValueForOption(optCodec); bool log = context.ParseResult.GetValueForOption(optLogging); - int result = Startup(configFile, debug, verbose, noreset, log, codec); - context.ExitCode = result; + await Startup(configFile, debug, verbose, noreset, log); } }); return await cmdRoot.InvokeAsync(args); } - static int Startup(FileInfo configFile, bool debug, bool verbose, bool noreset, bool log, string codec = null) + static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool noreset, bool log) { // Add handler for SIGINT - Console.CancelKeyPress += delegate { - Shutdown(); + ManualResetEvent startShutdown = new ManualResetEvent(false); + Console.CancelKeyPress += (sender, args) => { + args.Cancel = true; + startShutdown.Set(); }; // Logging setup @@ -160,17 +151,8 @@ static int Startup(FileInfo configFile, bool debug, bool verbose, bool noreset, Log.Verbose("Verbose logging enabled"); } - if (codec != null) - { - WebRTC.Codec = codec; - } - // Read config from toml - int result = ReadConfig(configFile); - if (result != 0) - { - return result; - } + ReadConfig(configFile); // Set up file logging (we do this after config reading) if (log) @@ -179,194 +161,97 @@ static int Startup(FileInfo configFile, bool debug, bool verbose, bool noreset, System.IO.Directory.CreateDirectory("logs"); // Get the timestamp string timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmmss"); - Log.Information("Logging to file: {Name}_{timestamp}.log", Config.DaemonName, timestamp); + Log.Information("Logging to file: {Name}_{timestamp}.log", Config.Daemon.Name, timestamp); // We append the file logger to the original created logger Log.Logger = new LoggerConfiguration() .WriteTo.Logger(Log.Logger) - .WriteTo.File($"logs/{Config.DaemonName.Replace(" ", "_")}_{timestamp}.log") + .WriteTo.File($"logs/{Config.Daemon.Name.Replace(" ", "_")}_{timestamp}.log") .MinimumLevel.ControlledBy(loggerSwitch) .CreateLogger(); } - // Start websocket server - DaemonWebsocket.StartWsServer(); - // Start radio - radio.Start(noreset); - // Infinite loop (with a sleep to give CPU a break - while (!shutdown) + // Setup Audio Devices + localAudio = new LocalAudio(Config.Audio.RxDevice, Config.Audio.TxDevice, radio, Config.Control.RxOnly); + + // Switch based on control mode + switch(Config.Control.ControlMode) { - Thread.Sleep(100); + case RadioControlMode.SB9600: + { + radio = new MotoSb9600Radio( + Config.Daemon.Name, + Config.Daemon.Desc, + Config.Control.RxOnly, + Config.Daemon.ListenAddress, + Config.Daemon.ListenPort, + Config.Control.Sb9600.SerialPort, + Config.Control.Sb9600.ControlHeadType, + Config.Control.Sb9600.RxLeds, + Config.Control.Sb9600.SoftkeyBindings, + localAudio.TxAudioCallback, + 8000, + Config.Softkeys, + Config.TextLookups.Zone, + Config.TextLookups.Channel + ); + } + break; + default: + { + Log.Error("Control mode {mode} not yet implemented!", Config.Control.ControlMode.ToString()); + Environment.Exit((int)ERRNO.EBADCONFIG); + } + break; } - return 0; + + // Setup RX audio callback + localAudio.RxSampleCallback += radio.RxSendPCM16Samples; + + // Start radio + radio.Start(noreset); + + // Start audio + await localAudio.Start(); + + // Wait for shutdown trigger + startShutdown.WaitOne(); + + // Stop radio + Log.Information("Shutting down..."); + radio.Stop(); + await localAudio.Stop(); + Log.CloseAndFlush(); + + Environment.Exit(0); } - internal static int ReadConfig(FileInfo configFile) + internal static void ReadConfig(FileInfo configFile) { Log.Debug("Reading config file {ConfigFilePath}", configFile); - // Read file - string toml = File.ReadAllText(configFile.FullName); - // Parse TOML - var config = Toml.ToModel(toml); - // Daemon Info - var info = (TomlTable)config["info"]; - Config.DaemonName = (string)info["name"]; - Config.DaemonDesc = (string)info["desc"]; - Log.Debug(" Name: {DaemonName}", Config.DaemonName); - Log.Debug(" Desc: {DaemonDesc}", Config.DaemonDesc); - // Daemon Network Config - var net = (TomlTable)config["network"]; - Config.DaemonIP = IPAddress.Parse((string)net["ip"]); - Config.DaemonPort = (int)(long)net["port"]; - Log.Debug(" Address: {IP}:{Port}", Config.DaemonIP, Config.DaemonPort); - // Audio config - var audio = (TomlTable)config["audio"]; - Config.TxAudioDevice = (string)audio["txDevice"]; - Config.RxAudioDevice = (string)audio["rxDevice"]; - Log.Debug(" TX device: {TxDevice}", Config.TxAudioDevice); - Log.Debug(" RX device: {RxDevice}", Config.RxAudioDevice); - // Lookups - List zoneLookups = new List(); - List chanLookups = new List(); - var lookupCfg = (TomlTable)config["lookups"]; - var cfgZoneLookups = (TomlArray)lookupCfg["zoneLookup"]; - var cfgChanLookups = (TomlArray)lookupCfg["chanLookup"]; - foreach ( TomlArray lookup in cfgZoneLookups ) - { - zoneLookups.Add(new TextLookup((string)lookup[0], (string)lookup[1])); - } - foreach ( TomlArray lookup in cfgChanLookups ) - { - chanLookups.Add(new TextLookup((string)lookup[0], (string)lookup[1])); - } - Log.Debug("Loaded zone text lookups: {ZoneLookups}", zoneLookups); - Log.Debug("Loaded channel text lookups: {ChannelLookups}", chanLookups); - // Control Config - var radioCfg = (TomlTable)config["radio"]; - string controlType = (string)radioCfg["type"]; - bool rxOnly = (bool)radioCfg["rxOnly"]; - WebRTC.RxOnly = rxOnly; - /// - /// None Control Type (aka non-controlled RX only radio) - /// - if (controlType == "none") - { - var noneConfig = (TomlTable)config["none"]; - string zoneName = (string)noneConfig["zone"]; - string chanName = (string)noneConfig["chan"]; - Log.Debug(" Control: Non-controlled radio"); - radio = new Radio(Config.DaemonName, Config.DaemonDesc, RadioType.ListenOnly, zoneName, chanName); - // Update websocket radio object - DaemonWebsocket.radio = radio; - } - /// - /// Motorola SB9600 control - /// - else if (controlType == "sb9600") + + try { - // Parse the SB9600 config options - var sb9600config = (TomlTable)config["sb9600"]; - SB9600.HeadType head = (SB9600.HeadType)Enum.Parse(typeof(SB9600.HeadType), (string)sb9600config["head"]); - string port = (string)sb9600config["port"]; - Log.Debug(" Control: {HeadType}-head SB9600 radio on port {SerialPort}", head, port); - - // Parse softkeys and button bindings - List softkeys = new List(); - var cfgSoftkeys = (TomlTable)config["softkeys"]; - // We convert the button bindings to a more parsable array - var cfgButtonBinding = (TomlArray)cfgSoftkeys["buttonBinding"]; - List btnBindings = new List(); - foreach ( TomlArray binding in cfgButtonBinding ) - { - btnBindings.Add([(string)binding[0], (string)binding[1]]); - } - // Make sure we have button bindings - if (btnBindings == null) - { - Log.Error("No button bindings defined!"); - return 1; - } - Log.Debug("Loaded button bindings: {buttonBindings}", btnBindings); - // We iterate over each softkey entry - var cfgSoftkeyList = (TomlArray)cfgSoftkeys["softkeyList"]; - foreach ( string softkey in cfgSoftkeyList ) + using (FileStream stream = new FileStream(configFile.FullName, FileMode.Open, FileAccess.Read, FileShare.Read)) { - Softkey key = new Softkey(); - // Make sure the softkey name is valid - if (!Enum.IsDefined(typeof(SoftkeyName), softkey)) + using (TextReader reader = new StreamReader(stream)) { - Log.Error("Softkey name {name} is not defined!", softkey); - return 1; - } - key.Name = (SoftkeyName)Enum.Parse(typeof(SoftkeyName), softkey); - // Make sure that there's a valid binding for this softkey - string btnName = btnBindings.Find(b => b[1] == key.Name.ToString())[0]; - if (btnName == null) - { - Log.Error("Softkey name {name} not found in button binding map!", key.Name); - return 1; - } - // Create the button and assign it to the softkey - byte btnCode; - if (head == SB9600.HeadType.W9) - btnCode = ControlHeads.W9.Buttons[btnName]; - else if (head == SB9600.HeadType.M3) - btnCode = ControlHeads.M3.Buttons[btnName]; - else - { - Log.Error("Head type {Head} does not support button bindings!", head); - return 1; + // Read all yaml + string yml = reader.ReadToEnd(); + + // Parse to object + IDeserializer ymlDeserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + Config = ymlDeserializer.Deserialize(yml); } - key.Button = new ControlHeads.Button(btnCode, btnName); - // Add the softkey to the list - softkeys.Add(key); } - - // Parse using LEDs for RX states - bool rxLeds = false; - if (sb9600config.ContainsKey("useLedsForRx")) - rxLeds = (bool)sb9600config["useLedsForRx"]; - Log.Information("Using RX LEDs for RX state"); - - // Create SB9600 radio object - radio = new Radio(Config.DaemonName, Config.DaemonDesc, RadioType.SB9600, head, port, rxOnly, zoneLookups, chanLookups, softkeys, rxLeds); - radio.StatusCallback = DaemonWebsocket.SendRadioStatus; - - // Update websocket radio object - DaemonWebsocket.radio = radio; } - /// - /// CM108 single-channel PTT controlled radio - /// - else if (controlType == "cm108") + catch (Exception ex) { - // TODO: Implement this lol + Log.Error(ex, "Failed to read configuration file {configFile}", configFile); + Environment.Exit((int)rc2_core.ERRNO.ENOCONFIG); } - else - { - Log.Error("Unknown radio control type specified: {InvalidControlType}", controlType); - return 1; - } - - // Audio Recording Config (optional) - if (config.ContainsKey("recording")) - { - var recCfg = (TomlTable)config["recording"]; - bool record = (bool)recCfg["enabled"]; - WebRTC.Record = record; - if (record) - { - string recPath = (string)recCfg["path"]; - WebRTC.RecPath = recPath; - double recRxGain = (double)recCfg["rxGain"]; - double recTxGain = (double)recCfg["txGain"]; - WebRTC.SetRecGains(recRxGain, recTxGain); - int recTimeout = Convert.ToInt32(recCfg["timeout"]); - radio.RecTimeout = recTimeout; - Log.Information("Recording enabled, path {recPath}, RX Gain {rxGain}, TX Gain {txGain}, Timeout {timeout}", recPath, recRxGain, recTxGain, recTimeout); - } - } - - return 0; } static void ListAudioDeices() @@ -425,18 +310,5 @@ static void GetAudioDeviceInfo(string devName) } SDL2Helper.QuitSDL(); } - - /// - /// Shutdown the daemon - /// Accessible publicly so any task can initiate shutdown - /// - public static void Shutdown() - { - Log.Warning("Caught SIGINT, shutting down"); - radio.Stop(); - DaemonWebsocket.StopWsServer(); - Log.CloseAndFlush(); - shutdown = true; - } } } \ No newline at end of file diff --git a/daemon/Radio.MotoSB9600.cs b/daemon/Radio.MotoSB9600.cs index 89ad660..d80a547 100644 --- a/daemon/Radio.MotoSB9600.cs +++ b/daemon/Radio.MotoSB9600.cs @@ -25,16 +25,20 @@ public class MotoSb9600Config /// public SB9600.HeadType ControlHeadType = SB9600.HeadType.W9; /// + /// Whether to use RX LEDs as an additional RX state trigger + /// + public bool RxLeds = false; + /// /// Softkey binding dictionary /// - public Dictionary SoftkeyBindings; + public Dictionary SoftkeyBindings; } public class MotoSb9600Radio : rc2_core.Radio { private SB9600 sb9600; - private Dictionary softkeyBindings; + private Dictionary softkeyBindings; /// /// Initialize a new Motorola SB9600 radio @@ -55,9 +59,9 @@ public class MotoSb9600Radio : rc2_core.Radio public MotoSb9600Radio( string name, string desc, bool rxOnly, IPAddress listenAddress, int listenPort, - string serialPortName, SB9600.HeadType headType, bool rxLeds, Dictionary softkeyBindings, + string serialPortName, SB9600.HeadType headType, bool rxLeds, Dictionary softkeyBindings, Action txAudioCallback, int txAudioSampleRate, - List softkeys, + List softkeys, List zoneLookups = null, List chanLookups = null ) : base(name, desc, rxOnly, listenAddress, listenPort, softkeys, zoneLookups, chanLookups, txAudioCallback, txAudioSampleRate) { @@ -65,14 +69,18 @@ public MotoSb9600Radio( this.softkeyBindings = softkeyBindings; // Init SB9600 sb9600 = new SB9600(serialPortName, headType, this.softkeyBindings, this, rxLeds); + sb9600.StatusCallback += () => { + this.RadioStatusCallback(); + }; } /// /// Start the base radio as well as the SB9600 services /// /// - public new void Start(bool reset = false) + public override void Start(bool reset = false) { + Log.Information($"Starting new Motorola SB9600 radio instance"); base.Start(reset); sb9600.Start(reset); } diff --git a/daemon/SB9600.cs b/daemon/SB9600.cs index a69fa3e..9e4a9ff 100644 --- a/daemon/SB9600.cs +++ b/daemon/SB9600.cs @@ -25,10 +25,10 @@ public enum IndicatorStates : byte public class Indicator { public byte Code { get; set; } - public string Name { get; set; } + public IndicatorName Name { get; set; } public IndicatorStates State { get; set; } - public Indicator(byte code, string name, IndicatorStates state) + public Indicator(byte code, IndicatorName name, IndicatorStates state) { Code = code; Name = name; @@ -48,55 +48,140 @@ public Button(byte code, string name) } } + /// + /// Valid button names for the supported control heads + /// + public enum ButtonName + { + ptt, + knob_vol, + vip_1, + vip_2, + radio_sel, + rssi, + spkr_routing, + btn_kp_1, + btn_kp_2, + btn_kp_3, + btn_kp_4, + btn_kp_5, + btn_kp_6, + btn_kp_7, + btn_kp_8, + btn_kp_9, + btn_kp_s, + btn_kp_0, + btn_kp_p, + btn_mode_down, + btn_mode_up, + btn_vol_down, + btn_vol_up, + btn_sel, + btn_home, + btn_dim, + btn_top_1, + btn_top_2, + btn_top_3, + btn_top_4, + btn_top_5, + btn_top_6, + btn_left_top, + btn_left_mid, + btn_left_bot, + btn_bot_1, + btn_bot_2, + btn_bot_3, + btn_bot_4, + btn_bot_5, + btn_bot_6, + btn_kp_a, + btn_kp_b, + btn_kp_c, + btn_kp_d + } + + public enum IndicatorName + { + monitor, + scan, + scan_pri, + direct, + busy, + pri, + non_pri, + transmit, + top_1, + top_2, + top_3, + top_4, + top_5, + top_6, + bot_1, + bot_2, + bot_3, + bot_4, + bot_5, + bot_6 + } + public static class M3 { - public static readonly Dictionary Buttons = new Dictionary() + /// + /// SB9600 opcode mappings for M3 control head buttons + /// + public static readonly Dictionary Buttons = new Dictionary() { - { "ptt", 0x01 }, - { "knob_vol", 0x02 }, - { "btn_left_top", 0x60 }, - { "btn_left_mid", 0x61 }, - { "btn_left_bot", 0x62 }, - { "btn_bot_1", 0x63 }, - { "btn_bot_2", 0x64 }, - { "btn_bot_3", 0x65 }, - { "btn_bot_4", 0x66 }, - { "btn_bot_5", 0x67 }, - { "btn_bot_6", 0x68 }, - { "btn_kp_1", 0x31 }, - { "btn_kp_2", 0x32 }, - { "btn_kp_3", 0x33 }, - { "btn_kp_4", 0x34 }, - { "btn_kp_5", 0x35 }, - { "btn_kp_6", 0x36 }, - { "btn_kp_7", 0x37 }, - { "btn_kp_8", 0x38 }, - { "btn_kp_9", 0x39 }, - { "btn_kp_*", 0x3A }, - { "btn_kp_0", 0x30 }, - { "btn_kp_#", 0x3B }, - { "btn_kp_a", 0x69 }, - { "btn_kp_b", 0x6A }, - { "btn_kp_c", 0x6B }, - { "btn_kp_d", 0x6D }, + { ButtonName.ptt, 0x01 }, + { ButtonName.knob_vol, 0x02 }, + { ButtonName.btn_left_top, 0x60 }, + { ButtonName.btn_left_mid, 0x61 }, + { ButtonName.btn_left_bot, 0x62 }, + { ButtonName.btn_bot_1, 0x63 }, + { ButtonName.btn_bot_2, 0x64 }, + { ButtonName.btn_bot_3, 0x65 }, + { ButtonName.btn_bot_4, 0x66 }, + { ButtonName.btn_bot_5, 0x67 }, + { ButtonName.btn_bot_6, 0x68 }, + { ButtonName.btn_kp_1, 0x31 }, + { ButtonName.btn_kp_2, 0x32 }, + { ButtonName.btn_kp_3, 0x33 }, + { ButtonName.btn_kp_4, 0x34 }, + { ButtonName.btn_kp_5, 0x35 }, + { ButtonName.btn_kp_6, 0x36 }, + { ButtonName.btn_kp_7, 0x37 }, + { ButtonName.btn_kp_8, 0x38 }, + { ButtonName.btn_kp_9, 0x39 }, + { ButtonName.btn_kp_s, 0x3A }, + { ButtonName.btn_kp_0, 0x30 }, + { ButtonName.btn_kp_p, 0x3B }, + { ButtonName.btn_kp_a, 0x69 }, + { ButtonName.btn_kp_b, 0x6A }, + { ButtonName.btn_kp_c, 0x6B }, + { ButtonName.btn_kp_d, 0x6D }, }; - public static readonly Dictionary Indicators = new Dictionary() + /// + /// SB9600 opcode mappings for M3 control head indicators + /// + public static readonly Dictionary Indicators = new Dictionary() { - { "monitor", 0x01 }, - { "scan", 0x04 }, - { "scan_pri", 0x05 }, - { "direct", 0x07 }, - { "led_amber", 0x0D }, - { "led_red", 0x0B }, - { "ind_bot_1", 0x14 }, - { "ind_bot_2", 0x15 }, - { "ind_bot_3", 0x16 }, - { "ind_bot_4", 0x17 }, - { "ind_bot_5", 0x18 }, - { "ind_bot_6", 0x19 }, + { IndicatorName.monitor, 0x01 }, + { IndicatorName.scan, 0x04 }, + { IndicatorName.scan_pri, 0x05 }, + { IndicatorName.direct, 0x07 }, + { IndicatorName.busy, 0x0D }, + { IndicatorName.transmit, 0x0B }, + { IndicatorName.bot_1, 0x14 }, + { IndicatorName.bot_2, 0x15 }, + { IndicatorName.bot_3, 0x16 }, + { IndicatorName.bot_4, 0x17 }, + { IndicatorName.bot_5, 0x18 }, + { IndicatorName.bot_6, 0x19 }, }; + /// + /// Strings which are ignored on the M3 control head screen text + /// public static readonly List IgnoredStrings = new List() { "SELF TEST", @@ -106,53 +191,59 @@ public static class M3 public static class W9 { - public static readonly Dictionary Buttons = new Dictionary() + /// + /// SB9600 opcode mappings for W9 control head buttons + /// + public static readonly Dictionary Buttons = new Dictionary() { - { "ptt", 0x01 }, - { "vip_1", 0x06 }, - { "vip_2", 0x07 }, - { "radio_sel", 0x10 }, - { "rssi", 0x11 }, - { "spkr_routing", 0x12 }, - { "btn_kp_1", 0x31 }, - { "btn_kp_2", 0x32 }, - { "btn_kp_3", 0x33 }, - { "btn_kp_4", 0x34 }, - { "btn_kp_5", 0x35 }, - { "btn_kp_6", 0x36 }, - { "btn_kp_7", 0x37 }, - { "btn_kp_8", 0x38 }, - { "btn_kp_9", 0x39 }, - { "btn_kp_*", 0x3A }, - { "btn_kp_0", 0x30 }, - { "btn_kp_#", 0x3B }, - { "btn_mode_down", 0x50 }, - { "btn_mode_up", 0x51 }, - { "btn_vol_down", 0x52 }, - { "btn_vol_up", 0x53 }, - { "btn_sel", 0x60 }, - { "btn_home", 0x61 }, - { "btn_dim", 0x62 }, - { "btn_top_1", 0x63 }, - { "btn_top_2", 0x64 }, - { "btn_top_3", 0x65 }, - { "btn_top_4", 0x66 }, - { "btn_top_5", 0x67 }, - { "btn_top_6", 0x68 }, + { ButtonName.ptt, 0x01 }, + { ButtonName.vip_1, 0x06 }, + { ButtonName.vip_2, 0x07 }, + { ButtonName.radio_sel, 0x10 }, + { ButtonName.rssi, 0x11 }, + { ButtonName.spkr_routing, 0x12 }, + { ButtonName.btn_kp_1, 0x31 }, + { ButtonName.btn_kp_2, 0x32 }, + { ButtonName.btn_kp_3, 0x33 }, + { ButtonName.btn_kp_4, 0x34 }, + { ButtonName.btn_kp_5, 0x35 }, + { ButtonName.btn_kp_6, 0x36 }, + { ButtonName.btn_kp_7, 0x37 }, + { ButtonName.btn_kp_8, 0x38 }, + { ButtonName.btn_kp_9, 0x39 }, + { ButtonName.btn_kp_s, 0x3A }, + { ButtonName.btn_kp_0, 0x30 }, + { ButtonName.btn_kp_p, 0x3B }, + { ButtonName.btn_mode_down, 0x50 }, + { ButtonName.btn_mode_up, 0x51 }, + { ButtonName.btn_vol_down, 0x52 }, + { ButtonName.btn_vol_up, 0x53 }, + { ButtonName.btn_sel, 0x60 }, + { ButtonName.btn_home, 0x61 }, + { ButtonName.btn_dim, 0x62 }, + { ButtonName.btn_top_1, 0x63 }, + { ButtonName.btn_top_2, 0x64 }, + { ButtonName.btn_top_3, 0x65 }, + { ButtonName.btn_top_4, 0x66 }, + { ButtonName.btn_top_5, 0x67 }, + { ButtonName.btn_top_6, 0x68 }, }; - public static readonly Dictionary Indicators = new Dictionary() + /// + /// SB9600 opcode mappings for W9 control head indicators + /// + public static readonly Dictionary Indicators = new Dictionary() { - { "ind_top_1", 0x07 }, - { "ind_top_2", 0x08 }, - { "ind_top_3", 0x09 }, - { "ind_top_4", 0x0A }, - { "ind_top_5", 0x0B }, - { "ind_top_6", 0x0C }, - { "ind_pri", 0x0D }, - { "ind_nonpri", 0x0E }, - { "ind_busy", 0x0F }, - { "ind_xmit", 0x10 }, + { IndicatorName.top_1, 0x07 }, + { IndicatorName.top_2, 0x08 }, + { IndicatorName.top_3, 0x09 }, + { IndicatorName.top_4, 0x0A }, + { IndicatorName.top_5, 0x0B }, + { IndicatorName.top_6, 0x0C }, + { IndicatorName.pri, 0x0D }, + { IndicatorName.non_pri, 0x0E }, + { IndicatorName.busy, 0x0F }, + { IndicatorName.transmit, 0x10 }, }; } @@ -162,24 +253,24 @@ public static class W9 /// /// /// - public static string GetButton(SB9600.HeadType head, byte code) + public static ButtonName GetButton(SB9600.HeadType head, byte code) { - Dictionary buttons; + Dictionary buttons; if (head == SB9600.HeadType.M3) buttons = M3.Buttons; else if (head == SB9600.HeadType.W9) buttons = W9.Buttons; else - return null; + throw new NotImplementedException($"Head type {head} is not implemented!"); - foreach (KeyValuePair button in buttons) + foreach (KeyValuePair button in buttons) { if (button.Value == code) { return button.Key; } } - return null; + throw new ArgumentException($"Button code {code} is not defined for control head type {head}"); } /// @@ -190,15 +281,15 @@ public static string GetButton(SB9600.HeadType head, byte code) /// public static Indicator GetIndicator(SB9600.HeadType head, byte code) { - Dictionary indicators; + Dictionary indicators; if (head == SB9600.HeadType.M3) indicators = M3.Indicators; else if (head == SB9600.HeadType.W9) indicators = W9.Indicators; else - return null; + throw new NotImplementedException($"Control head type {head} is not yet implemented!"); - foreach (KeyValuePair indicator in indicators) + foreach (KeyValuePair indicator in indicators) { if (indicator.Value == code) { @@ -207,8 +298,7 @@ public static Indicator GetIndicator(SB9600.HeadType head, byte code) return ind; } } - Log.Warning("Could not find matching indidactor for code {code:X2}", code); - return null; + throw new ArgumentException($"Inidicator code {code} is not defined for control head type {head}"); } } @@ -261,7 +351,7 @@ public class SB9600 public delegate void Callback(); public Callback StatusCallback { get; set; } - private Dictionary softkeyMappings; + private Dictionary softkeyBindings; private bool newStatus = false; @@ -281,7 +371,6 @@ public enum HeadType { W9 = 0, M3 = 1, - O5 = 2 } private static readonly byte[] sb9600CrcTable = @@ -700,13 +789,13 @@ public DelayedMessage(long execTime, SBEPMsg msg) } } - public SB9600(string portName, HeadType controlHead, Dictionary softkeyMappings, MotoSb9600Radio radio, bool rxLeds = false) + public SB9600(string portName, HeadType controlHead, Dictionary softkeyBindings, MotoSb9600Radio radio, bool rxLeds = false) { Port = new SerialPort(portName); Port.BaudRate = 9600; ControlHead = controlHead; RxLeds = rxLeds; - this.softkeyMappings = softkeyMappings; + this.softkeyBindings = softkeyBindings; this.radio = radio; } @@ -1100,13 +1189,9 @@ private bool processSB9600(byte[] msgBytes) /// case (byte)SB9600Opcodes.BUTCTL: // Lookup the button - string buttonName = ControlHeads.GetButton(ControlHead, msg.Data[0]); + ControlHeads.ButtonName buttonName = ControlHeads.GetButton(ControlHead, msg.Data[0]); // Ignore knobs for now - if (buttonName == null) - { - Log.Warning("Unhandled button code {ButtonCode:X2} for control head {ControlHead}!", msg.Data[0], ControlHead); - } - else if (buttonName.Contains("knob")) { } + if (buttonName == ControlHeads.ButtonName.knob_vol) { } else { if (msg.Data[1] == 0x01) @@ -1296,7 +1381,8 @@ private int processSBEP(byte[] msgBytes) // Check for RX state by indicator state, if enabled if (RxLeds) { - if (indicator.Name.Contains("ind_nonpri") || indicator.Name.Contains("ind_pri")) + // Detect RX state from W9 head using pri/non-pri LEDs + if (indicator.Name == ControlHeads.IndicatorName.non_pri || indicator.Name == ControlHeads.IndicatorName.pri) { if (indicator.State != ControlHeads.IndicatorStates.OFF) { @@ -1323,7 +1409,7 @@ private int processSBEP(byte[] msgBytes) switch (indicator.Name) { // Scanning Icon (the "Z") - case "scan": + case ControlHeads.IndicatorName.scan: Log.Verbose("Got new scanning state: {scanState}", indicator.State); if (indicator.State == ControlHeads.IndicatorStates.ON) radio.Status.ScanState = ScanState.Scanning; @@ -1331,7 +1417,7 @@ private int processSBEP(byte[] msgBytes) radio.Status.ScanState = ScanState.NotScanning; break; // Scan priority dot (Z.) - case "scan_pri": + case ControlHeads.IndicatorName.scan_pri: Log.Verbose("Got new scan priority state: {priState}", indicator.State); if (indicator.State == ControlHeads.IndicatorStates.ON) radio.Status.PriorityState = PriorityState.Priority1; @@ -1341,15 +1427,17 @@ private int processSBEP(byte[] msgBytes) radio.Status.PriorityState = PriorityState.NoPriority; break; // Low power L icon - case "low_power": + // TODO: implement this + /* + case ControlHeads.IndicatorName.: Log.Verbose("Got new low power state: {lpState}", indicator.State); if (indicator.State == ControlHeads.IndicatorStates.ON) radio.Status.PowerState = PowerState.LowPower; else radio.Status.PowerState = PowerState.HighPower; - break; + break;*/ // Monitor Icon (the speaker) - case "monitor": + case ControlHeads.IndicatorName.monitor: Log.Verbose("Got new monitor state: {monState}", indicator.State); if (indicator.State == ControlHeads.IndicatorStates.ON) radio.Status.Monitor = true; @@ -1357,20 +1445,20 @@ private int processSBEP(byte[] msgBytes) radio.Status.Monitor = false; break; // Talkaround Icon - case "direct": + case ControlHeads.IndicatorName.direct: Log.Verbose("Got new direct state: {state}", indicator.State); if (indicator.State == ControlHeads.IndicatorStates.ON) radio.Status.Direct = true; else radio.Status.Direct = false; break; - // Amber icon - we use this as a fallback for detecting RX state if the status message doesn't work for whatever reason - case "led_amber": + // Amber LED/busy icon - we use this as a fallback for detecting RX state if the status message doesn't work for whatever reason + case ControlHeads.IndicatorName.busy: if (indicator.State == ControlHeads.IndicatorStates.ON) { if (radio.Status.State != RadioState.Receiving) { - Log.Information("Radio now receiving, source: amber LED"); + Log.Information("Radio now receiving, source: busy indicator"); radio.Status.State = RadioState.Receiving; newStatus = true; } @@ -1379,7 +1467,7 @@ private int processSBEP(byte[] msgBytes) { if (radio.Status.State != RadioState.Idle && radio.Status.State != RadioState.Transmitting) { - Log.Information("Radio now idle, source: amber LED"); + Log.Information("Radio now idle, source: busy indicator"); radio.Status.State = RadioState.Idle; newStatus = true; } @@ -1389,20 +1477,28 @@ private int processSBEP(byte[] msgBytes) } // W9 and M3 can get softkey statuses from the top & bottom indicators, respectively - if ((ControlHead == HeadType.W9 && indicator.Name.Contains("ind_top_")) || (ControlHead == HeadType.M3 && indicator.Name.Contains("ind_bot_"))) + string indicatorNameString = Enum.GetName(typeof(ControlHeads.IndicatorName), indicator.Name); + if ((ControlHead == HeadType.W9 && indicatorNameString.Contains("top_")) || (ControlHead == HeadType.M3 && indicatorNameString.Contains("bot_"))) { - // Get the button name from the indicator name - string btnName = indicator.Name.Replace("ind", "btn"); + // Append btn_ to get the corresponding button name + string btnNameString = "btn_" + indicatorNameString; + + // Convert to button name + ControlHeads.ButtonName btnName; + if (!Enum.TryParse(btnNameString, out btnName)) + { + throw new ArgumentException($"Button name {btnNameString} is not valid!"); + } // See if this button is present in our button bindings - if (softkeyMappings.ContainsKey(btnName)) + if (softkeyBindings.ContainsKey(btnName)) { - // Get the softkey from our mapping list - Softkey mappedKey = softkeyMappings[btnName]; + // Get the softkey name from our mapping list + SoftkeyName mappedKeyName = softkeyBindings[btnName]; // Find the softkey in the radio's softkey list and update its state accordingly - if (radio.Status.Softkeys.Contains(mappedKey)) + if (radio.Status.Softkeys.Any(c => c.Name == mappedKeyName)) { - foreach ( Softkey softkey in radio.Status.Softkeys.Where(k => k.Name == mappedKey.Name)) + foreach ( Softkey softkey in radio.Status.Softkeys.Where(k => k.Name == mappedKeyName)) { if (indicator.State == ControlHeads.IndicatorStates.ON) softkey.State = SoftkeyState.On; @@ -1413,7 +1509,7 @@ private int processSBEP(byte[] msgBytes) } } // Update non-softkey radio states (SCAN, MON, etc) based on softkey name - switch (mappedKey.Name) + switch (mappedKeyName) { // Scan softkey maps to scan state case SoftkeyName.SCAN: @@ -1539,7 +1635,7 @@ private void serialLoop(object _token) if (!Reset()) { Log.Error("Failed to reset the radio! Exiting..."); - Daemon.Shutdown(); + radio.Stop(); return; } } @@ -1672,16 +1768,16 @@ private void serialLoop(object _token) /// /// Get an SB9600 button opcode from a softkey name using the softkey mapping /// - /// Softkey to lookup + /// Softkey name to lookup /// byte opcode for the SB9600 button based on the control head /// - private byte getButtonCodeFromSoftkeyMapping(Softkey softkey) + private byte getButtonCodeFromSoftkeyBinding(SoftkeyName name) { // Identify button name based on softkey name & mapping - if (softkeyMappings.ContainsValue(softkey)) + if (softkeyBindings.ContainsValue(name)) { // Get button name from softkey - string buttonName = softkeyMappings.First(mapping => mapping.Value == softkey).Key; + ControlHeads.ButtonName buttonName = softkeyBindings.First(mapping => mapping.Value == name).Key; // Get button code from button name based on head type switch (ControlHead) { @@ -1689,13 +1785,9 @@ private byte getButtonCodeFromSoftkeyMapping(Softkey softkey) return ControlHeads.W9.Buttons[buttonName]; case HeadType.M3: return ControlHeads.M3.Buttons[buttonName]; - case HeadType.O5: - // O5 is special since we only have softkeys - // TODO: this - break; } } - throw new ArgumentException($"Softkey {softkey.Name} is not mapped in softkey list!"); + throw new ArgumentException($"Softkey {name} is not mapped in softkey list!"); } public void SendButton(byte code, byte value) @@ -1732,13 +1824,10 @@ public bool SetTransmit(bool tx) switch (ControlHead) { case HeadType.W9: - SendButton(ControlHeads.W9.Buttons["ptt"], btnVal); + SendButton(ControlHeads.W9.Buttons[ControlHeads.ButtonName.ptt], btnVal); break; case HeadType.M3: - SendButton(ControlHeads.M3.Buttons["ptt"], btnVal); - break; - case HeadType.O5: - //SendButton(ControlHeads.O5.Buttons["ptt"], 0x01); + SendButton(ControlHeads.M3.Buttons[ControlHeads.ButtonName.ptt], btnVal); break; default: Log.Error("Transmit not defined for headtype {Head}", ControlHead); @@ -1757,18 +1846,13 @@ public bool ChangeChannel(bool down) switch (ControlHead) { case HeadType.W9: - string btn = down ? "btn_mode_down" : "btn_mode_up"; - ToggleButton(ControlHeads.W9.Buttons[btn]); + ControlHeads.ButtonName btnName = down ? ControlHeads.ButtonName.btn_mode_down : ControlHeads.ButtonName.btn_mode_up; + ToggleButton(ControlHeads.W9.Buttons[btnName]); break; case HeadType.M3: // M3 channel up/down is defined by programming, so we first idenfity the softkey name and then find it in the mapping list SoftkeyName name = down ? SoftkeyName.CHDN : SoftkeyName.CHUP; - Softkey key = radio.Status.Softkeys.Find(s => s.Name == name); - ToggleButton(getButtonCodeFromSoftkeyMapping(key)); - break; - case HeadType.O5: - byte steps = (byte)(down ? 0xFF : 0x01); - //SendButton(ControlHeads.O5.Buttons["knob_chan"], steps); + ToggleButton(getButtonCodeFromSoftkeyBinding(name)); break; default: Log.Error("ChangeChannel not defined for headtype {Head}", ControlHead); @@ -1779,19 +1863,15 @@ public bool ChangeChannel(bool down) public bool PressButton(SoftkeyName name) { - // Find the true button name based on the mapping - Softkey key = radio.Status.Softkeys.Find(s => s.Name == name); // Send the button command for the mapped button - SendButton(getButtonCodeFromSoftkeyMapping(key), 0x01); + SendButton(getButtonCodeFromSoftkeyBinding(name), 0x01); return true; } public bool ReleaseButton(SoftkeyName name) { - // Find the true button name based on the mapping - Softkey key = radio.Status.Softkeys.Find(s => s.Name == name); // Send the button command for the mapped button - SendButton(getButtonCodeFromSoftkeyMapping(key), 0x00); + SendButton(getButtonCodeFromSoftkeyBinding(name), 0x00); return true; } } diff --git a/daemon/WebRTC.cs b/daemon/WebRTC.cs deleted file mode 100644 index b44086c..0000000 --- a/daemon/WebRTC.cs +++ /dev/null @@ -1,333 +0,0 @@ -using Serilog; -using SIPSorcery.Net; -using SIPSorcery.Media; -using SIPSorceryMedia.SDL2; -using SIPSorceryMedia.FFmpeg; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using SIPSorceryMedia.Abstractions; -using netcore_cli; -using MathNet.Numerics.Statistics; -using System.Net; -using NAudio; -using NAudio.Wave; -using NAudio.Utils; -using NAudio.Wave.SampleProviders; - -namespace daemon -{ - internal class WebRTC - { - // Objects for RX audio processing - private static SDL2AudioSource RxSource = null; - private static AudioEncoder RxEncoder = null; - private static AudioFormat RxFormat = AudioFormat.Empty; - - // Objects for TX audio processing - private static SDL2AudioEndPoint TxEndpoint = null; - private static AudioEncoder TxEncoder = null; - private static AudioFormat TxFormat = AudioFormat.Empty; - - // We make separate encoders for recording since some codecs can be time-variant - private static AudioEncoder RecRxEncoder = null; - private static AudioEncoder RecTxEncoder = null; - - // Objects for TX/RX audio recording - public static bool Record = false; // Whether or not recording to audio files is enabled - public static string RecPath = null; // Folder to store recordings - public static string RecTsFmt = "yyyy-MM-dd_HHmmss"; // Timestamp format string - public static bool RecTxInProgress = false; // Flag to indicate if a file is currently being recorded - public static bool RecRxInProgress = false; - private static float recRxGain = 1; - private static float recTxGain = 1; - - // Recording format (TODO: Make configurable) - private static WaveFormat recFormat = null; - // Output wave file writers - private static WaveFileWriter recTxWriter = null; - private static WaveFileWriter recRxWriter = null; - - // WebRTC variables - private static MediaStreamTrack RtcTrack = null; - private static RTCPeerConnection pc = null; - public static string Codec { get; set; } = "G722"; - - // Flag whether our radio is RX only - public static bool RxOnly {get; set;} = false; - - public static Task CreatePeerConnection() - { - Log.Debug("New client connected to RTC endpoint, creating peer connection"); - // Create RTC configuration and peer connection - RTCConfiguration config = new RTCConfiguration - { - }; - pc = new RTCPeerConnection(config); - - // Init SDL2 - SDL2Helper.InitSDL(); - Log.Debug("SDL2 init done"); - - // RX audio setup - RxEncoder = new AudioEncoder(); - RecRxEncoder = new AudioEncoder(); - RxSource = new SDL2AudioSource(Daemon.Config.RxAudioDevice, RxEncoder); - Log.Debug("RX audio using input {RxInput}", Daemon.Config.RxAudioDevice); - - RxSource.OnAudioSourceError += (e) => { - Log.Error("Got RX source error: {error}", e); - }; - - // TX audio setup - if (!RxOnly) - { - TxEncoder = new AudioEncoder(); - RecTxEncoder = new AudioEncoder(); - TxEndpoint = new SDL2AudioEndPoint(Daemon.Config.TxAudioDevice, TxEncoder); - Log.Debug("TX audio using output {TxOutput}", Daemon.Config.TxAudioDevice); - - TxEndpoint.OnAudioSinkError += (e) => { - Log.Error("Got TX endpoint error: {error}", e); - }; - } - else - { - Log.Warning("RX only radio defined, skipping TX audio setup"); - } - - - Log.Debug("Created SDL2 audio sources/sinks and encoder"); - - Log.Verbose("Client supported formats:"); - foreach (var format in RxEncoder.SupportedFormats) - { - Log.Verbose("{FormatName}", format.FormatName); - } - - // Add the RX track to the peer connection - if (!RxEncoder.SupportedFormats.Any(f => f.FormatName == Codec)) - { - Log.Error("Specified format {SpecFormat} not supported by audio encoder!", Codec); - throw new ArgumentException("Invalid codec specified!"); - } - if (!RxOnly) - { - RtcTrack = new MediaStreamTrack(RxEncoder.SupportedFormats.Find(f => f.FormatName == Codec), MediaStreamStatusEnum.SendRecv); - Log.Debug("Added send/recv audio track to peer connection"); - } - else - { - RtcTrack = new MediaStreamTrack(RxEncoder.SupportedFormats.Find(f => f.FormatName == Codec), MediaStreamStatusEnum.SendOnly); - Log.Debug("Added send-only audio track to peer connection"); - } - pc.addTrack(RtcTrack); - - - // RX Audio Sample Callback - RxSource.OnAudioSourceEncodedSample += (durationRtpUnits, samples) => { - //Log.Verbose("Got {numSamples} encoded samples from RX audio source", sample.Length); - pc.SendAudio(durationRtpUnits, samples); - // Optional write to file - if (Record && recRxWriter != null) - { - // Decode samples to pcm - short[] pcmSamples = RecRxEncoder.DecodeAudio(samples, RxFormat); - // Convert to float s16 - float[] s16Samples = new float[pcmSamples.Length]; - for (int n = 0; n < pcmSamples.Length; n++) - { - s16Samples[n] = pcmSamples[n] / 32768f * recRxGain; - } - // Add to buffer - recRxWriter.WriteSamples(s16Samples, 0, s16Samples.Length); - } - }; - - // Audio format negotiation callback - pc.OnAudioFormatsNegotiated += (formats) => - { - // Get the format - RxFormat = formats.Find(f => f.FormatName == Codec); - // Set the source to use the format - RxSource.SetAudioSourceFormat(RxFormat); - Log.Debug("Negotiated RX audio format {AudioFormat} ({ClockRate}/{Chs})", RxFormat.FormatName, RxFormat.ClockRate, RxFormat.ChannelCount); - // Set our wave and buffer writers to the proper sample rate - recFormat = new WaveFormat(RxFormat.ClockRate, 16, 1); - if (!RxOnly) - { - TxFormat = formats.Find(f => f.FormatName == Codec); - TxEndpoint.SetAudioSinkFormat(TxFormat); - Log.Debug("Negotiated TX audio format {AudioFormat} ({ClockRate}/{Chs})", TxFormat.FormatName, TxFormat.ClockRate, TxFormat.ChannelCount); - } - }; - - // Connection state change callback - pc.onconnectionstatechange += ConnectionStateChange; - - // Debug Stuff - pc.OnReceiveReport += (re, media, rr) => Log.Verbose("RTCP report received {Media} from {RE}\n{Report}", media, re, rr.GetDebugSummary()); - pc.OnSendReport += (media, sr) => Log.Verbose("RTCP report sent for {Media}\n{Summary}", media, sr.GetDebugSummary()); - pc.GetRtpChannel().OnStunMessageSent += (msg, ep, isRelay) => - { - Log.Verbose("STUN {MessageType} sent to {Endpoint}.", msg.Header.MessageType, ep); - }; - pc.GetRtpChannel().OnStunMessageReceived += (msg, ep, isRelay) => - { - Log.Verbose("STUN {MessageType} received from {Endpoint}.", msg.Header.MessageType, ep); - //Log.Verbose(msg.ToString()); - }; - pc.oniceconnectionstatechange += (state) => Log.Verbose("ICE connection state change to {ICEState}.", state); - - // RTP Samples callback - pc.OnRtpPacketReceived += (IPEndPoint rep, SDPMediaTypesEnum media, RTPPacket rtpPkt) => - { - if (media == SDPMediaTypesEnum.audio) - { - //Log.Verbose("Got RTP audio from {Endpoint} - ({length}-byte payload)", rep.ToString(), rtpPkt.Payload.Length); - if (!RxOnly) - TxEndpoint.GotAudioRtp( - rep, - rtpPkt.Header.SyncSource, - rtpPkt.Header.SequenceNumber, - rtpPkt.Header.Timestamp, - rtpPkt.Header.PayloadType, - rtpPkt.Header.MarkerBit == 1, - rtpPkt.Payload - ); - // Save TX audio to file, if we're supposed to and the file is open - if (Record && recTxWriter != null) - { - // Get samples - byte[] samples = rtpPkt.Payload; - // Decode samples - short[] pcmSamples = RecTxEncoder.DecodeAudio(samples, TxFormat); - // Convert to float s16 - float[] s16Samples = new float[pcmSamples.Length]; - for (int n = 0; n < pcmSamples.Length; n++) - { - s16Samples[n] = pcmSamples[n] / 32768f * recTxGain; - } - // Add to buffer - recTxWriter.WriteSamples(s16Samples, 0, s16Samples.Length); - } - } - }; - - return Task.FromResult(pc); - } - - /// - /// Handler for RTC connection state chagne - /// - /// the new connection state - private static async void ConnectionStateChange(RTCPeerConnectionState state) - { - Log.Information("Peer connection state change to {PCState}.", state); - - if (state == RTCPeerConnectionState.failed) - { - Log.Error("Peer connection failed"); - Log.Debug("Closing peer connection"); - pc.Close("Connection failed"); - } - else if (state == RTCPeerConnectionState.closed) - { - Log.Debug("Closing audio"); - await CloseAudio(); - } - else if (state == RTCPeerConnectionState.connected) - { - Log.Debug("Starting audio"); - await StartAudio(); - } - } - - public static void Stop(string reason) - { - Log.Warning("Stopping WebRTC with reason {Reason}", reason); - pc.Close(reason); - } - - private static async Task StartAudio() - { - await RxSource.StartAudio(); - if (!RxOnly) - await TxEndpoint.StartAudioSink(); - Log.Debug("Audio started"); - } - - private static async Task CloseAudio() - { - // Close audio - await RxSource.CloseAudio(); - if (!RxOnly) - await TxEndpoint.CloseAudioSink(); - // De-init SDL2 - SDL2Helper.QuitSDL(); - Log.Debug("SDL2 audio closed"); - } - - /// - /// Start a wave recording with the specified file prefix - /// - /// filename prefix, appended with timestamp - public static void RecStartTx(string name) - { - // Only create a new file if recording is enabled - if (Record && !RecTxInProgress) - { - // Get full filepath - string filename = $"{RecPath}/{DateTime.Now.ToString(RecTsFmt)}_{name.Replace(' ', '_')}_TX.wav"; - // Create writer - recTxWriter = new WaveFileWriter(filename, recFormat); - Log.Debug("Starting new TX recording: {file}", filename); - // Set Flag - RecTxInProgress = true; - } - } - - public static void RecStartRx(string name) - { - // Only create a new file if recording is enabled - if (Record && !RecRxInProgress) - { - // Get full filepath - string filename = $"{RecPath}/{DateTime.Now.ToString(RecTsFmt)}_{name.Replace(' ', '_')}_RX.wav"; - // Create writer - recRxWriter = new WaveFileWriter(filename, recFormat); - Log.Debug("Starting new RX recording: {file}", filename); - // Set Flag - RecRxInProgress = true; - } - } - - /// - /// Stop a wave recording - /// - public static void RecStop() - { - if (recTxWriter != null) - { - recTxWriter.Close(); - recTxWriter = null; - } - if (recRxWriter != null) - { - recRxWriter.Close(); - recRxWriter = null; - } - RecTxInProgress = false; - RecRxInProgress = false; - Log.Debug("Stopped recording"); - } - - public static void SetRecGains(double rxGainDb, double txGainDb) - { - recRxGain = (float)Math.Pow(10, rxGainDb/20); - recTxGain = (float)Math.Pow(10, txGainDb/20); - } - } -} diff --git a/daemon/config.example.yml b/daemon/config.example.yml index e671207..18827c5 100644 --- a/daemon/config.example.yml +++ b/daemon/config.example.yml @@ -35,46 +35,36 @@ control: controlHeadType: 0 # Softkey button binding (maps SB9600 buttons to configured softkeys below) softkeyBindings: - # Each list entry is in the format [ sb9600 button name, softkey name ] + # Each entry is in the format sb9600 button name: softkey name # valid SB9600 buttons can be found in the configuration documentation - - [ "btn_top_1", "MON" ] - - [ "btn_top_2", "LPWR" ] - - [ "btn_top_3", "SCAN" ] - - [ "btn_top_4", "DIR" ] - - [ "btn_top_5", "SEC" ] - - [ "btn_top_6", "" ] - - [ "btn_kp_1", "CALL" ] - - [ "btn_kp_2", "PAGE" ] - - [ "btn_kp_3", "TGRP" ] - - [ "btn_kp_4", "" ] - - [ "btn_kp_5", "" ] - - [ "btn_kp_6", "" ] - - [ "btn_kp_7", "" ] - - [ "btn_kp_8", "" ] - - [ "btn_kp_9", "" ] - - [ "btn_kp_*", "RCL" ] - - [ "btn_kp_0", "" ] - - [ "btn_kp_#", "DEL" ] - - [ "btn_home", "HOME" ] - - [ "btn_sel", "SEL" ] + btn_top_1: MON + btn_top_2: LPWR + btn_top_3: SCAN + btn_top_4: DIR + btn_top_5: SEC + btn_top_6: + btn_kp_1: CALL + btn_kp_2: PAGE + btn_kp_3: TGRP + btn_kp_4: + btn_kp_5: + btn_kp_6: + btn_kp_7: + btn_kp_8: + btn_kp_9: + btn_kp_s: RCL + btn_kp_0: + btn_kp_p: DEL + btn_home: HOME + btn_sel: SEL # Audio settings for radio TX/RX audio # Run `daemon list-audio` to get valid names for tx/rx devices audio: # TX audio device (speaker) txDevice: "C-Media USB Headphone Set, USB Audio" - # TX audio (linear value, 1.0 = no gain/attenuation) - txGain: 1.0 # RX audio device (microphone) rxDevice: "C-Media USB Headphone Set, USB Audio" - # RX audio gain - rxGain: 1.0 - # Settings for audio recording - recording: - # Whether audio recording is enabled - enabled: false - # Path for recordings: - recPath: . # Text lookups for Zone/Channel text replacement with single-line displays textLookups: diff --git a/daemon/daemon.csproj b/daemon/daemon.csproj index d04c642..6dd9ce3 100644 --- a/daemon/daemon.csproj +++ b/daemon/daemon.csproj @@ -26,6 +26,7 @@ + diff --git a/daemon/rc2-core b/daemon/rc2-core index 2135d6d..fec0140 160000 --- a/daemon/rc2-core +++ b/daemon/rc2-core @@ -1 +1 @@ -Subproject commit 2135d6decb8181fc6b88d508bd1083729197a670 +Subproject commit fec014013df19d5a9eb9ba1b58bb96857040d6a5 From d8223a94a4eaa33186ee141dbac1ace1130c40d9 Mon Sep 17 00:00:00 2001 From: W3AXL <29879554+W3AXL@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:09:22 -0500 Subject: [PATCH 05/18] bidirectional audio now fully working --- daemon/LocalAudio.cs | 26 ++++++++++++-------------- daemon/Program.cs | 8 +++----- daemon/Radio.MotoSB9600.cs | 5 +++-- daemon/SB9600.cs | 3 +-- daemon/rc2-core | 2 +- 5 files changed, 20 insertions(+), 24 deletions(-) diff --git a/daemon/LocalAudio.cs b/daemon/LocalAudio.cs index 70514d0..2782191 100644 --- a/daemon/LocalAudio.cs +++ b/daemon/LocalAudio.cs @@ -57,7 +57,7 @@ internal class LocalAudio private rc2_core.Radio radio; // RX audio callback action - public Action RxSampleCallback; + public Action RxEncodedSampleCallback; public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool rxOnly = false) { @@ -76,11 +76,11 @@ public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool r Log.Error("Got RX audio error: {error}", e); }; // Setup RX sample callback - rxSource.OnAudioSourceRawSample += (AudioSamplingRatesEnum sampleRate, uint durationMs, short[] samples) => { - RxSampleCallback(samples, (uint)sampleRate); + rxSource.OnAudioSourceEncodedSample += (uint durationRtpUnits, byte[] samples) => { + //Log.Verbose("Got {count} encoded RX samples", samples.Length); + RxEncodedSampleCallback(durationRtpUnits, samples); }; Log.Information(" RX: {rxDevice}", rxDevice); - // Setup TX audio devices if we aren't rx-only if (!rxOnly) { txEncoder = new AudioEncoder(); @@ -92,14 +92,18 @@ public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool r Log.Information(" TX: {txDevice}", txDevice); } - public async Task Start() + public void Start(AudioFormat audioFormat) { - await rxSource.StartAudio(); + // Set audio formats + rxSource.SetAudioSourceFormat(audioFormat); + txEndpoint.SetAudioSinkFormat(audioFormat); + // Start! + rxSource.StartAudio(); if (txEndpoint != null) { - await txEndpoint.StartAudioSink(); + txEndpoint.StartAudioSink(); } - Log.Debug("Audio devices started"); + Log.Debug("Audio devices started using format {format}/{rate}/{chans}", audioFormat.FormatName, audioFormat.ClockRate, audioFormat.ChannelCount); } public async Task Stop() @@ -116,12 +120,6 @@ public async Task Stop() public void TxAudioCallback(short[] pcm16Samples) { - // Ignore if we're not transmitting - if (radio.Status.State != rc2_core.RadioState.Transmitting) - { - return; - } - // Convert the short[] samples into byte[] samples byte[] pcm16Bytes = new byte[pcm16Samples.Length * 2]; Buffer.BlockCopy(pcm16Samples, 0, pcm16Bytes, 0, pcm16Samples.Length * 2); diff --git a/daemon/Program.cs b/daemon/Program.cs index 629075d..fff9da9 100644 --- a/daemon/Program.cs +++ b/daemon/Program.cs @@ -189,7 +189,8 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no Config.Control.Sb9600.RxLeds, Config.Control.Sb9600.SoftkeyBindings, localAudio.TxAudioCallback, - 8000, + 16000, + localAudio.Start, Config.Softkeys, Config.TextLookups.Zone, Config.TextLookups.Channel @@ -205,13 +206,10 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no } // Setup RX audio callback - localAudio.RxSampleCallback += radio.RxSendPCM16Samples; + localAudio.RxEncodedSampleCallback += radio.RxSendEncodedSamples; // Start radio radio.Start(noreset); - - // Start audio - await localAudio.Start(); // Wait for shutdown trigger startShutdown.WaitOne(); diff --git a/daemon/Radio.MotoSB9600.cs b/daemon/Radio.MotoSB9600.cs index d80a547..d38c35b 100644 --- a/daemon/Radio.MotoSB9600.cs +++ b/daemon/Radio.MotoSB9600.cs @@ -8,6 +8,7 @@ using System.Net; using System.Text; using System.Threading.Tasks; +using SIPSorceryMedia.Abstractions; namespace moto_sb9600 { @@ -60,10 +61,10 @@ public MotoSb9600Radio( string name, string desc, bool rxOnly, IPAddress listenAddress, int listenPort, string serialPortName, SB9600.HeadType headType, bool rxLeds, Dictionary softkeyBindings, - Action txAudioCallback, int txAudioSampleRate, + Action txAudioCallback, int txAudioSampleRate, Action rtcFormatCallback, List softkeys, List zoneLookups = null, List chanLookups = null - ) : base(name, desc, rxOnly, listenAddress, listenPort, softkeys, zoneLookups, chanLookups, txAudioCallback, txAudioSampleRate) + ) : base(name, desc, rxOnly, listenAddress, listenPort, softkeys, zoneLookups, chanLookups, txAudioCallback, txAudioSampleRate, rtcFormatCallback) { // Save softkey lookups this.softkeyBindings = softkeyBindings; diff --git a/daemon/SB9600.cs b/daemon/SB9600.cs index 9e4a9ff..8da99cc 100644 --- a/daemon/SB9600.cs +++ b/daemon/SB9600.cs @@ -997,14 +997,13 @@ private bool processSB9600(byte[] msgBytes) { radio.Status.State = RadioState.Transmitting; newStatus = true; - Log.Information("Radio now transmitting"); } else if (radio.Status.State != RadioState.Receiving && radio.Status.State != RadioState.Idle) { radio.Status.State = RadioState.Idle; newStatus = true; - Log.Information("Radio no longer transmitting"); } + Log.Information("Radio state now {state}", radio.Status.State); } break; default: diff --git a/daemon/rc2-core b/daemon/rc2-core index fec0140..57e7136 160000 --- a/daemon/rc2-core +++ b/daemon/rc2-core @@ -1 +1 @@ -Subproject commit fec014013df19d5a9eb9ba1b58bb96857040d6a5 +Subproject commit 57e7136cc02e00836f411de2bd9ed6a543998ff9 From 0d6224845afc2ef7d9acf611056c579b8f49bf61 Mon Sep 17 00:00:00 2001 From: Patrick W3AXL Date: Tue, 4 Mar 2025 14:30:41 -0500 Subject: [PATCH 06/18] small client updates for alert tone mic muting --- console/client.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/console/client.js b/console/client.js index 3dacb48..169b7e4 100644 --- a/console/client.js +++ b/console/client.js @@ -886,6 +886,8 @@ function dialNumber(radioId, number, digitTime, delayTime) { function startAlert(mode) { // Start PTT startPtt(false); + // Ensure mic doesn't unmute (should be covered by the above false but it gets weird sometimes) + txUnmuteMic = false; // Set and start tone gen switch (mode) { case 1: @@ -910,6 +912,8 @@ function sendAlert() { sendAlert(); }, 50); } else { + // Ensure mic is muted + muteMic(); console.debug("Radio transmitting, starting alert tone"); audio.tones.start(); } From 823fc7a2dafb8ff626eaa72b3f89167646fea691 Mon Sep 17 00:00:00 2001 From: W3AXL <29879554+W3AXL@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:46:59 -0500 Subject: [PATCH 07/18] updated rc2 core version --- daemon/rc2-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/rc2-core b/daemon/rc2-core index 57e7136..e63a4e3 160000 --- a/daemon/rc2-core +++ b/daemon/rc2-core @@ -1 +1 @@ -Subproject commit 57e7136cc02e00836f411de2bd9ed6a543998ff9 +Subproject commit e63a4e3d03682a4744f64a359007caaca93fa7ab From 72b0a626c6492147acdac83991683289061ce757 Mon Sep 17 00:00:00 2001 From: Patrick W3AXL Date: Tue, 25 Mar 2025 20:45:13 -0400 Subject: [PATCH 08/18] large updates to radio configuration management and theming --- console/client.js | 367 +++++++++++++------ console/css/custom.css | 268 ++++++++++---- console/dialogs/edit-radio-preload.js | 10 + console/dialogs/edit-radio.html | 139 +++++++ console/{ => dialogs}/midi-preload.js | 0 console/{ => dialogs}/midi.html | 6 +- console/{ => dialogs}/peripherals-preload.js | 2 +- console/{ => dialogs}/peripherals.html | 6 +- console/main-preload.js | 4 + console/{index.html => main-window.html} | 84 +---- console/main.js | 63 +++- 11 files changed, 680 insertions(+), 269 deletions(-) create mode 100644 console/dialogs/edit-radio-preload.js create mode 100644 console/dialogs/edit-radio.html rename console/{ => dialogs}/midi-preload.js (100%) rename console/{ => dialogs}/midi.html (97%) rename console/{ => dialogs}/peripherals-preload.js (78%) rename console/{ => dialogs}/peripherals.html (93%) rename console/{index.html => main-window.html} (87%) diff --git a/console/client.js b/console/client.js index d12108b..d3ce3ab 100644 --- a/console/client.js +++ b/console/client.js @@ -177,11 +177,14 @@ const radioCardTemplate = document.querySelector('#card-template'); const alertTemplate = document.querySelector("#alert-dialog-template"); // Radio JSON validation -const validColors = ["red","amber","green","blue","purple"]; +const validColors = ["red","amber", "yellow", "green", "teal", "blue", "purple"]; // Extension websocket connection var extensionWs = null; +// Whether we're currently editing a radio +var editingRadioIdx = -1; + /*********************************************************************************** State variables ***********************************************************************************/ @@ -522,9 +525,11 @@ function deselectRadios() { * Populate radio cards based on the radios in radios[] and bind their buttons */ function populateRadios() { + console.debug("Populating radio cards from initial config"); // Add a card for each radio in the list radios.forEach((radio, index) => { - console.log("Adding radio " + radio.name); + console.info("Adding radio " + radio.name); + console.debug(radio); // Add the radio card addRadioCard("radio" + String(index), radio.name, radio.color); // Update edit list @@ -552,6 +557,9 @@ function clearRadios() { * @param {string} name Name to display in header */ function addRadioCard(id, name, color) { + // Log + console.debug(`Adding card for radio ${name} (id ${id})`); + // New, much easier way to add new cards var newCard = radioCardTemplate.content.cloneNode(true); newCard.querySelector(".radio-card").classList.add(color); @@ -575,37 +583,228 @@ function addRadioCard(id, name, color) { $("#main-layout").append(newCard); } -function addRadioToEditTable(radio) { - $("#edit-radios-table tr:last").after(` - ${radio.name}${radio.address}${radio.port} - `); +/** + * Add a radio to the edit radios table + * @param {Radio} radio radio object to add + * @param {int} index optional index in the table to overwrite + */ +function addRadioToEditTable(radio, index = null) { + // Get nice pretty display value for pan + let panValue = "C"; + if (radio.pan != 0) + { + const panPercent = Math.abs(radio.pan / 1.0).toFixed(2) * 100; + if (radio.pan < 0) + { + panValue = `L ${panPercent}%`; + } + else + { + panValue = `R ${panPercent}%`; + } + } + // Create HTML content + const tableRowHtml = ` + ${radio.name} + ${radio.address} + ${radio.port} + ${radio.color} + ${panValue} + + +   + + + + + ` + if (index != null) + { + console.debug(`Updating edit table row ${index} for radio ${radio.name}`); + $(`#edit-radios-table tr:eq(${index})`).html(tableRowHtml); + } + else + { + console.debug(`Adding edit table row for radio ${radio.name} to end of table`); + $("#edit-radios-table tr:last").after(`${tableRowHtml}`); + } } +/** + * Show the radio dialog for a new radio (empty) + */ +function showAddRadioDialog() +{ + window.electronAPI.showRadioConfig(null); +} + +/** + * Show the radio dialog for an existing radio + * @param {int} editRow + * @param {str} name + */ +function editRadio(editRow, name) +{ + // Find the radio + const idx = radios.findIndex((radio) => radio.name == name); + // Verify found + if (idx < 0) + { + alert(`Unable to edit radio ${name}: could not find radio in list`); + return; + } + // Get radio config + const radioConfig = config.Radios[idx] + // Flag editing + editingRadioIdx = idx; + console.info(`Now editing radio ${radioConfig.name}`); + console.debug(radioConfig); + // Show window + window.electronAPI.showRadioConfig(radioConfig); +} + +/** + * Delete a radio + * @param {int} editRow row in the table + * @param {str} name name of the radio + */ function deleteRadio(editRow, name) { - var found = false; - console.warn(`Removing radio ${name}`); - // Remove from config and radio objects - radios.forEach((radio, index) => { - if (radio.name == name) { - // Update list objects - config.Radios.splice(index, 1); - radios.splice(index, 1); - // Remove radio card - console.debug("Removing radio card by identifier: " + `.radio-card:contains("${name}")`); - $(`.radio-card:contains("${name}")`).remove(); - // Update List - $(editRow).closest("tr").remove(); - // Save config - saveConfig(); - // update flag - found = true; - } - }); - if (!found) { - alert("Failed to delete radio!"); + // Find the radio + const idx = radios.findIndex((radio) => radio.name == name); + // Verify found + if (idx < 0) + { + alert(`Unable to delete radio ${name}: could not find radio in list`); + return; } + // Log + console.info(`Removing radio ${name})`) + console.debug(config.Radios[idx]); + // Remove from config and radio list + config.Radios.splice(idx, 1); + radios.splice(idx, 1); + // Remove card + $(`.radio-card:contains("${name}")`).remove(); + // Remove row in radio table + $(editRow).closest("tr").remove(); + // Save config + saveConfig(); } +/** + * Handle radio edit dialog cancel + */ +window.electronAPI.cancelRadioConfig(() => { + if (editingRadioIdx >= 0) + { + console.debug("Clearing edit radio flag, edit cancelled"); + editingRadioIdx = -1; + } +}); + +/** + * New handler for getting new radio configurations from the radio config window + */ +window.electronAPI.saveRadioConfig((event, radioConfig) => { + // Debug print + console.debug('Got new radio config from radio edit window!'); + console.debug(radioConfig); + + // Handle edit of an existing radio first + if (editingRadioIdx >= 0) + { + console.info(`Updating radio at index ${editingRadioIdx}`); + console.debug(radioConfig); + + // Store index + const idx = editingRadioIdx; + // Clear flag + editingRadioIdx = -1; + + // Update radio config at index + config.Radios[idx] = radioConfig; + saveConfig(); + + // Disconnect radio if connected + if (radios[idx].status.State != 'Disconnected') + { + disconnectRadio(idx); + } + + // Update radio in main list + radios[idx].name = radioConfig.name; + radios[idx].address = radioConfig.address; + radios[idx].port = radioConfig.port; + radios[idx].color = radioConfig.color; + radios[idx].pan = radioConfig.pan; + + // Find the table row for this radio and get its index + let editTableRow = $(`#edit-radios-table tr:contains('${radioConfig.name}')`); + const editTableIndex = editTableRow.index(); + + // Update the row at the index + addRadioToEditTable(radioConfig, editTableIndex); + + // Update card + updateRadioCard(idx); + + // Return + return; + } + + // Validate radio doesn't already exist + if (config.Radios.some(radio => radio.name === radioConfig.name)) + { + alert(`Radio with name ${radioConfig.name} already exists!`); + return; + } + if (config.Radios.some(radio => radio.address === radioConfig.address) && config.Radios.some(radio => radio.port === radioConfig.port)) + { + alert(`Radio at destination ${radioConfig.address}:${radioConfig.port} already exists!`); + return; + } + // Validate color selection + if (!validColors.includes(radioConfig.color)) + { + alert(`Invalid radio color selected: ${radioConfig.color}`); + return; + } + + // Save new radio + config.Radios.push(radioConfig); + saveConfig(); + + // Copy config to a new radio object (this gets added to our current radios) + var newRadio = radioConfig; + + // Populate defaults + newRadio.status = { State: 'Disconnected' }; + newRadio.rtc = {}; + newRadio.wsConn = null; + newRadio.audioSrc = null; + + // Get the index for this new radio (will be at the end of the list) + const newRadioIdx = radios.length; + + // Append to config + radios.push(newRadio); + + // Populate new radio + console.log("Adding radio " + newRadio.name); + + // Add the radio card + addRadioCard("radio" + String(newRadioIdx), newRadio.name, newRadio.color); + + // Populate its text + updateRadioCard(newRadioIdx); + + // Update edit list + addRadioToEditTable(newRadio); + + // Clear form + newRadioClear(); +}); + function stopClick(event, obj) { event.stopPropagation(); event.preventDefault(); @@ -618,10 +817,26 @@ function updateRadioCard(idx) { // Get card object var radioCard = $("#radio" + String(idx)); - // Update card name & description (we limit the header name to 14 characters) - radioCard.find(".radio-name").html(radio.status.Name ? radio.status.Name.substring(0,14) : `Radio ${idx}`); + // Update card name & description + radioCard.find(".radio-name").html(radio.status.Name ? radio.status.Name : radio.name); radioCard.find(".radio-name").attr("title", radio.status.Description); + // Update color if changed + if (!radioCard.hasClass(radio.color)) + { + const cardClasses = radioCard.attr('class').split(/\s+/); + cardClasses.forEach((className) => { + if (validColors.some(color => color === className)) + { + const oldColor = className + console.debug(`Updating radio card color from ${oldColor} to ${radio.color}`); + radioCard.removeClass(oldColor); + radioCard.addClass(radio.color); + } + }) + + } + // Limit zone & channel text to 27/18 characters // TODO: figure out dynamic scaling of channel/zone text so we don't have to do this if (radio.status.ZoneName != null) { @@ -787,24 +1002,6 @@ function startPtt(micActive) { alertStopTimeout = null; } - // Old logic that doesn't use the ACK below - // Unmute mic after timeout, if requested - /**if (micActive) { - setTimeout( unmuteMic, audio.micUnmuteDelay); - } - // Play TPT - playSound("sound-ptt"); - // Send radio keyup after latency timeout - setTimeout( function() { - radios[selectedRadioIdx].wsConn.send(JSON.stringify( - { - "radio": { - "command": "startTx" - } - } - )); - }, radios[selectedRadioIdx].rtc.txLatency);**/ - // Flag that we want the mic to unmute or not txUnmuteMic = micActive; // Send the command @@ -1323,8 +1520,6 @@ async function readConfig() { } // Default mute (not muted) radios[idx].mute = false; - // Default name (used for logging until we get the proper name) - radios[idx].name = `Radio ${idx}`; }); // Populate radio cards @@ -1378,58 +1573,6 @@ function newRadioClear() { $('#new-radio-pan').val(0); } -function newRadioAdd() { - // Get values - const newRadioAddress = $('#new-radio-address').val(); - const newRadioPort = $('#new-radio-port').val(); - const newRadioColor = $('#new-radio-color').val(); - const newRadioPan = $('#new-radio-pan').val(); - - // Create the new radio entry - var newRadio = { - address: newRadioAddress, - port: newRadioPort, - color: newRadioColor, - pan: newRadioPan, - }; - - // Validate - if (!validColors.includes(newRadio.color)) { - console.warn(`Color ${newRadio.color} not valid, defaulting to blue`); - radios[idx].color = "blue"; - } - - // Save config - config.Radios.push(newRadio); - saveConfig(); - - // Populate default values - newRadio.status = { State: 'Disconnected' }; - newRadio.rtc = {}; - newRadio.wsConn = null; - newRadio.audioSrc = null; - - // Get the index - var newRadioIdx = radios.length; - - // Default name (used for logging until we get the proper name) - newRadio.name = `Radio ${newRadioIdx}`; - - // Append to config - radios.push(newRadio); - - // Populate new radio - console.log("Adding radio " + newRadio.name); - // Add the radio card - addRadioCard("radio" + String(newRadioIdx), newRadio.name, newRadio.color); - // Populate its text - updateRadioCard(newRadioIdx); - // Update edit list - addRadioToEditTable(newRadio); - // Clear form - newRadioClear(); -} - /*********************************************************************************** WebRTC Functions @@ -1957,7 +2100,7 @@ function startAudioDevices() { // Create gain node for output volume and connect it to the default output device audio.outputGain = audio.context.createGain(); - audio.outputGain.gain.value = 0.75; + audio.outputGain.gain.value = Math.pow($("#console-volume").val() / 100, 2); audio.outputGain.connect(audio.context.destination); // Start audio input @@ -2160,8 +2303,11 @@ function zeroAudioMeters() function volumeSlider() { // Convert 0-100 to 0-1 for multiplication with audio, using an inverse-square curve for better "logarithmic" volume const newVol = Math.pow($("#console-volume").val() / 100, 2); - // Set gain node to new value - audio.outputGain.gain.value = newVol; + // Set gain node to new value if it exists + if (audio.outputGain != null) + { + audio.outputGain.gain.value = newVol; + } // Set volume of each ui html sound const uiSounds = document.getElementsByClassName("ui-audio"); for (var i = 0; i < uiSounds.length; i++) { @@ -2954,8 +3100,19 @@ function extensionConnect() { extensionWs.close(); return; } + // Prepare URL + const wsUrl = `ws://${config.Extension.address}:${config.Extension.port}`; + // Verify valid address + try { + const url = new URL(wsUrl); + } + catch (_) + { + alert("Invalid extension URL, cannot open connection!"); + return; + } // Create the connection - extensionWs = new WebSocket(`ws://${config.Extension.address}:${config.Extension.port}`); + extensionWs = new WebSocket(wsUrl); // Create websocket extensionWs.onerror = function(event) { handleExtensionError(event) }; extensionWs.onmessage = function(event) { recvExtensionMessage(event) }; diff --git a/console/css/custom.css b/console/css/custom.css index 66cae51..2eea8d9 100644 --- a/console/css/custom.css +++ b/console/css/custom.css @@ -38,23 +38,69 @@ --color-btn-dark: #1A1A1A; --color-btn-pressed: #3D3D3D; - /* Dynamic Card Colors */ - --color-card-darkred: #291C1C; - --color-card-midred: #523838; - --color-card-lightred: #B87D7D; - --color-card-darkamber: #292015; - --color-card-midamber: #52402B; - --color-card-lightamber: #B88E60; - --color-card-darkgreen: #1C291E; - --color-card-midgreen: #38523B; - --color-card-lightgreen: #7DB885; - --color-card-darkblue: #1C2529; - --color-card-midblue: #384b52; - --color-card-lightblue: #7DA8B8; - --color-card-darkpurple: #1F1C29; - --color-card-midpurple: #3F3852; - --color-card-lightpurple: #8D7DB8; - + /* Dynamic Card Color Values */ + --value-card-darkest: 5%; + --value-card-dark: 15%; + --value-card-mid: 30%; + --value-card-light: 60%; + --value-card-text: 90%; + --sat-card-text: 50%; + /* Red */ + --hue-card-red: 0; + --sat-card-red: 25%; + --color-card-red-black: hsl(var(--hue-card-red), var(--sat-card-red), var(--value-card-darkest)); + --color-card-red-dark: hsl(var(--hue-card-red), var(--sat-card-red), var(--value-card-dark)); + --color-card-red-mid: hsl(var(--hue-card-red), var(--sat-card-red), var(--value-card-mid)); + --color-card-red-light: hsl(var(--hue-card-red), var(--sat-card-red), var(--value-card-light)); + --color-card-red-text: hsl(var(--hue-card-red), var(--sat-card-text), var(--value-card-text)); + /* Amber */ + --hue-card-amber: 20; + --sat-card-amber: 40%; + --color-card-amber-black: hsl(var(--hue-card-amber), var(--sat-card-amber), var(--value-card-darkest)); + --color-card-amber-dark: hsl(var(--hue-card-amber), var(--sat-card-amber), var(--value-card-dark)); + --color-card-amber-mid: hsl(var(--hue-card-amber), var(--sat-card-amber), var(--value-card-mid)); + --color-card-amber-light: hsl(var(--hue-card-amber), var(--sat-card-amber), var(--value-card-light)); + --color-card-amber-text: hsl(var(--hue-card-amber), var(--sat-card-text), var(--value-card-text)); + /* Yellow */ + --hue-card-yellow: 50; + --sat-card-yellow: 40%; + --color-card-yellow-black: hsl(var(--hue-card-yellow), var(--sat-card-yellow), var(--value-card-darkest)); + --color-card-yellow-dark: hsl(var(--hue-card-yellow), var(--sat-card-yellow), var(--value-card-dark)); + --color-card-yellow-mid: hsl(var(--hue-card-yellow), var(--sat-card-yellow), var(--value-card-mid)); + --color-card-yellow-light: hsl(var(--hue-card-yellow), var(--sat-card-yellow), var(--value-card-light)); + --color-card-yellow-text: hsl(var(--hue-card-yellow), var(--sat-card-text), var(--value-card-text)); + /* Green */ + --hue-card-green: 110; + --sat-card-green: 25%; + --color-card-green-black: hsl(var(--hue-card-green), var(--sat-card-green), var(--value-card-darkest)); + --color-card-green-dark: hsl(var(--hue-card-green), var(--sat-card-green), var(--value-card-dark)); + --color-card-green-mid: hsl(var(--hue-card-green), var(--sat-card-green), var(--value-card-mid)); + --color-card-green-light: hsl(var(--hue-card-green), var(--sat-card-green), var(--value-card-light)); + --color-card-green-text: hsl(var(--hue-card-green), var(--sat-card-text), var(--value-card-text)); + /* Teal */ + --hue-card-teal: 170; + --sat-card-teal: 25%; + --color-card-teal-black: hsl(var(--hue-card-teal), var(--sat-card-teal), var(--value-card-darkest)); + --color-card-teal-dark: hsl(var(--hue-card-teal), var(--sat-card-teal), var(--value-card-dark)); + --color-card-teal-mid: hsl(var(--hue-card-teal), var(--sat-card-teal), var(--value-card-mid)); + --color-card-teal-light: hsl(var(--hue-card-teal), var(--sat-card-teal), var(--value-card-light)); + --color-card-teal-text: hsl(var(--hue-card-teal), var(--sat-card-text), var(--value-card-text)); + /* Blue */ + --hue-card-blue: 210; + --sat-card-blue: 25%; + --color-card-blue-black: hsl(var(--hue-card-blue), var(--sat-card-blue), var(--value-card-darkest)); + --color-card-blue-dark: hsl(var(--hue-card-blue), var(--sat-card-blue), var(--value-card-dark)); + --color-card-blue-mid: hsl(var(--hue-card-blue), var(--sat-card-blue), var(--value-card-mid)); + --color-card-blue-light: hsl(var(--hue-card-blue), var(--sat-card-blue), var(--value-card-light)); + --color-card-blue-text: hsl(var(--hue-card-blue), var(--sat-card-text), var(--value-card-text)); + /* Purple */ + --hue-card-purple: 270; + --sat-card-purple: 25%; + --color-card-purple-black: hsl(var(--hue-card-purple), var(--sat-card-purple), var(--value-card-darkest)); + --color-card-purple-dark: hsl(var(--hue-card-purple), var(--sat-card-purple), var(--value-card-dark)); + --color-card-purple-mid: hsl(var(--hue-card-purple), var(--sat-card-purple), var(--value-card-mid)); + --color-card-purple-light: hsl(var(--hue-card-purple), var(--sat-card-purple), var(--value-card-light)); + --color-card-purple-text: hsl(var(--hue-card-purple), var(--sat-card-text), var(--value-card-text)); } @font-face { @@ -470,19 +516,25 @@ ion-icon { /* Dynamic Header Colors */ .radio-card.red .header { - background-color: var(--color-card-midred); + background-color: var(--color-card-red-mid); } .radio-card.amber .header { - background-color: var(--color-card-midamber); + background-color: var(--color-card-amber-mid); +} +.radio-card.yellow .header { + background-color: var(--color-card-yellow-mid); } .radio-card.green .header { - background-color: var(--color-card-midgreen); + background-color: var(--color-card-green-mid); +} +.radio-card.teal .header { + background-color: var(--color-card-teal-mid); } .radio-card.blue .header { - background-color: var(--color-card-midblue); + background-color: var(--color-card-blue-mid); } .radio-card.purple .header { - background-color: var(--color-card-midpurple); + background-color: var(--color-card-purple-mid); } /* Hover highlight */ @@ -496,25 +548,64 @@ ion-icon { opacity: 0.75; } -/* Dynamic Header Text Color */ -.radio-card.red .header h2 { - color: var(--color-card-lightred); +/* Dynamic Header/Zone Text Color */ +.radio-card.red .header h2, +.radio-card.red #zone-text { + color: var(--color-card-red-light); } -.radio-card.amber .header h2 { - color: var(--color-card-lightamber); +.radio-card.amber .header h2, +.radio-card.amber #zone-text { + color: var(--color-card-amber-light); } -.radio-card.green .header h2 { - color: var(--color-card-lightgreen); +.radio-card.yellow .header h2, +.radio-card.yellow #zone-text { + color: var(--color-card-yellow-light); } -.radio-card.blue .header h2 { - color: var(--color-card-lightblue); +.radio-card.green .header h2, +.radio-card.green #zone-text { + color: var(--color-card-green-light); } -.radio-card.purple .header h2 { - color: var(--color-card-lightpurple); +.radio-card.teal .header h2, +.radio-card.teal #zone-text { + color: var(--color-card-teal-light); +} +.radio-card.blue .header h2, +.radio-card.blue #zone-text { + color: var(--color-card-blue-light); +} +.radio-card.purple .header h2, +.radio-card.purple #zone-text { + color: var(--color-card-purple-light); } -.radio-card.selected .header h2 { - color: var(--color-txt-light); +/* Selected Card Header/Zone Text Color */ +.radio-card.red.selected .header h2, +.radio-card.red.selected #zone-text { + color: var(--color-card-red-text); +} +.radio-card.amber.selected .header h2, +.radio-card.amber.selected #zone-text { + color: var(--color-card-amber-text); +} +.radio-card.yellow.selected .header h2, +.radio-card.yellow.selected #zone-text { + color: var(--color-card-yellow-text); +} +.radio-card.green.selected .header h2, +.radio-card.green.selected #zone-text { + color: var(--color-card-green-text); +} +.radio-card.teal.selected .header h2, +.radio-card.teal.selected #zone-text { + color: var(--color-card-teal-text); +} +.radio-card.blue.selected .header h2, +.radio-card.blue.selected #zone-text { + color: var(--color-card-blue-text); +} +.radio-card.purple.selected .header h2, +.radio-card.purple.selected #zone-text { + color: var(--color-card-purple-text); } .radio-card .header h2 { @@ -537,7 +628,7 @@ ion-icon { .radio-card .icon-stack a, .radio-card .icon-stack .scan-icons { - width: 24px; + width: 20px; display: inline-flex; } @@ -704,23 +795,31 @@ ion-icon { .radio-card.red .panning-dropdown, .radio-card.red .dtmf-dropdown { - background-color: var(--color-card-midred); + background-color: var(--color-card-red-mid); } .radio-card.amber .panning-dropdown, .radio-card.amber .dtmf-dropdown { - background-color: var(--color-card-midamber); + background-color: var(--color-card-amber-mid); +} +.radio-card.yellow .panning-dropdown, +.radio-card.yellow .dtmf-dropdown { + background-color: var(--color-card-yellow-mid); } .radio-card.green .panning-dropdown, .radio-card.green .dtmf-dropdown { - background-color: var(--color-card-midgreen); + background-color: var(--color-card-green-mid); +} +.radio-card.teal .panning-dropdown, +.radio-card.teal .dtmf-dropdown { + background-color: var(--color-card-teal-mid); } .radio-card.blue .panning-dropdown, .radio-card.blue .dtmf-dropdown { - background-color: var(--color-card-midblue); + background-color: var(--color-card-blue-mid); } .radio-card.purple .panning-dropdown, .radio-card.purple .dtmf-dropdown { - background-color: var(--color-card-midpurple); + background-color: var(--color-card-purple-mid); } .panning-dropdown.closed, @@ -751,19 +850,25 @@ ion-icon { /* Dynamic Content Color */ .radio-card.red .content { - background-color: var(--color-card-darkred); + background-color: var(--color-card-red-dark); } .radio-card.amber .content { - background-color: var(--color-card-darkamber); + background-color: var(--color-card-amber-dark); +} +.radio-card.yellow .content { + background-color: var(--color-card-yellow-dark); } .radio-card.green .content { - background-color: var(--color-card-darkgreen); + background-color: var(--color-card-green-dark); +} +.radio-card.teal .content { + background-color: var(--color-card-teal-dark); } .radio-card.blue .content { - background-color: var(--color-card-darkblue); + background-color: var(--color-card-blue-dark); } .radio-card.purple .content { - background-color: var(--color-card-darkpurple); + background-color: var(--color-card-purple-dark); } .radio-card #zone-text { @@ -771,28 +876,33 @@ ion-icon { font-size: 22px; } -/* Dynamic Zone Text Color */ -.radio-card.red #zone-text { - color: var(--color-card-lightred); +.radio-card #channel-text { + font-family: "Iosevka Bold"; + font-size: 28px; + margin-top: -4px; } -.radio-card.amber #zone-text { - color: var(--color-card-lightamber); + +/* Dynamic Channel Text Color */ +.radio-card.red #channel-text { + color: var(--color-card-red-text); } -.radio-card.green #zone-text { - color: var(--color-card-lightgreen); +.radio-card.amber #channel-text { + color: var(--color-card-amber-text); } -.radio-card.blue #zone-text { - color: var(--color-card-lightblue); +.radio-card.yellow #channel-text { + color: var(--color-card-yellow-text); } -.radio-card.purple #zone-text { - color: var(--color-card-lightpurple); +.radio-card.green #channel-text { + color: var(--color-card-green-text); } - -.radio-card #channel-text { - font-family: "Iosevka Bold"; - font-size: 28px; - color: var(--color-txt-light); - margin-top: -4px; +.radio-card.teal #channel-text { + color: var(--color-card-teal-text); +} +.radio-card.blue #channel-text { + color: var(--color-card-blue-text); +} +.radio-card.purple #channel-text { + color: var(--color-card-purple-text); } .radio-card.selected #channel-text { @@ -832,19 +942,25 @@ ion-icon { /* Dynamic Icon Color */ .radio-card.red .audio-bar { - color: var(--color-card-midred); + color: var(--color-card-red-mid); } .radio-card.amber .audio-bar { - color: var(--color-card-midamber); + color: var(--color-card-amber-mid); +} +.radio-card.yellow .audio-bar { + color: var(--color-card-yellow-mid); } .radio-card.green .audio-bar { - color: var(--color-card-midgreen); + color: var(--color-card-green-mid); +} +.radio-card.teal .audio-bar { + color: var(--color-card-teal-mid); } .radio-card.blue .audio-bar { - color: var(--color-card-midblue); + color: var(--color-card-blue-mid); } .radio-card.purple .audio-bar { - color: var(--color-card-midpurple); + color: var(--color-card-purple-mid); } /******************************** @@ -940,15 +1056,15 @@ ion-icon { } #ptt { - background-color: var(--color-card-midred) !important; - color: var(--color-card-lightred); + background-color: var(--color-card-red-mid) !important; + color: var(--color-card-red-light); clip-path: polygon(15% 0, 100% 0, 100% 70%, 85% 100%, 0 100%, 0 30%); width: 64px; } #ptt.pressed, #ptt:active:hover { - background-color: var(--color-card-darkred) !important; + background-color: var(--color-card-red-dark) !important; color: var(--color-txt-light); } @@ -959,14 +1075,14 @@ ion-icon { #alert-bar-icon.pressed, #alert-bar-icon:active:hover { - background-color: var(--color-card-darkamber) !important; + background-color: var(--color-card-amber-dark) !important; color: var(--color-txt-light); } #alert-bar-icon, .alert-btn-icon { - background-color: var(--color-card-midamber) !important; - color: var(--color-card-lightamber); + background-color: var(--color-card-amber-mid) !important; + color: var(--color-card-amber-light); clip-path: polygon(15% 0, 100% 0, 100% 70%, 85% 100%, 0 100%, 0 30%); width: 64px; } @@ -1006,8 +1122,8 @@ ion-icon { .popup { position: fixed; - width: 480px; - top: -16px; + width: 540px; + top: -48px; left: 0; right: 0; margin: 5% auto; diff --git a/console/dialogs/edit-radio-preload.js b/console/dialogs/edit-radio-preload.js new file mode 100644 index 0000000..8191196 --- /dev/null +++ b/console/dialogs/edit-radio-preload.js @@ -0,0 +1,10 @@ +const { contextBridge, ipcRenderer } = require('electron/renderer') + +contextBridge.exposeInMainWorld('electronAPI', { + // Save/show config + populateRadioConfig: (radioConfig) => ipcRenderer.on('populateRadioConfig', radioConfig), + // Add new radio + saveRadioConfig: (radioConfig) => ipcRenderer.invoke('saveRadioConfig', radioConfig), + // Cancel edit + cancelRadioConfig: (data) => ipcRenderer.invoke('cancelRadioConfig', data), +}); \ No newline at end of file diff --git a/console/dialogs/edit-radio.html b/console/dialogs/edit-radio.html new file mode 100644 index 0000000..f487bf9 --- /dev/null +++ b/console/dialogs/edit-radio.html @@ -0,0 +1,139 @@ + + + + Configure Radios + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/console/midi-preload.js b/console/dialogs/midi-preload.js similarity index 100% rename from console/midi-preload.js rename to console/dialogs/midi-preload.js diff --git a/console/midi.html b/console/dialogs/midi.html similarity index 97% rename from console/midi.html rename to console/dialogs/midi.html index 5e0f850..4c3a222 100644 --- a/console/midi.html +++ b/console/dialogs/midi.html @@ -4,16 +4,16 @@ Configure MIDI Interface - + - + - +