diff --git a/app/routes/views.py b/app/routes/views.py index f182b8a..d06bc72 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -72,6 +72,26 @@ def contact_existing_list(): ) +@views_bp.route('/console') +def console(): + """ + Interactive meshcli console - chat-style command interface. + + Connects via WebSocket to meshcore-bridge for real-time command execution. + """ + # Build WebSocket URL for meshcore-bridge + # Browser connects directly to bridge on port 5001 + # Use the same hostname the user is accessing but with port 5001 + host = request.host.split(':')[0] # Get hostname without port + bridge_ws_url = f"http://{host}:5001" + + return render_template( + 'console.html', + device_name=config.MC_DEVICE_NAME, + bridge_ws_url=bridge_ws_url + ) + + @views_bp.route('/health') def health(): """ diff --git a/app/static/js/console.js b/app/static/js/console.js new file mode 100644 index 0000000..d8aab6a --- /dev/null +++ b/app/static/js/console.js @@ -0,0 +1,274 @@ +/** + * mc-webui Console - Chat-style meshcli interface + * + * Provides interactive command console for meshcli via WebSocket. + * Commands are sent to meshcore-bridge and responses are displayed + * in a chat-like format. + */ + +let socket = null; +let isConnected = false; +let commandHistory = []; +let historyIndex = -1; +let pendingCommandDiv = null; + +// Initialize on page load +document.addEventListener('DOMContentLoaded', function() { + console.log('Console page initialized'); + connectWebSocket(); + setupInputHandlers(); +}); + +/** + * Connect to WebSocket server on meshcore-bridge + */ +function connectWebSocket() { + updateStatus('connecting'); + + // Get WebSocket URL - bridge runs on port 5001 + // Use same hostname as current page but different port + const bridgeUrl = window.MC_CONFIG?.bridgeWsUrl || + `${window.location.protocol}//${window.location.hostname}:5001`; + + console.log('Connecting to WebSocket:', bridgeUrl); + + try { + socket = io(bridgeUrl + '/console', { + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + timeout: 20000 + }); + + // Connection events + socket.on('connect', () => { + console.log('WebSocket connected'); + isConnected = true; + updateStatus('connected'); + enableInput(true); + addMessage('Connected to meshcli', 'system'); + }); + + socket.on('disconnect', (reason) => { + console.log('WebSocket disconnected:', reason); + isConnected = false; + updateStatus('disconnected'); + enableInput(false); + addMessage('Disconnected from meshcli', 'error'); + + // Clear pending command indicator + if (pendingCommandDiv) { + pendingCommandDiv.classList.remove('pending'); + pendingCommandDiv = null; + } + }); + + socket.on('connect_error', (error) => { + console.error('WebSocket connection error:', error); + updateStatus('disconnected'); + }); + + // Console events from server + socket.on('console_status', (data) => { + console.log('Console status:', data); + if (data.message) { + addMessage(data.message, 'system'); + } + }); + + socket.on('command_response', (data) => { + console.log('Command response:', data); + + // Clear pending indicator + if (pendingCommandDiv) { + pendingCommandDiv.classList.remove('pending'); + pendingCommandDiv = null; + } + + // Display response + if (data.success) { + const output = data.output || '(no output)'; + addMessage(output, 'response'); + } else { + addMessage(`Error: ${data.error}`, 'error'); + } + scrollToBottom(); + }); + + } catch (error) { + console.error('Failed to create WebSocket connection:', error); + updateStatus('disconnected'); + addMessage('Failed to connect: ' + error.message, 'error'); + } +} + +/** + * Setup input form handlers + */ +function setupInputHandlers() { + const form = document.getElementById('consoleForm'); + const input = document.getElementById('commandInput'); + + // Form submit + form.addEventListener('submit', (e) => { + e.preventDefault(); + sendCommand(); + }); + + // Command history navigation with arrow keys + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + navigateHistory(-1); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + navigateHistory(1); + } + }); +} + +/** + * Send command to meshcli + */ +function sendCommand() { + const input = document.getElementById('commandInput'); + const command = input.value.trim(); + + if (!command || !isConnected) { + return; + } + + // Add to history (avoid duplicates at end) + if (commandHistory.length === 0 || commandHistory[commandHistory.length - 1] !== command) { + commandHistory.push(command); + // Limit history size + if (commandHistory.length > 100) { + commandHistory.shift(); + } + } + historyIndex = commandHistory.length; + + // Show command in chat with pending indicator + pendingCommandDiv = addMessage(command, 'command pending'); + + // Send to server + socket.emit('send_command', { command: command }); + + // Clear input + input.value = ''; + scrollToBottom(); +} + +/** + * Navigate command history + * @param {number} direction -1 for older, 1 for newer + */ +function navigateHistory(direction) { + const input = document.getElementById('commandInput'); + + if (commandHistory.length === 0) { + return; + } + + historyIndex += direction; + + // Clamp to valid range + if (historyIndex < 0) { + historyIndex = 0; + } + if (historyIndex >= commandHistory.length) { + historyIndex = commandHistory.length; + input.value = ''; + return; + } + + input.value = commandHistory[historyIndex]; + + // Move cursor to end + setTimeout(() => { + input.selectionStart = input.selectionEnd = input.value.length; + }, 0); +} + +/** + * Add message to console display + * @param {string} text Message text + * @param {string} type Message type: 'command', 'response', 'error', 'system' + * @returns {HTMLElement} The created message div + */ +function addMessage(text, type) { + const container = document.getElementById('consoleMessages'); + const div = document.createElement('div'); + div.className = `console-message ${type}`; + div.textContent = text; + container.appendChild(div); + return div; +} + +/** + * Scroll messages container to bottom + */ +function scrollToBottom() { + const container = document.getElementById('consoleMessages'); + // Use setTimeout to ensure DOM is updated + setTimeout(() => { + container.scrollTop = container.scrollHeight; + }, 10); +} + +/** + * Update connection status indicator + * @param {string} status 'connected', 'disconnected', or 'connecting' + */ +function updateStatus(status) { + const dot = document.getElementById('statusDot'); + const text = document.getElementById('statusText'); + + if (!dot || !text) return; + + dot.className = `status-dot ${status}`; + + switch (status) { + case 'connected': + text.textContent = 'Connected'; + text.className = 'text-success'; + break; + case 'disconnected': + text.textContent = 'Disconnected'; + text.className = 'text-danger'; + break; + case 'connecting': + text.textContent = 'Connecting...'; + text.className = 'text-warning'; + break; + } +} + +/** + * Enable or disable input controls + * @param {boolean} enabled + */ +function enableInput(enabled) { + const input = document.getElementById('commandInput'); + const btn = document.getElementById('sendBtn'); + + if (input) { + input.disabled = !enabled; + if (enabled) { + input.focus(); + } + } + + if (btn) { + btn.disabled = !enabled; + } +} + +// Cleanup on page unload +window.addEventListener('beforeunload', () => { + if (socket) { + socket.disconnect(); + } +}); diff --git a/app/static/js/sw.js b/app/static/js/sw.js index b17c616..9b1c19b 100644 --- a/app/static/js/sw.js +++ b/app/static/js/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'mc-webui-v3'; +const CACHE_NAME = 'mc-webui-v4'; const ASSETS_TO_CACHE = [ '/', '/static/css/style.css', @@ -6,6 +6,7 @@ const ASSETS_TO_CACHE = [ '/static/js/dm.js', '/static/js/contacts.js', '/static/js/message-utils.js', + '/static/js/console.js', '/static/images/android-chrome-192x192.png', '/static/images/android-chrome-512x512.png', // Bootstrap 5.3.2 (local) @@ -19,7 +20,11 @@ const ASSETS_TO_CACHE = [ '/static/vendor/emoji-picker-element/index.js', '/static/vendor/emoji-picker-element/picker.js', '/static/vendor/emoji-picker-element/database.js', - '/static/vendor/emoji-picker-element-data/en/emojibase/data.json' + '/static/vendor/emoji-picker-element-data/en/emojibase/data.json', + // Socket.IO client 4.x (local) + '/static/vendor/socket.io/socket.io.min.js', + // Console page + '/console' ]; // Install event - cache core assets diff --git a/app/static/vendor/socket.io/socket.io.min.js b/app/static/vendor/socket.io/socket.io.min.js new file mode 100644 index 0000000..2276074 --- /dev/null +++ b/app/static/vendor/socket.io/socket.io.min.js @@ -0,0 +1,7 @@ +/*! + * Socket.IO v4.7.4 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).io=e()}(this,(function(){"use strict";function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(e)}function e(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function n(t,e){for(var n=0;nt.length)&&(e=t.length);for(var n=0,r=new Array(e);n=t.length?{done:!0}:{done:!1,value:t[r++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,s=!0,a=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return s=t.done,t},e:function(t){a=!0,o=t},f:function(){try{s||null==n.return||n.return()}finally{if(a)throw o}}}}var v=Object.create(null);v.open="0",v.close="1",v.ping="2",v.pong="3",v.message="4",v.upgrade="5",v.noop="6";var g=Object.create(null);Object.keys(v).forEach((function(t){g[v[t]]=t}));var m,b={type:"error",data:"parser error"},k="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),w="function"==typeof ArrayBuffer,_=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t&&t.buffer instanceof ArrayBuffer},A=function(t,e,n){var r=t.type,i=t.data;return k&&i instanceof Blob?e?n(i):O(i,n):w&&(i instanceof ArrayBuffer||_(i))?e?n(i):O(new Blob([i]),n):n(v[r]+(i||""))},O=function(t,e){var n=new FileReader;return n.onload=function(){var t=n.result.split(",")[1];e("b"+(t||""))},n.readAsDataURL(t)};function E(t){return t instanceof Uint8Array?t:t instanceof ArrayBuffer?new Uint8Array(t):new Uint8Array(t.buffer,t.byteOffset,t.byteLength)}for(var T="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",R="undefined"==typeof Uint8Array?[]:new Uint8Array(256),C=0;C<64;C++)R[T.charCodeAt(C)]=C;var B,S="function"==typeof ArrayBuffer,N=function(t,e){if("string"!=typeof t)return{type:"message",data:x(t,e)};var n=t.charAt(0);return"b"===n?{type:"message",data:L(t.substring(1),e)}:g[n]?t.length>1?{type:g[n],data:t.substring(1)}:{type:g[n]}:b},L=function(t,e){if(S){var n=function(t){var e,n,r,i,o,s=.75*t.length,a=t.length,u=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var c=new ArrayBuffer(s),h=new Uint8Array(c);for(e=0;e>4,h[u++]=(15&r)<<4|i>>2,h[u++]=(3&i)<<6|63&o;return c}(t);return x(n,e)}return{base64:!0,data:t}},x=function(t,e){return"blob"===e?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},P=String.fromCharCode(30);function q(){return new TransformStream({transform:function(t,e){!function(t,e){k&&t.data instanceof Blob?t.data.arrayBuffer().then(E).then(e):w&&(t.data instanceof ArrayBuffer||_(t.data))?e(E(t.data)):A(t,!1,(function(t){m||(m=new TextEncoder),e(m.encode(t))}))}(t,(function(n){var r,i=n.length;if(i<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,i);else if(i<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,i)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(i))}t.data&&"string"!=typeof t.data&&(r[0]|=128),e.enqueue(r),e.enqueue(n)}))}})}function j(t){return t.reduce((function(t,e){return t+e.length}),0)}function D(t,e){if(t[0].length===e)return t.shift();for(var n=new Uint8Array(e),r=0,i=0;i1?e-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this._hostname()+this._port()+this.opts.path+this._query(e)}},{key:"_hostname",value:function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"}},{key:"_port",value:function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""}},{key:"_query",value:function(t){var e=function(t){var e="";for(var n in t)t.hasOwnProperty(n)&&(e.length&&(e+="&"),e+=encodeURIComponent(n)+"="+encodeURIComponent(t[n]));return e}(t);return e.length?"?"+e:""}}]),i}(U),z="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_".split(""),J=64,$={},Q=0,X=0;function G(t){var e="";do{e=z[t%J]+e,t=Math.floor(t/J)}while(t>0);return e}function Z(){var t=G(+new Date);return t!==K?(Q=0,K=t):t+"."+G(Q++)}for(;X0&&void 0!==arguments[0]?arguments[0]:{};return i(t,{xd:this.xd,cookieJar:this.cookieJar},this.opts),new st(this.uri(),t)}},{key:"doWrite",value:function(t,e){var n=this,r=this.request({method:"POST",data:t});r.on("success",e),r.on("error",(function(t,e){n.onError("xhr post error",t,e)}))}},{key:"doPoll",value:function(){var t=this,e=this.request();e.on("data",this.onData.bind(this)),e.on("error",(function(e,n){t.onError("xhr poll error",e,n)})),this.pollXhr=e}}]),s}(W),st=function(t){o(i,t);var n=l(i);function i(t,r){var o;return e(this,i),H(f(o=n.call(this)),r),o.opts=r,o.method=r.method||"GET",o.uri=t,o.data=void 0!==r.data?r.data:null,o.create(),o}return r(i,[{key:"create",value:function(){var t,e=this,n=F(this.opts,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");n.xdomain=!!this.opts.xd;var r=this.xhr=new nt(n);try{r.open(this.method,this.uri,!0);try{if(this.opts.extraHeaders)for(var o in r.setDisableHeaderCheck&&r.setDisableHeaderCheck(!0),this.opts.extraHeaders)this.opts.extraHeaders.hasOwnProperty(o)&&r.setRequestHeader(o,this.opts.extraHeaders[o])}catch(t){}if("POST"===this.method)try{r.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(t){}try{r.setRequestHeader("Accept","*/*")}catch(t){}null===(t=this.opts.cookieJar)||void 0===t||t.addCookies(r),"withCredentials"in r&&(r.withCredentials=this.opts.withCredentials),this.opts.requestTimeout&&(r.timeout=this.opts.requestTimeout),r.onreadystatechange=function(){var t;3===r.readyState&&(null===(t=e.opts.cookieJar)||void 0===t||t.parseCookies(r)),4===r.readyState&&(200===r.status||1223===r.status?e.onLoad():e.setTimeoutFn((function(){e.onError("number"==typeof r.status?r.status:0)}),0))},r.send(this.data)}catch(t){return void this.setTimeoutFn((function(){e.onError(t)}),0)}"undefined"!=typeof document&&(this.index=i.requestsCount++,i.requests[this.index]=this)}},{key:"onError",value:function(t){this.emitReserved("error",t,this.xhr),this.cleanup(!0)}},{key:"cleanup",value:function(t){if(void 0!==this.xhr&&null!==this.xhr){if(this.xhr.onreadystatechange=rt,t)try{this.xhr.abort()}catch(t){}"undefined"!=typeof document&&delete i.requests[this.index],this.xhr=null}}},{key:"onLoad",value:function(){var t=this.xhr.responseText;null!==t&&(this.emitReserved("data",t),this.emitReserved("success"),this.cleanup())}},{key:"abort",value:function(){this.cleanup()}}]),i}(U);if(st.requestsCount=0,st.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",at);else if("function"==typeof addEventListener){addEventListener("onpagehide"in I?"pagehide":"unload",at,!1)}function at(){for(var t in st.requests)st.requests.hasOwnProperty(t)&&st.requests[t].abort()}var ut="function"==typeof Promise&&"function"==typeof Promise.resolve?function(t){return Promise.resolve().then(t)}:function(t,e){return e(t,0)},ct=I.WebSocket||I.MozWebSocket,ht="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),ft=function(t){o(i,t);var n=l(i);function i(t){var r;return e(this,i),(r=n.call(this,t)).supportsBinary=!t.forceBase64,r}return r(i,[{key:"name",get:function(){return"websocket"}},{key:"doOpen",value:function(){if(this.check()){var t=this.uri(),e=this.opts.protocols,n=ht?{}:F(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(n.headers=this.opts.extraHeaders);try{this.ws=ht?new ct(t,e,n):e?new ct(t,e):new ct(t)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()}}},{key:"addEventListeners",value:function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws._socket.unref(),t.onOpen()},this.ws.onclose=function(e){return t.onClose({description:"websocket connection closed",context:e})},this.ws.onmessage=function(e){return t.onData(e.data)},this.ws.onerror=function(e){return t.onError("websocket error",e)}}},{key:"write",value:function(t){var e=this;this.writable=!1;for(var n=function(){var n=t[r],i=r===t.length-1;A(n,e.supportsBinary,(function(t){try{e.ws.send(t)}catch(t){}i&&ut((function(){e.writable=!0,e.emitReserved("drain")}),e.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){a.enqueue(b);break}i=l*Math.pow(2,32)+f.getUint32(4),r=3}else{if(j(n)t){a.enqueue(b);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=e.readable.pipeThrough(n).getReader(),i=q();i.readable.pipeTo(e.writable),t.writer=i.writable.getWriter();!function e(){r.read().then((function(n){var r=n.done,i=n.value;r||(t.onPacket(i),e())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.writer.write(o).then((function(){return t.onOpen()}))}))})))}},{key:"write",value:function(t){var e=this;this.writable=!1;for(var n=function(){var n=t[r],i=r===t.length-1;e.writer.write(n).then((function(){i&&ut((function(){e.writable=!0,e.emitReserved("drain")}),e.setTimeoutFn)}))},r=0;r1&&void 0!==arguments[1]?arguments[1]:{};return e(this,a),(r=s.call(this)).binaryType="arraybuffer",r.writeBuffer=[],n&&"object"===t(n)&&(o=n,n=null),n?(n=vt(n),o.hostname=n.host,o.secure="https"===n.protocol||"wss"===n.protocol,o.port=n.port,n.query&&(o.query=n.query)):o.host&&(o.hostname=vt(o.host).host),H(f(r),o),r.secure=null!=o.secure?o.secure:"undefined"!=typeof location&&"https:"===location.protocol,o.hostname&&!o.port&&(o.port=r.secure?"443":"80"),r.hostname=o.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=o.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=o.transports||["polling","websocket","webtransport"],r.writeBuffer=[],r.prevBufferLen=0,r.opts=i({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},o),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var e={},n=t.split("&"),r=0,i=n.length;r1))return this.writeBuffer;for(var t,e=1,n=0;n=57344?n+=3:(r++,n+=4);return n}(t):Math.ceil(1.33*(t.byteLength||t.size))),n>0&&e>this.maxPayload)return this.writeBuffer.slice(0,n);e+=2}return this.writeBuffer}},{key:"write",value:function(t,e,n){return this.sendPacket("message",t,e,n),this}},{key:"send",value:function(t,e,n){return this.sendPacket("message",t,e,n),this}},{key:"sendPacket",value:function(t,e,n,r){if("function"==typeof e&&(r=e,e=void 0),"function"==typeof n&&(r=n,n=null),"closing"!==this.readyState&&"closed"!==this.readyState){(n=n||{}).compress=!1!==n.compress;var i={type:t,data:e,options:n};this.emitReserved("packetCreate",i),this.writeBuffer.push(i),r&&this.once("flush",r),this.flush()}}},{key:"close",value:function(){var t=this,e=function(){t.onClose("forced close"),t.transport.close()},n=function n(){t.off("upgrade",n),t.off("upgradeError",n),e()},r=function(){t.once("upgrade",n),t.once("upgradeError",n)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():e()})):this.upgrading?r():e()),this}},{key:"onError",value:function(t){a.priorWebsocketSuccess=!1,this.emitReserved("error",t),this.onClose("transport error",t)}},{key:"onClose",value:function(t,e){"opening"!==this.readyState&&"open"!==this.readyState&&"closing"!==this.readyState||(this.clearTimeoutFn(this.pingTimeoutTimer),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),"function"==typeof removeEventListener&&(removeEventListener("beforeunload",this.beforeunloadEventListener,!1),removeEventListener("offline",this.offlineEventListener,!1)),this.readyState="closed",this.id=null,this.emitReserved("close",t,e),this.writeBuffer=[],this.prevBufferLen=0)}},{key:"filterUpgrades",value:function(t){for(var e=[],n=0,r=t.length;n=0&&e.num1?e-1:0),r=1;r1?n-1:0),i=1;in._opts.retries&&(n._queue.shift(),e&&e(t));else if(n._queue.shift(),e){for(var i=arguments.length,o=new Array(i>1?i-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this._queue.length){var e=this._queue[0];e.pending&&!t||(e.pending=!0,e.tryCount++,this.flags=e.flags,this.emit.apply(this,e.args))}}},{key:"packet",value:function(t){t.nsp=this.nsp,this.io._packet(t)}},{key:"onopen",value:function(){var t=this;"function"==typeof this.auth?this.auth((function(e){t._sendConnectPacket(e)})):this._sendConnectPacket(this.auth)}},{key:"_sendConnectPacket",value:function(t){this.packet({type:Bt.CONNECT,data:this._pid?i({pid:this._pid,offset:this._lastOffset},t):t})}},{key:"onerror",value:function(t){this.connected||this.emitReserved("connect_error",t)}},{key:"onclose",value:function(t,e){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,e)}},{key:"onpacket",value:function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var e=new Error(t.data.message);e.data=t.data.data,this.emitReserved("connect_error",e)}}},{key:"onevent",value:function(t){var e=t.data||[];null!=t.id&&e.push(this.ack(t.id)),this.connected?this.emitEvent(e):this.receiveBuffer.push(Object.freeze(e))}},{key:"emitEvent",value:function(t){if(this._anyListeners&&this._anyListeners.length){var e,n=y(this._anyListeners.slice());try{for(n.s();!(e=n.n()).done;){e.value.apply(this,t)}}catch(t){n.e(t)}finally{n.f()}}p(s(a.prototype),"emit",this).apply(this,t),this._pid&&t.length&&"string"==typeof t[t.length-1]&&(this._lastOffset=t[t.length-1])}},{key:"ack",value:function(t){var e=this,n=!1;return function(){if(!n){n=!0;for(var r=arguments.length,i=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}It.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var e=Math.random(),n=Math.floor(e*this.jitter*t);t=0==(1&Math.floor(10*e))?t-n:t+n}return 0|Math.min(t,this.max)},It.prototype.reset=function(){this.attempts=0},It.prototype.setMin=function(t){this.ms=t},It.prototype.setMax=function(t){this.max=t},It.prototype.setJitter=function(t){this.jitter=t};var Ft=function(n){o(s,n);var i=l(s);function s(n,r){var o,a;e(this,s),(o=i.call(this)).nsps={},o.subs=[],n&&"object"===t(n)&&(r=n,n=void 0),(r=r||{}).path=r.path||"/socket.io",o.opts=r,H(f(o),r),o.reconnection(!1!==r.reconnection),o.reconnectionAttempts(r.reconnectionAttempts||1/0),o.reconnectionDelay(r.reconnectionDelay||1e3),o.reconnectionDelayMax(r.reconnectionDelayMax||5e3),o.randomizationFactor(null!==(a=r.randomizationFactor)&&void 0!==a?a:.5),o.backoff=new It({min:o.reconnectionDelay(),max:o.reconnectionDelayMax(),jitter:o.randomizationFactor()}),o.timeout(null==r.timeout?2e4:r.timeout),o._readyState="closed",o.uri=n;var u=r.parser||qt;return o.encoder=new u.Encoder,o.decoder=new u.Decoder,o._autoConnect=!1!==r.autoConnect,o._autoConnect&&o.open(),o}return r(s,[{key:"reconnection",value:function(t){return arguments.length?(this._reconnection=!!t,this):this._reconnection}},{key:"reconnectionAttempts",value:function(t){return void 0===t?this._reconnectionAttempts:(this._reconnectionAttempts=t,this)}},{key:"reconnectionDelay",value:function(t){var e;return void 0===t?this._reconnectionDelay:(this._reconnectionDelay=t,null===(e=this.backoff)||void 0===e||e.setMin(t),this)}},{key:"randomizationFactor",value:function(t){var e;return void 0===t?this._randomizationFactor:(this._randomizationFactor=t,null===(e=this.backoff)||void 0===e||e.setJitter(t),this)}},{key:"reconnectionDelayMax",value:function(t){var e;return void 0===t?this._reconnectionDelayMax:(this._reconnectionDelayMax=t,null===(e=this.backoff)||void 0===e||e.setMax(t),this)}},{key:"timeout",value:function(t){return arguments.length?(this._timeout=t,this):this._timeout}},{key:"maybeReconnectOnOpen",value:function(){!this._reconnecting&&this._reconnection&&0===this.backoff.attempts&&this.reconnect()}},{key:"open",value:function(t){var e=this;if(~this._readyState.indexOf("open"))return this;this.engine=new gt(this.uri,this.opts);var n=this.engine,r=this;this._readyState="opening",this.skipReconnect=!1;var i=jt(n,"open",(function(){r.onopen(),t&&t()})),o=function(n){e.cleanup(),e._readyState="closed",e.emitReserved("error",n),t?t(n):e.maybeReconnectOnOpen()},s=jt(n,"error",o);if(!1!==this._timeout){var a=this._timeout,u=this.setTimeoutFn((function(){i(),o(new Error("timeout")),n.close()}),a);this.opts.autoUnref&&u.unref(),this.subs.push((function(){e.clearTimeoutFn(u)}))}return this.subs.push(i),this.subs.push(s),this}},{key:"connect",value:function(t){return this.open(t)}},{key:"onopen",value:function(){this.cleanup(),this._readyState="open",this.emitReserved("open");var t=this.engine;this.subs.push(jt(t,"ping",this.onping.bind(this)),jt(t,"data",this.ondata.bind(this)),jt(t,"error",this.onerror.bind(this)),jt(t,"close",this.onclose.bind(this)),jt(this.decoder,"decoded",this.ondecoded.bind(this)))}},{key:"onping",value:function(){this.emitReserved("ping")}},{key:"ondata",value:function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}}},{key:"ondecoded",value:function(t){var e=this;ut((function(){e.emitReserved("packet",t)}),this.setTimeoutFn)}},{key:"onerror",value:function(t){this.emitReserved("error",t)}},{key:"socket",value:function(t,e){var n=this.nsps[t];return n?this._autoConnect&&!n.active&&n.connect():(n=new Ut(this,t,e),this.nsps[t]=n),n}},{key:"_destroy",value:function(t){for(var e=0,n=Object.keys(this.nsps);e=this._reconnectionAttempts)this.backoff.reset(),this.emitReserved("reconnect_failed"),this._reconnecting=!1;else{var n=this.backoff.duration();this._reconnecting=!0;var r=this.setTimeoutFn((function(){e.skipReconnect||(t.emitReserved("reconnect_attempt",e.backoff.attempts),e.skipReconnect||e.open((function(n){n?(e._reconnecting=!1,e.reconnect(),t.emitReserved("reconnect_error",n)):e.onreconnect()})))}),n);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}}},{key:"onreconnect",value:function(){var t=this.backoff.attempts;this._reconnecting=!1,this.backoff.reset(),this.emitReserved("reconnect",t)}}]),s}(U),Mt={};function Vt(e,n){"object"===t(e)&&(n=e,e=void 0);var r,i=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2?arguments[2]:void 0,r=t;n=n||"undefined"!=typeof location&&location,null==t&&(t=n.protocol+"//"+n.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?n.protocol+t:n.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==n?n.protocol+"//"+t:"https://"+t),r=vt(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var i=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+i+":"+r.port+e,r.href=r.protocol+"://"+i+(n&&n.port===r.port?"":":"+r.port),r}(e,(n=n||{}).path||"/socket.io"),o=i.source,s=i.id,a=i.path,u=Mt[s]&&a in Mt[s].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?r=new Ft(o,n):(Mt[s]||(Mt[s]=new Ft(o,n)),r=Mt[s]),i.query&&!n.query&&(n.query=i.queryKey),r.socket(i.path,n)}return i(Vt,{Manager:Ft,Socket:Ut,io:Vt,connect:Vt}),Vt})); +//# sourceMappingURL=socket.io.min.js.map diff --git a/app/templates/base.html b/app/templates/base.html index 4da61eb..d56d8f0 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -109,6 +109,13 @@
Configuration
+ + + + + + + + + + + + + + + diff --git a/app/templates/index.html b/app/templates/index.html index 2fab65b..a940a88 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -43,7 +43,8 @@ /* Modal fullscreen - remove all margins and padding */ #dmModal .modal-dialog.modal-fullscreen, - #contactsModal .modal-dialog.modal-fullscreen { + #contactsModal .modal-dialog.modal-fullscreen, + #consoleModal .modal-dialog.modal-fullscreen { margin: 0 !important; width: 100vw !important; max-width: 100vw !important; @@ -52,14 +53,16 @@ } #dmModal .modal-content, - #contactsModal .modal-content { + #contactsModal .modal-content, + #consoleModal .modal-content { border: none !important; border-radius: 0 !important; height: 100vh !important; } #dmModal .modal-body, - #contactsModal .modal-body { + #contactsModal .modal-body, + #consoleModal .modal-body { overflow: hidden !important; } @@ -171,6 +174,23 @@ + + + {% endblock %} {% block extra_scripts %} @@ -232,6 +252,17 @@ } }); } + + // Console modal - reload iframe when opened to reset WebSocket connection + const consoleModal = document.getElementById('consoleModal'); + if (consoleModal) { + consoleModal.addEventListener('show.bs.modal', function () { + const consoleFrame = document.getElementById('consoleFrame'); + if (consoleFrame) { + consoleFrame.src = '/console'; + } + }); + } }); {% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index 80a18e6..4e374af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: restart: unless-stopped devices: - "${MC_SERIAL_PORT}:${MC_SERIAL_PORT}" + ports: + - "5001:5001" # Expose for WebSocket console access volumes: - "${MC_CONFIG_DIR}:/root/.config/meshcore:rw" environment: diff --git a/meshcore-bridge/bridge.py b/meshcore-bridge/bridge.py index ae967ca..9f2a0ad 100644 --- a/meshcore-bridge/bridge.py +++ b/meshcore-bridge/bridge.py @@ -18,8 +18,10 @@ import time import json import queue import uuid +import shlex from pathlib import Path from flask import Flask, request, jsonify +from flask_socketio import SocketIO, emit logging.basicConfig( level=logging.INFO, @@ -29,6 +31,9 @@ logger = logging.getLogger(__name__) app = Flask(__name__) +# Initialize SocketIO with gevent for async support +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='gevent') + # Configuration MC_SERIAL_PORT = os.getenv('MC_SERIAL_PORT', '/dev/ttyUSB0') MC_CONFIG_DIR = os.getenv('MC_CONFIG_DIR', '/config') @@ -275,6 +280,18 @@ class MeshCLISession: logger.info(f"Command [{cmd_id}] completed (timeout-based)") response_dict["done"] = True event.set() + + # If this is a WebSocket command, emit response to that client + if cmd_id.startswith("ws_"): + socket_id = response_dict.get("socket_id") + if socket_id: + output = '\n'.join(response_dict.get("response", [])) + socketio.emit('command_response', { + 'success': True, + 'output': output, + 'cmd_id': cmd_id + }, room=socket_id, namespace='/console') + if self.current_cmd_id == cmd_id: self.current_cmd_id = None return @@ -429,6 +446,73 @@ class MeshCLISession: 'returncode': 0 } + def execute_ws_command(self, command_text, socket_id, timeout=DEFAULT_TIMEOUT): + """ + Execute a CLI command from WebSocket client. + + The response will be emitted via socketio.emit in _monitor_response_timeout. + + Args: + command_text: Raw command string from user + socket_id: WebSocket session ID for response routing + timeout: Max time to wait for response + + Returns: + Dict with success status (response already emitted via WebSocket) + """ + cmd_id = f"ws_{uuid.uuid4().hex[:8]}" + + # Parse command into args (respects quotes) + try: + args = shlex.split(command_text) + except ValueError: + args = command_text.split() + + # Build command line - use double quotes for args with spaces/special chars + quoted_args = [] + for arg in args: + if ' ' in arg or '"' in arg or "'" in arg: + escaped = arg.replace('"', '\\"') + quoted_args.append(f'"{escaped}"') + else: + quoted_args.append(arg) + + command = ' '.join(quoted_args) + event = threading.Event() + response_dict = { + "event": event, + "response": [], + "done": False, + "error": None, + "last_line_time": time.time(), + "socket_id": socket_id # Track which WebSocket client sent this + } + + # Queue command + self.command_queue.put((cmd_id, command, event, response_dict)) + logger.info(f"WebSocket command [{cmd_id}] queued: {command}") + + # Wait for completion + if not event.wait(timeout): + logger.error(f"WebSocket command [{cmd_id}] timeout after {timeout}s") + + # Cleanup + with self.pending_lock: + if cmd_id in self.pending_commands: + del self.pending_commands[cmd_id] + + # Emit error to client + socketio.emit('command_response', { + 'success': False, + 'error': f'Command timeout after {timeout} seconds', + 'cmd_id': cmd_id + }, room=socket_id, namespace='/console') + + return {'success': False, 'error': f'Command timeout after {timeout}s'} + + # Response already emitted in _monitor_response_timeout + return {'success': True} + def shutdown(self): """Gracefully shutdown session""" logger.info("Shutting down meshcli session") @@ -789,6 +873,43 @@ def set_manual_add_contacts(): }), 500 +# ============================================================================= +# WebSocket handlers for console +# ============================================================================= + +@socketio.on('connect', namespace='/console') +def console_connect(): + """Handle console client connection""" + logger.info(f"Console client connected: {request.sid}") + emit('console_status', {'status': 'connected', 'message': 'Connected to meshcli'}) + + +@socketio.on('disconnect', namespace='/console') +def console_disconnect(): + """Handle console client disconnection""" + logger.info(f"Console client disconnected: {request.sid}") + + +@socketio.on('send_command', namespace='/console') +def handle_console_command(data): + """Handle command from console client""" + if not meshcli_session or not meshcli_session.process: + emit('command_response', {'success': False, 'error': 'meshcli session not available'}) + return + + command_text = data.get('command', '').strip() + if not command_text: + return + + logger.info(f"Console command from {request.sid}: {command_text}") + + # Execute command asynchronously using socketio background task + def execute_async(): + meshcli_session.execute_ws_command(command_text, request.sid) + + socketio.start_background_task(execute_async) + + if __name__ == '__main__': logger.info(f"Starting MeshCore Bridge on port 5001") logger.info(f"Serial port: {MC_SERIAL_PORT}") @@ -807,5 +928,5 @@ if __name__ == '__main__': logger.error(f"Failed to initialize meshcli session: {e}") logger.error("Bridge will start but /cli endpoint will be unavailable") - # Run on all interfaces to allow Docker network access - app.run(host='0.0.0.0', port=5001, debug=False) + # Run with SocketIO (supports WebSocket) on all interfaces + socketio.run(app, host='0.0.0.0', port=5001, debug=False) diff --git a/meshcore-bridge/requirements.txt b/meshcore-bridge/requirements.txt index 00a8d24..646a5d5 100644 --- a/meshcore-bridge/requirements.txt +++ b/meshcore-bridge/requirements.txt @@ -1,3 +1,10 @@ # MeshCore Bridge - Minimal dependencies Flask==3.0.0 Werkzeug==3.0.1 + +# WebSocket support for console +flask-socketio==5.3.6 +python-socketio==5.10.0 +python-engineio==4.8.1 +gevent==23.9.1 +gevent-websocket==0.10.1