Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr), deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles live in install/ with CHANGEME placeholders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
979 lines
33 KiB
JavaScript
979 lines
33 KiB
JavaScript
/* 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 → 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) ? '▾' : '▸';
|
||
|
||
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 = '▸';
|
||
}else{
|
||
EXPANDED.add(row.cs);
|
||
detail.style.display = '';
|
||
tdChevron.innerHTML = '▾';
|
||
}
|
||
});
|
||
}
|
||
|
||
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>→ 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 = '▾';
|
||
}else{
|
||
EXPANDED.delete(cs);
|
||
if(detail) detail.style.display = 'none';
|
||
if(chev) chev.innerHTML = '▸';
|
||
}
|
||
});
|
||
}
|
||
|
||
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();
|
||
}
|
||
};
|
||
})();
|