Files
reflector-dashboard/ctcss_view.js
T

988 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Reflector Dashboard — web front-end for SvxReflector
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
/* CTCSS Table view + export (beta)
* - One row per node (export can be multiple rows per node)
* - TX CTCSS = the access/mapping tone (CTCSS_TO_TG) the radio sends to pick a TG
* - RX CTCSS = the node's REPORT_CTCSS (the tone SvxLink transmits, TX_CTCSS),
* filled in from node.reportCtcss when the node publishes it
* - 2m: 144-148 MHz forced NFM + 12.5 kHz
* - UHF: 432-480 MHz user selectable (default NFM + 12.5 kHz)
*/
(function(){
'use strict';
const SELECTED_CALLSIGNS = new Set();
let HAS_USER_SELECTION = false;
// baseCallsign / callsignCompare / parseMHz / fmtFreq come from lib/fmt.js.
function normalizeTone(v){
if(v == null) return '';
const s = String(v).replace(',', '.');
const m = s.match(/(\d+(?:\.\d+)?)/);
return m ? m[1] : '';
}
// The node's REPORT_CTCSS: the tone SvxLink transmits (TX_CTCSS) and that the
// radio should tone-squelch on (RX CTCSS). REPORT_CTCSS=77 in svxlink.conf
// arrives here as reportCtcss:77, normalized to "77.0". Returns "" when the
// node does not publish it (older SvxLink, or no reportCtcss in the node info).
function getReportTone(n){
const v = (n == null) ? null : (n.reportCtcss ?? n.report_ctcss ?? n.reportCTCSS);
if(v == null || v === '') return '';
const num = parseFloat(String(v).replace(',', '.'));
if(!Number.isFinite(num) || num <= 0) return '';
return num.toFixed(1);
}
function syncSelectionFromDOM(){
SELECTED_CALLSIGNS.clear();
document.querySelectorAll('#ctcssRows input[type=checkbox]')?.forEach(cb => {
const cs = cb.dataset.callsign;
if(cb.checked && cs) SELECTED_CALLSIGNS.add(cs);
});
}
const LS_KEY = 'svxportal_ctcss_anytone_defaults_v1';
const DEFAULTS = {
uhfMode: 'NFM', // 'NFM' | 'FM'
uhfBandwidth: '12.5', // '12.5' | '25'
power: 'Low', // AnyTone CPS: Low/Mid/High
startNo: 1, // AnyTone CSV "No." start value
contact: 'Sweden',
contactCallType: 'Group Call',
contactTg: '240',
radioId: '' // if empty: base callsign is used
};
function clampStr(v){ return (v == null) ? '' : String(v); }
function loadDefaults(){
try{
const raw = localStorage.getItem(LS_KEY);
if(!raw) return {...DEFAULTS};
const j = JSON.parse(raw);
return {...DEFAULTS, ...(j||{})};
}catch(_){
return {...DEFAULTS};
}
}
function saveDefaults(d){
try{ localStorage.setItem(LS_KEY, JSON.stringify(d)); }catch(_){}
}
function inRange(x, a, b){
return Number.isFinite(x) && x >= a && x <= b;
}
function bandModeBw(rxMHz, d){
if(inRange(rxMHz, 144.0, 148.0)) return {mode:'NFM', bw:'12.5'};
if(inRange(rxMHz, 432.0, 480.0)) return {mode:d.uhfMode, bw:d.uhfBandwidth};
return {mode:d.uhfMode, bw:d.uhfBandwidth};
}
function pickFreqFromMap(m){
if(!m || typeof m !== 'object') return null;
for(const v of Object.values(m)){
if(v == null) continue;
const f = (typeof v === 'object') ? (v.freq ?? v.frequency) : v;
const mhz = parseMHz(f);
if(Number.isFinite(mhz) && mhz > 0) return f;
}
return null;
}
function getRxTx(n){
const q = (n && Array.isArray(n.qth) && n.qth.length) ? (n.qth[0] || {}) : (n || {});
let rx = (n && (n.rxFreq ?? n.rx ?? n.rx_frequency)) ?? null;
let tx = (n && (n.txFreq ?? n.tx ?? n.tx_frequency)) ?? null;
if(rx == null){
const qrx = q.rx ?? q.rxFreq ?? q.rx_frequency ?? q.frequency ?? q.freq;
rx = (qrx && typeof qrx === 'object') ? pickFreqFromMap(qrx) : qrx;
}
if(tx == null){
const qtx = q.tx ?? q.txFreq ?? q.tx_frequency;
tx = (qtx && typeof qtx === 'object') ? pickFreqFromMap(qtx) : qtx;
}
const rxF = fmtFreq(rx);
const txF = fmtFreq((tx == null || tx === '') ? rx : tx);
return {rx: rxF, tx: txF, rxMHz: parseMHz(rxF)};
}
function getLocation(n){
const q = (n && Array.isArray(n.qth) && n.qth.length) ? (n.qth[0] || {}) : {};
return clampStr(q.name || n?.location || '');
}
function normalizeMappings(m){
const out = [];
if(!m) return out;
if(Array.isArray(m)){
for(const it of m){
if(!it) continue;
const tone = it.tone ?? it.ctcss ?? it.pl ?? it.hz ?? it.freq;
const tg = it.tg ?? it.talkgroup ?? it.dest;
if(tone == null || tg == null) continue;
out.push({tone: clampStr(tone), tg: clampStr(tg)});
}
return out;
}
if(typeof m === 'object'){
for(const [k,v] of Object.entries(m)){
if(v == null) continue;
out.push({tone: clampStr(k), tg: clampStr(v)});
}
return out;
}
return out;
}
function getNodeMappings(cs, n, status){
const m = n?.toneToTalkgroup;
if(!m || typeof m !== 'object' || Array.isArray(m)) return [];
const out = [];
for(const [k,v] of Object.entries(m)){
const tone = normalizeTone(k);
const tg = (v == null) ? '' : clampStr(v);
if(!tone || !tg) continue;
out.push({tone, tg});
}
out.sort((a,b) => parseMHz(a.tone) - parseMHz(b.tone));
return out;
}
function ensureSkeleton(){
const panel = document.getElementById('ctcssPanel');
if(!panel) return null;
if(panel.dataset.inited === '1') return panel;
// Normalize/replace format options (remove legacy generic/chirp; AnyTone split into 878/890; add FTM-400, IC 5100, IC 7100)
const fmtSel = document.getElementById('ctcssFormat');
if(fmtSel){
const wanted = [
{value:'anytone878', label:'AnyTone 878'},
{value:'anytone890', label:'AnyTone 890'},
{value:'ftm400', label:'FTM-400'},
{value:'ic5100', label:'IC 5100'},
{value:'ic7100', label:'IC 7100'}
];
// Migrate legacy single 'anytone' selection to 'anytone878'.
let cur = String(fmtSel.value || '').trim();
if(cur === 'anytone') cur = 'anytone878';
fmtSel.innerHTML = wanted.map(o => `<option value="${o.value}">${o.label}</option>`).join('');
fmtSel.value = wanted.some(o => o.value === cur) ? cur : 'anytone878';
}
const anyBox = document.getElementById('ctcssAnyToneDefaults');
if(anyBox){
const d = loadDefaults();
anyBox.innerHTML = `
<div class="fw-bold mb-2">AnyTone defaults</div>
<div class="d-flex flex-wrap gap-2 align-items-end">
<div>
<div class="small text-secondary">UHF (432–480) mode</div>
<select id="ctcssAnyUhfMode" class="form-select form-select-sm">
<option value="NFM">NFM</option>
<option value="FM">FM</option>
</select>
</div>
<div>
<div class="small text-secondary">UHF (432–480) bandwidth</div>
<select id="ctcssAnyUhfBw" class="form-select form-select-sm">
<option value="12.5">12.5 kHz</option>
<option value="25">25 kHz</option>
</select>
</div>
<div>
<div class="small text-secondary">Power</div>
<select id="ctcssAnyPower" class="form-select form-select-sm">
<option value="Low">Low</option>
<option value="Mid">Mid</option>
<option value="High">High</option>
</select>
</div>
<div>
<div class="small text-secondary">Start No</div>
<input id="ctcssAnyStartNo" type="number" min="1" step="1" class="form-control form-control-sm" style="max-width: 6rem;" value="${Number.isFinite(d.startNo)?d.startNo:1}">
</div>
<div class="ms-auto small text-secondary">
RX CTCSS = node REPORT_CTCSS (Squelch Mode &rarr; CTCSS/DCS). TX = access tone.
</div>
</div>
<div class="small text-secondary mt-2">
2m (144–148): forced NFM + 12.5 kHz.
</div>
`;
const uhfModeEl = document.getElementById('ctcssAnyUhfMode');
const uhfBwEl = document.getElementById('ctcssAnyUhfBw');
const pwrEl = document.getElementById('ctcssAnyPower');
const startNoEl = document.getElementById('ctcssAnyStartNo');
if(uhfModeEl) uhfModeEl.value = d.uhfMode;
if(uhfBwEl) uhfBwEl.value = d.uhfBandwidth;
if(pwrEl) pwrEl.value = d.power;
if(startNoEl) startNoEl.value = String(Number.isFinite(d.startNo)?d.startNo:1);
const save = () => {
const nd = loadDefaults();
if(uhfModeEl) nd.uhfMode = uhfModeEl.value;
if(uhfBwEl) nd.uhfBandwidth = uhfBwEl.value;
if(pwrEl) nd.power = pwrEl.value;
if(startNoEl){
const n = parseInt(startNoEl.value, 10);
nd.startNo = Number.isFinite(n) && n > 0 ? n : 1;
}
saveDefaults(nd);
};
uhfModeEl?.addEventListener('change', () => { save(); window.renderCTCSS?.(window.__svxportal_lastStatus || {}); });
uhfBwEl?.addEventListener('change', () => { save(); window.renderCTCSS?.(window.__svxportal_lastStatus || {}); });
pwrEl?.addEventListener('change', () => { save(); });
startNoEl?.addEventListener('change', () => { save(); });
}
document.getElementById('ctcssSelectAll')?.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('#ctcssRows input[type=checkbox]')?.forEach(cb => cb.checked = true);
HAS_USER_SELECTION = true;
syncSelectionFromDOM();
updateNote();
});
document.getElementById('ctcssSelectNone')?.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('#ctcssRows input[type=checkbox]')?.forEach(cb => cb.checked = false);
HAS_USER_SELECTION = true;
syncSelectionFromDOM();
updateNote();
});
document.getElementById('ctcssFormat')?.addEventListener('change', () => {
const fmt = document.getElementById('ctcssFormat')?.value || 'anytone878';
const anyBox = document.getElementById('ctcssAnyToneDefaults');
if(anyBox) anyBox.style.display = fmt.startsWith('anytone') ? 'block' : 'none';
});
document.getElementById('ctcssExport')?.addEventListener('click', (e) => {
e.preventDefault();
exportSelected();
});
document.getElementById('ctcssExpandAll')?.addEventListener('click', (e) => {
e.preventDefault();
setAllExpanded(true);
});
document.getElementById('ctcssCollapseAll')?.addEventListener('click', (e) => {
e.preventDefault();
setAllExpanded(false);
});
panel.dataset.inited = '1';
return panel;
}
function updateNote(msg){
const el = document.getElementById('ctcssNote');
if(!el) return;
if(typeof msg === 'string'){
el.textContent = msg;
return;
}
const total = document.querySelectorAll('#ctcssRows input[type=checkbox]').length;
const sel = Array.from(document.querySelectorAll('#ctcssRows input[type=checkbox]')).filter(cb => cb.checked).length;
el.textContent = `Nodes: ${total} — selected: ${sel}. Export: one row per tone, or one row without CTCSS if the node has none.`;
}
function buildData(status){
const nodes = status?.nodes || {};
// Local reflector's own nodes only — skip trunk/remote nodes (tagged
// remoteTrunk), same rule as clients_view.js.
const calls = Object.keys(nodes)
.filter(cs => nodes[cs] && !nodes[cs].remoteTrunk)
.sort(callsignCompare);
const rows = [];
for(const cs of calls){
const n = nodes[cs] || {};
const maps = getNodeMappings(cs, n, status);
const {rx, tx, rxMHz} = getRxTx(n);
rows.push({ cs, location: getLocation(n), rx, tx, rxMHz, maps, reportCtcss: getReportTone(n) });
}
return rows;
}
const EXPANDED = new Set();
function renderTable(data){
const tbody = document.getElementById('ctcssRows');
if(!tbody) return;
tbody.innerHTML = '';
for(const row of data){
const tr = document.createElement('tr');
tr.className = 'ctcssMain';
tr.dataset.callsign = row.cs;
const tdChevron = document.createElement('td');
tdChevron.className = 'ctcssChevron';
tdChevron.innerHTML = EXPANDED.has(row.cs) ? '&#9662;' : '&#9656;';
const tdCb = document.createElement('td');
tdCb.className = 'ctcssCb';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'form-check-input';
cb.dataset.callsign = row.cs;
cb.checked = SELECTED_CALLSIGNS.has(row.cs);
cb.addEventListener('click', (e) => { e.stopPropagation(); });
cb.addEventListener('change', (e) => {
e.stopPropagation();
HAS_USER_SELECTION = true;
if(cb.checked) SELECTED_CALLSIGNS.add(row.cs);
else SELECTED_CALLSIGNS.delete(row.cs);
updateNote();
});
tdCb.appendChild(cb);
const tdCs = document.createElement('td');
tdCs.textContent = row.cs;
const tdLoc = document.createElement('td');
tdLoc.textContent = row.location;
const tdRx = document.createElement('td');
tdRx.className = 'nowrap';
tdRx.textContent = row.rx || '-';
const tdTx = document.createElement('td');
tdTx.className = 'nowrap';
tdTx.textContent = row.tx || '-';
const tdMap = document.createElement('td');
tdMap.className = 'nowrap';
tdMap.textContent = row.maps.length.toString();
tr.appendChild(tdChevron);
tr.appendChild(tdCb);
tr.appendChild(tdCs);
tr.appendChild(tdLoc);
tr.appendChild(tdRx);
tr.appendChild(tdTx);
tr.appendChild(tdMap);
tbody.appendChild(tr);
const detail = document.createElement('tr');
detail.className = 'ctcssDetail';
detail.dataset.callsign = row.cs;
detail.style.display = EXPANDED.has(row.cs) ? '' : 'none';
const detailTd = document.createElement('td');
detailTd.colSpan = 7;
detailTd.innerHTML = renderDetailHTML(row);
detail.appendChild(detailTd);
tbody.appendChild(detail);
tr.addEventListener('click', () => {
const isOpen = EXPANDED.has(row.cs);
if(isOpen){
EXPANDED.delete(row.cs);
detail.style.display = 'none';
tdChevron.innerHTML = '&#9656;';
}else{
EXPANDED.add(row.cs);
detail.style.display = '';
tdChevron.innerHTML = '&#9662;';
}
});
}
updateNote();
}
function renderDetailHTML(row){
const rxCtcss = row.reportCtcss || '';
if(!row.maps || !row.maps.length){
const rxNote = rxCtcss ? ` Node transmits RX CTCSS ${rxCtcss} Hz (REPORT_CTCSS).` : '';
return `<div class="ctcssEmpty">No CTCSS tone mappings configured for this node.${rxNote}</div>`;
}
const rxF = row.rx || '-';
const txF = row.tx || '-';
const rxCtcssCell = rxCtcss ? `${rxCtcss}` : '<span class="text-secondary">-</span>';
const rows = row.maps.map(m => {
const tone = clampStr(m.tone);
const tg = clampStr(m.tg);
return `<tr>
<td>${tone} Hz</td>
<td>TG ${tg}</td>
<td>${rxF}</td>
<td>${txF}</td>
<td>${tone}</td>
<td>${rxCtcssCell}</td>
</tr>`;
}).join('');
return `<table class="ctcssSub">
<thead><tr>
<th>CTCSS</th><th>&rarr; TG</th><th>RX</th><th>TX</th><th>TX CTCSS</th><th>RX CTCSS</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
function setAllExpanded(open){
document.querySelectorAll('#ctcssRows tr.ctcssMain').forEach(tr => {
const cs = tr.dataset.callsign;
if(!cs) return;
const detail = tr.nextElementSibling;
const chev = tr.querySelector('.ctcssChevron');
if(open){
EXPANDED.add(cs);
if(detail) detail.style.display = '';
if(chev) chev.innerHTML = '&#9662;';
}else{
EXPANDED.delete(cs);
if(detail) detail.style.display = 'none';
if(chev) chev.innerHTML = '&#9656;';
}
});
}
function selectedCallsigns(){
return Array.from(SELECTED_CALLSIGNS);
}
function mkName(cs, tg){
const n = Number(tg);
if(!Number.isFinite(n) || n <= 0) return String(cs);
return `${cs} TG${n}`;
}
function csvEscape(v){
const s = clampStr(v);
if(/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
// AnyTone CPS CSV exports quote every field (including headers).
function csvQuoteAll(v){
const s = clampStr(v);
return `"${s.replace(/"/g, '""')}"`;
}
function downloadText(filename, text){
const blob = new Blob([text], {type:'text/plain;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportSelected(){
const status = window.__svxportal_lastStatus || {};
const all = buildData(status);
const selected = new Set(selectedCallsigns());
const flat = [];
for(const row of all){
if(!selected.has(row.cs)) continue;
if(!row.maps.length){
flat.push({
callsign: row.cs,
tg: '',
tone: '',
rx: row.rx,
tx: row.tx,
reportCtcss: row.reportCtcss
});
continue;
}
for(const m of row.maps){
flat.push({
callsign: row.cs,
tg: m.tg,
tone: m.tone,
rx: row.rx,
tx: row.tx,
reportCtcss: row.reportCtcss
});
}
}
if(!flat.length){
updateNote('Nothing selected to export.');
return;
}
const fmt = document.getElementById('ctcssFormat')?.value || 'anytone878';
if(fmt === 'anytone890'){
downloadText('svxportal-ctcss-anytone-890.csv', exportAnyTone890(flat));
return;
}
if(fmt === 'anytone878' || fmt === 'anytone'){
downloadText('svxportal-ctcss-anytone-878.csv', exportAnyTone878(flat));
return;
}
if(fmt === 'ftm400'){
downloadText('svxportal-ctcss-400.csv', exportFTM400(flat));
return;
}
if(fmt === 'ic5100'){
downloadText('svxportal-ctcss-5100.csv', exportIC5100(flat));
return;
}
if(fmt === 'ic7100'){
downloadText('svxportal-ctcss-7100.csv', exportIC7100(flat));
return;
}
// Fallback
downloadText('svxportal-ctcss-anytone-878.csv', exportAnyTone878(flat));
}
function fmtMHzFixed(v, decimals){
const mhz = parseMHz(v);
if(!Number.isFinite(mhz)) return clampStr(v);
return mhz.toFixed(decimals);
}
function fmtToneSpace(v){
const n = parseFloat(String(v).replace(/[^0-9.]/g,''));
if(!Number.isFinite(n)) return clampStr(v);
return n.toFixed(1) + ' Hz';
}
function fmtToneNoSpace(v){
const n = parseFloat(String(v).replace(/[^0-9.]/g,''));
if(!Number.isFinite(n)) return clampStr(v);
return n.toFixed(1) + 'Hz';
}
function fmtOffsetHuman(rxMHz, txMHz){
if(!Number.isFinite(rxMHz) || !Number.isFinite(txMHz) || rxMHz === txMHz) return ' ';
const diff = Math.abs(txMHz - rxMHz);
if(diff >= 1.0) return diff.toFixed(2) + ' MHz';
return String(Math.round(diff * 1000)) + ' kHz';
}
function fmtOffsetKHz(rxMHz, txMHz){
if(!Number.isFinite(rxMHz) || !Number.isFinite(txMHz) || rxMHz === txMHz) return ' ';
return String(Math.round(Math.abs(txMHz - rxMHz) * 1000)) + ' kHz';
}
function exportFTM400(rows){
const d = loadDefaults();
const header = [
'Channel Number','Receive Frequency','Transmit Frequency','Offset Frequency','Offset Direction',
'Operating Mode','Name','Show Name','Tone Mode','CTCSS','DCS','Tx Power','Skip','Step','Clock Shift','Comment','User CTCSS',''
];
const out = [header.join(',')];
let ch = 1;
for(const r of rows){
const rxMHz = parseMHz(r.rx);
const txMHz = parseMHz(r.tx || r.rx);
const mb = bandModeBw(rxMHz, d);
const isSimplex = (Number.isFinite(rxMHz) && Number.isFinite(txMHz) && rxMHz === txMHz);
const dir = isSimplex ? 'Simplex' : (txMHz > rxMHz ? 'Plus' : 'Minus');
const off = isSimplex ? ' ' : fmtOffsetHuman(rxMHz, txMHz);
const opMode = (String(mb.mode).toUpperCase() === 'NFM') ? 'FM Narrow' : 'FM';
const hasTone = !!(r.tone && String(r.tone).trim());
const toneMode = hasTone ? 'Tone' : 'None';
const ctcss = hasTone ? fmtToneSpace(r.tone) : '';
out.push([
csvEscape(String(ch++)),
csvEscape(fmtMHzFixed(r.rx, 5)),
csvEscape(fmtMHzFixed(r.tx || r.rx, 5)),
csvEscape(off),
csvEscape(dir),
csvEscape(opMode),
csvEscape(mkName(r.callsign, r.tg)),
csvEscape('Small'),
csvEscape(toneMode),
csvEscape(ctcss),
csvEscape('023'),
csvEscape('Low'),
csvEscape('Off'),
csvEscape('12.5 kHz'),
csvEscape('Off'),
csvEscape(''),
csvEscape('300 Hz'),
csvEscape('')
].join(','));
}
return out.join('\n') + '\n';
}
function exportIC7100(rows){
const header = [
'Channel Number','Receive Frequency','Transmit Frequency','Offset Frequency','Offset Direction',
'Operating Mode','Name','Tone Mode','CTCSS','Rx CTCSS','DCS','DCS Polarity','Select Scan','Comment',
'Digital Squelch','Digital Code','Your Callsign','Rpt-1 CallSign','Rpt-2 CallSign','Filter','Tx Data Mode',
'Data Mode','Split Tone Mode','Split CTCSS','Split Rx CTCSS','Split DCS','Split DCS Polarity','Tx Filter',
'Tx Operating Mode',''
];
const out = [header.join(',')];
let ch = 1;
for(const r of rows){
const rxMHz = parseMHz(r.rx);
const txMHz = parseMHz(r.tx || r.rx);
const isSimplex = (Number.isFinite(rxMHz) && Number.isFinite(txMHz) && rxMHz === txMHz);
const dir = isSimplex ? 'Simplex' : (txMHz > rxMHz ? 'DUP+' : 'DUP-');
const off = isSimplex ? ' ' : fmtOffsetKHz(rxMHz, txMHz);
// Icom (7100/5100) has no separate TX-encode / RX-decode CTCSS on a single
// analog channel — TSQL uses one tone both ways — so REPORT_CTCSS is NOT
// forced onto RX here (it would break access or be inactive). TX/access
// tone only; see the web table for the node's RX CTCSS.
const hasTone = !!(r.tone && String(r.tone).trim());
const toneMode = hasTone ? 'Tone' : 'None';
const ctcss = hasTone ? fmtToneSpace(r.tone) : '';
out.push([
csvEscape(String(ch++)),
csvEscape(fmtMHzFixed(r.rx, 6)),
csvEscape(fmtMHzFixed(r.tx || r.rx, 6)),
csvEscape(off),
csvEscape(dir),
csvEscape('FM'),
csvEscape(mkName(r.callsign, r.tg)),
csvEscape(toneMode),
csvEscape(ctcss),
csvEscape(ctcss),
csvEscape('023'),
csvEscape('Both N'),
csvEscape('Off'),
csvEscape(''),
csvEscape(''),
csvEscape(''),
csvEscape(''),
csvEscape(''),
csvEscape(''),
csvEscape('Filter 1'),
csvEscape(' '),
csvEscape(' '),
csvEscape('None'),
csvEscape(''),
csvEscape(''),
csvEscape('023'),
csvEscape('Both N'),
csvEscape('Filter 1'),
csvEscape('FM'),
csvEscape('')
].join(','));
}
return out.join('\n') + '\n';
}
function exportIC5100(rows){
const header = [
'CH No','Frequency','Dup','Offset','TS','Mode','Name','SKIP','TONE','Repeater Tone',
'TSQL Frequency','DTCS Code','DTCS Polarity','DV SQL','DV CSQL Code','Your Call Sign','RPT1 Call Sign','RPT2 Call Sign'
];
const out = [header.join(';')];
let ch = 0;
for(const r of rows){
const rxMHz = parseMHz(r.rx);
const txMHz = parseMHz(r.tx || r.rx);
const isSimplex = (Number.isFinite(rxMHz) && Number.isFinite(txMHz) && rxMHz === txMHz);
const dup = isSimplex ? 'OFF' : (txMHz > rxMHz ? 'DUP+' : 'DUP-');
// Typical analog offsets (used even when duplex is OFF in CS-5100 exports)
let off = '0.600000';
if(Number.isFinite(rxMHz) && rxMHz >= 200.0) off = '5.000000';
if(!isSimplex && Number.isFinite(rxMHz) && Number.isFinite(txMHz)){
off = Math.abs(txMHz - rxMHz).toFixed(6);
}
let ts = '12.5kHz';
if(Number.isFinite(rxMHz) && rxMHz < 200.0) ts = '6.25kHz';
if(Number.isFinite(rxMHz) && rxMHz >= 400.0) ts = '25kHz';
const hasTone = !!(r.tone && String(r.tone).trim());
const toneMode = hasTone ? 'TONE' : '';
const tone = hasTone ? fmtToneNoSpace(r.tone) : '';
out.push([
csvEscape(String(ch++).padStart(2,'0')),
csvEscape(fmtMHzFixed(r.rx, 6)),
csvEscape(dup),
csvEscape(off),
csvEscape(ts),
csvEscape('FM'),
csvEscape(mkName(r.callsign, r.tg)),
csvEscape('OFF'),
csvEscape(toneMode),
csvEscape(tone),
csvEscape(tone),
csvEscape('023'),
csvEscape('BOTH N'),
csvEscape('OFF'),
csvEscape('0'),
csvEscape('CQCQCQ'),
csvEscape(''),
csvEscape('')
].join(';'));
}
return out.join('\n') + '\n';
}
// ----- AnyTone export -----------------------------------------------------
// The D578UV (878) and D578UVII Plus (890) CPS use almost the same channel
// layout. There are two model-specific functions below — exportAnyTone878()
// and exportAnyTone890() — so it is obvious which one to change for each radio.
// Both share buildAnyTone878Rows() for the common per-channel logic.
//
// 878 = 56 columns. 890 = 77 columns: the first 55 are positionally identical
// (a few are only renamed), then 21 extra NXDN/misc columns, then "txcc"
// (which is the 878 "TxCC" column moved to the end).
const ANYTONE_HEADER_878 = [
'No.','Channel Name','Receive Frequency','Transmit Frequency','Channel Type','Transmit Power','Band Width',
'CTCSS/DCS Decode','CTCSS/DCS Encode','Contact','Contact Call Type','Contact TG/DMR ID','Radio ID',
'Busy Lock/TX Permit','Squelch Mode','Optional Signal','DTMF ID','2Tone ID','5Tone ID','PTT ID',
'RX Color Code','Slot','Scan List','Receive Group List','PTT Prohibit','Reverse','Simplex TDMA',
'Slot Suit','AES Digital Encryption','Digital Encryption','Call Confirmation','Talk Around(Simplex)',
'Work Alone','Custom CTCSS','2TONE Decode','Ranging','Through Mode','APRS RX','Analog APRS PTT Mode',
'Digital APRS PTT Mode','APRS Report Type','Digital APRS Report Channel','Correct Frequency[Hz]',
'SMS Confirmation','Exclude channel from roaming','DMR MODE','DataACK Disable','R5toneBot','R5ToneEot',
'Auto Scan','Ana Aprs Mute','Send Talker Alias','AnaAprsTxPath','ARC4','ex_emg_kind','TxCC'
];
const ANYTONE_HEADER_890 = [
'No.','Channel Name','Receive Frequency','Transmit Frequency','Channel Type','Transmit Power','Band Width',
'CTCSS/DCS Decode','CTCSS/DCS Encode','Contact/Talk Group','Contact/Talk Group Call Type',
'Contact/Talk Group TG/DMR ID','Radio ID','Busy Lock/TX Permit','Squelch Mode','Optional Signal',
'DTMF ID','2Tone ID','5Tone ID','PTT ID','RX Color Code','Slot','Scan List','Receive Group List',
'PTT Prohibit','Reverse','Digital Duplex','Slot Suit','AES Digital Encryption','Digital Encryption',
'Call Confirmation','Talk Around(Simplex)','Work Alone','Custom CTCSS','2TONE Decode','Ranging',
'Idle TX','APRS RX','Analog APRS PTT Mode','Digital APRS PTT Mode','APRS Report Type',
'Digital APRS Report Channel','Correct Frequency[Hz]','SMS Confirmation','Exclude channel from roaming',
'DMR MODE','DataACK Disable','R5toneBot','R5ToneEot','Auto Scan','Ana APRS Mute',
'Send Talker Alias DMR/NX','AnaAprsTxPath','ARC4','ex_emg_kind','Rpga_Mdc','DisturEn','DisturFreq',
'dmr_crc_ignore','compand','tx_talkalaes','dup_call','tx_int','BtRxState','idle_tx','nxdn_wn',
'NxdnRpga','nxdnSqCon','NxdnTxBusy','NxDnPttId','EnRan','DeRan','NxdnEncry','NxdnGroupId',
'NxdnIdNum','NxdnStateNum','txcc'
];
// Remap one 56-column 878 row to the 77-column 890 layout.
// Columns 0..54 are positionally identical; the 21 inserted columns (55..75)
// default to '0' for analog channels (matches a real 890 CPS export); the 878
// "TxCC" column (index 55) becomes the 890 "txcc" column (index 76).
function convert878RowTo890(row878){
const out = new Array(ANYTONE_HEADER_890.length).fill('0');
for(let i = 0; i <= 54; i++) out[i] = row878[i];
out[76] = row878[55];
return out;
}
// Shared per-channel builder. Returns an array of 56-element rows (878 layout),
// already sorted. Both 878 and 890 exports start from this.
function buildAnyTone878Rows(rows){
const d = loadDefaults();
const startNo = (() => {
const n = parseInt(d.startNo, 10);
return Number.isFinite(n) && n > 0 ? n : 1;
})();
const template = [
'1',
'Home SVX',
'434.40000',
'434.40000',
'A-Analog',
'Low',
'12.5K',
'79.7',
'136.5',
'Sweden',
'Group Call',
'240',
'SA4AGM',
'Off',
'Carrier',
'DTMF',
'2',
'1',
'1',
'Off',
'1',
'1',
'None',
'None',
'Off',
'Off',
'Off',
'Off',
'Normal Encryption',
'Off',
'Off',
'Off',
'Off',
'251.1',
'1',
'Off',
'Off',
'Off',
'Off',
'Off',
'Digital',
'1',
'0',
'Off',
'0',
'1',
'0',
'0',
'0',
'0',
'0',
'0',
'0',
'0',
'0',
'1'
];
const tgSortVal = (tg) => {
const n = parseInt(tg, 10);
return Number.isFinite(n) && n > 0 ? n : Number.POSITIVE_INFINITY;
};
const toneSortVal = (t) => {
const n = parseFloat(String(t || '').replace(',', '.'));
return Number.isFinite(n) ? n : Number.POSITIVE_INFINITY;
};
const sorted = (rows || []).slice().sort((a,b) => {
const ta = tgSortVal(a.tg);
const tb = tgSortVal(b.tg);
if(ta !== tb) return ta - tb;
const c = callsignCompare(a.callsign, b.callsign);
if(c) return c;
return toneSortVal(a.tone) - toneSortVal(b.tone);
});
const built = [];
let idx = 0;
for(const r of sorted){
const mb = bandModeBw(parseMHz(r.rx), d);
const bw = (mb.bw === '25') ? '25K' : '12.5K';
const row = template.slice();
// No.
row[0] = String(startNo + idx++);
// Channel Name
row[1] = mkName(r.callsign, r.tg);
// RX/TX
row[2] = r.rx;
row[3] = r.tx;
// Channel Type
row[4] = 'A-Analog';
// Power / Bandwidth
row[5] = d.power || row[5];
row[6] = bw;
// CTCSS/DCS Decode (RX) = node REPORT_CTCSS; Encode (TX) = access/mapping tone.
// When the node publishes a report tone, switch Squelch Mode to CTCSS/DCS so
// the radio only opens on this node (SvxLink sends it always, TX_CTCSS=ALWAYS).
const rxTone = r.reportCtcss ? String(r.reportCtcss) : '';
row[7] = rxTone || 'Off';
row[8] = r.tone ? r.tone : 'Off';
if(rxTone) row[14] = 'CTCSS/DCS';
// Contact defaults (kept for CPS compatibility)
row[9] = d.contact || row[9];
row[10] = d.contactCallType || row[10];
row[11] = d.contactTg || row[11];
// Radio ID (default: base callsign)
row[12] = (d.radioId && String(d.radioId).trim()) ? String(d.radioId).trim() : baseCallsign(r.callsign);
// If no CTCSS/tg mapping: name is only the callsign and there is no TX tone.
// The RX decode tone (row[7]/Squelch Mode) is kept — the node still sends it.
if(!r.tone || !r.tg){
row[1] = String(r.callsign);
row[8] = 'Off';
}
built.push(row);
}
return built;
}
// AnyTone CPS CSV files use CRLF line endings (every line, including a trailing
// one) and no BOM. 890 CPS rejects/mis-imports plain-LF files; 878 is lenient.
const ANYTONE_EOL = '\r\n';
// AnyTone D578UV (878) export.
function exportAnyTone878(rows){
const built = buildAnyTone878Rows(rows);
const out = [ANYTONE_HEADER_878.map(csvQuoteAll).join(',')];
for(const row of built) out.push(row.map(csvQuoteAll).join(','));
return out.join(ANYTONE_EOL) + ANYTONE_EOL;
}
// AnyTone D578UVII Plus (890) export — same channels, wider 77-column layout.
function exportAnyTone890(rows){
const built = buildAnyTone878Rows(rows);
const out = [ANYTONE_HEADER_890.map(csvQuoteAll).join(',')];
for(const row of built) out.push(convert878RowTo890(row).map(csvQuoteAll).join(','));
return out.join(ANYTONE_EOL) + ANYTONE_EOL;
}
window.renderCTCSS = function renderCTCSS(status){
const panel = ensureSkeleton();
if(!panel) return;
window.__svxportal_lastStatus = status || {};
const fmt = document.getElementById('ctcssFormat')?.value || 'anytone878';
const anyBox = document.getElementById('ctcssAnyToneDefaults');
if(anyBox) anyBox.style.display = fmt.startsWith('anytone') ? 'block' : 'none';
const data = buildData(status);
if(!HAS_USER_SELECTION){
for(const row of data) SELECTED_CALLSIGNS.add(row.cs);
}
renderTable(data);
if(!data.length){
updateNote('No nodes found in /svxportal/api/status.');
}else{
updateNote();
}
};
})();