diff --git a/app/device_manager.py b/app/device_manager.py index 16c9979..c2b143b 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -2784,15 +2784,15 @@ class DeviceManager: logger.error(f"discover_path failed: {e}") return {'success': False, 'error': str(e)} - def change_path(self, name_or_key: str, path: str) -> Dict: - """Change the path to a contact.""" + def change_path(self, name_or_key: str, path: str, hash_size: int = 1) -> Dict: + """Change the path to a contact. hash_size: 1/2/3 bytes per hop.""" if not self.is_connected: return {'success': False, 'error': 'Device not connected'} contact = self.resolve_contact(name_or_key) if not contact: return {'success': False, 'error': f"Contact not found: {name_or_key}"} try: - self.execute(self.mc.commands.change_contact_path(contact, path), timeout=10) + self.execute(self._change_path_async(contact, path, hash_size=hash_size), timeout=10) return {'success': True, 'message': f'Path changed for {contact.get("adv_name", name_or_key)}'} except Exception as e: logger.error(f"change_path failed: {e}") diff --git a/app/main.py b/app/main.py index 7194a94..614f4bd 100644 --- a/app/main.py +++ b/app/main.py @@ -372,7 +372,6 @@ def handle_chat_disconnect(): def handle_console_connect(): """Handle console WebSocket connection""" logger.info("Console WebSocket client connected") - emit('console_status', {'message': 'Connected to mc-webui console'}) @socketio.on('disconnect', namespace='/console') @@ -494,7 +493,7 @@ def _execute_console_command(args: list) -> str: meaningful = raw[:hop_count * hash_size * 2] chunk = hash_size * 2 hops = [meaningful[i:i+chunk].upper() for i in range(0, len(meaningful), chunk)] - path_str = '→'.join(hops) if hops else f'len:{opl}' + path_str = ','.join(hops) if hops else f'len:{opl}' elif opl == 0: path_str = 'Direct' else: @@ -814,7 +813,7 @@ def _execute_console_command(args: list) -> str: meaningful = raw[:hop_count * hash_size * 2] chunk = hash_size * 2 hops = [meaningful[i:i+chunk].upper() for i in range(0, len(meaningful), chunk)] - path_str = ' → '.join(hops) if hops else f'len:{opl}' + path_str = ','.join(hops) if hops else f'len:{opl}' return f"Path to {name}: {path_str} ({hop_count} hops)" elif opl == 0: return f"Path to {name}: Direct" @@ -845,14 +844,36 @@ def _execute_console_command(args: list) -> str: elif cmd == 'change_path' and len(args) >= 3: name = args[1] - path = args[2] - result = device_manager.change_path(name, path) + # Recombine remaining args so space-separated input ("d1 90 05 54") works + raw = ' '.join(args[2:]) + if ',' in raw: + chunks = [c.strip() for c in raw.split(',') if c.strip()] + first_len = len(chunks[0]) if chunks else 0 + if first_len in (2, 4, 6): + hash_size = first_len // 2 + else: + return "Error: hop must be 1, 2, or 3 bytes (2/4/6 hex chars)" + path_hex = ''.join(chunks) + else: + path_hex = raw.replace(' ', '').replace('→', '').replace('->', '') + hash_size = 1 + try: + bytes.fromhex(path_hex) + except ValueError: + return f"Error: invalid hex in path: {raw}" + if not path_hex or len(path_hex) % (hash_size * 2) != 0: + return f"Error: path length not aligned to {hash_size}-byte hops" + result = device_manager.change_path(name, path_hex, hash_size=hash_size) if result.get('success'): return result.get('message', 'OK') return f"Error: {result.get('error')}" elif cmd == 'change_path': - return "Usage: change_path \n Path: hex string, e.g. 6a61" + return ("Usage: change_path \n" + " hops: comma-separated hex, e.g. d1,90,05,54 (1-byte hops)\n" + " 5e34,d1ac (2-byte hops)\n" + " 5e346e,d1ac2c (3-byte hops)\n" + " Spaces or continuous hex also accepted.") elif cmd == 'advert_path' and len(args) >= 2: name = ' '.join(args[1:]) @@ -1253,7 +1274,7 @@ def _execute_console_command(args: list) -> str: " path — Show path to contact\n" " disc_path — Discover new path\n" " reset_path — Reset path to flood\n" - " change_path

