diff options
Diffstat (limited to 'stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js')
| -rw-r--r-- | stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js | 609 |
1 files changed, 609 insertions, 0 deletions
diff --git a/stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js b/stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js new file mode 100644 index 0000000..9988a77 --- /dev/null +++ b/stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js @@ -0,0 +1,609 @@ +(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 = '<option value="">—</option>'; + deviceSelect.disabled = true; + return; + } + for (i = 0; i < devices.length; i++) { + var d = devices[i]; + var sel = d.id === status.device ? ' selected' : ''; + html += '<option value="' + escapeAttr(d.id) + '"' + sel + '>' + + escapeHtml(d.id) + '</option>'; + } + deviceSelect.innerHTML = html; + deviceSelect.disabled = !sessionReady; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .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 <text> /monitor [on|off|toggle]\n' + + '/connect /disconnect\n' + + '/device <id> /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 <id>'); + 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(); +})(); |
