diff --git a/application/views/interface_assets/footer.php b/application/views/interface_assets/footer.php index f68fa7de1..68754d456 100644 --- a/application/views/interface_assets/footer.php +++ b/application/views/interface_assets/footer.php @@ -131,6 +131,9 @@ var lang_cat_websocket_radio = "= __("WebSocket Radio"); ?>"; var lang_qso_location_is_fetched_from_provided_gridsquare = "= __("Location is fetched from provided gridsquare"); ?>"; var lang_qso_location_is_fetched_from_dxcc_coordinates = "= __("Location is fetched from DXCC coordinates (no gridsquare provided)"); ?>"; + + // CAT Configuration + var cat_timeout_minutes = Math.floor(optionslib->get_option('cat_timeout_interval'); ?> / 60); @@ -1436,716 +1439,27 @@ mymap.on('mousemove', onQsoMapMove); uri->segment(1) == "qso" || ($this->uri->segment(1) == "contesting" && $this->uri->segment(2) != "add")) { ?> + - + + input->get('manual'); + if ($manual === null || $manual == '0') { ?> + + session->userdata('user_dxwaterfall_enable') == 'Y') { ?> + + + + diff --git a/assets/js/cat.js b/assets/js/cat.js new file mode 100644 index 000000000..fb2953200 --- /dev/null +++ b/assets/js/cat.js @@ -0,0 +1,716 @@ +/** + * RADIO CAT CONTROL FUNCTIONS + * + * Connection Types: + * - WebSocket ('ws'): Real-time communication for live updates + * - AJAX Polling: Periodic requests every 3 seconds for non-WebSocket radios + * + * Data Flow: + * WebSocket: Server → handleWebSocketData() → updateCATui() → displayRadioStatus() + * Polling: updateFromCAT() → AJAX → updateCATui() → displayRadioStatus() + * + * Dependencies: + * - jQuery + * - Bootstrap tooltips + * - Global variables from footer.php: base_url, site_url, lang_cat_*, cat_timeout_minutes + * - Optional: DX Waterfall functions (setFrequency, handleCATFrequencyUpdate, initCATTimings, dxwaterfall_cat_state) + * - Optional: showToast (from common.js) + * - Required: frequencyToBand, catmode, setRst functions + * + * Required DOM Elements: + * - select.radios Radio selection dropdown + * - #frequency Main frequency input field + * - #band Band selection dropdown + * - .mode Mode selection element(s) + * + * Optional DOM Elements: + * - #radio_status Container for radio status display (created if missing) + * - #radio_cat_state Radio CAT status card (dynamically created/removed) + * - #frequency_rx RX frequency for split operation + * - #band_rx RX band for split operation + * - #freq_calculated Alternative frequency field + * - #sat_name Satellite name field + * - #sat_mode Satellite mode field + * - #transmit_power Transmit power field + * - #selectPropagation Propagation mode selector + */ + +$(document).ready(function() { + // Global flag for CAT updates (used by all pages, not just DX Waterfall) + var cat_updating_frequency = false; + + // Global variable for currently selected radio + var selectedRadioId = null; + + // Cache for radio CAT URLs to avoid repeated AJAX calls + var radioCatUrlCache = {}; + + // Cache for radio names to avoid repeated AJAX calls + var radioNameCache = {}; + + /** + * Initialize WebSocket connection for real-time radio updates + * Handles connection, reconnection logic, and error states + */ + // Javascript for controlling rig frequency. + let websocket = null; + let reconnectAttempts = 0; + let websocketEnabled = false; + let websocketIntentionallyClosed = false; // Flag to prevent auto-reconnect when user switches away + let CATInterval=null; + var updateFromCAT_lock = 0; // This mechanism prevents multiple simultaneous calls to query the CAT interface information + var updateFromCAT_lockTimeout = null; // Timeout to release lock if AJAX fails + + // CAT Configuration Constants + const CAT_CONFIG = { + POLL_INTERVAL: 3000, // Polling interval in milliseconds + WEBSOCKET_RECONNECT_MAX: 5, + WEBSOCKET_RECONNECT_DELAY_MS: 2000, + AJAX_TIMEOUT_MS: 5000, + LOCK_TIMEOUT_MS: 10000 + }; + + function initializeWebSocketConnection() { + try { + // Note: Browser will log WebSocket connection errors to console if server is unreachable + // This is native browser behavior and cannot be suppressed - errors are handled in GUI via onerror handler + websocket = new WebSocket('ws://localhost:54322'); + + websocket.onopen = function(event) { + reconnectAttempts = 0; + websocketEnabled = true; + }; + + websocket.onmessage = function(event) { + try { + const data = JSON.parse(event.data); + handleWebSocketData(data); + } catch (error) { + // Invalid JSON data received - silently ignore + } + }; + + websocket.onerror = function(error) { + if ($('.radios option:selected').val() != '0') { + var radioName = $('select.radios option:selected').text(); + displayRadioStatus('error', radioName); + } + websocketEnabled = false; + }; + + websocket.onclose = function(event) { + websocketEnabled = false; + + // Only attempt to reconnect if the closure was not intentional + if (!websocketIntentionallyClosed && reconnectAttempts < CAT_CONFIG.WEBSOCKET_RECONNECT_MAX) { + setTimeout(() => { + reconnectAttempts++; + initializeWebSocketConnection(); + }, CAT_CONFIG.WEBSOCKET_RECONNECT_DELAY_MS * reconnectAttempts); + } else if (!websocketIntentionallyClosed) { + // Only show error if it wasn't an intentional close AND radio is not "None" + if ($('.radios option:selected').val() != '0') { + var radioName = $('select.radios option:selected').text(); + displayRadioStatus('error', radioName); + } + websocketEnabled = false; + } + }; + } catch (error) { + websocketEnabled=false; + } + } + + /** + * Handle incoming WebSocket data messages + * Processes 'welcome' and 'radio_status' message types + * @param {object} data - Message data from WebSocket server + */ + function handleWebSocketData(data) { + // Handle welcome message + if (data.type === 'welcome') { + return; + } + + // Handle radio status updates + if (data.type === 'radio_status' && data.radio && ($(".radios option:selected").val() == 'ws')) { + data.updated_minutes_ago = Math.floor((Date.now() - data.timestamp) / 60000); + // Cache the radio data + updateCATui(data); + } + } + + /** + * Update UI elements from CAT data + * Maps CAT values to form fields, handling empty/zero values appropriately + * @param {string} ui - jQuery selector for UI element + * @param {*} cat - CAT data value to display + * @param {boolean} allow_empty - Whether to update UI with empty values + * @param {boolean} allow_zero - Whether to update UI with zero values + * @param {function} callback_on_update - Optional callback when update occurs + */ + const cat2UI = function(ui, cat, allow_empty, allow_zero, callback_on_update) { + // Check, if cat-data is available + if(cat == null) { + return; + } else if (typeof allow_empty !== 'undefined' && !allow_empty && cat == '') { + return; + } else if (typeof allow_zero !== 'undefined' && !allow_zero && cat == '0' ) { + return; + } + + // Don't update frequency field if user is currently editing it + if (ui.attr('id') === 'frequency' || ui.attr('id') === 'freq_calculated') { + if (ui.is(':focus') || $('#freq_calculated').is(':focus')) { + return; + } + } + + // Only update the ui-element, if cat-data has changed + if (ui.data('catValue') != cat) { + ui.val(cat); + ui.data('catValue',cat); + if (typeof callback_on_update === 'function') { callback_on_update(cat); } + } + } + + /** + * Format frequency for display based on user preference + * @param {number} freq - Frequency in Hz + * @returns {string|null} Formatted frequency with unit or null if invalid + */ + function format_frequency(freq) { + // Return null if frequency is invalid + if (freq == null || freq == 0 || freq == '' || isNaN(freq)) { + return null; + } + + const qrgunit = localStorage.getItem('qrgunit_' + $('#band').val()) || 'kHz'; // Default to kHz if not set + let frequency_formatted=null; + if (qrgunit == 'Hz') { + frequency_formatted=freq; + } else if (qrgunit == 'kHz') { + frequency_formatted=(freq / 1000); + } else if (qrgunit == 'MHz') { + frequency_formatted=(freq / 1000000); + } else if (qrgunit == 'GHz') { + frequency_formatted=(freq / 1000000000); + } + return frequency_formatted+''+qrgunit; + } + + /** + * Tune radio to a specific frequency and mode via CAT interface + * @param {string} radioId - Radio ID (or 'ws' for WebSocket), optional - defaults to selectedRadioId + * @param {number} freqHz - Frequency in Hz + * @param {string} mode - Radio mode (e.g., 'usb', 'lsb', 'cw'), optional - auto-detects if not provided + * @param {function} onSuccess - Optional callback called on successful tuning + * @param {function} onError - Optional callback called on tuning error + * @param {boolean} skipWaterfall - If true, skip DX Waterfall integration + */ + window.tuneRadioToFrequency = function(radioId, freqHz, mode, onSuccess, onError, skipWaterfall) { + // Default radioId to global selectedRadioId if not provided + if (typeof radioId === 'undefined' || radioId === null || radioId === '') { + radioId = selectedRadioId; + } + + // Default mode to current mode if not provided + if (typeof mode === 'undefined' || mode === null || mode === '') { + mode = $('#mode').val() ? $('#mode').val().toLowerCase() : 'usb'; + } else { + mode = mode.toLowerCase(); + } + + // Check if DX Waterfall is ACTIVE and should handle this (only if not called from within DX Waterfall) + // DX Waterfall is active if dxWaterfall object exists AND has a canvas (meaning it's initialized) + if (!skipWaterfall && typeof setFrequency === 'function' && typeof dxWaterfall !== 'undefined' && dxWaterfall.canvas) { + const catAvailable = (typeof dxwaterfall_cat_state !== 'undefined' && + (dxwaterfall_cat_state === 'polling' || dxwaterfall_cat_state === 'websocket')); + + if (catAvailable) { + const freqKHz = freqHz / 1000; + setFrequency(freqKHz, false); // false = not from DX Waterfall + return; + } + } + + // Direct client-side radio control via CAT interface + if (radioId && radioId != 0 && radioId != '') { + // Get the CAT URL for the radio + let catUrl; + + if (radioId === 'ws') { + // WebSocket radio uses localhost gateway + catUrl = 'http://127.0.0.1:54321'; + } else { + // Check if CAT URL is cached + if (radioCatUrlCache[radioId]) { + // Use cached CAT URL + catUrl = radioCatUrlCache[radioId]; + performRadioTuning(catUrl, freqHz, mode, onSuccess, onError); + return; + } else { + // Fetch CAT URL from radio data and cache it + $.ajax({ + url: base_url + 'index.php/radio/json/' + radioId, + type: 'GET', + dataType: 'json', + timeout: CAT_CONFIG.AJAX_TIMEOUT_MS, + success: function(radioData) { + if (radioData.cat_url) { + // Cache the CAT URL and radio name for future use + radioCatUrlCache[radioId] = radioData.cat_url; + if (radioData.radio) { + radioNameCache[radioId] = radioData.radio; + } + performRadioTuning(radioData.cat_url, freqHz, mode, onSuccess, onError); + } else { + if (typeof onError === 'function') { + onError(null, 'error', lang_cat_no_url_configured); + } + } + }, + error: function(jqXHR, textStatus, errorThrown) { + if (typeof onError === 'function') { + onError(jqXHR, textStatus, errorThrown); + } + } + }); + return; // Exit here for non-WebSocket radios + } + } + + // For WebSocket radios, tune immediately + performRadioTuning(catUrl, freqHz, mode, onSuccess, onError); + } + }; + + /** + * Perform the actual radio tuning via CAT interface + * Sends frequency and mode to radio via HTTP request + * @param {string} catUrl - CAT interface URL for the radio + * @param {number} freqHz - Frequency in Hz + * @param {string} mode - Radio mode (validated against supported modes) + * @param {function} onSuccess - Callback on successful tuning + * @param {function} onError - Callback on tuning error + */ + function performRadioTuning(catUrl, freqHz, mode, onSuccess, onError) { + // Validate and normalize mode parameter + const validModes = ['lsb', 'usb', 'cw', 'fm', 'am', 'rtty', 'pkt', 'dig', 'pktlsb', 'pktusb', 'pktfm']; + const catMode = mode && validModes.includes(mode.toLowerCase()) ? mode.toLowerCase() : 'usb'; + + // Format: {cat_url}/{frequency}/{mode} + const url = catUrl + '/' + freqHz + '/' + catMode; + + // Make request with proper error handling + fetch(url, { + method: 'GET' + }) + .then(response => { + if (response.ok) { + // Success - HTTP 200-299, get response text + return response.text(); + } else { + // HTTP error status (4xx, 5xx) + throw new Error('HTTP ' + response.status); + } + }) + .then(data => { + // Call success callback with response data + if (typeof onSuccess === 'function') { + onSuccess(data); + } + }) + .catch(error => { + // Only show error on actual failures (network error, HTTP error, etc.) + const freqMHz = (freqHz / 1000000).toFixed(3); + const errorTitle = lang_cat_radio_tuning_failed; + const errorMsg = lang_cat_failed_to_tune + ' ' + freqMHz + ' MHz (' + catMode.toUpperCase() + '). ' + lang_cat_not_responding; + + // Use showToast if available (from qso.js), otherwise use Bootstrap alert + if (typeof showToast === 'function') { + showToast(errorTitle, errorMsg, 'bg-danger text-white', 5000); + } + + // Call error callback if provided + if (typeof onError === 'function') { + onError(null, 'error', error.message); + } + }); + } + + /** + * Display radio status panel with unified styling + * Handles success (active connection), error (connection lost), timeout (stale data), and not_logged_in states + * Includes visual feedback with color-coded icon and blink animation on updates + * @param {string} state - Display state: 'success', 'error', 'timeout', or 'not_logged_in' + * @param {object|string} data - Radio data object (success) or radio name string (error/timeout/not_logged_in) + */ + function displayRadioStatus(state, data) { + var iconClass, content; + var baseStyle = '