— Change path to contact\n" + " change_path — Change path (e.g. d1,90,05,54 or 5e34,d1ac)\n" " advert_path — Get path from advert\n" " share_contact — Share contact with mesh\n" " export_contact — Export contact URI\n" diff --git a/app/routes/api.py b/app/routes/api.py index 8c4d629..e83d887 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -4869,6 +4869,94 @@ def clear_console_history(): }), 500 +# ============================================================ +# Console Output History API (persistent transcript) +# ============================================================ + +CONSOLE_OUTPUT_FILE = 'console_output_history.json' +CONSOLE_OUTPUT_MAX_ENTRIES = 500 +CONSOLE_OUTPUT_ALLOWED_TYPES = {'command', 'response', 'error', 'system'} + + +def _get_console_output_path(): + return Path(config.MC_CONFIG_DIR) / CONSOLE_OUTPUT_FILE + + +def _load_console_output(): + path = _get_console_output_path() + try: + if path.exists(): + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get('entries', []) + except Exception as e: + logger.error(f"Error loading console output history: {e}") + return [] + + +def _save_console_output(entries): + path = _get_console_output_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w', encoding='utf-8') as f: + json.dump({'entries': entries}, f, ensure_ascii=False) + return True + except Exception as e: + logger.error(f"Error saving console output history: {e}") + return False + + +@api_bp.route('/console/output', methods=['GET']) +def get_console_output(): + """Get persisted console output transcript.""" + try: + return jsonify({'success': True, 'entries': _load_console_output()}), 200 + except Exception as e: + logger.error(f"Error getting console output: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/console/output', methods=['POST']) +def add_console_output(): + """Append an entry to the console output transcript.""" + try: + data = request.get_json() or {} + entry_type = data.get('type', 'system') + text = data.get('text', '') + if entry_type not in CONSOLE_OUTPUT_ALLOWED_TYPES: + return jsonify({'success': False, 'error': 'Invalid type'}), 400 + if not isinstance(text, str) or not text: + return jsonify({'success': False, 'error': 'Empty text'}), 400 + + entries = _load_console_output() + entries.append({ + 'ts': datetime.now().isoformat(timespec='seconds'), + 'type': entry_type, + 'text': text, + }) + if len(entries) > CONSOLE_OUTPUT_MAX_ENTRIES: + entries = entries[-CONSOLE_OUTPUT_MAX_ENTRIES:] + + if _save_console_output(entries): + return jsonify({'success': True}), 200 + return jsonify({'success': False, 'error': 'Failed to save'}), 500 + except Exception as e: + logger.error(f"Error adding console output: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/console/output', methods=['DELETE']) +def clear_console_output(): + """Clear persisted console output transcript.""" + try: + if _save_console_output([]): + return jsonify({'success': True, 'message': 'Output cleared'}), 200 + return jsonify({'success': False, 'error': 'Failed to clear'}), 500 + except Exception as e: + logger.error(f"Error clearing console output: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + # ============================================================================= # Backup Endpoints # ============================================================================= diff --git a/app/static/js/console.js b/app/static/js/console.js index b33fef9..a12ef47 100644 --- a/app/static/js/console.js +++ b/app/static/js/console.js @@ -14,12 +14,14 @@ let historyIndex = -1; let pendingCommandDiv = null; // Initialize on page load -document.addEventListener('DOMContentLoaded', function() { +document.addEventListener('DOMContentLoaded', async function() { console.log('Console page initialized'); loadServerHistory(); + await loadOutputHistory(); connectWebSocket(); setupInputHandlers(); setupHistoryDropdown(); + setupClearOutputButton(); }); /** @@ -49,7 +51,6 @@ function connectWebSocket() { isConnected = true; updateStatus('connected'); enableInput(true); - addMessage('Connected to meshcli', 'system'); }); socket.on('disconnect', (reason) => { @@ -57,7 +58,7 @@ function connectWebSocket() { isConnected = false; updateStatus('disconnected'); enableInput(false); - addMessage('Disconnected from meshcli', 'error'); + addMessage('Disconnected', 'error'); // Clear pending command indicator if (pendingCommandDiv) { @@ -200,14 +201,20 @@ function navigateHistory(direction) { * Add message to console display * @param {string} text Message text * @param {string} type Message type: 'command', 'response', 'error', 'system' + * (may include extra modifier classes like 'command pending') + * @param {boolean} persist Whether to save to the persistent transcript (default true) * @returns {HTMLElement} The created message div */ -function addMessage(text, type) { +function addMessage(text, type, persist = true) { const container = document.getElementById('consoleMessages'); const div = document.createElement('div'); div.className = `console-message ${type}`; div.textContent = text; container.appendChild(div); + if (persist) { + const baseType = (type || '').split(' ')[0]; + saveOutputEntry(baseType, text); + } return div; } @@ -421,3 +428,77 @@ function selectHistoryItem(command) { historyMenu.classList.remove('show'); } } + + +// ============================================================ +// Persistent console output transcript +// ============================================================ + +/** + * Load persisted output entries and render them as historic (faded) items, + * followed by a divider marking the start of the current session. + */ +async function loadOutputHistory() { + try { + const response = await fetch('/api/console/output'); + const data = await response.json(); + if (!data.success || !Array.isArray(data.entries) || data.entries.length === 0) { + return; + } + const container = document.getElementById('consoleMessages'); + if (!container) return; + for (const entry of data.entries) { + const div = document.createElement('div'); + div.className = `console-message ${entry.type} historic`; + div.textContent = entry.text; + container.appendChild(div); + } + const divider = document.createElement('hr'); + divider.className = 'history-divider'; + container.appendChild(divider); + } catch (error) { + console.error('Failed to load output history:', error); + } +} + +/** + * POST a single transcript entry to the server (fire-and-forget). + */ +function saveOutputEntry(type, text) { + try { + fetch('/api/console/output', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, text }) + }).catch(err => console.error('Failed to persist output entry:', err)); + } catch (error) { + console.error('Failed to persist output entry:', error); + } +} + +/** + * Clear the persisted transcript and the current display. + */ +async function clearOutputHistory() { + try { + await fetch('/api/console/output', { method: 'DELETE' }); + } catch (error) { + console.error('Failed to clear output history:', error); + } + const container = document.getElementById('consoleMessages'); + if (container) { + container.innerHTML = ''; + } +} + +/** + * Wire the trash button next to the history dropdown. + */ +function setupClearOutputButton() { + const btn = document.getElementById('clearOutputBtn'); + if (!btn) return; + btn.addEventListener('click', (e) => { + e.preventDefault(); + clearOutputHistory(); + }); +} diff --git a/app/templates/console.html b/app/templates/console.html index 05ebf56..a7ea2c0 100644 --- a/app/templates/console.html +++ b/app/templates/console.html @@ -39,6 +39,11 @@ flex-shrink: 0; } + .device-name { + color: #4ecdc4; + font-weight: 500; + } + .console-messages { flex: 1; overflow-y: auto; @@ -81,6 +86,16 @@ font-style: italic; } + .console-message.historic { + opacity: 0.55; + } + + .history-divider { + border: none; + border-top: 1px dashed #0f3460; + margin: 0.5rem 0 1rem 0; + } + .console-input-area { background-color: #16213e; border-top: 1px solid #0f3460; @@ -254,7 +269,7 @@

- {{ device_name }} + {{ device_name }}
Connecting... @@ -263,10 +278,7 @@
-
- Type a meshcli command and press Enter. - Examples: infos, contacts, help -
+
Type 'help' for available commands.
@@ -281,6 +293,10 @@
No commands in history
+ +