(function () { 'use strict'; var term = document.getElementById('term'); var connStatus = document.getElementById('conn-status'); var form = document.getElementById('input-bar'); var input = document.getElementById('cmd'); var menuBtn = document.getElementById('menu-btn'); var menuPanel = document.getElementById('menu-panel'); var deviceSelect = document.getElementById('device-select'); var hdrDevice = document.getElementById('hdr-device'); var hdrCallerid = document.getElementById('hdr-callerid'); var hdrCallid = document.getElementById('hdr-callid'); var hdrAx25 = document.getElementById('hdr-ax25'); var hdrConnected = document.getElementById('hdr-connected'); var wsUrl = window.MAX25_WS_URL; var defaults = window.MAX25_SESSION_DEFAULTS || {}; var ws = null; var reconnectTimer = null; var reconnectDelayMs = 2000; var reconnectAttempt = 0; var lineBuf = ''; var rxLines = []; var RX_MAX = 200; var waitingReply = false; var pendingCmd = null; var devices = []; var sessionReady = false; var bootstrapQueue = []; var status = { device: '', callerid: '', callid: '', ax25_ui: true, connected: false, monitor: false, stack: '' }; function classifyLine(line) { if (!line) { return 'ignore'; } if (line === 'OK') { return 'ok'; } if (line.indexOf('ERR ') === 0) { return 'err'; } if (line.indexOf('STATUS ') === 0) { return 'status'; } if (line.indexOf('RX ') === 0) { return 'rx'; } if (line.indexOf('EVENT ') === 0) { return 'event'; } if (line.indexOf('DEVICE ') === 0) { return 'device'; } return 'other'; } function parseKvLine(payload) { var out = {}; var parts = payload.split(' '); var i; for (i = 0; i < parts.length; i++) { var eq = parts[i].indexOf('='); if (eq > 0) { out[parts[i].slice(0, eq)] = parts[i].slice(eq + 1); } } return out; } function parseStatus(line) { var kv = parseKvLine(line.slice(7)); if (kv.device !== undefined) { status.device = kv.device; } if (kv.callerid !== undefined) { status.callerid = kv.callerid; } if (kv.callid !== undefined) { status.callid = kv.callid; } if (kv.ax25_ui !== undefined) { status.ax25_ui = kv.ax25_ui === 'on'; } if (kv.connected !== undefined) { status.connected = kv.connected === 'yes'; } if (kv.stack !== undefined) { status.stack = kv.stack; } updateHeader(); } function parseDeviceLine(line) { var kv = parseKvLine(line.slice(7)); if (!kv.id) { return null; } return { id: kv.id, hardware: kv.hardware || '', serial: kv.serial || '', stack: kv.stack || '', enabled: kv.enabled || 'yes', error: kv.error || '', voice: kv.voice || '' }; } function validCallsign(value) { if (!value) { return false; } var m = /^([A-Z0-9]{1,6})(?:-([0-9]{1,2}))?$/i.exec(value.trim()); if (!m) { return false; } if (m[2] !== undefined && parseInt(m[2], 10) > 15) { return false; } return true; } function updateHeader() { hdrDevice.textContent = status.device || '-'; hdrCallerid.textContent = status.callerid || '-'; hdrCallid.textContent = status.callid || '-'; hdrAx25.textContent = status.ax25_ui ? 'on' : 'off'; hdrConnected.textContent = status.connected ? 'yes' : 'no'; } function refreshDeviceSelect() { var html = ''; var i; if (devices.length === 0) { deviceSelect.innerHTML = ''; deviceSelect.disabled = true; return; } for (i = 0; i < devices.length; i++) { var d = devices[i]; var sel = d.id === status.device ? ' selected' : ''; html += ''; } deviceSelect.innerHTML = html; deviceSelect.disabled = !sessionReady; } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>'); } function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '"'); } function appendRx(text) { var lines = String(text).split('\n'); var i; for (i = 0; i < lines.length; i++) { if (lines[i] === '' && i === lines.length - 1) { continue; } rxLines.push(lines[i]); if (rxLines.length > RX_MAX) { rxLines.shift(); } } term.textContent = rxLines.join('\n') + (rxLines.length ? '\n' : ''); term.scrollTop = term.scrollHeight; } function setConnStatus(s) { connStatus.textContent = s; } function setInputEnabled(enabled) { input.disabled = !enabled; if (enabled) { input.focus(); } } function sendRaw(cmd) { if (!ws || ws.readyState !== WebSocket.OPEN) { return false; } ws.send(cmd + '\n'); return true; } function sendCmd(cmd, label) { pendingCmd = label || cmd; waitingReply = true; return sendRaw(cmd); } function applyEvent(line) { if (line === 'EVENT connected') { status.connected = true; updateHeader(); return true; } if (line === 'EVENT disconnected') { status.connected = false; updateHeader(); return true; } return false; } var bootstrapped = false; function handleLine(line) { var kind = classifyLine(line); if (kind === 'rx') { appendRx(line.slice(3)); return; } if (kind === 'event') { if (applyEvent(line)) { appendRx(line); } return; } if (waitingReply) { if (kind === 'status') { parseStatus(line); appendRx('STATUS device=' + (status.device || '-') + ' stack=' + (status.stack || '-') + ' connected=' + (status.connected ? 'yes' : 'no') + ' callerid=' + (status.callerid || '-') + ' callid=' + (status.callid || '-')); return; } if (kind === 'device') { var dev = parseDeviceLine(line); if (dev) { devices.push(dev); } if (pendingCmd === 'GET DEVICES') { appendRx(line); } return; } if (kind === 'ok') { waitingReply = false; if (pendingCmd === 'GET DEVICES') { refreshDeviceSelect(); } pendingCmd = null; if (bootstrapQueue.length > 0) { window.setTimeout(runBootstrapStep, 0); } return; } if (kind === 'err') { waitingReply = false; appendRx(line); pendingCmd = null; if (bootstrapQueue.length > 0) { bootstrapQueue = []; } return; } return; } if (kind === 'status') { parseStatus(line); if (!bootstrapped) { bootstrapped = true; startBootstrap(); } return; } if (kind === 'err') { appendRx(line); } } function feedText(chunk) { lineBuf += chunk; var idx; while ((idx = lineBuf.indexOf('\n')) >= 0) { var line = lineBuf.slice(0, idx); lineBuf = lineBuf.slice(idx + 1); if (line.endsWith('\r')) { line = line.slice(0, -1); } handleLine(line); } } function queueBootstrap(cmd, label) { bootstrapQueue.push({ cmd: cmd, label: label || cmd }); } function runBootstrapStep() { if (!sessionReady || waitingReply || bootstrapQueue.length === 0) { return; } var step = bootstrapQueue.shift(); sendCmd(step.cmd, step.label); } function startBootstrap() { bootstrapQueue = []; devices = []; refreshDeviceSelect(); sessionReady = true; if (defaults.callerid && defaults.callerid !== status.callerid) { queueBootstrap('SET CALLERID ' + defaults.callerid.toUpperCase()); } if (defaults.callid && defaults.callid !== status.callid) { queueBootstrap('SET CALLID ' + defaults.callid.toUpperCase()); } queueBootstrap('SET AX25_UI ' + (defaults.ax25_ui !== false ? 'on' : 'off')); if (defaults.device) { queueBootstrap('SET DEVICE ' + defaults.device); } queueBootstrap('GET DEVICES', 'GET DEVICES'); if (defaults.connect_on_start !== false) { queueBootstrap('CONNECT'); } queueBootstrap('GET STATUS'); sessionReady = true; runBootstrapStep(); } function fetchDevices() { devices = []; sendCmd('GET DEVICES', 'GET DEVICES'); } function setDevice(id) { if (!id) { return; } var known = false; var i; for (i = 0; i < devices.length; i++) { if (devices[i].id === id) { known = true; break; } } if (devices.length === 0) { appendRx('ERR device list empty — run /devices first'); return; } if (!known) { appendRx('ERR unknown device (not in max25d): ' + id); return; } sendCmd('SET DEVICE ' + id); window.setTimeout(function () { sendCmd('GET STATUS'); }, 50); } function slashHelp() { appendRx( '-- MAX25 browser commands (F10 menu equivalent) --\n' + '/callerid [id] /callid [id] /status\n' + '/send /monitor [on|off|toggle]\n' + '/connect /disconnect\n' + '/device /devices\n' + '/ax25_ui on|off /help\n' + 'Plain text (no /) is sent as SEND (like max25-terminal Enter).' ); } function handleSlash(line) { var parts = line.slice(1).trim().split(/\s+/); var cmd = (parts[0] || '').toLowerCase(); var arg = parts.slice(1).join(' '); menuPanel.classList.remove('open'); menuBtn.setAttribute('aria-expanded', 'false'); if (cmd === 'help' || cmd === 'menu') { slashHelp(); return; } if (cmd === 'callerid' || cmd === 'caller') { if (arg) { if (!validCallsign(arg)) { appendRx('ERR invalid CALLERID'); return; } sendCmd('SET CALLERID ' + arg.toUpperCase()); } else { var v = window.prompt('CALLERID', status.callerid || defaults.callerid || ''); if (v && validCallsign(v)) { sendCmd('SET CALLERID ' + v.toUpperCase()); } } return; } if (cmd === 'callid' || cmd === 'call') { if (arg) { if (!validCallsign(arg)) { appendRx('ERR invalid CALLID'); return; } sendCmd('SET CALLID ' + arg.toUpperCase()); } else { var w = window.prompt('CALLID', status.callid || defaults.callid || ''); if (w && validCallsign(w)) { sendCmd('SET CALLID ' + w.toUpperCase()); } } return; } if (cmd === 'status') { sendCmd('GET STATUS'); return; } if (cmd === 'send') { if (!arg) { arg = window.prompt('Send line', ''); } if (arg) { sendCmd('SEND ' + arg); } return; } if (cmd === 'monitor') { var on; if (arg === 'on' || arg === 'yes' || arg === '1') { on = true; } else if (arg === 'off' || arg === 'no' || arg === '0') { on = false; } else { on = !status.monitor; } status.monitor = on; sendCmd('MONITOR ' + (on ? 'on' : 'off')); appendRx(on ? 'MONITOR on' : 'MONITOR off'); return; } if (cmd === 'connect') { sendCmd(status.connected ? 'DISCONNECT' : 'CONNECT'); return; } if (cmd === 'disconnect') { sendCmd('DISCONNECT'); return; } if (cmd === 'devices') { fetchDevices(); return; } if (cmd === 'device') { if (!arg) { fetchDevices(); appendRx('Pick device from dropdown or: /device '); return; } setDevice(arg); return; } if (cmd === 'ax25_ui' || cmd === 'ax25-ui') { var flag = (arg || '').toLowerCase(); if (flag !== 'on' && flag !== 'off') { flag = status.ax25_ui ? 'off' : 'on'; } sendCmd('SET AX25_UI ' + flag); return; } if (cmd === 'quit' || cmd === 'exit') { if (ws) { ws.close(); } return; } appendRx('ERR unknown /command — try /help'); } function scheduleReconnect() { if (reconnectTimer) { return; } reconnectAttempt += 1; setConnStatus('reconnecting in ' + (reconnectDelayMs / 1000) + 's'); reconnectTimer = window.setTimeout(function () { reconnectTimer = null; connect(); }, reconnectDelayMs); } function connect() { setConnStatus('connecting'); setInputEnabled(false); sessionReady = false; lineBuf = ''; ws = new WebSocket(wsUrl); ws.onopen = function () { reconnectAttempt = 0; setConnStatus('connected'); setInputEnabled(true); }; ws.onmessage = function (ev) { feedText(ev.data); }; ws.onclose = function (ev) { var reason = ev.reason ? ' (' + ev.reason + ')' : ''; appendRx('\n[websocket disconnected: code ' + ev.code + reason + ']\n'); setConnStatus('disconnected: ' + ev.code); ws = null; sessionReady = false; setInputEnabled(false); deviceSelect.disabled = true; scheduleReconnect(); }; ws.onerror = function () { setConnStatus('error'); setInputEnabled(false); }; } form.addEventListener('submit', function (ev) { ev.preventDefault(); if (!ws || ws.readyState !== WebSocket.OPEN || waitingReply) { return; } var text = input.value; input.value = ''; if (!text) { return; } if (text.charAt(0) === '/') { handleSlash(text); return; } sendCmd('SEND ' + text); }); menuBtn.addEventListener('click', function (ev) { ev.stopPropagation(); var open = menuPanel.classList.toggle('open'); menuBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); }); document.addEventListener('click', function () { menuPanel.classList.remove('open'); menuBtn.setAttribute('aria-expanded', 'false'); }); menuPanel.addEventListener('click', function (ev) { ev.stopPropagation(); var btn = ev.target.closest('button[data-action]'); if (!btn) { return; } var action = btn.getAttribute('data-action'); if (action === 'callerid') { handleSlash('/callerid'); } else if (action === 'callid') { handleSlash('/callid'); } else if (action === 'status') { handleSlash('/status'); } else if (action === 'send') { handleSlash('/send'); } else if (action === 'monitor') { handleSlash('/monitor toggle'); } else if (action === 'connect') { handleSlash('/connect'); } else if (action === 'devices') { handleSlash('/devices'); } else if (action === 'ax25_ui') { handleSlash('/ax25_ui'); } else if (action === 'help') { handleSlash('/help'); } }); deviceSelect.addEventListener('change', function () { if (deviceSelect.value) { setDevice(deviceSelect.value); } }); updateHeader(); connect(); })();