diff --git a/.idea/deployment.xml b/.idea/deployment.xml index 2c3038e1..db577955 100644 --- a/.idea/deployment.xml +++ b/.idea/deployment.xml @@ -6,7 +6,7 @@ - + diff --git a/pwnagotchi/defaults.toml b/pwnagotchi/defaults.toml index 13b0b0ec..b8807a63 100644 --- a/pwnagotchi/defaults.toml +++ b/pwnagotchi/defaults.toml @@ -222,6 +222,12 @@ origin = "" port = 8080 on_frame = "" +[ui.web.theme] +# Theme customization for the web interface. Use RGB values for the accent color. +accent_r = 76 +accent_g = 175 +accent_b = 80 + [ui.display] enabled = false rotation = 180 @@ -262,4 +268,4 @@ mount = "/var/tmp/pwnagotchi" size = "10M" sync = 3600 zram = true -rsync = true +rsync = true \ No newline at end of file diff --git a/pwnagotchi/plugins/default/auto_backup.py b/pwnagotchi/plugins/default/auto_backup.py index 2ed124e5..f98e56e0 100644 --- a/pwnagotchi/plugins/default/auto_backup.py +++ b/pwnagotchi/plugins/default/auto_backup.py @@ -5,148 +5,390 @@ import os import subprocess import time import socket +import threading +import glob +from flask import render_template_string + class AutoBackup(plugins.Plugin): - __author__ = 'WPA2' - __version__ = '1.1.3' - __license__ = 'GPL3' - __description__ = 'Backs up files when internet is available, with support for excludes.' + __author__ = "WPA2" + __version__ = "2.2" + __license__ = "GPL3" + __description__ = ( + "Backs up Pwnagotchi configuration and data, keeping recent backups." + ) + + # Hardcoded defaults for Pwnagotchi + DEFAULT_FILES = [ + "/root/settings.yaml", + "/root/client_secrets.json", + "/root/.api-report.json", + "/root/.ssh", + "/root/.bashrc", + "/root/.profile", + "/root/peers", + "/etc/pwnagotchi/", + "/usr/local/share/pwnagotchi/custom-plugins", + "/etc/ssh/", + "/home/pi/handshakes/", + "/home/pi/.bashrc", + "/home/pi/.profile", + "/home/pi/.wpa_sec_uploads", + ] + + DEFAULT_INTERVAL_SECONDS = 60 * 60 # 60 minutes + DEFAULT_MAX_BACKUPS = 3 + DEFAULT_EXCLUDE = [ + "/etc/pwnagotchi/logs/*", + "*.bak", + "*.tmp", + ] def __init__(self): self.ready = False self.tries = 0 - # Used to throttle repeated log messages for "backup not due yet" self.last_not_due_logged = 0 - # Store the status file path separately. - self.status_file = '/root/.auto-backup' + self.status_file = "/root/.auto-backup" self.status = StatusFile(self.status_file) + self.lock = threading.Lock() + self.backup_in_progress = False + self.hostname = socket.gethostname() + self._agent = None def on_loaded(self): - required_options = ['files', 'interval', 'backup_location', 'max_tries'] - for opt in required_options: - if opt not in self.options or self.options[opt] is None: - logging.error(f"AUTO-BACKUP: Option '{opt}' is not set.") - return + """Validate only required option: backup_location""" + if ( + "backup_location" not in self.options + or self.options["backup_location"] is None + ): + logging.error("AUTO-BACKUP: Option 'backup_location' is not set.") + return - # If no custom command(s) are provided, use the default plain tar command. - # The command includes a placeholder for {excludes} so that if no excludes are set, it will be empty. - if 'commands' not in self.options or not self.options['commands']: - self.options['commands'] = ["tar cf {backup_file} {excludes} {files}"] - self.ready = True - logging.info("AUTO-BACKUP: Successfully loaded.") + self.hostname = socket.gethostname() - def get_interval_seconds(self): - """ - Convert the interval option into seconds. - Supports: - - "daily" for 24 hours, - - "hourly" for 60 minutes, - - or a numeric value (interpreted as minutes). - """ - interval = self.options['interval'] - if isinstance(interval, str): - if interval.lower() == "daily": - return 24 * 60 * 60 - elif interval.lower() == "hourly": - return 60 * 60 - else: - try: - minutes = float(interval) - return minutes * 60 - except ValueError: - logging.error("AUTO-BACKUP: Invalid interval format. Defaulting to daily interval.") - return 24 * 60 * 60 - elif isinstance(interval, (int, float)): - return float(interval) * 60 + # Read config with internal defaults - DO NOT modify self.options + self.files = self.options.get("files", self.DEFAULT_FILES) + self.interval_seconds = self.options.get( + "interval_seconds", self.DEFAULT_INTERVAL_SECONDS + ) + self.max_backups = self.options.get( + "max_backups_to_keep", self.DEFAULT_MAX_BACKUPS + ) + self.exclude = self.options.get("exclude", self.DEFAULT_EXCLUDE) + self.include = self.options.get("include", []) + + # Handle commands: if old format, use correct default internally + commands = self.options.get("commands", ["tar", "czf"]) + if isinstance(commands, str) or ( + isinstance(commands, list) + and len(commands) == 1 + and isinstance(commands[0], str) + and "{" in str(commands) + ): + logging.warning( + "AUTO-BACKUP: Old command format detected in config, using default: tar czf" + ) + self.commands = ["tar", "czf"] + elif not commands: + self.commands = ["tar", "czf"] else: - logging.error("AUTO-BACKUP: Unrecognized type for interval. Defaulting to daily interval.") - return 24 * 60 * 60 + self.commands = commands + + # Validate include paths if specified + if self.include: + if not isinstance(self.include, list): + self.include = [self.include] + + for path in self.include: + if not os.path.exists(path): + logging.warning( + f"AUTO-BACKUP: include path '{path}' does not exist, will skip if still missing at backup time" + ) + + self.ready = True + include_msg = ( + f", includes: {len(self.include)} additional path(s)" + if self.include + else "" + ) + logging.info( + f"AUTO-BACKUP: Plugin loaded for host '{self.hostname}'. Interval: 60min, Backups kept: {self.max_backups}{include_msg}" + ) def is_backup_due(self): - """ - Determines if enough time has passed since the last backup. - If the status file does not exist, a backup is due. - """ - interval_sec = self.get_interval_seconds() + """Check if backup is due based on interval.""" try: last_backup = os.path.getmtime(self.status_file) except OSError: - # Status file doesn't existβ€”backup is due. return True - now = time.time() - return (now - last_backup) >= interval_sec + return (time.time() - last_backup) >= self.interval_seconds - def on_internet_available(self, agent): + def _cleanup_old_backups(self): + """Deletes the oldest backups if we exceed the limit.""" + try: + backup_dir = self.options["backup_location"] + max_keep = self.max_backups + + # Filter by this device's hostname + search_pattern = os.path.join( + backup_dir, f"{self.hostname}-backup-*.tar.gz" + ) + files = glob.glob(search_pattern) + + if not files: + logging.debug("AUTO-BACKUP: No backup files found for cleanup") + return + + # Sort files by modification time (oldest first) + files.sort(key=os.path.getmtime) + + # Calculate how many to delete + if len(files) > max_keep: + num_to_delete = len(files) - max_keep + logging.info( + f"AUTO-BACKUP: Found {len(files)} backups, keeping {max_keep}, deleting {num_to_delete} old backup(s)..." + ) + + for old_file in files[:num_to_delete]: + try: + os.remove(old_file) + logging.info( + f"AUTO-BACKUP: Deleted: {os.path.basename(old_file)}" + ) + except OSError as e: + logging.error(f"AUTO-BACKUP: Failed to delete {old_file}: {e}") + + except Exception as e: + logging.error(f"AUTO-BACKUP: Cleanup error: {e}") + + def _run_backup_thread(self, agent, existing_files): + """Execute backup in separate thread.""" + try: + backup_location = self.options["backup_location"] + + # Create backup directory if it doesn't exist + if not os.path.exists(backup_location): + try: + os.makedirs(backup_location) + logging.info( + f"AUTO-BACKUP: Created backup directory: {backup_location}" + ) + except OSError as e: + logging.error( + f"AUTO-BACKUP: Failed to create backup directory: {e}" + ) + return + + # Add timestamp to filename + timestamp = time.strftime("%Y%m%d-%H%M%S") + backup_file = os.path.join( + backup_location, f"{self.hostname}-backup-{timestamp}.tar.gz" + ) + + # Try to update display if agent is available + if agent: + try: + display = agent.view() + display.set("status", "Backing up...") + display.update() + except: + pass + + logging.info(f"AUTO-BACKUP: Starting backup to {backup_file}...") + + # Build command + command_list = list(self.commands) + command_list.append(backup_file) + + # Add exclusions + for pattern in self.exclude: + command_list.append(f"--exclude={pattern}") + + # Add files to backup + command_list.extend(existing_files) + + # Execute backup command + process = subprocess.Popen( + command_list, + shell=False, + stdin=None, + stdout=open("/dev/null", "w"), + stderr=subprocess.PIPE, + ) + _, stderr_output = process.communicate() + + if process.returncode != 0: + raise OSError( + f"Backup command failed with code {process.returncode}: {stderr_output.decode('utf-8').strip()}" + ) + + logging.info(f"AUTO-BACKUP: Backup successful: {backup_file}") + + # Run cleanup after successful backup + self._cleanup_old_backups() + + # Try to update display if agent is available + if agent: + try: + display = agent.view() + display.set("status", "Backup done!") + display.update() + except: + pass + + # Update status file timestamp + self.status.update() + + # Reset try counter on success + self.tries = 0 + + except Exception as e: + self.tries += 1 + logging.error(f"AUTO-BACKUP: Backup error (attempt {self.tries}): {e}") + finally: + self.backup_in_progress = False + + def on_ready(self, agent): + """Called when Pwnagotchi is ready. Set up backup scheduler.""" if not self.ready: return - if self.options['max_tries'] and self.tries >= self.options['max_tries']: - logging.info("AUTO-BACKUP: Maximum tries reached, skipping backup.") + self._agent = agent + + # Start background scheduler thread + scheduler_thread = threading.Thread( + target=self._backup_scheduler_loop, daemon=True, name="AutoBackupScheduler" + ) + scheduler_thread.start() + + logging.info("AUTO-BACKUP: Periodic backup scheduler started") + + def on_webhook(self, path, request): + """Handle web UI requests.""" + if request.method == "GET": + if path == "/" or not path: + action_path = ( + request.path + if request.path.endswith("/backup") + else "%s/backup" % request.path + ) + ret = 'AUTO Backup' + ret += "

AUTO Backup

" + ret += "

Status: " + if self.backup_in_progress: + ret += "Backup in progress..." + else: + ret += "Ready" + ret += "

" + ret += '
' % action_path + ret += '' + ret += '' + ret += "
" + ret += "
" + ret += "

Configuration

" + ret += '' + ret += ( + "" + ) + ret += ( + "" + ) + ret += ( + "" + ) + ret += ( + "" + ) + ret += "
Backup Location:" + + self.options.get("backup_location", "Not set") + + "
Interval:" + + str(self.interval_seconds // 60) + + " minutes
Max Backups:" + + str(self.max_backups) + + "
Include Paths:" + + (", ".join(self.include) if self.include else "None") + + "
" + ret += "" + return render_template_string(ret) + + elif request.method == "POST": + if path == "backup" or path == "/backup": + result = self.manual_backup(self._agent) + ret = 'AUTO Backup' + ret += "

AUTO Backup

" + ret += "

" + result["status"] + "

" + ret += 'Back' + ret += "" + return render_template_string(ret) + + return "Not found" + + def _backup_scheduler_loop(self): + """Background thread that checks if backup is due every minute.""" + while True: + try: + if self.ready: + agent = getattr(self, "_agent", None) + self._periodic_backup_check(agent) + time.sleep(60) + except Exception as e: + logging.error(f"AUTO-BACKUP: Scheduler error: {e}") + + def _get_backup_files(self): + """Collect all files to backup.""" + existing_files = list(filter(os.path.exists, self.files)) + if self.include: + for path in self.include: + if os.path.exists(path): + existing_files.append(path) + logging.debug(f"AUTO-BACKUP: Added include path: {path}") + return existing_files + + def _periodic_backup_check(self, agent=None): + """Periodic backup check.""" + if agent is None: + agent = getattr(self, "_agent", None) + + if not self.ready or self.backup_in_progress: + return + + if self.tries >= 3: return if not self.is_backup_due(): - now = time.time() - # Log "backup not due" only once every 600 seconds. - if now - self.last_not_due_logged > 600: - logging.info("AUTO-BACKUP: Backup not due yet based on the interval.") - self.last_not_due_logged = now return - # Only include files/directories that exist to prevent errors. - existing_files = list(filter(lambda f: os.path.exists(f), self.options['files'])) + existing_files = self._get_backup_files() if not existing_files: - logging.warning("AUTO-BACKUP: No files found to backup.") + logging.warning("AUTO-BACKUP: No files to backup exist") return - files_to_backup = " ".join(existing_files) - # Build excludes string if configured. - # Use get() so that if 'exclude' is missing or empty, we default to an empty list. - excludes = "" - exclude_list = self.options.get('exclude', []) - if exclude_list: - for pattern in exclude_list: - excludes += f" --exclude='{pattern}'" + self.backup_in_progress = True + backup_thread = threading.Thread( + target=self._run_backup_thread, + args=(agent, existing_files), + daemon=True, + name="AutoBackupThread", + ) + backup_thread.start() + logging.debug("AUTO-BACKUP: Backup thread started") - # Get the backup location from config. - backup_location = self.options['backup_location'] + def manual_backup(self, agent): + """Manually trigger a backup.""" + if self.backup_in_progress: + return {"status": "Backup already in progress"} - # Retrieve the global config from agent. If agent.config is callable, call it. - global_config = getattr(agent, 'config', None) - if callable(global_config): - global_config = global_config() - if global_config is None: - global_config = {} - pwnagotchi_name = global_config.get('main', {}).get('name', socket.gethostname()) - backup_file = os.path.join(backup_location, f"{pwnagotchi_name}-backup.tar") + existing_files = self._get_backup_files() + if not existing_files: + return {"status": "No files to backup"} - try: - display = agent.view() - logging.info("AUTO-BACKUP: Starting backup process...") - display.set('status', 'Backing up ...') - display.update() - - # Execute each backup command. - for cmd in self.options['commands']: - formatted_cmd = cmd.format(backup_file=backup_file, files=files_to_backup, excludes=excludes) - logging.info(f"AUTO-BACKUP: Running command: {formatted_cmd}") - process = subprocess.Popen( - formatted_cmd, - shell=True, - stdin=None, - stdout=open("/dev/null", "w"), - stderr=subprocess.STDOUT, - executable="/bin/bash" - ) - process.wait() - if process.returncode > 0: - raise OSError(f"Command failed with return code: {process.returncode}") - - logging.info(f"AUTO-BACKUP: Backup completed successfully. File created at {backup_file}") - display.set('status', 'Backup done!') - display.update() - self.status.update() - except OSError as os_e: - self.tries += 1 - logging.error(f"AUTO-BACKUP: Backup error: {os_e}") - display.set('status', 'Backup failed!') - display.update() + self.backup_in_progress = True + backup_thread = threading.Thread( + target=self._run_backup_thread, + args=(agent, existing_files), + daemon=True, + name="AutoBackupThread", + ) + backup_thread.start() + logging.info("AUTO-BACKUP: Manual backup triggered") + return {"status": "Backup started - check logs for details"} diff --git a/pwnagotchi/plugins/default/bt-tether.py b/pwnagotchi/plugins/default/bt-tether.py index 066ed5e4..7115293b 100644 --- a/pwnagotchi/plugins/default/bt-tether.py +++ b/pwnagotchi/plugins/default/bt-tether.py @@ -1,5040 +1,509 @@ -""" -Bluetooth Tether Plugin for Pwnagotchi - -Required System Packages: - sudo apt-get update - sudo apt-get install -y bluez network-manager python3-dbus python3-toml - -Features: -- Bluetooth tethering to mobile phones (iOS & Android) -- Auto-discovery of trusted devices with tethering capability -- Works with iOS randomized MAC addresses -- Auto-reconnect functionality -- Web UI for easy device pairing and management -- No manual MAC configuration needed - -Setup: -1. Install packages: sudo apt-get install -y bluez network-manager python3-dbus python3-toml -2. Enable services: - sudo systemctl enable bluetooth && sudo systemctl start bluetooth - sudo systemctl enable NetworkManager && sudo systemctl start NetworkManager -3. Access web UI at http://:8080/plugins/bt-tether -4. Scan and pair your phone - it will auto-connect from then on! - -Configuration (config.toml): - - [main.plugins.bt-tether] - enabled = true - auto_reconnect = true # Auto reconnect on disconnect (default: true) - show_on_screen = true # Master switch: show status on display - show_mini_status = true # Show mini status indicator (C/N/P/D) - mini_status_position = [110, 0] # Position for mini status - show_detailed_status = true # Show detailed status line with IP - detailed_status_position = [0, 82] # Position for detailed status line -""" - -import subprocess -import threading -import time import logging -import os +import subprocess import re -import traceback -import json -import datetime -from pwnagotchi.plugins import Plugin -from flask import render_template_string, request, jsonify +import time +from flask import abort, render_template_string +import pwnagotchi.plugins as plugins import pwnagotchi.ui.fonts as fonts from pwnagotchi.ui.components import LabeledValue from pwnagotchi.ui.view import BLACK -from pwnagotchi import plugins -import pwnagotchi -try: - import dbus - import dbus.service - - DBUS_AVAILABLE = True -except ImportError: - DBUS_AVAILABLE = False - logging.warning("[bt-tether] dbus/GLib not available, BLE advertising disabled") - -HTML_TEMPLATE = """ - - - Bluetooth Tether - - +TEMPLATE = """ +{% extends "base.html" %} +{% set active_page = "bt-tether" %} +{% block title %} + {{ title }} +{% endblock %} +{% block meta %} + + +{% endblock %} +{% block styles %} +{{ super() }} - - -
-
-

πŸ”· Bluetooth Tether

-
v{{ version }}
-
- -
- - -
-

πŸ“± Connection Status

-
-
Trusted Devices:
-
Loading...
-
- - -
-
Connection Status:
- -
πŸ“± Paired: Checking...
-
πŸ” Trusted: Checking...
-
πŸ”΅ Connected: Checking...
-
🌐 Internet: Checking...
- -
- - - - - -
-

πŸ“‹ Output

-
-
-
Fetching logs...
-
-
- -
- - -
- -
- - - - - - -
- - - - - - - + +
+ + + + + + + + + + + + + + + + + + + + + +
ItemConfiguration
Bluetooth{{bluetooth|safe}}
Device{{device|safe}}
Connection{{connection|safe}}
+
+{% endblock %} """ +# We all love crazy regex patterns +MAC_PTTRN = r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$" +IP_PTTRN = r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$" +DNS_PTTRN = r"^\s*((\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*[ ,;]\s*)+((\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*[ ,;]?\s*)$" -class BTTetherHelper(Plugin): - __author__ = "wsvdmeer" - __version__ = "1.2.5" + +class BTTether(plugins.Plugin): + __author__ = "Jayofelony, modified by fmatray and wsvdmeer" + __version__ = "1.4" __license__ = "GPL3" - __description__ = "Guided Bluetooth tethering with user instructions" + __description__ = "A new BT-Tether plugin" - # State constants for detailed status display - STATE_IDLE = "IDLE" - STATE_INITIALIZING = "INITIALIZING" - STATE_SCANNING = "SCANNING" - STATE_PAIRING = "PAIRING" - STATE_TRUSTING = "TRUSTING" - STATE_CONNECTING = "CONNECTING" - STATE_CONNECTED = "CONNECTED" - STATE_RECONNECTING = "RECONNECTING" - STATE_DISCONNECTING = "DISCONNECTING" - STATE_UNTRUSTING = "UNTRUSTING" - STATE_DISCONNECTED = "DISCONNECTED" - STATE_ERROR = "ERROR" + def __init__(self): + self.ready = False + self.options = dict() + self.phone_name = None + self.mac = None - # Bluetooth UUID constants - NAP_UUID = "00001116-0000-1000-8000-00805f9b34fb" + @staticmethod + def exec_cmd(cmd, args, pattern=None): + try: + result = subprocess.run( + [cmd] + args, check=True, capture_output=True, text=True + ) + if pattern: + return result.stdout.find(pattern) + return result + except Exception as exp: + logging.error(f"[BT-Tether] Error with {cmd}") + logging.error(f"[BT-Tether] Exception : {exp}") + raise exp - # Timing constants - BLUETOOTH_SERVICE_STARTUP_DELAY = 3 - MONITOR_INITIAL_DELAY = 5 - MONITOR_PAUSED_CHECK_INTERVAL = 10 # Check every 10 seconds when paused - SCAN_DURATION = 30 - DEVICE_OPERATION_DELAY = 1 - DEVICE_OPERATION_LONGER_DELAY = 2 - SCAN_STOP_DELAY = 0.5 - # Pairing configuration constants - PAIRING_SCAN_WAIT_TIMEOUT = ( - 15 # Max seconds to wait for device to appear in BlueZ cache during pairing - ) - PAIRING_PASSKEY_TIMEOUT = ( - 90 # Max seconds to wait for passkey confirmation on phone - ) - PAIRING_RETRY_DELAY = 2 # Seconds between pairing retry attempts - PAIRING_MAX_RETRIES = 2 # Max pairing attempts before giving up - SCAN_MAC_PATTERN = re.compile( - r"([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})" - ) - SCAN_ANSI_PATTERN = re.compile(r"(\x1b\[[0-9;]*m|\x08)") - PROCESS_CLEANUP_DELAY = 0.2 - DBUS_OPERATION_RETRY_DELAY = 0.1 - AGENT_LOG_MONITOR_TIMEOUT = 90 # Seconds to monitor agent log for passkey - FALLBACK_INIT_TIMEOUT = 15 # Seconds to wait for on_ready() before fallback init - PAN_INTERFACE_WAIT = 2 # Seconds to wait for PAN interface after connection - INTERNET_VERIFY_WAIT = 2 # Seconds to wait before verifying internet connectivity - DHCP_KILL_WAIT = 0.5 # Wait after killing dhclient - DHCP_RELEASE_WAIT = 1 # Wait after releasing DHCP lease + def bluetoothctl(self, args, pattern=None): + return self.exec_cmd("bluetoothctl", args, pattern) - # Reconnect configuration constants - DEFAULT_RECONNECT_INTERVAL = 60 # Default seconds between reconnect checks - MAX_RECONNECT_FAILURES = 5 # Max consecutive failures before cooldown - DEFAULT_RECONNECT_FAILURE_COOLDOWN = 300 # Default cooldown in seconds (5 minutes) + def nmcli(self, args, pattern=None): + return self.exec_cmd("nmcli", args, pattern) - # UI and buffer constants - UI_LOG_MAXLEN = 100 # Maximum number of log messages in UI buffer + def _get_bt_connection_name(self): + # Get bluetooth connections that contains the phone-name + bt_connections = [ + connection.split(":")[0].strip() + for connection in self.nmcli( + ["-t", "-f", "NAME,TYPE", "connection", "show"] + ).stdout.splitlines() + if connection.endswith(":bluetooth") + and self.options["phone-name"] in connection + ] - # Subprocess timeout constants - SUBPROCESS_TIMEOUT_SHORT = 1 # For quick operations (process cleanup) - SUBPROCESS_TIMEOUT_MEDIUM = 2 # For moderate operations (network checks) - SUBPROCESS_TIMEOUT_NORMAL = 3 # For standard operations (minor bluetoothctl) - SUBPROCESS_TIMEOUT_STANDARD = 5 # For main bluetoothctl operations - SUBPROCESS_TIMEOUT_LONG = 10 # For long-running operations (device removal) + if len(bt_connections) == 1: + return bt_connections[0] - # UI polling intervals (milliseconds) - UI_STATUS_POLL_INTERVAL = 2000 # Connection status check interval - UI_LOG_POLL_INTERVAL = 5000 # Log refresh interval + if len(bt_connections) > 1: + # Multiple matches found: check the MAC + for bt_connection in bt_connections: + if ( + self.nmcli( + ["-f", "bluetooth.bdaddr", "connection", "show", bt_connection], + self.options["mac"].upper(), + ) + != -1 + ): + return bt_connection - # Operation delay constants - OPERATION_SHORT_DELAY = 0.5 # General short delay between operations - OPERATION_MEDIUM_DELAY = 3 # Medium delay for settlement/hardware stabilization - - # Internal plugin flag - not a user-configurable option - csrf_exempt = True + # Fallback to the old logic + logging.error( + f"[BT-Tether] Connection not found for {self.options['phone-name']}git" + ) + return self.options["phone-name"] + " Network" def on_loaded(self): - """Initialize plugin configuration and data structures only - no heavy operations""" - from collections import deque + logging.info("[BT-Tether] plugin loaded.") - self.phone_mac = "" - self._status = self.STATE_IDLE - self._message = "Ready" - self._scanning = False - self._stop_scan = False - self._last_scan_devices = [] - self._discovered_devices = {} - self._scan_complete_time = 0 - self.lock = threading.Lock() - self.agent_process = None - self.agent_log_fd = None - self.agent_log_path = None - self.current_passkey = None + def on_config_changed(self, config): + if "phone-name" not in self.options: + logging.error("[BT-Tether] Phone name not provided") + return + if not ("mac" in self.options and re.match(MAC_PTTRN, self.options["mac"])): + logging.error("[BT-Tether] Error with mac address") + return - self._ui_logs = deque(maxlen=self.UI_LOG_MAXLEN) - self._ui_log_lock = threading.Lock() + if not ( + "phone" in self.options + and self.options["phone"].lower() in ["android", "ios"] + ): + logging.error("[BT-Tether] Phone type not supported") + return + if self.options["phone"].lower() == "android": + address = self.options.get("ip", "192.168.44.2") + gateway = self.options.get("gateway", "192.168.44.1") + elif self.options["phone"].lower() == "ios": + address = self.options.get("ip", "172.20.10.2") + gateway = self.options.get("gateway", "172.20.10.1") + if not re.match(IP_PTTRN, address): + logging.error(f"[BT-Tether] IP error: {address}") + return - self.show_on_screen = self.options.get("show_on_screen", True) - self.show_mini_status = self.options.get("show_mini_status", True) - self.mini_status_position = self.options.get("mini_status_position", [110, 0]) - self.show_detailed_status = self.options.get("show_detailed_status", True) - self.detailed_status_position = self.options.get( - "detailed_status_position", [0, 82] - ) - self.auto_reconnect = self.options.get("auto_reconnect", True) - self.reconnect_interval = self.options.get( - "reconnect_interval", self.DEFAULT_RECONNECT_INTERVAL - ) + self.phone_name = self._get_bt_connection_name() + self.mac = self.options["mac"].upper() + dns = self.options.get("dns", "8.8.8.8 1.1.1.1") + if not re.match(DNS_PTTRN, dns): + if dns == "": + logging.error(f"[BT-Tether] Empty DNS setting") + else: + logging.error(f"[BT-Tether] Wrong DNS setting: '{dns}'") + return + dns = re.sub("[\s,;]+", " ", dns).strip() # DNS cleaning - self._bluetoothctl_lock = threading.Lock() - - self._connection_in_progress = False - self._connection_start_time = None - self._disconnecting = False - self._disconnect_start_time = None - self._untrusting = False - self._untrust_start_time = None - self._initializing = True - - self.OPERATION_TIMEOUT = 120 - - self._monitor_thread = None - self._monitor_stop = threading.Event() - self._monitor_paused = threading.Event() - self._last_known_connected = False - self._reconnect_failure_count = 0 - self._max_reconnect_failures = self.MAX_RECONNECT_FAILURES - self._reconnect_failure_cooldown = self.options.get( - "reconnect_failure_cooldown", self.DEFAULT_RECONNECT_FAILURE_COOLDOWN - ) - self._first_failure_time = None - self._user_requested_disconnect = False - - self._screen_needs_refresh = False - - # Cached UI status - updated by background threads, read by on_ui_update - # This prevents blocking subprocess calls during UI updates - self._cached_ui_status = { - "paired": False, - "trusted": False, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - } - self._cached_ui_status_lock = threading.Lock() - self._ui_reference = None - - self._initialization_done = threading.Event() - self._fallback_thread = None - self._last_known_pan_active = False - - self._log("INFO", "Plugin configuration loaded") - - # Start fallback initialization thread in case on_ready() is never called - self._fallback_thread = threading.Thread( - target=self._fallback_initialization, daemon=True - ) - self._fallback_thread.start() - - def _fallback_initialization(self): - """Fallback initialization if on_ready() is not called within timeout""" - if not self._initialization_done.wait(timeout=self.FALLBACK_INIT_TIMEOUT): - self._log( - "WARNING", "on_ready() was not called, using fallback initialization" + try: + # Configure connection. Metric is set to 200 to prefer connection over USB + self.nmcli( + [ + "connection", + "modify", + f"{self.phone_name}", + "connection.type", + "bluetooth", + "bluetooth.type", + "panu", + "bluetooth.bdaddr", + f"{self.mac}", + "connection.autoconnect", + "yes", + "connection.autoconnect-retries", + "0", + "ipv4.method", + "manual", + "ipv4.dns", + f"{dns}", + "ipv4.addresses", + f"{address}/24", + "ipv4.gateway", + f"{gateway}", + "ipv4.route-metric", + "200", + ] + ) + # Configure Device to autoconnect + self.nmcli( + ["device", "set", f"{self.mac}", "autoconnect", "yes", "managed", "yes"] + ) + self.nmcli(["connection", "reload"]) + self.ready = True + logging.info(f"[BT-Tether] Connection {self.phone_name} configured") + except Exception as e: + logging.error(f"[BT-Tether] Error while configuring: {e}") + return + try: + time.sleep(5) # Give some delay to configure before going up + self.nmcli(["connection", "up", f"{self.phone_name}"]) + except Exception as e: + logging.error(f"[BT-Tether] Failed to connect to device: {e}") + logging.error( + f"[BT-Tether] Failed to connect to device: have you enabled bluetooth tethering on your phone?" ) - if not self._initialization_done.is_set(): - self._initialization_done.set() - self._initialize_bluetooth_services() def on_ready(self, agent): - """Called when everything is ready and the main loop is about to start""" - self._log("INFO", "on_ready() called, initializing Bluetooth services...") - if not self._initialization_done.is_set(): - self._initialization_done.set() - self._initialize_bluetooth_services() - - def _initialize_bluetooth_services(self): - """Initialize Bluetooth services - called by either on_ready() or fallback""" - with self.lock: - self._initializing = True - self._screen_needs_refresh = True - try: - try: - subprocess.run( - ["pkill", "-9", "bluetoothctl"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - self._log("INFO", "Cleaned up lingering bluetoothctl processes") - except Exception as e: - self._log("DEBUG", f"Process cleanup: {e}") - - # Restart bluetooth service to ensure clean state - try: - self._log("INFO", "Restarting Bluetooth service...") - subprocess.run( - ["systemctl", "restart", "bluetooth"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=self.SUBPROCESS_TIMEOUT_STANDARD, - ) - time.sleep(self.BLUETOOTH_SERVICE_STARTUP_DELAY) - self._log("INFO", "Bluetooth service restarted") - except Exception as e: - self._log("WARNING", f"Failed to restart Bluetooth service: {e}") - - # Verify localhost routing is intact (critical for bettercap API) - try: - self._verify_localhost_route() - except Exception as e: - self._log("WARNING", f"Initial localhost check failed: {e}") - - self._start_pairing_agent() - - if self.auto_reconnect: - self._start_monitoring_thread() - - self._set_device_name() - - self._log("INFO", "Bluetooth services initialized") - - if self.auto_reconnect: - self._log("INFO", "Checking for trusted devices to auto-connect...") - best_device = self._find_best_device_to_connect(log_results=False) - if best_device: - self._log( - "INFO", - f"Found trusted device: {best_device['name']}, starting connection...", - ) - self._update_cached_ui_status(mac=best_device["mac"]) - - with self.lock: - self._connection_in_progress = True - self._connection_start_time = time.time() - self._user_requested_disconnect = False - self.phone_mac = best_device["mac"] - self._initializing = False - self.status = self.STATE_CONNECTING - self.message = f"Auto-connecting to {best_device['name']}..." - self._screen_needs_refresh = True - self._log( - "INFO", - f"Initialization complete (auto-connect starting) - initializing flag cleared: {not self._initializing}", - ) - - self._monitor_paused.clear() - threading.Thread( - target=self._connect_thread, args=(best_device,), daemon=True - ).start() - else: - self._log( - "INFO", "No trusted devices found. Pair a device via web UI." - ) - # Update cached UI status to show no device - self._update_cached_ui_status( - status={ - "paired": False, - "trusted": False, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - } - ) - # No device to connect, end initialization AFTER cache update - with self.lock: - self._initializing = False - self._screen_needs_refresh = True - - # Force immediate screen update by calling on_ui_update if UI reference available - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update after init: {e}" - ) - self._log( - "INFO", - f"Initialization complete - initializing flag cleared: {not self._initializing}", - ) - else: - # Auto-reconnect disabled, update cached UI then end initialization - self._update_cached_ui_status() - with self.lock: - self._initializing = False - self._screen_needs_refresh = True - self._log( - "INFO", - f"Initialization complete (auto-reconnect disabled) - initializing flag cleared: {not self._initializing}", - ) - - # Force immediate screen update by calling on_ui_update if UI reference available - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update after init: {e}" - ) + logging.info(f"[BT-Tether] Disabling bettercap's BLE module") + agent.run("ble.recon off", verbose_errors=False) except Exception as e: - self._log("ERROR", f"Failed to initialize Bluetooth services: {e}") - # Update cached UI to show current state - self._update_cached_ui_status() - with self.lock: - self._initializing = ( - False # Mark initialization as complete even on error - ) - self._screen_needs_refresh = True - self._log( - "INFO", - f"Initialization error handler - initializing flag cleared: {not self._initializing}", - ) - self._log("ERROR", f"Traceback: {traceback.format_exc()}") - - # Force immediate screen update by calling on_ui_update if UI reference available - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as update_error: - logging.debug( - f"[bt-tether] Error forcing UI update after init error: {update_error}" - ) + logging.info(f"[BT-Tether] Bettercap BLE was already off.") def on_unload(self, ui): - """Cleanup when plugin is unloaded""" + with ui._lock: + ui.remove_element("bluetooth") try: - self._log("INFO", "Unloading plugin, cleaning up resources...") - - if self._monitor_thread and self._monitor_thread.is_alive(): - self._monitor_stop.set() - self._monitor_thread.join(timeout=self.SUBPROCESS_TIMEOUT_STANDARD) - - if self.agent_process and self.agent_process.poll() is None: - try: - self.agent_process.terminate() - self.agent_process.wait(timeout=self.SUBPROCESS_TIMEOUT_NORMAL) - except subprocess.TimeoutExpired: - logging.warning("[bt-tether] Agent didn't terminate, killing...") - try: - self.agent_process.kill() - self.agent_process.wait(timeout=self.SUBPROCESS_TIMEOUT_SHORT) - except Exception as kill_err: - logging.error(f"[bt-tether] Agent kill failed: {kill_err}") - except Exception as e: - logging.debug(f"[bt-tether] Agent terminate failed: {e}") - - if self.agent_log_fd: - try: - if isinstance(self.agent_log_fd, int): - os.close(self.agent_log_fd) - else: - self.agent_log_fd.close() - self.agent_log_fd = None - except Exception as e: - logging.debug(f"[bt-tether] Failed to close agent log: {e}") - - if self.agent_log_path and os.path.exists(self.agent_log_path): - try: - os.remove(self.agent_log_path) - except Exception as e: - logging.debug(f"[bt-tether] Failed to remove agent log: {e}") - - self._log("INFO", "Plugin unloaded successfully") + self.nmcli(["connection", "down", f"{self.phone_name}"]) except Exception as e: - logging.error(f"[bt-tether] Error during unload: {e}") - - def _log(self, level, message): - """Log to both system logger and UI log buffer""" - full_message = f"[bt-tether] {message}" - level_upper = level.upper() - if level_upper == "ERROR": - logging.error(full_message) - elif level_upper == "WARNING": - logging.warning(full_message) - elif level_upper == "DEBUG": - logging.debug(full_message) - else: - logging.info(full_message) - - with self._ui_log_lock: - self._ui_logs.append( - { - "timestamp": datetime.datetime.now().strftime("%H:%M:%S"), - "level": level_upper, - "message": message, - } - ) - - @property - def status(self): - return self._status - - @status.setter - def status(self, value): - self._status = value - - @property - def message(self): - return self._message - - @message.setter - def message(self, value): - self._message = value - - def _set_state(self, status, message, **kwargs): - """Update plugin state atomically under lock and trigger screen refresh. - - Sets status, message, _screen_needs_refresh=True, and any extra attributes - passed as keyword arguments (e.g. _connection_in_progress=False). - """ - with self.lock: - self.status = status - self.message = message - for key, value in kwargs.items(): - setattr(self, key, value) - self._screen_needs_refresh = True - - def _emit_event(self, event_name, event_data): - """Emit a custom event to other plugins""" - try: - self._log("DEBUG", f"_emit_event() called: {event_name}") - event_data.setdefault("pwnagotchi_name", self._get_pwnagotchi_name()) - - self._log("DEBUG", f"Calling plugins.on() for event: {event_name}") - plugins.on(event_name, None, event_data) - self._log("DEBUG", f"Event emitted: {event_name}") - for key, value in event_data.items(): - self._log("DEBUG", f" β€’ {key}: {value}") - except Exception as e: - self._log("WARNING", f"Failed to emit event {event_name}: {e}") - self._log("WARNING", f"Traceback: {traceback.format_exc()}") + logging.error(f"[BT-Tether] Failed to disconnect from device: {e}") def on_ui_setup(self, ui): - """Setup UI elements to display Bluetooth status on screen""" - self._ui_reference = ui - - if self.show_on_screen and self.show_mini_status: - pos = ( - tuple(self.mini_status_position) - if isinstance(self.mini_status_position, (list, tuple)) - else self.mini_status_position - ) - + with ui._lock: ui.add_element( - "bt-status", + "bluetooth", LabeledValue( color=BLACK, label="BT", - value="D", - position=pos, + value="-", + position=(ui.width() / 2 - 10, 0), label_font=fonts.Bold, text_font=fonts.Medium, ), ) - if self.show_on_screen and self.show_detailed_status: - ui.add_element( - "bt-detail", - LabeledValue( - color=BLACK, - label="", - value="BT:--", - position=tuple(self.detailed_status_position), - label_font=fonts.Small, - text_font=fonts.Small, - ), - ) - def on_ui_update(self, ui): - """Update Bluetooth status on screen - MUST be non-blocking""" - if not self.show_on_screen: + if not self.ready: return - - try: - with self.lock: - initializing = self._initializing - connection_in_progress = self._connection_in_progress - connection_start_time = self._connection_start_time - disconnecting = self._disconnecting - disconnect_start_time = self._disconnect_start_time - untrusting = self._untrusting - untrust_start_time = self._untrust_start_time - phone_mac = self.phone_mac - screen_needs_refresh = self._screen_needs_refresh - status_str = self.status - message_str = self.message - scanning = self._scanning - if screen_needs_refresh: - self._screen_needs_refresh = False - - if initializing: - logging.debug( - f"[bt-tether] on_ui_update() - initializing flag is TRUE, screen_needs_refresh={screen_needs_refresh}" - ) - else: - logging.debug( - f"[bt-tether] on_ui_update() - initializing flag is FALSE, will show status: {status_str}" - ) - - with self._cached_ui_status_lock: - cached_status = self._cached_ui_status.copy() - - current_time = time.time() - - if connection_in_progress and connection_start_time: - if current_time - connection_start_time > self.OPERATION_TIMEOUT: - logging.warning( - f"[bt-tether] Connection timeout ({self.OPERATION_TIMEOUT}s) - clearing stuck flag" - ) - with self.lock: - self._connection_in_progress = False - self._connection_start_time = None - self.status = self.STATE_ERROR - self.message = "Connection timeout - operation took too long" - self._screen_needs_refresh = True - self._update_cached_ui_status() - connection_in_progress = False - - if disconnecting and disconnect_start_time: - if current_time - disconnect_start_time > self.OPERATION_TIMEOUT: - logging.warning( - f"[bt-tether] Disconnect timeout ({self.OPERATION_TIMEOUT}s) - clearing stuck flag" - ) - with self.lock: - self._disconnecting = False - self._disconnect_start_time = None - disconnecting = False - - if untrusting and untrust_start_time: - if current_time - untrust_start_time > self.OPERATION_TIMEOUT: - logging.warning( - f"[bt-tether] Untrust timeout ({self.OPERATION_TIMEOUT}s) - clearing stuck flag" - ) - with self.lock: - self._untrusting = False - self._untrust_start_time = None - untrusting = False - - if initializing: - if self.show_mini_status: - ui.set("bt-status", "I") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Initializing") - return - - if scanning: - if self.show_mini_status: - ui.set("bt-status", "S") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Scanning") - return - - if disconnecting: - if self.show_mini_status: - ui.set("bt-status", "D") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Disconnecting") - return - - if untrusting: - if self.show_mini_status: - ui.set("bt-status", "T") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Untrusting") - return - - if connection_in_progress: - if status_str == self.STATE_CONNECTED: - pass - elif status_str == self.STATE_PAIRING: - if self.show_mini_status: - ui.set("bt-status", "P") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Pairing") - return - elif status_str == self.STATE_TRUSTING: - if self.show_mini_status: - ui.set("bt-status", "T") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Trusting") - return - elif status_str == self.STATE_CONNECTING: - if self.show_mini_status: - ui.set("bt-status", ">") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Connecting") - return - elif status_str == self.STATE_RECONNECTING: - if cached_status.get("connected") or cached_status.get( - "pan_active" - ): - pass - else: - if self.show_mini_status: - ui.set("bt-status", "R") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Reconnecting") - return - else: - if self.show_mini_status: - ui.set("bt-status", ">") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Connecting") - return - - if not phone_mac and not cached_status.get("paired", False): - if self.show_mini_status: - ui.set("bt-status", "X") - if self.show_detailed_status: - ui.set("bt-detail", "BT:No device") - return - - if cached_status.get("pan_active", False): - display = "C" - elif cached_status.get("connected", False) and cached_status.get( - "trusted", False - ): - display = "T" - elif cached_status.get("connected", False): - display = "N" - elif cached_status.get("paired", False): - display = "P" - else: - display = "X" - - if self.show_mini_status: - ui.set("bt-status", display) - - if self.show_detailed_status: - try: - detailed = self._format_detailed_status(cached_status) - ui.set("bt-detail", detailed) - except Exception as detail_error: - logging.debug(f"[bt-tether] Detailed status error: {detail_error}") - ui.set("bt-detail", f"BT:{display}") - - except Exception as e: - logging.debug(f"[bt-tether] UI update error: {e}") + with ui._lock: + status = "" try: - if self.show_mini_status: - ui.set("bt-status", "?") - if self.show_detailed_status: - ui.set("bt-detail", "BT:Error") - except Exception as ui_err: - logging.debug(f"[bt-tether] Failed to set error UI: {ui_err}") - - def _format_detailed_status(self, status): - """Format detailed status line for screen display""" - with self.lock: - disconnecting = self._disconnecting - connection_in_progress = self._connection_in_progress - untrusting = self._untrusting - - connected = status.get("connected", False) - paired = status.get("paired", False) - trusted = status.get("trusted", False) - pan_active = status.get("pan_active", False) - ip_address = status.get("ip_address", None) - - with self.lock: - status_str = self.status - - if disconnecting: - return "BT:Disconnecting..." - elif untrusting: - return "BT:Untrusting..." - elif connection_in_progress: - if status_str == self.STATE_CONNECTED: - pass - elif status_str == self.STATE_RECONNECTING: - return "BT:Reconnecting..." - else: - return "BT:Connecting..." - - if pan_active: - if ip_address: - return f"BT:{ip_address}" - else: - return "BT:Connected" - elif connected and trusted: - return "BT:Trusted" - elif connected: - return "BT:Connected" - elif paired: - return "BT:Paired" - else: - return "BT:Disconnected" - - def _update_cached_ui_status(self, status=None, mac=None): - """Update the cached UI status from a background thread""" - try: - if status is None: - target_mac = mac if mac else self.phone_mac - if target_mac: - status = self._get_current_status(target_mac) - else: - status = { - "paired": False, - "trusted": False, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - } - - with self._cached_ui_status_lock: - self._cached_ui_status = status.copy() - - with self.lock: - self._screen_needs_refresh = True - - except Exception as e: - logging.debug(f"[bt-tether] Failed to update cached UI status: {e}") - - def _start_pairing_agent(self): - """Start a persistent bluetoothctl agent to handle pairing requests""" - try: - if self.agent_process and self.agent_process.poll() is None: - self._log("INFO", "Pairing agent already running") - return - - self._log("INFO", "Starting persistent pairing agent...") - - agent_commands = """power on -agent KeyboardDisplay -default-agent -""" - - env = dict(os.environ) - env["NO_COLOR"] = "1" - env["TERM"] = "dumb" - - import tempfile - - self.agent_log_fd, self.agent_log_path = tempfile.mkstemp( - prefix="bt-agent-", suffix=".log" - ) - logging.info( - f"[bt-tether] Agent output will be logged to: {self.agent_log_path}" - ) - - self.agent_process = subprocess.Popen( - ["bluetoothctl"], - stdin=subprocess.PIPE, - stdout=self.agent_log_fd, - stderr=self.agent_log_fd, - text=False, - env=env, - ) - - try: - self.agent_process.stdin.write(agent_commands.encode()) - self.agent_process.stdin.flush() - except BrokenPipeError: - self._log( - "WARNING", - "Agent process stdin pipe broken - process may have exited", - ) - return - - logging.info( - "[bt-tether] βœ“ Persistent pairing agent started (KeyboardDisplay mode - passkey will be shown)" - ) - logging.info( - f"[bt-tether] πŸ”‘ Passkeys will appear in: {self.agent_log_path}" - ) - except Exception as e: - logging.error(f"[bt-tether] Failed to start pairing agent: {e}") - if self.agent_log_fd: - try: - os.close(self.agent_log_fd) - except: - pass - self.agent_log_fd = None - if self.agent_log_path and os.path.exists(self.agent_log_path): - try: - os.remove(self.agent_log_path) - except: - pass - self.agent_log_path = None - - def _start_monitoring_thread(self): - """Start background thread to monitor connection and auto-reconnect if dropped""" - try: - if self._monitor_thread and self._monitor_thread.is_alive(): - self._log("INFO", "Monitoring thread already running") - return - - self._monitor_stop.clear() - self._monitor_thread = threading.Thread( - target=self._connection_monitor_loop, daemon=True - ) - self._monitor_thread.start() - self._log( - "INFO", - f"Started connection monitoring (interval: {self.reconnect_interval}s)", - ) - except Exception as e: - logging.error(f"[bt-tether] Failed to start monitoring thread: {e}") - - def _connection_monitor_loop(self): - """Background loop to monitor connection status and reconnect if needed""" - logging.info("[bt-tether] Connection monitor started") - - time.sleep(self.MONITOR_INITIAL_DELAY) - - while not self._monitor_stop.is_set(): - try: - with self.lock: - connection_in_progress = self._connection_in_progress - - if connection_in_progress: - time.sleep(self.reconnect_interval) - continue - - best_device = self._find_best_device_to_connect(log_results=False) - - if not best_device: - if not self._monitor_paused.is_set(): - self._log( - "INFO", "No trusted devices to monitor. Monitor paused." - ) - self._monitor_paused.set() - - time.sleep(self.reconnect_interval) - continue - - current_mac = best_device["mac"] - device_name = best_device["name"] - - if self._monitor_paused.is_set(): - self._monitor_paused.clear() - logging.info( - f"[bt-tether] Monitor resumed - found device: {device_name}" - ) - - status = self._get_full_connection_status(current_mac) - self._update_cached_ui_status(status=status, mac=current_mac) - - if not status["connected"]: - self._log( - "DEBUG", f"Monitoring device: {device_name} ({current_mac})" - ) - - pan_active = status.get("pan_active", False) - self._last_known_pan_active = pan_active - - with self.lock: - user_requested_disconnect = self._user_requested_disconnect - + # Checking connection if ( - self._last_known_connected - and not status["connected"] - and not user_requested_disconnect + self.nmcli( + [ + "-w", + "0", + "-g", + "GENERAL.STATE", + "connection", + "show", + self.phone_name, + ], + "activated", + ) + != -1 ): - logging.warning( - f"[bt-tether] Connection to {device_name} dropped! Attempting to reconnect..." - ) - - event_data = { - "mac": current_mac, - "device": device_name, - "reason": "connection_dropped", - } - self._emit_event("bt_tether_disconnected", event_data) - self._log( - "INFO", f"Event emitted: device disconnected - {device_name}" - ) - - with self.lock: - self.status = self.STATE_DISCONNECTED - self.message = f"Connection to {device_name} dropped" - self._screen_needs_refresh = True - - self._update_cached_ui_status( - status={ - "paired": True, - "trusted": True, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - }, - mac=current_mac, - ) - - with self.lock: - self.status = self.STATE_RECONNECTING - self.message = ( - f"Connection lost to {device_name}, reconnecting..." - ) - self._connection_in_progress = True - self._connection_start_time = time.time() - self._initializing = False - self._screen_needs_refresh = True - - # Attempt to reconnect to this device - success = self._reconnect_device() - - if success: - # Update phone_mac to the device we successfully connected to - self.phone_mac = current_mac - self.options["mac"] = self.phone_mac - # Mark as connected so we don't trigger the second reconnect block - self._last_known_connected = True - self._reconnect_failure_count = 0 - self._first_failure_time = None - else: - # Reconnection failed - update last known state to disconnected - self._last_known_connected = False - self._reconnect_failure_count += 1 - if self._first_failure_time is None: - self._first_failure_time = time.time() - if ( - self._reconnect_failure_count - >= self._max_reconnect_failures - ): - self._log( - "WARNING", - f"⚠️ Auto-reconnect paused after {self._max_reconnect_failures} failed attempts", - ) - self._log( - "INFO", - f"πŸ“± Will retry after {self._reconnect_failure_cooldown}s cooldown, or reconnect manually via web UI", - ) - with self.lock: - self.status = self.STATE_DISCONNECTED - self.message = f"Auto-reconnect paused - retrying in {self._reconnect_failure_cooldown}s" - self._connection_in_progress = ( - False # Clear flag to show proper status - ) - self._screen_needs_refresh = True - - # Skip the rest of this iteration - we already handled reconnection - # Using stale `status` below would cause a double-reconnect attempt - time.sleep(self.reconnect_interval) - continue - - # Force screen refresh if connection state changed - if self._last_known_connected != status["connected"]: - with self.lock: - self._screen_needs_refresh = True - - # Update last known state (do this AFTER checking for changes) - self._last_known_connected = status["connected"] - - # Only try to reconnect if device is BOTH paired AND trusted (and not blocked) - # Also check if we haven't exceeded max failures and user didn't manually disconnect - with self.lock: - connection_in_progress = self._connection_in_progress - user_requested_disconnect = self._user_requested_disconnect + ui.set("bluetooth", "U") + return + else: + ui.set("bluetooth", "D") + status = "BT Conn. down" + # Checking device if ( - status["paired"] - and status["trusted"] - and not status["connected"] - and not connection_in_progress - and self._reconnect_failure_count < self._max_reconnect_failures - and not user_requested_disconnect + self.nmcli( + ["-w", "0", "-g", "GENERAL.STATE", "device", "show", self.mac], + "(connected)", + ) + != -1 ): - logging.info( - f"[bt-tether] Device {device_name} is paired/trusted but not connected. Attempting connection..." - ) - with self.lock: - self.status = self.STATE_CONNECTING - self.message = f"Reconnecting to {device_name}..." - self._connection_in_progress = True - self._connection_start_time = time.time() - self._initializing = ( - False # Ensure initializing flag is cleared - ) - self._screen_needs_refresh = True - - # Update cached UI to show disconnected immediately before reconnecting - # This ensures the UI shows the transition through the connecting state - self._update_cached_ui_status(status=status, mac=current_mac) - - success = self._reconnect_device() - - if success: - # Reset failure counter on successful connection - self._reconnect_failure_count = 0 - self._first_failure_time = None - # Update phone_mac to the successful device - self.phone_mac = current_mac - self.options["mac"] = self.phone_mac - # Update last known state so next iteration doesn't re-trigger - self._last_known_connected = True - else: - # Increment failure counter - self._reconnect_failure_count += 1 - # Track when failures started - if self._first_failure_time is None: - self._first_failure_time = time.time() - # Update cached UI to show disconnected state after failure - self._update_cached_ui_status(mac=current_mac) - if ( - self._reconnect_failure_count - >= self._max_reconnect_failures - ): - self._log( - "WARNING", - f"⚠️ Auto-reconnect paused after {self._max_reconnect_failures} failed attempts", - ) - self._log( - "INFO", - f"πŸ“± Will retry after {self._reconnect_failure_cooldown}s cooldown, or reconnect manually via web UI", - ) - with self.lock: - self.status = self.STATE_DISCONNECTED - self.message = f"Auto-reconnect paused - retrying in {self._reconnect_failure_cooldown}s" - self._connection_in_progress = ( - False # Clear flag to show proper status - ) - self._screen_needs_refresh = True - elif self._reconnect_failure_count >= self._max_reconnect_failures: - # Already exceeded max failures - check if cooldown period has elapsed - if self._first_failure_time: - time_since_first_failure = ( - time.time() - self._first_failure_time - ) - if time_since_first_failure >= self._reconnect_failure_cooldown: - # Cooldown period elapsed, reset counter and try again - self._log( - "INFO", - f"Cooldown period elapsed ({self._reconnect_failure_cooldown}s), resetting failure counter and retrying...", - ) - self._reconnect_failure_count = 0 - self._first_failure_time = None - elif not status["paired"] or not status["trusted"]: - # Device not paired/trusted (or blocked), don't attempt auto-reconnect - # Reset failure counter since this is intentional - self._reconnect_failure_count = 0 - self._first_failure_time = None - logging.debug( - f"[bt-tether] Device not ready for auto-reconnect (paired={status['paired']}, trusted={status['trusted']})" - ) - + ui.set("bluetooth", "C") + status += "\nBT dev conn." + else: + ui.set("bluetooth", "-") + status += "\nBT dev disconn." + ui.set("status", status) except Exception as e: - logging.error(f"[bt-tether] Monitor loop error: {e}") - - # Wait for next check - time.sleep(self.reconnect_interval) - - logging.info("[bt-tether] Connection monitor stopped") - - def _reconnect_device(self): - """Attempt to reconnect to a previously paired device""" - try: - # Find best device if no MAC is set - if not self.phone_mac: - best_device = self._find_best_device_to_connect() - if not best_device: - self._log("DEBUG", "No trusted devices found for reconnection") - return False - mac = best_device["mac"] - self.phone_mac = mac - else: - mac = self.phone_mac - - with self.lock: - self._connection_in_progress = True - self._connection_start_time = time.time() - self._initializing = False - - self._log("INFO", f"Reconnecting to {mac}...") - - # Check if device is blocked - devices_output = self._run_cmd( - ["bluetoothctl", "devices", "Blocked"], - capture=True, - timeout=self.SUBPROCESS_TIMEOUT_STANDARD, - ) - if devices_output and devices_output != "Timeout" and mac in devices_output: - self._log("INFO", f"Unblocking device {mac}...") - self._run_cmd(["bluetoothctl", "unblock", mac], capture=True) - time.sleep(self.DEVICE_OPERATION_DELAY) - - # Trust the device - self._log("INFO", f"Ensuring device is trusted...") - self._run_cmd(["bluetoothctl", "trust", mac], capture=True) - time.sleep(self.DEVICE_OPERATION_DELAY) - - # Try NAP connection (this will also establish Bluetooth connection if needed) - self._log("INFO", f"Attempting NAP connection...") - nap_connected = self._connect_nap_dbus(mac) - - if nap_connected: - self._log("INFO", f"βœ“ Reconnection successful") - - # Wait for PAN interface - time.sleep(self.PAN_INTERFACE_WAIT) - - # Check if PAN interface is up - if self._pan_active(): - iface = self._get_pan_interface() - self._log("INFO", f"βœ“ PAN interface active: {iface}") - - # Setup network with DHCP - if self._setup_network_dhcp(iface): - self._log("INFO", f"βœ“ Network setup successful") - - # Verify internet connectivity - time.sleep(self.INTERNET_VERIFY_WAIT) - if self._check_internet_connectivity(): - self._log("INFO", f"βœ“ Internet connectivity verified!") - - # Update cached UI status FIRST while flag is still True - self._update_cached_ui_status(mac=mac) - - # Emit connected event (mirrors original Discord notification trigger) - self._emit_event( - "bt_tether_connected", - { - "mac": mac, - "device": mac, - "ip": self._get_current_ip() or "unknown", - "interface": iface, - }, - ) - - # Then update status and clear flags - with self.lock: - self.status = self.STATE_CONNECTED - self.message = f"βœ“ Reconnected! Internet via {iface}" - self._connection_in_progress = False - self._connection_start_time = None - self._initializing = False - self._screen_needs_refresh = True - return True - else: - logging.warning( - f"[bt-tether] Reconnected but no internet detected" - ) - # Update cached UI status FIRST while flag is still True - self._update_cached_ui_status(mac=mac) - - # Then update status and clear flags - with self.lock: - self.status = self.STATE_CONNECTED - self.message = f"Reconnected via {iface} but no internet" - self._connection_in_progress = False - self._connection_start_time = None - self._initializing = False - self._screen_needs_refresh = True - return True - else: - logging.warning( - f"[bt-tether] NAP connected but no interface detected" - ) - # Update cached UI status FIRST while flag is still True - self._update_cached_ui_status(mac=mac) - - # Then update status and clear flags - with self.lock: - self.status = self.STATE_CONNECTED - self.message = "Reconnected but no PAN interface" - self._connection_in_progress = False - self._connection_start_time = None - self._initializing = False - self._screen_needs_refresh = True - return True - else: - logging.warning(f"[bt-tether] Reconnection failed") - self._set_state( - self.STATE_DISCONNECTED, - "Reconnection failed. Will retry later.", - _connection_in_progress=False, - _connection_start_time=None, - _initializing=False, - ) - # Force cached UI to show disconnected (clear any lingering IP/interface) - self._update_cached_ui_status( - status={ - "paired": True, - "trusted": True, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - }, - mac=mac, - ) - return False - - except Exception as e: - logging.error(f"[bt-tether] Reconnection error: {e}") - self._set_state( - self.STATE_DISCONNECTED, - f"Reconnection error: {str(e)[:50]}", - _connection_in_progress=False, - _connection_start_time=None, - _initializing=False, - ) - # Force cached UI to show disconnected (clear any lingering IP/interface) - self._update_cached_ui_status( - status={ - "paired": True, - "trusted": True, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - }, - mac=mac, - ) - return False - finally: - # Ensure flag is cleared - with self.lock: - if self._connection_in_progress: - self._connection_in_progress = False - - def _monitor_agent_log_for_passkey(self, passkey_found_event): - """Monitor agent log file for passkey display in real-time and auto-confirm""" - try: - import time - - logging.info("[bt-tether] Monitoring agent log for passkey...") - - # Tail the agent log file - with open(self.agent_log_path, "r") as f: - # Seek to end of file - f.seek(0, 2) - - # Monitor for configured timeout - start_time = time.time() - last_prompt = None - while time.time() - start_time < self.AGENT_LOG_MONITOR_TIMEOUT: - # Exit early if passkey found - if passkey_found_event.is_set(): - logging.info("[bt-tether] Passkey found, stopping log monitor") - break - - line = f.readline() - if line: - clean_line = self._strip_ansi_codes(line.strip()) - if clean_line: - # Look for passkey or confirmation request - if ( - "passkey" in clean_line.lower() - or "confirm passkey" in clean_line.lower() - ): - # Extract passkey number (usually 6 digits) - - passkey_match = re.search( - r"passkey\s+(\d{6})", clean_line, re.IGNORECASE - ) - if passkey_match: - self.current_passkey = passkey_match.group(1) - self._log( - "WARNING", - f"πŸ”‘ PASSKEY: {self.current_passkey} - Confirm on phone!", - ) - logging.info( - f"[bt-tether] πŸ”‘ PASSKEY: {self.current_passkey} captured from agent log" - ) - - # Update status message so it shows prominently in web UI - with self.lock: - self.status = self.STATE_PAIRING - self.message = f"πŸ”‘ PASSKEY: {self.current_passkey}\n\nVerify this matches on your phone, then tap PAIR!" - - # Auto-confirm passkey on Pwnagotchi side - if ( - self.agent_process - and self.agent_process.poll() is None - ): - try: - self._log( - "INFO", - "βœ… Auto-confirming on Pwnagotchi & waiting for phone...", - ) - if ( - self.agent_process.stdin - and not self.agent_process.stdin.closed - ): - self.agent_process.stdin.write(b"yes\n") - self.agent_process.stdin.flush() - except Exception as confirm_err: - logging.error( - f"[bt-tether] Failed to auto-confirm: {confirm_err}" - ) - - passkey_found_event.set() - elif "request confirmation" in clean_line.lower(): - self._log("INFO", f"πŸ“± {clean_line}") - elif clean_line.endswith("#"): - # Only log prompt changes to reduce spam - if clean_line != last_prompt: - last_prompt = clean_line - logging.debug(f"[bt-tether] Prompt: {clean_line}") - elif not clean_line.startswith("[CHG]"): - # Log other important output at debug level - logging.debug(f"[bt-tether] Agent: {clean_line}") - else: - # No new data, sleep briefly - time.sleep(self.DBUS_OPERATION_RETRY_DELAY) - - self._log( - "INFO", - f"Agent log monitoring timeout ({self.AGENT_LOG_MONITOR_TIMEOUT}s)", - ) - except Exception as e: - self._log("ERROR", f"Error monitoring agent log: {e}") + logging.error(f"[BT-Tether] Error on update: {e}") def on_webhook(self, path, request): - try: - # Normalize path by stripping leading slash - clean_path = path.lstrip("/") if path else "" - - if not clean_path: - with self.lock: - return render_template_string( - HTML_TEMPLATE, - mac=self.phone_mac, - status=self.status, - message=self.message, - version=self.__version__, - ) - - if clean_path == "trusted-devices": - devices = self._get_trusted_devices() - return jsonify({"devices": devices}) - - if clean_path == "connect": - mac = request.args.get("mac", "").strip().upper() - - # If MAC provided, use it; otherwise find best device automatically - if mac and self._validate_mac(mac): - with self.lock: - self.phone_mac = mac - self.options["mac"] = self.phone_mac - self.start_connection() - # Force immediate screen update to show connecting state - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update on connect: {e}" - ) - return jsonify( - {"success": True, "message": f"Connection started to {mac}"} - ) - else: - # No MAC or invalid MAC - use smart device selection - best_device = self._find_best_device_to_connect() - if best_device: - with self.lock: - self.phone_mac = best_device["mac"] - self.options["mac"] = self.phone_mac - self.start_connection() - # Force immediate screen update to show connecting state - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update on connect: {e}" - ) - return jsonify( - { - "success": True, - "message": f"Connection started to {best_device['name']} ({best_device['mac']})", - } - ) - else: - return jsonify( - { - "success": False, - "message": "No suitable devices found - pair a device first or set MAC address", - } - ) - - if clean_path == "pair-device": - mac = request.args.get("mac", "").strip().upper() - if mac and self._validate_mac(mac): - with self.lock: - self.phone_mac = mac - self.options["mac"] = self.phone_mac - - # Check if connection is already in progress - if self._connection_in_progress: - return jsonify( - { - "success": False, - "message": "Connection already in progress", - } - ) - - # Stop any ongoing background scan and set connection in progress - self._stop_scan = True - self._scanning = False - self._connection_in_progress = True - self._connection_start_time = time.time() - self._user_requested_disconnect = False - self._screen_needs_refresh = True - - # Reset failure counter - self._reconnect_failure_count = 0 - - # Unpause monitor - self._monitor_paused.clear() - - # Create device info for unpaired device (will be paired during connection) - device_info = { - "mac": mac, - "name": request.args.get("name", "Unknown Device"), - "paired": False, - "trusted": False, - "connected": False, - "has_nap": True, # Assume it has NAP, will be verified during connection - } - - # Start connection thread directly with device info - threading.Thread( - target=self._connect_thread, args=(device_info,), daemon=True - ).start() - - # Force immediate screen update to show pairing state - if self._ui_reference: - self.on_ui_update(self._ui_reference) - - return jsonify( - {"success": True, "message": f"Pairing started with {mac}"} - ) - else: - return jsonify({"success": False, "message": "Invalid MAC address"}) - - if clean_path == "status": - with self.lock: - return jsonify( - { - "status": self.status, - "message": self.message, - "mac": self.phone_mac, - "disconnecting": self._disconnecting, - "untrusting": self._untrusting, - "initializing": self._initializing, - "connection_in_progress": self._connection_in_progress, - } - ) - - if clean_path == "disconnect": - mac = request.args.get("mac", "").strip().upper() - if mac and self._validate_mac(mac): - # Set flags immediately so UI shows disconnecting state - with self.lock: - self._user_requested_disconnect = True - self._disconnecting = True - self._disconnect_start_time = ( - time.time() - ) # Track when disconnect started - self._screen_needs_refresh = True - - # Run disconnect in background thread so UI can update - def do_disconnect(): - try: - # Return value intentionally ignored - state is communicated via flags - self._disconnect_device(mac) - except Exception as e: - logging.error( - f"[bt-tether] Background disconnect error: {e}" - ) - # Ensure flags are cleared even on error - with self.lock: - self._disconnecting = False - self._connection_in_progress = False - - thread = threading.Thread(target=do_disconnect, daemon=True) - thread.start() - - # Force immediate screen update by calling on_ui_update if UI reference available - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update on disconnect: {e}" - ) - - # Return immediately so pwnagotchi UI can refresh - return jsonify({"success": True, "message": "Disconnect started"}) - else: - return jsonify({"success": False, "message": "Invalid MAC"}) - - if clean_path == "unpair": - mac = request.args.get("mac", "").strip().upper() - if mac and self._validate_mac(mac): - result = self._unpair_device(mac) - return jsonify(result) - else: - return jsonify({"success": False, "message": "Invalid MAC"}) - - if clean_path == "pair-status": - mac = request.args.get("mac", "").strip().upper() - if mac and self._validate_mac(mac): - status = self._check_pair_status(mac) - return jsonify(status) - else: - return jsonify({"paired": False, "connected": False}) - - if clean_path == "scan": - with self.lock: - # If already scanning, return current real-time results - if self._scanning: - devices_to_return = list(self._discovered_devices.values()) - return jsonify({"devices": devices_to_return, "scanning": True}) - - # Clear state for a fresh scan - self._last_scan_devices = [] - self._discovered_devices = {} - self._scan_complete_time = 0 - self._scanning = True - self._screen_needs_refresh = True - - # Run scan in background thread - def run_scan_bg(): - try: - devices = self._scan_devices() - with self.lock: - self._last_scan_devices = devices - # Rebuild _discovered_devices from final list - self._discovered_devices = { - device["mac"]: device for device in devices - } - self._scan_complete_time = time.time() - self._scanning = False # Mark scan as complete - logging.info( - f"[bt-tether] Scan complete, found {len(devices)} devices" - ) - except Exception as e: - logging.error(f"[bt-tether] Background scan error: {e}") - with self.lock: - self._scanning = False # Clear flag even on error - - thread = threading.Thread(target=run_scan_bg, daemon=True) - thread.start() - - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug(f"[bt-tether] Error forcing UI update: {e}") - - return jsonify({"devices": [], "scanning": True}) - - if clean_path == "scan-progress": - with self.lock: - devices = list(self._discovered_devices.values()) - scanning = self._scanning - return jsonify( - {"scanning": scanning, "devices": devices, "count": len(devices)} - ) - - if clean_path == "connection-status": - mac = request.args.get("mac", "").strip().upper() - if mac and self._validate_mac(mac): - status = self._get_full_connection_status(mac) - return jsonify(status) - else: - return jsonify( - { - "paired": False, - "trusted": False, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - "default_route_interface": None, - } - ) - - if clean_path == "test-internet": - result = self._test_internet_connectivity() - return jsonify(result) - - if clean_path == "logs": - with self._ui_log_lock: - logs = list(self._ui_logs) - return jsonify({"logs": logs}) - - return "Not Found", 404 - except Exception as e: - logging.error(f"[bt-tether] Webhook error: {e}") - return "Error", 500 - - def _validate_mac(self, mac): - """Validate MAC address format""" - - return bool(re.match(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$", mac)) - - def _disconnect_device(self, mac): - """Disconnect from a Bluetooth device and remove trust to prevent auto-reconnect""" - try: - # Set flags to stop auto-reconnect and indicate disconnecting state - with self.lock: - self._user_requested_disconnect = True - # Don't set _connection_in_progress during disconnect - causes "Connecting" to show - self._disconnecting = True # Set disconnecting flag for UI - self._disconnect_start_time = time.time() # Track disconnect start time - self._initializing = False # Clear initializing flag - self.status = self.STATE_DISCONNECTING # Set status for consistency - self.message = f"Disconnecting from device..." - self._screen_needs_refresh = ( - True # Force screen update to show disconnecting - ) - - # Update cached UI to show disconnecting state immediately - self._update_cached_ui_status( - status={ - "paired": True, - "trusted": True, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - }, - mac=mac, - ) - - # Wait briefly for any ongoing reconnect to complete - time.sleep(self.OPERATION_SHORT_DELAY) - - self._log("INFO", f"Disconnecting from device {mac}...") - - # FIRST: Disconnect NAP profile via DBus if connected + if not self.ready: + return """ + BT-tether: Error + Plugin not ready + """ + if path == "/" or not path: try: - import dbus - - bus = dbus.SystemBus() - manager = dbus.Interface( - bus.get_object("org.bluez", "/"), - "org.freedesktop.DBus.ObjectManager", - ) - objects = manager.GetManagedObjects() - device_path = None - for path, interfaces in objects.items(): - if "org.bluez.Device1" in interfaces: - props = interfaces["org.bluez.Device1"] - if props.get("Address") == mac: - device_path = path - break - - if device_path: - device = dbus.Interface( - bus.get_object("org.bluez", device_path), "org.bluez.Device1" - ) - try: - self._log("INFO", "Disconnecting NAP profile...") - device.DisconnectProfile(self.NAP_UUID) - time.sleep(self.DEVICE_OPERATION_DELAY) - self._log("INFO", "NAP profile disconnected") - except Exception as e: - logging.debug(f"[bt-tether] NAP disconnect: {e}") + bluetooth = self.bluetoothctl(["info", self.mac]) + bluetooth = bluetooth.stdout.replace("\n", "
") except Exception as e: - logging.debug(f"[bt-tether] DBus operation: {e}") + bluetooth = "Error while checking bluetoothctl" - # Disconnect the Bluetooth connection - self._log("INFO", "Disconnecting Bluetooth...") - result = self._run_cmd(["bluetoothctl", "disconnect", mac], capture=True) - self._log("INFO", f"Disconnect result: {result}") - time.sleep(self.DEVICE_OPERATION_LONGER_DELAY) - - # Remove trust to prevent automatic reconnection - self._log("INFO", "Removing trust to prevent auto-reconnect...") - # Keep showing "Disconnecting" state throughout the entire cleanup process - # No need to switch to "Untrusting" state - it's all part of disconnect - - # Update cached UI to show untrusting state - self._update_cached_ui_status( - status={ - "paired": True, - "trusted": False, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - }, - mac=mac, - ) - - trust_result = self._run_cmd(["bluetoothctl", "untrust", mac], capture=True) - self._log("INFO", f"Untrust result: {trust_result}") - time.sleep(self.DEVICE_OPERATION_DELAY) - - # Update cached UI status after untrust but before clearing flag - self._update_cached_ui_status( - status={ - "paired": True, - "trusted": False, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - }, - mac=mac, - ) - - with self.lock: - # Keep disconnecting state throughout - don't switch states - self._disconnect_start_time = time.time() - self.message = f"Finalizing disconnect..." - self._screen_needs_refresh = True - - # Block the device BEFORE removing it to prevent reconnection attempts - self._log("INFO", "Blocking device to prevent reconnection...") - block_result = self._run_cmd(["bluetoothctl", "block", mac], capture=True) - self._log("INFO", f"Block result: {block_result}") - time.sleep(self.DEVICE_OPERATION_DELAY) - - # Unpair (remove) the device completely - self._log("INFO", "Removing device to unpair...") - remove_result = self._run_cmd(["bluetoothctl", "remove", mac], capture=True) - self._log("INFO", f"Remove result: {remove_result}") - time.sleep( - self.DEVICE_OPERATION_LONGER_DELAY - ) # Wait longer for changes to propagate - - self._log( - "INFO", f"Device {mac} disconnected, blocked and removed successfully" - ) - - # Emit disconnection event for manual user disconnect - event_data = { - "mac": mac, - "device": self.phone_mac or "unknown", - "reason": "user_request", - } - self._emit_event("bt_tether_disconnected", event_data) - - # Update cached UI status to disconnected state FIRST - self._update_cached_ui_status( - status={ - "paired": False, - "trusted": False, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - } - ) - - # Then update internal state - with self.lock: - self.status = self.STATE_DISCONNECTED - self.message = "No device" # Show "No device" when fully disconnected - self._disconnecting = False # Clear disconnecting flag - self._disconnect_start_time = None - self._last_known_connected = False - # Clear phone_mac so monitor doesn't try to reconnect - self.phone_mac = "" - # Clear passkey after disconnect - self.current_passkey = None - self._screen_needs_refresh = True - - # Force immediate screen update to show fully disconnected state - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update on disconnected: {e}" - ) - - # Return success - return { - "success": True, - "message": f"Device {mac} disconnected, unpaired, and blocked", - } - except Exception as e: - self._log("ERROR", f"Disconnect error: {e}") - # Update cached UI status to show error FIRST - self._update_cached_ui_status() - - self._set_state( - self.STATE_ERROR, - f"Disconnect failed: {str(e)[:50]}", - _initializing=False, - ) - return {"success": False, "message": f"Disconnect failed: {str(e)}"} - finally: - # Always clear the flags, even if disconnect fails - with self.lock: - self._disconnecting = False - self._disconnect_start_time = None - self._untrusting = False - self._untrust_start_time = None - - def _unpair_device(self, mac): - """Unpair a Bluetooth device""" - try: - self._log("INFO", f"Unpairing device {mac}...") - result = self._run_cmd( - ["bluetoothctl", "remove", mac], - capture=True, - timeout=self.SUBPROCESS_TIMEOUT_LONG, - ) - - if result == "Timeout": - self._log("WARNING", "Unpair command timed out") - # Still consider it successful - device is likely already gone - return { - "success": True, - "message": "Device was already unpaired or removed", - } - elif result and "Device has been removed" in result: - self._log("INFO", f"Device {mac} unpaired successfully") - - # Update internal state - with self.lock: - self.status = self.STATE_DISCONNECTED - self.message = "Device unpaired" - self._last_known_connected = False - # Clear passkey after unpair - self.current_passkey = None - self._screen_needs_refresh = True - - # Update cached UI status - self._update_cached_ui_status() - - return { - "success": True, - "message": f"Device {mac} unpaired successfully", - } - elif result and ( - "not available" in result or "not found" in result.lower() - ): - self._log("INFO", f"Device {mac} was already removed") - return { - "success": True, - "message": f"Device {mac} was already unpaired", - } - else: - self._log("WARNING", f"Unpair result: {result}") - return {"success": True, "message": f"Unpair command sent: {result}"} - except Exception as e: - self._log("ERROR", f"Unpair error: {e}") - return {"success": False, "message": f"Unpair failed: {str(e)}"} - - def _check_pair_status(self, mac): - """Check if a device is already paired""" - try: - info = self._run_cmd(["bluetoothctl", "info", mac], capture=True) - if not info or "Device" not in info: - return {"paired": False, "connected": False, "known_to_bluez": False} - - paired = "Paired: yes" in info - connected = "Connected: yes" in info - - logging.debug( - f"[bt-tether] Device {mac} - Paired: {paired}, Connected: {connected}" - ) - return {"paired": paired, "connected": connected, "known_to_bluez": True} - except Exception as e: - self._log("ERROR", f"Pair status check error: {e}") - return {"paired": False, "connected": False, "known_to_bluez": False} - - def _get_current_status(self, mac): - """Get current connection status - no cache, direct check""" - try: - # Quick check: look for active PAN interface first (fastest indicator) - # Check for both bnep and bt-pan interfaces try: - pan_result = subprocess.run( - ["ip", "link", "show"], - capture_output=True, - text=True, - timeout=self.SUBPROCESS_TIMEOUT_MEDIUM, - ) - if pan_result.returncode == 0: - # Find the PAN interface name (bnep0, bnep1, bt-pan, etc.) - pan_iface = None - for link_line in pan_result.stdout.split("\n"): - for prefix in ("bnep", "bt-pan"): - if prefix in link_line: - # Extract interface name from lines like "5: bnep0: <...>" - match = re.search(r"\d+:\s+(\S+?)[@:]", link_line) - if match: - pan_iface = match.group(1) - break - if pan_iface: - break - - if pan_iface: - # Check if PAN interface has an IP address - try: - ip_result = subprocess.run( - ["ip", "addr", "show", pan_iface], - capture_output=True, - text=True, - timeout=self.SUBPROCESS_TIMEOUT_MEDIUM, - ) - if ( - ip_result.returncode == 0 - and "inet " in ip_result.stdout - ): - # Extract IP address from the output - ip_address = None - for line in ip_result.stdout.split("\n"): - if "inet " in line and not "127.0.0.1" in line: - parts = line.strip().split() - for part in parts: - if part.startswith("inet"): - continue - if "/" in part and "." in part: - ip_address = part.split("/")[0] - break - if ip_address: - break - - # PAN interface exists and has IP, we're connected with internet - return { - "paired": True, - "trusted": True, - "connected": True, - "pan_active": True, - "interface": pan_iface, - "ip_address": ip_address, - } - except Exception as ip_err: - logging.debug(f"[bt-tether] IP check failed: {ip_err}") - except Exception as pan_err: - logging.debug(f"[bt-tether] PAN check failed: {pan_err}") - - # Quick bluetoothctl check with minimal timeout - try: - result = subprocess.run( - ["bluetoothctl", "info", mac], - capture_output=True, - text=True, - timeout=self.SUBPROCESS_TIMEOUT_NORMAL, - ) - - if result.returncode == 0 and result.stdout: - info = result.stdout - paired = "Paired: yes" in info - connected = "Connected: yes" in info - trusted = "Trusted: yes" in info - - return { - "paired": paired, - "trusted": trusted, - "connected": connected, - "pan_active": False, # Already checked above - "interface": None, - "ip_address": None, - } - except Exception as bt_err: - logging.debug(f"[bt-tether] bluetoothctl check failed: {bt_err}") - - # Fallback to disconnected if all checks fail - return { - "paired": False, - "trusted": False, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - } - - except Exception as e: - logging.debug(f"[bt-tether] Status check error: {e}") - return { - "paired": False, - "trusted": False, - "connected": False, - "pan_active": False, - "interface": None, - "ip_address": None, - } - - def _get_full_connection_status(self, mac): - """Get complete connection status for web UI - includes additional fields""" - # Get base status - status = self._get_current_status(mac) - - # Add default_route_interface for web UI display - try: - status["default_route_interface"] = self._get_default_route_interface() - except Exception as e: - logging.debug(f"[bt-tether] Failed to get default route interface: {e}") - status["default_route_interface"] = None - - return status - - def _get_trusted_devices(self): - """Get list of all trusted Bluetooth devices with their info""" - try: - trusted_devices = [] - - # Get list of all paired devices - devices_output = self._run_cmd( - ["bluetoothctl", "devices", "Paired"], - capture=True, - timeout=self.SUBPROCESS_TIMEOUT_LONG, - ) - - if not devices_output or devices_output == "Timeout": - return trusted_devices - - # Check each device for trust status and get detailed info - for line in devices_output.split("\n"): - if line.strip() and line.startswith("Device"): - parts = line.strip().split(" ", 2) - if len(parts) >= 2: - mac = parts[1] - name = parts[2] if len(parts) > 2 else "Unknown Device" - - # Get device info to check trust status and capabilities - info = self._run_cmd( - ["bluetoothctl", "info", mac], - capture=True, - timeout=self.SUBPROCESS_TIMEOUT_STANDARD, - ) - if info and "Trusted: yes" in info: - # Parse additional device info - device_info = { - "mac": mac, - "name": name, - "trusted": True, - "paired": "Paired: yes" in info, - "connected": "Connected: yes" in info, - "has_nap": self.NAP_UUID in info, # NAP UUID - } - trusted_devices.append(device_info) - - return trusted_devices - - except Exception as e: - self._log("ERROR", f"Failed to get trusted devices: {e}") - return [] - - def _find_best_device_to_connect(self, log_results=True): - """Find the best device to connect to (trusted devices first, then configured MAC) - - Args: - log_results: Whether to log the results (default True, set False to reduce spam) - """ - try: - # First check for trusted devices with NAP capability - trusted_devices = self._get_trusted_devices() - - # Filter for devices that support NAP (tethering) - nap_devices = [d for d in trusted_devices if d["has_nap"]] - - if nap_devices: - if log_results: - self._log( - "INFO", - f"Found {len(nap_devices)} trusted device(s) with tethering capability", - ) - - # Prioritization logic: - # 1. Currently connected devices first - # 2. Devices that match configured MAC (if any) - # 3. First available device - - connected_devices = [d for d in nap_devices if d["connected"]] - if connected_devices: - device = connected_devices[0] - if log_results: - self._log( - "INFO", - f"Using already connected device: {device['name']} ({device['mac']})", - ) - return device - - # If we have a configured MAC, prefer it if it's in the trusted list - if self.phone_mac: - for device in nap_devices: - if device["mac"].upper() == self.phone_mac.upper(): - self._log( - "INFO", - f"Using configured trusted device: {device['name']} ({device['mac']})", - ) - return device - - # Return first available NAP device - device = nap_devices[0] - self._log( - "INFO", - f"Auto-selected NAP device: {device['name']} ({device['mac']})", - ) - return device - - # No devices found - # Only warn if explicitly requested to log results - if log_results: - self._log( - "WARNING", - "No trusted devices with tethering capability found", - ) - return None - - except Exception as e: - self._log("ERROR", f"Failed to find best device: {e}") - return None - - def _scan_devices(self): - """Scan for Bluetooth devices using interactive bluetoothctl session""" - try: - logging.info("[bt-tether] Starting device scan...") - # Reset stop flag at start of new scan - self._stop_scan = False - self._log("INFO", "Starting scan...") - self._log("INFO", f"Scanning for {self.SCAN_DURATION} seconds...") - discovered_devices = {} - device_types = {} # Track whether each device is NEW or already PAIRED - - # Pre-populate with cached paired devices so they appear immediately in the UI - self._log("DEBUG", "Loading existing paired devices...") - try: - paired_output = self._run_cmd( - ["bluetoothctl", "devices", "Paired"], - capture=True, - timeout=self.SUBPROCESS_TIMEOUT_STANDARD, - ) - if paired_output and paired_output != "Timeout": - for line in paired_output.split("\n"): - if line.strip() and line.startswith("Device"): - parts = line.strip().split(" ", 2) - if len(parts) >= 3: - mac = parts[1].upper() - name = parts[2] - if mac not in discovered_devices: - discovered_devices[mac] = name - device_types[mac] = "PAIRED" - self._log( - "DEBUG", - f"Pre-loaded cached device: {name} ({mac})", - ) + device = self.nmcli(["-w", "0", "device", "show", self.mac]) + device = device.stdout.replace("\n", "
") except Exception as e: - logging.debug(f"[bt-tether] Error pre-loading paired devices: {e}") + device = "Error while checking nmcli device" - # Update _discovered_devices with cached devices so /scan-progress shows - # paired devices immediately while the active scan runs - with self.lock: - self._discovered_devices = { - mac: { - "mac": mac, - "name": discovered_devices[mac], - "type": device_types.get(mac, "UNKNOWN"), - } - for mac in discovered_devices - } - - lines_read = 0 try: - # Ensure Bluetooth is powered on - self._log("DEBUG", "Ensuring Bluetooth is powered on...") - self._run_cmd( - ["bluetoothctl", "power", "on"], - timeout=self.SUBPROCESS_TIMEOUT_STANDARD, + connection = self.nmcli( + ["-w", "0", "connection", "show", self.phone_name] ) - time.sleep(self.OPERATION_SHORT_DELAY) - - mac_pattern = self.SCAN_MAC_PATTERN - ansi_pattern = self.SCAN_ANSI_PATTERN - self._log("DEBUG", "Starting bluetoothctl in interactive mode...") - scan_start = time.time() - scan_process = None - try: - env = dict(os.environ) - env["TERM"] = "dumb" - scan_process = subprocess.Popen( - ["bluetoothctl"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, # Line buffered - env=env, - ) - # Send scan on command to start scanning - scan_process.stdin.write("scan on\n") - scan_process.stdin.flush() - except Exception as e: - self._log("ERROR", f"Failed to start scan: {e}") - scan_process = None - - if scan_process: - self._log("DEBUG", f"Scanning for {self.SCAN_DURATION} seconds...") - self._log("DEBUG", f"Process started, PID: {scan_process.pid}") - scan_end_time = time.time() + self.SCAN_DURATION - try: - while time.time() < scan_end_time and not self._stop_scan: - try: - import select - - ready = select.select( - [scan_process.stdout], [], [], 0.5 - ) - if ready[0]: - line = scan_process.stdout.readline() - if not line: - break - line = line.strip() - if not line: - continue - lines_read += 1 - # Strip ANSI codes for pattern matching - clean_line = ansi_pattern.sub("", line) - # Parse discovery events: "[NEW] Device MAC Name" - if "[NEW]" in clean_line and "Device" in clean_line: - mac_match = mac_pattern.search(clean_line) - if mac_match: - mac = mac_match.group(1).upper() - remainder = clean_line[ - mac_match.end(): - ].strip() - name = ( - remainder if remainder else "(unnamed)" - ) - if mac not in discovered_devices: - discovered_devices[mac] = name - device_types[mac] = "NEW" - self._log( - "INFO", - f"[NEW] {name} ({mac})", - ) - # Update real-time list for /scan-progress - with self.lock: - self._discovered_devices[mac] = { - "mac": mac, - "name": name, - "type": device_types[mac], - } - except select.error: - pass - finally: - # Stop scan and close bluetoothctl - self._log("DEBUG", "Stopping scan...") - try: - try: - self._run_cmd( - ["bluetoothctl", "scan", "off"], - timeout=self.SUBPROCESS_TIMEOUT_NORMAL, - ) - except Exception: - pass - time.sleep(self.SCAN_STOP_DELAY) - scan_process.stdin.write("quit\n") - scan_process.stdin.flush() - try: - scan_process.wait( - timeout=self.SUBPROCESS_TIMEOUT_MEDIUM - ) - logging.info( - "[bt-tether] Bluetoothctl process exited cleanly" - ) - except subprocess.TimeoutExpired: - logging.info( - "[bt-tether] Force killing bluetoothctl after timeout" - ) - scan_process.kill() - scan_process.wait(timeout=self.SUBPROCESS_TIMEOUT_SHORT) - except Exception as e: - logging.debug(f"[bt-tether] Error stopping scan: {e}") - try: - scan_process.kill() - except Exception: - pass - - elapsed = time.time() - scan_start - self._log( - "INFO", - f"Scan completed in {elapsed:.1f}s, found {len(discovered_devices)} device(s)", - ) + connection = connection.stdout.replace("\n", "
") except Exception as e: - self._log("ERROR", f"Error during scan: {e}") - logging.exception("[bt-tether] Scan exception:") + connection = "Error while checking nmcli connection" - # Pick up any devices that were paired during the scan itself - self._log("DEBUG", "Checking for any newly paired devices...") - try: - paired_output = self._run_cmd( - ["bluetoothctl", "devices", "Paired"], - capture=True, - timeout=self.SUBPROCESS_TIMEOUT_STANDARD, - ) - if paired_output and paired_output != "Timeout": - for line in paired_output.split("\n"): - if line.strip() and line.startswith("Device"): - parts = line.strip().split(" ", 2) - if len(parts) >= 3: - mac = parts[1].upper() - name = parts[2] - if mac not in discovered_devices: - discovered_devices[mac] = name - device_types[mac] = "PAIRED" - with self.lock: - self._discovered_devices[mac] = { - "mac": mac, - "name": name, - "type": "PAIRED", - } - self._log( - "INFO", - f"Found device paired during scan: {name} ({mac})", - ) - except Exception as e: - logging.debug( - f"[bt-tether] Error checking for newly paired devices: {e}" - ) - - # Convert to list format - devices = [ - { - "mac": mac, - "name": discovered_devices[mac], - "type": device_types.get(mac, "UNKNOWN"), - } - for mac in discovered_devices - ] - logging.info(f"[bt-tether] Scan complete. Found {len(devices)} devices") - if devices: - self._log("INFO", f"=== Discovered {len(devices)} device(s) ===") - for i, device in enumerate(devices, 1): - self._log("INFO", f" [{i}] {device['name']} ({device['mac']})") - else: - self._log("WARNING", "No devices found during scan") - self._log("WARNING", "Ensure phone Bluetooth is ON and discoverable") - return devices - - except Exception as e: - self._log("ERROR", f"Scan error: {e}") - logging.exception("[bt-tether] Scan exception:") - return [] - - def start_connection(self): - with self.lock: - # Find the best device to connect to (trusted devices or configured MAC) - best_device = self._find_best_device_to_connect() - - if not best_device: - self.status = self.STATE_ERROR - self.message = "No trusted devices found - scan and pair a device first" - self._screen_needs_refresh = True - return - - # Update current target MAC - self.phone_mac = best_device["mac"] - self.options["mac"] = self.phone_mac - - # Check if connection is already in progress (prevents multiple threads) - if self._connection_in_progress: - self._log( - "WARNING", - "Connection already in progress, ignoring duplicate request", - ) - self.message = "Connection already in progress" - self._screen_needs_refresh = True - return - - if self.status in [self.STATE_PAIRING, self.STATE_CONNECTING]: - self._log( - "WARNING", "Already pairing/connecting, ignoring duplicate request" - ) - self.message = "Connection already in progress" - self._screen_needs_refresh = True - return - - # Set flag INSIDE the lock to prevent race condition - self._connection_in_progress = True - self._connection_start_time = time.time() # Track when connection started - self._user_requested_disconnect = False # Re-enable auto-reconnect - self.status = self.STATE_CONNECTING - self.message = f"Connecting to {best_device['name']}..." - self.phone_mac = best_device[ - "mac" - ] # Set phone_mac immediately so screen knows which device we're connecting to - - # Update cached UI status immediately so screen shows connecting state - # Use current status - device may be paired/trusted from before - self._update_cached_ui_status(mac=best_device["mac"]) - - # Reset failure counter on manual reconnect - self._reconnect_failure_count = 0 - - # Unpause monitor since we have a device to monitor - self._monitor_paused.clear() - - # Pass device info to connection thread - threading.Thread( - target=self._connect_thread, args=(best_device,), daemon=True - ).start() - - def _connect_thread(self, target_device): - """Full automatic connection thread with pairing and connection logic""" - try: - mac = target_device["mac"] - device_name = target_device["name"] - self._log("INFO", f"Starting connection to {device_name} ({mac})...") - - # Check if Bluetooth is responsive, restart if needed - if not self._restart_bluetooth_if_needed(): - self._log( - "ERROR", - "Bluetooth service is unresponsive and couldn't be restarted", - ) - with self.lock: - self.status = self.STATE_ERROR - self.message = "Bluetooth service unresponsive. Try: sudo systemctl restart bluetooth" - self._connection_in_progress = False - return - - # Make Pwnagotchi discoverable and pairable - self._log("INFO", f"Making Pwnagotchi discoverable...") - with self.lock: - self.message = f"Making Pwnagotchi discoverable for {device_name}..." - self._screen_needs_refresh = True - self._run_cmd(["bluetoothctl", "discoverable", "on"], capture=True) - self._run_cmd(["bluetoothctl", "pairable", "on"], capture=True) - time.sleep(self.DEVICE_OPERATION_LONGER_DELAY) - - # First check current pairing status - with self.lock: - self.message = f"Checking pairing status with {device_name}..." - self._screen_needs_refresh = True - pair_status = self._check_pair_status(mac) - - # If device is not trusted/paired, we need to pair first - if not pair_status["paired"]: - known_to_bluez = pair_status.get("known_to_bluez", False) - - if known_to_bluez: - # BlueZ has a stale/broken bond β€” remove it for a clean pair - self._log( - "INFO", - "Device not paired but known to BlueZ. Removing stale bond...", - ) - with self.lock: - self.message = f"Clearing stale pairing with {device_name}..." - self._screen_needs_refresh = True - self._run_cmd(["bluetoothctl", "remove", mac], capture=True) - time.sleep(self.DEVICE_OPERATION_DELAY) - needs_discovery = True # remove wiped BlueZ cache, must rediscover - else: - # Truly new device β€” BlueZ already knows it from the background scan - self._log( - "INFO", "Device not yet paired. Starting fresh pairing..." - ) - needs_discovery = False # BlueZ still has it from scan - - self._log("INFO", f"Unblocking {device_name} in case it was blocked...") - with self.lock: - self.message = f"Unblocking {device_name}..." - self._screen_needs_refresh = True - self._run_cmd(["bluetoothctl", "unblock", mac], capture=True) - time.sleep(self.DEVICE_OPERATION_DELAY) - - # Start pairing process - set PAIRING state - self._log( - "INFO", - f"Device not paired. Starting pairing process with {device_name}...", - ) - with self.lock: - self.status = self.STATE_PAIRING - self.message = f"Pairing with {device_name}..." - self._screen_needs_refresh = True - - # Brief delay to ensure PAIRING state is displayed - time.sleep(self.OPERATION_SHORT_DELAY) - - # Attempt pairing - this will show dialog on phone - if not self._pair_device_interactive( - mac, needs_discovery=needs_discovery - ): - self._log("ERROR", f"Pairing with {device_name} failed!") - with self.lock: - self.status = self.STATE_ERROR - self.message = f"Pairing with {device_name} failed. Did you accept the dialog?" - self._connection_in_progress = False - self._screen_needs_refresh = True - # Force immediate screen update to show error state - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update on pairing error: {e}" - ) - return - - self._log("INFO", f"Pairing with {device_name} successful!") - else: - self._log("INFO", f"Device {device_name} already paired") - with self.lock: - self.message = f"Device {device_name} already paired βœ“" - self._screen_needs_refresh = True - - # Trust the device - set TRUSTING state - logging.info(f"[bt-tether] Trusting device {device_name}...") - with self.lock: - self.status = self.STATE_TRUSTING - self.message = f"Trusting {device_name}..." - self._screen_needs_refresh = True - - # Brief delay to ensure TRUSTING state is displayed - time.sleep(self.OPERATION_SHORT_DELAY) - - self._run_cmd(["bluetoothctl", "trust", mac]) - - # Wait until the phone's NAP service UUID appears in bluetoothctl info. - # This is more reliable than a fixed sleep: the NAP UUID appearing means - # the phone's tethering/NAP service is actually ready to accept connections. - # br-connection-create-socket occurs when we connect before this is ready. - NAP_UUID = "00001116" # NAP profile UUID prefix - NAP_WAIT_TIMEOUT = 15 - logging.info( - f"[bt-tether] Waiting for {device_name} NAP service to be ready..." + logging.debug(device) + return render_template_string( + TEMPLATE, + title="BT-Tether", + bluetooth=bluetooth, + device=device, + connection=connection, ) - with self.lock: - self.message = f"Waiting for {device_name} to be ready..." - self._screen_needs_refresh = True - nap_ready = False - nap_wait_start = time.time() - while time.time() - nap_wait_start < NAP_WAIT_TIMEOUT: - info = self._run_cmd( - ["bluetoothctl", "info", mac], - capture=True, - timeout=self.SUBPROCESS_TIMEOUT_NORMAL, - ) - if info and NAP_UUID in info: - elapsed = time.time() - nap_wait_start - logging.info(f"[bt-tether] NAP service ready after {elapsed:.1f}s") - nap_ready = True - break - time.sleep(self.DEVICE_OPERATION_DELAY) - if not nap_ready: - logging.warning( - f"[bt-tether] NAP UUID not seen after {NAP_WAIT_TIMEOUT}s - proceeding anyway" - ) - - # Proceed directly to NAP connection (this establishes BT connection if needed) - self._log("INFO", "Connecting to NAP profile...") - with self.lock: - self.status = self.STATE_CONNECTING - self.message = "Connecting to NAP profile for internet..." - self._screen_needs_refresh = True - - # Brief delay to ensure CONNECTING state is displayed - time.sleep(self.OPERATION_SHORT_DELAY) - - # Try to establish PAN connection - self._log("INFO", "Establishing PAN connection...") - with self.lock: - self.status = self.STATE_CONNECTING - self.message = "Connecting to NAP profile for internet..." - self._screen_needs_refresh = True - - # Try DBus connection to NAP profile (with retry for br-connection-busy) - nap_connected = False - for retry in range(3): - if retry > 0: - self._log( - "info", f"Retrying NAP connection (attempt {retry + 1}/3)..." - ) - with self.lock: - self.message = f"NAP retry {retry + 1}/3..." - self._screen_needs_refresh = True - time.sleep( - self.OPERATION_MEDIUM_DELAY - ) # Wait for previous connection attempt to settle - - nap_connected = self._connect_nap_dbus(mac) - if nap_connected: - break - else: - self._log("WARNING", f"NAP attempt {retry + 1} failed") - with self.lock: - self.message = f"NAP attempt {retry + 1}/3 failed..." - self._screen_needs_refresh = True - - if nap_connected: - self._log("INFO", "NAP connection successful!") - - # Check if PAN interface is up - if self._pan_active(): - iface = self._get_pan_interface() - self._log("INFO", f"βœ“ PAN interface active: {iface}") - - # Wait for interface initialization - self._log("INFO", "Waiting for interface initialization...") - time.sleep(2) - - # Setup network with DHCP - if self._setup_network_dhcp(iface): - self._log("INFO", "βœ“ Network setup successful") - - # Ensure DNS is configured from DHCP - self._log("INFO", "Verifying DNS configuration...") - try: - with open("/etc/resolv.conf", "r") as f: - resolv_content = f.read() - nameservers = [ - line.strip() - for line in resolv_content.split("\n") - if line.strip().startswith("nameserver") - ] - if nameservers: - self._log( - "INFO", - f"βœ“ DNS configured: {', '.join([ns.split()[1] for ns in nameservers])}", - ) - else: - self._log( - "WARNING", - "No nameservers found in /etc/resolv.conf - DNS may not work", - ) - except Exception as e: - self._log("WARNING", f"Could not verify DNS config: {e}") - else: - self._log( - "warning", - "Network setup failed, connection may not work", - ) - - # Wait a bit for network to stabilize - time.sleep(self.DEVICE_OPERATION_LONGER_DELAY) - - # Verify internet connectivity - self._log("INFO", "Checking internet connectivity...") - with self.lock: - self.message = "Verifying internet connection..." - self._screen_needs_refresh = True - - if self._check_internet_connectivity(): - self._log("INFO", "βœ“ Internet connectivity verified!") - - # Get current IP address for event data - try: - current_ip = self._get_current_ip() - if current_ip: - self._log("INFO", f"Current IP address: {current_ip}") - - # Now test DNS resolution after we have confirmed IP - self._log("INFO", "Testing DNS resolution...") - try: - import socket - - socket.gethostbyname("google.com") - self._log("INFO", "βœ“ DNS resolution working") - except socket.gaierror: - self._log( - "WARNING", - "DNS resolution failed - check /etc/resolv.conf", - ) - except Exception as dns_e: - self._log("WARNING", f"DNS test error: {dns_e}") - except Exception as e: - self._log("ERROR", f"Failed to get IP or test DNS: {e}") - - # Update cached UI status with fresh data FIRST - self._update_cached_ui_status(mac=mac) - - # Emit connected event for plugins to listen to - self._emit_event( - "bt_tether_connected", - { - "mac": mac, - "device": device_name, - "ip": self._get_current_ip() or "unknown", - "interface": iface, - }, - ) - - # Then set status and clear flags atomically - with self.lock: - self.status = self.STATE_CONNECTED - self.message = f"βœ“ Connected! Internet via {iface}" - self._connection_in_progress = False - self._connection_start_time = None - self._initializing = False - self._screen_needs_refresh = True - - # Log for debugging - self._log("DEBUG", "Connection complete, flags cleared") - - # Force immediate screen update to show IP/connected state - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update on success: {e}" - ) - - else: - self._log("WARNING", "No internet connectivity detected") - # Update cached UI status FIRST - self._update_cached_ui_status(mac=mac) - - with self.lock: - self.status = self.STATE_CONNECTED - self.message = ( - f"Connected via {iface} but no internet access" - ) - self._connection_in_progress = False - self._connection_start_time = None - self._initializing = False - self._screen_needs_refresh = True - - # Force immediate screen update - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update on no-internet: {e}" - ) - else: - self._log("WARNING", "NAP connected but no interface detected") - # Update cached UI status first - self._update_cached_ui_status(mac=mac) - - with self.lock: - self.status = self.STATE_CONNECTED - self.message = "Connected but no internet. Enable Bluetooth tethering on phone." - self._connection_in_progress = False - self._connection_start_time = None - self._initializing = False - self._screen_needs_refresh = True - else: - self._log("WARNING", "NAP connection failed") - - # Update cached UI status FIRST - self._update_cached_ui_status(mac=mac) - - # Then clear flags so on_ui_update doesn't show connecting - with self.lock: - self.status = self.STATE_CONNECTED - self.message = "Bluetooth connected but tethering failed. Enable tethering on phone." - self._connection_in_progress = False # Clear connection flag - self._connection_start_time = None - self._initializing = False # Clear initializing flag - self._screen_needs_refresh = True - # Force immediate screen update - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update on NAP failure: {e}" - ) - - except Exception as e: - self._log("ERROR", f"Connection thread error: {e}") - self._log("ERROR", f"Traceback: {traceback.format_exc()}") - # Update cached UI status to show error FIRST - self._update_cached_ui_status() - - self._set_state( - self.STATE_ERROR, - f"Connection error: {str(e)}", - _connection_in_progress=False, - _connection_start_time=None, - ) - finally: - # Clear the flag if not already cleared (error cases) - with self.lock: - if self._connection_in_progress: - self._connection_in_progress = False - self._connection_start_time = None - - # Force immediate screen update to show final state (connected or error) - if self._ui_reference: - try: - self.on_ui_update(self._ui_reference) - except Exception as e: - logging.debug( - f"[bt-tether] Error forcing UI update in finally: {e}" - ) - - def _strip_ansi_codes(self, text): - """Remove ANSI color/control codes from text""" - if not text: - return text - - # Remove ANSI escape sequences - ansi_escape = re.compile(r"\x1b\[[0-9;]*[mGKHF]|\x01|\x02") - text = ansi_escape.sub("", text) - - # Filter out bluetoothctl status lines ([CHG], [DEL], [NEW]) to prevent log parser errors - # These cause pwnagotchi's log parser to throw errors like "time data 'CHG' does not match format" - lines = text.split("\n") - filtered_lines = [] - for line in lines: - # Skip lines that start with bluetoothctl status markers - stripped = line.strip() - if not ( - stripped.startswith("[CHG]") - or stripped.startswith("[DEL]") - or stripped.startswith("[NEW]") - ): - filtered_lines.append(line) - - return "\n".join(filtered_lines) - - def _check_bluetooth_responsive(self): - """Quick check if bluetoothctl is responsive""" - try: - result = subprocess.run( - ["bluetoothctl", "show"], - capture_output=True, - timeout=self.SUBPROCESS_TIMEOUT_NORMAL, # Short timeout for health check - text=True, - ) - return result.returncode == 0 - except Exception as e: - logging.debug(f"[bt-tether] Bluetooth responsive check failed: {e}") - return False - - def _restart_bluetooth_if_needed(self): - """Restart Bluetooth service if it's unresponsive""" - if not self._check_bluetooth_responsive(): - logging.warning("[bt-tether] Bluetooth appears hung, restarting service...") - try: - subprocess.run( - ["pkill", "-9", "bluetoothctl"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=self.SUBPROCESS_TIMEOUT_MEDIUM, - ) - subprocess.run( - ["systemctl", "restart", "bluetooth"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=self.SUBPROCESS_TIMEOUT_STANDARD, # Reduced timeout for RPi Zero W2 - ) - time.sleep(self.OPERATION_MEDIUM_DELAY) # Extra time on slow hardware - self._log("INFO", "Bluetooth service restarted") - return True - except Exception as e: - self._log("ERROR", f"Failed to restart Bluetooth: {e}") - return False - return True - - def _run_cmd(self, cmd, capture=False, timeout=None): - """Run shell command with error handling and deadlock prevention""" - if timeout is None: - timeout = self.SUBPROCESS_TIMEOUT_LONG - # Use lock to prevent multiple bluetoothctl commands from running simultaneously - with self._bluetoothctl_lock: - try: - # Disable bluetoothctl color output to prevent ANSI codes in logs - env = dict(os.environ) - env["NO_COLOR"] = "1" # Standard env var to disable colors - env["TERM"] = "dumb" # Make terminal report as non-color capable - - if capture: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=timeout, env=env - ) - # Return combined output with ANSI codes stripped to prevent log parser errors - output = result.stdout + result.stderr - return self._strip_ansi_codes(output) - else: - subprocess.run( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=timeout, - env=env, - ) - return None - except subprocess.TimeoutExpired: - logging.error( - f"[bt-tether] Command timeout ({timeout}s): {' '.join(cmd)}" - ) - # Kill hung bluetoothctl after timeout (only if it's a bluetoothctl command) - if cmd and cmd[0] == "bluetoothctl": - try: - subprocess.run( - ["pkill", "-9", "bluetoothctl"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=2, - ) - time.sleep( - self.PROCESS_CLEANUP_DELAY - ) # Brief pause to let process die - except Exception as e: - self._log("DEBUG", f"Process kill failed: {e}") - return "Timeout" - except Exception as e: - logging.error(f"[bt-tether] Command failed: {' '.join(cmd)}") - logging.error(f"[bt-tether] Exception: {e}") - return None - - def _setup_network_dhcp(self, iface): - """Setup network for bnep0 interface using dhclient""" - try: - self._log("INFO", f"Setting up network for {iface}...") - - # Ensure interface is up - self._log("INFO", f"Ensuring {iface} is up...") - subprocess.run( - ["sudo", "ip", "link", "set", iface, "up"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=5, - ) - - # Use dhclient directly (more reliable for Bluetooth PAN) - return self._setup_dhclient(iface) - - except subprocess.TimeoutExpired: - self._log("ERROR", "Network setup timed out") - return False - except Exception as e: - self._log("ERROR", f"Network setup error: {e}") - return False - - def _kill_dhclient_for_interface(self, iface): - """Kill dhclient processes specifically managing the given interface. - - Uses PID-based targeting to avoid killing dhclient processes for other interfaces. - Only kills processes where the interface appears as a separate argument. - """ - try: - # Get all dhclient PIDs - result = subprocess.run( - ["pidof", "dhclient"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=3, - ) - - if result.returncode != 0 or not result.stdout.strip(): - # No dhclient processes running - return - - pids = result.stdout.strip().split() - killed_any = False - - for pid in pids: - try: - # Get command line for this PID - ps_result = subprocess.run( - ["ps", "-p", pid, "-o", "args="], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=2, - ) - - if ps_result.returncode != 0: - continue - - cmdline = ps_result.stdout.strip() - - # Parse dhclient command line more carefully - # dhclient command format: dhclient [options] [interface] - # The interface is typically the last argument - args = cmdline.split() - - # The interface must be the LAST argument and match EXACTLY - # This prevents matching "dhclient eth0" when looking for "eth0-backup" - # or "dhclient bnep0" matching a config file path containing "bnep0" - if args and args[-1] == iface: - self._log( - "DEBUG", - f"Killing dhclient PID {pid} for {iface} (cmdline: {cmdline})", - ) - subprocess.run( - ["sudo", "kill", pid], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=3, - ) - killed_any = True - else: - self._log( - "DEBUG", - f"Skipping PID {pid} - not managing {iface} (cmdline: {cmdline})", - ) - except Exception as e: - self._log("DEBUG", f"Error checking PID {pid}: {e}") - continue - - if killed_any: - time.sleep( - self.OPERATION_SHORT_DELAY - ) # Brief wait for processes to exit - - except Exception as e: - self._log("DEBUG", f"Error in _kill_dhclient_for_interface: {e}") - - def _setup_dhclient(self, iface): - """Request DHCP on interface""" - try: - self._log("INFO", f"Setting up {iface} for DHCP...") - - # Bring interface up - subprocess.run( - ["sudo", "ip", "link", "set", iface, "up"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=5, - ) - - # Check which DHCP client is available - has_dhcpcd = ( - subprocess.run(["which", "dhcpcd"], capture_output=True).returncode == 0 - ) - has_dhclient = ( - subprocess.run(["which", "dhclient"], capture_output=True).returncode - == 0 - ) - - self._log("INFO", f"Requesting DHCP on {iface}...") - dhcp_success = False - - if has_dhcpcd: - self._log("INFO", "Using dhcpcd...") - # Release any existing lease first - subprocess.run( - ["sudo", "dhcpcd", "-k", iface], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=5, - ) - time.sleep(self.DHCP_RELEASE_WAIT) - # Request new lease - result = subprocess.run( - ["sudo", "dhcpcd", "-4", "-n", iface], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=20, - ) - if result.stdout.strip(): - self._log("INFO", f"dhcpcd: {result.stdout.strip()}") - if result.returncode == 0: - dhcp_success = True - else: - self._log("WARNING", f"dhcpcd failed: {result.stderr.strip()}") - - elif has_dhclient: - self._log("INFO", "Using dhclient...") - # Kill any existing dhclient for this interface (PID-based targeting) - self._kill_dhclient_for_interface(iface) - time.sleep(self.DHCP_KILL_WAIT) - - # Request new lease with better error handling - try: - result = subprocess.run( - ["sudo", "dhclient", "-4", "-v", iface], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=30, - ) - combined = f"{result.stdout} {result.stderr}".strip() - - # Check for common error messages - if "Network error: Software caused connection abort" in combined: - self._log("WARNING", "dhclient: Connection aborted by phone") - self._log( - "WARNING", - "πŸ“± Make sure Bluetooth tethering is ENABLED on your phone!", - ) - self._log( - "WARNING", - "πŸ“± Settings β†’ Network β†’ Hotspot & tethering β†’ Bluetooth tethering", - ) - elif "DHCPDISCOVER" in combined and "No DHCPOFFERS" in combined: - self._log("WARNING", "dhclient: No DHCP response from phone") - self._log( - "WARNING", - "πŸ“± Phone is not providing DHCP - enable Bluetooth tethering!", - ) - - # Only log dhclient output if there's an error - if result.returncode != 0 and combined: - # Truncate long output - self._log("INFO", f"dhclient: {combined[:200]}") - - if result.returncode == 0: - dhcp_success = True - else: - self._log("WARNING", f"dhclient returned {result.returncode}") - - except subprocess.TimeoutExpired: - self._log("WARNING", "dhclient timed out after 30s") - # Kill hung dhclient (PID-based targeting) - try: - # Get all dhclient PIDs - result = subprocess.run( - ["pidof", "dhclient"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=3, - ) - - if result.returncode == 0 and result.stdout.strip(): - pids = result.stdout.strip().split() - - for pid in pids: - try: - # Get command line for this PID - ps_result = subprocess.run( - ["ps", "-p", pid, "-o", "args="], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=2, - ) - - if ps_result.returncode != 0: - continue - - cmdline = ps_result.stdout.strip() - - # Check if this dhclient is managing our interface - # The interface MUST be the last argument - args = cmdline.split() - if args and args[-1] == iface: - self._log( - "DEBUG", - f"Force killing dhclient PID {pid} for {iface} (cmdline: {cmdline})", - ) - subprocess.run( - ["sudo", "kill", "-9", pid], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=3, - ) - else: - self._log( - "DEBUG", - f"Skipping force-kill PID {pid} - not managing {iface} (cmdline: {cmdline})", - ) - except Exception as e: - self._log( - "DEBUG", f"Error force-killing PID {pid}: {e}" - ) - continue - except Exception as e: - self._log("DEBUG", f"Error in timeout dhclient cleanup: {e}") - - else: - self._log( - "ERROR", - "No DHCP client found! Install dhclient: sudo apt install isc-dhcp-client", - ) - return False - - # Check for IP with extended wait time (tethering may take time to fully start) - ip_addr = None - max_checks = 8 # Increased from 5 to give more time - - for attempt in range(max_checks): - ip_result = subprocess.run( - ["ip", "addr", "show", iface], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=5, - ) - - if ip_result.returncode == 0: - ip_match = re.search( - r"inet\s+(\d+\.\d+\.\d+\.\d+)", ip_result.stdout - ) - if ip_match: - ip_addr = ip_match.group(1) - if not ip_addr.startswith("169.254."): - self._log("INFO", f"βœ“ {iface} got IP: {ip_addr}") - break - else: - self._log( - "DEBUG", f"Link-local IP {ip_addr}, waiting for DHCP..." - ) - ip_addr = None - - if attempt < max_checks - 1: - self._log("DEBUG", f"Waiting for IP... ({(attempt + 1) * 2}s)") - time.sleep(2) - - if ip_addr: - self._verify_localhost_route() - return True - else: - self._log("ERROR", f"❌ No IP on {iface} after {max_checks * 2}s") - self._log("ERROR", "πŸ“± Enable Bluetooth tethering on your phone!") - self._log( - "ERROR", - "πŸ“± Settings β†’ Network & internet β†’ Hotspot & tethering β†’ Bluetooth tethering", - ) - return False - - except Exception as e: - logging.error(f"[bt-tether] Network setup error: {e}") - return False - - def _verify_localhost_route(self): - """Verify localhost routes correctly through loopback interface (critical for bettercap API)""" - try: - # Check localhost routing - result = subprocess.run( - ["ip", "route", "get", "127.0.0.1"], - capture_output=True, - text=True, - timeout=3, - ) - - if result.returncode == 0: - route_output = result.stdout.strip() - # Localhost should use 'lo' interface or 'local' keyword - if "lo" not in route_output and "local" not in route_output: - logging.warning( - f"[bt-tether] ⚠️ Localhost routing misconfigured: {route_output}" - ) - logging.warning( - "[bt-tether] ⚠️ This may prevent bettercap API from working!" - ) - logging.info("[bt-tether] Attempting to fix localhost route...") - - # Ensure loopback interface is up - subprocess.run( - ["sudo", "ip", "link", "set", "lo", "up"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=3, - ) - - # Add explicit localhost route if missing - subprocess.run( - ["sudo", "ip", "route", "add", "127.0.0.0/8", "dev", "lo"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=3, - ) - - logging.info("[bt-tether] βœ“ Localhost route protection applied") - else: - logging.debug(f"[bt-tether] Localhost route OK: {route_output}") - else: - logging.warning("[bt-tether] Could not verify localhost routing") - - except Exception as e: - logging.error(f"[bt-tether] Localhost route verification failed: {e}") - - def _check_internet_connectivity(self): - """Check if internet is accessible via Bluetooth interface specifically""" - try: - # Get the BT interface - bt_iface = self._get_pan_interface() or "bnep0" - - # First verify bnep0 has an IP - if not, no point testing connectivity - ip_result = subprocess.run( - ["ip", "addr", "show", bt_iface], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=5, - ) - - if ip_result.returncode != 0: - logging.warning(f"[bt-tether] {bt_iface} interface not found") - return False - - ip_match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", ip_result.stdout) - if not ip_match or ip_match.group(1).startswith("169.254."): - logging.warning(f"[bt-tether] {bt_iface} has no valid IP") - return False - - bt_ip = ip_match.group(1) - logging.info(f"[bt-tether] {bt_iface} has IP: {bt_ip}") - - # Log current routing table for diagnostics - route_check = subprocess.run( - ["ip", "route", "show"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=5, - ) - if route_check.returncode == 0: - logging.info(f"[bt-tether] Current routes:\n{route_check.stdout}") - - # Ping via the Bluetooth interface specifically - logging.info( - f"[bt-tether] Testing connectivity to 8.8.8.8 via {bt_iface}..." - ) - result = subprocess.run( - ["ping", "-c", "2", "-W", "3", "-I", bt_iface, "8.8.8.8"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=10, - ) - - if result.returncode == 0: - logging.info(f"[bt-tether] βœ“ Ping to 8.8.8.8 successful") - return True - else: - logging.warning(f"[bt-tether] Ping to 8.8.8.8 failed") - logging.warning(f"[bt-tether] Ping stderr: {result.stderr}") - logging.warning(f"[bt-tether] Ping stdout: {result.stdout}") - - # Try to ping the gateway to see if that works - gateway_check = subprocess.run( - ["ip", "route", "show", "default"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=5, - ) - if gateway_check.returncode == 0 and gateway_check.stdout: - - match = re.search(r"default via ([\d.]+)", gateway_check.stdout) - if match: - gateway = match.group(1) - logging.info( - f"[bt-tether] Testing connectivity to gateway {gateway}..." - ) - gw_result = subprocess.run( - ["ping", "-c", "2", "-W", "3", gateway], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=10, - ) - if gw_result.returncode == 0: - logging.warning( - f"[bt-tether] Gateway ping works, but internet ping failed - possible NAT/firewall issue" - ) - else: - logging.warning( - f"[bt-tether] Gateway ping also failed - phone may not be providing internet" - ) - - return False - except subprocess.TimeoutExpired: - logging.warning(f"[bt-tether] Ping timeout - no internet connectivity") - return False - except Exception as e: - logging.error(f"[bt-tether] Internet check error: {e}") - return False - - def _pan_active(self): - """Check if any PAN interface (bnep/bt-pan) is active - optimized for RPi Zero W2""" - try: - # More efficient: use ip link show instead of full ip a output - result = subprocess.run( - ["ip", "link", "show"], - capture_output=True, - text=True, - timeout=3, # Reduced timeout for efficiency - ) - - # Check for both bnep and bt-pan interfaces - has_bnep = "bnep" in result.stdout - has_bt_pan = "bt-pan" in result.stdout - - if has_bnep or has_bt_pan: - logging.debug( - f"[bt-tether] Found PAN interface (bnep={has_bnep}, bt-pan={has_bt_pan})" - ) - return True - - logging.debug("[bt-tether] No PAN interface found (bnep/bt-pan)") - return False - except Exception as e: - logging.error(f"[bt-tether] Failed to check PAN: {e}") - return False - - def _get_default_route_interface(self): - """Get the network interface that has the default route (lowest metric)""" - try: - result = subprocess.check_output( - ["ip", "route", "show", "default"], text=True, timeout=5 - ) - - if not result: - return None - - # Parse default route lines to find the one with lowest metric - # Format: "default via 192.168.1.1 dev eth0 metric 100" - - routes = [] - for line in result.strip().split("\n"): - if "default" in line: - # Extract interface name - dev_match = re.search(r"dev\s+(\S+)", line) - if dev_match: - iface = dev_match.group(1) - - # Extract metric (default to 0 if not specified) - metric_match = re.search(r"metric\s+(\d+)", line) - metric = int(metric_match.group(1)) if metric_match else 0 - - routes.append((iface, metric)) - - if not routes: - return None - - # Sort by metric (lowest first) and return the interface - routes.sort(key=lambda x: x[1]) - return routes[0][0] - - except Exception as e: - logging.debug(f"[bt-tether] Failed to get default route: {e}") - return None - - def _test_internet_connectivity(self): - """Test internet connectivity and return detailed results""" - try: - result = { - "ping_success": False, - "dns_success": False, - "bnep0_ip": None, - "default_route": None, - "dns_servers": None, - "dns_error": None, - "localhost_routes": None, - } - - # Test ping to 8.8.8.8 - try: - ping_result = subprocess.run( - ["ping", "-c", "2", "-W", "3", "8.8.8.8"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=5, - ) - result["ping_success"] = ping_result.returncode == 0 - logging.info( - f"[bt-tether] Ping test: {'Success' if result['ping_success'] else 'Failed'}" - ) - except Exception as e: - logging.warning(f"[bt-tether] Ping test error: {e}") - - # Test DNS resolution - try: - import socket - - # Try to resolve google.com using Python's socket library - socket.gethostbyname("google.com") - result["dns_success"] = True - logging.info("[bt-tether] DNS test: Success") - except socket.gaierror as e: - result["dns_success"] = False - result["dns_error"] = f"DNS resolution failed: {str(e)}" - logging.warning(f"[bt-tether] DNS test failed: {e}") - except Exception as e: - result["dns_success"] = False - result["dns_error"] = str(e) - logging.warning(f"[bt-tether] DNS test error: {e}") - - # Get DNS servers from resolv.conf - try: - with open("/etc/resolv.conf", "r") as f: - resolv_content = f.read() - dns_servers = [] - for line in resolv_content.split("\n"): - if line.strip().startswith("nameserver"): - dns_servers.append(line.strip().split()[1]) - result["dns_servers"] = ( - ", ".join(dns_servers) if dns_servers else "None" - ) - logging.info(f"[bt-tether] DNS servers: {result['dns_servers']}") - except Exception as e: - result["dns_servers"] = f"Error: {str(e)[:50]}" - logging.warning(f"[bt-tether] Get DNS servers error: {e}") - - # Get bnep0 IP address - try: - ip_result = subprocess.run( - ["ip", "addr", "show", "bnep0"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=5, - ) - if ip_result.returncode == 0: - - ip_match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", ip_result.stdout) - if ip_match: - result["bnep0_ip"] = ip_match.group(1) - logging.info(f"[bt-tether] bnep0 IP: {result['bnep0_ip']}") - except Exception as e: - logging.warning(f"[bt-tether] Get bnep0 IP error: {e}") - - # Get default route - try: - route_result = subprocess.run( - ["ip", "route", "show", "default"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=5, - ) - if route_result.returncode == 0 and route_result.stdout: - result["default_route"] = route_result.stdout.strip() - logging.info(f"[bt-tether] Default route: {result['default_route']}") - except Exception as e: - logging.warning(f"[bt-tether] Get default route error: {e}") - - # Get localhost route - CRITICAL for bettercap API access - try: - localhost_result = subprocess.run( - ["ip", "route", "get", "127.0.0.1"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=5, - ) - if localhost_result.returncode == 0 and localhost_result.stdout: - result["localhost_routes"] = localhost_result.stdout.strip() - # Localhost should use 'lo' interface - if ( - "lo" not in result["localhost_routes"] - and "local" not in result["localhost_routes"] - ): - logging.warning( - f"[bt-tether] ⚠️ WARNING: Localhost not routing through 'lo' interface!" - ) - logging.warning( - f"[bt-tether] ⚠️ This may prevent bettercap API from working: {result['localhost_routes']}" - ) - else: - logging.info( - f"[bt-tether] Localhost route: {result['localhost_routes']}" - ) - else: - result["localhost_routes"] = "Error getting localhost route" - except Exception as e: - result["localhost_routes"] = f"Error: {str(e)}" - logging.warning(f"[bt-tether] Get localhost route error: {e}") - - return result - - except Exception as e: - logging.error(f"[bt-tether] Internet connectivity test error: {e}") - return { - "ping_success": False, - "dns_success": False, - "bnep0_ip": None, - "default_route": None, - "dns_servers": None, - "dns_error": str(e), - } - - def _get_pan_interface(self): - """Get the name of the Bluetooth PAN interface if it exists""" - try: - out = subprocess.check_output(["ip", "link"], text=True, timeout=5) - # Look for bnep or bt-pan interface names - for line in out.split("\n"): - if "bnep" in line or "bt-pan" in line: - # Extract interface name (e.g., "2: bnep0:" -> "bnep0") - parts = line.split(":") - if len(parts) >= 2: - iface = parts[1].strip() - return iface - return None - except Exception as e: - logging.error(f"[bt-tether] Failed to get PAN interface: {e}") - return None - - def _get_interface_ip(self, iface): - """Get IP address of a network interface""" - try: - - result = subprocess.check_output( - ["ip", "-4", "addr", "show", iface], text=True, timeout=5 - ) - # Look for inet address (e.g., "inet 192.168.44.123/24") - match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", result) - if match: - return match.group(1) - return None - except Exception as e: - logging.debug(f"[bt-tether] Failed to get IP for {iface}: {e}") - return None - - def _pair_device_interactive(self, mac, needs_discovery=True): - """Pair device - persistent agent will handle the dialog. - - Args: - needs_discovery: True if BlueZ's device cache was wiped (e.g. after 'remove') - and a fresh scan is required before pairing. - False if the device is still present in BlueZ from the - background scan (new device, no remove was called). - """ - try: - logging.info(f"[bt-tether] Starting pairing with {mac}...") - - with self.lock: - self.message = "Scanning for phone..." - - # First ensure Bluetooth is powered on and in pairable mode - self._run_cmd(["bluetoothctl", "power", "on"], capture=True) - time.sleep(self.DEVICE_OPERATION_DELAY) - self._run_cmd(["bluetoothctl", "pairable", "on"], capture=True) - self._run_cmd(["bluetoothctl", "discoverable", "on"], capture=True) - time.sleep(self.DEVICE_OPERATION_DELAY) - - # Quick health check - ensure bluetoothctl is responsive before pairing - if not self._check_bluetooth_responsive(): - logging.warning( - "[bt-tether] Bluetooth service appears unresponsive - attempting recovery" - ) - self._restart_bluetooth_if_needed() - time.sleep(self.OPERATION_MEDIUM_DELAY) - - if not needs_discovery: - # Device is still in BlueZ's cache from the background scan β€” pair immediately - logging.info( - f"[bt-tether] Device {mac} already in BlueZ cache, no discovery needed" - ) - device_visible = True - else: - # BlueZ cache was wiped (after 'remove') β€” open an interactive bluetoothctl - # session and watch for the "[NEW] Device " event in real time. - # This reacts within milliseconds of the device reappearing instead of - # polling every second. - discovery_timeout = self.PAIRING_SCAN_WAIT_TIMEOUT - logging.info( - f"[bt-tether] Waiting for {mac} to reappear after remove (up to {discovery_timeout}s)..." - ) - - device_visible = False - scan_start = time.time() - scan_process = None - try: - env = dict(os.environ, TERM="dumb", NO_COLOR="1") - scan_process = subprocess.Popen( - ["bluetoothctl"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - env=env, - ) - scan_process.stdin.write("scan on\n") - scan_process.stdin.flush() - - target_mac = mac.upper() - while time.time() - scan_start < discovery_timeout: - try: - import select - - ready = select.select([scan_process.stdout], [], [], 0.5) - if ready[0]: - line = scan_process.stdout.readline() - if not line: - break - clean_line = self.SCAN_ANSI_PATTERN.sub( - "", line.strip() - ) - if "[NEW]" in clean_line and "Device" in clean_line: - m = self.SCAN_MAC_PATTERN.search(clean_line) - if m and m.group(1).upper() == target_mac: - device_visible = True - elapsed = time.time() - scan_start - logging.info( - f"[bt-tether] Device {mac} reappeared after {elapsed:.1f}s" - ) - break - except Exception: - pass - finally: - # Stop scan and close bluetoothctl - try: - self._run_cmd( - ["bluetoothctl", "scan", "off"], capture=True, timeout=3 - ) - time.sleep(self.SCAN_STOP_DELAY) - except Exception: - pass - if scan_process: - try: - scan_process.stdin.write("quit\n") - scan_process.stdin.flush() - scan_process.wait(timeout=self.SUBPROCESS_TIMEOUT_MEDIUM) - except Exception: - try: - scan_process.kill() - scan_process.wait(timeout=self.SUBPROCESS_TIMEOUT_SHORT) - except Exception: - pass - - if not device_visible: - elapsed = time.time() - scan_start - logging.warning( - f"[bt-tether] Device {mac} not found after {elapsed:.1f}s - attempting pair anyway" - ) - - with self.lock: - self.message = "Phone found! Initiating pairing..." - - # Start monitoring agent log for passkey in background - passkey_found = threading.Event() - monitor_thread = threading.Thread( - target=self._monitor_agent_log_for_passkey, - args=(passkey_found,), - daemon=True, - ) - monitor_thread.start() - - # Initiate pairing from Pwnagotchi side - agent will show passkey dialog on phone - logging.info(f"[bt-tether] Running: bluetoothctl pair {mac}") - logging.info( - f"[bt-tether] ⚠️ Pairing dialog will appear on your phone - confirm the passkey!" - ) - - try: - # Use subprocess.Popen to capture output in real-time - env = dict(os.environ) - env["NO_COLOR"] = "1" - env["TERM"] = "dumb" - - # Start pairing process - process = subprocess.Popen( - ["bluetoothctl", "pair", mac], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=env, - bufsize=1, # Line buffered - ) - - try: - # Read output in real-time to capture passkey immediately - output_lines = [] - passkey_found_in_output = False - - while True: - line = process.stdout.readline() - if not line: - # Process finished - break - - output_lines.append(line) - clean_line = self._strip_ansi_codes(line.strip()) - - # Look for passkey in real-time - if not passkey_found_in_output: - passkey_match = re.search( - r"passkey\s+(\d{6})", clean_line, re.IGNORECASE - ) - if passkey_match: - self.current_passkey = passkey_match.group(1) - passkey_found_in_output = True - self._log( - "WARNING", - f"πŸ”‘ PASSKEY: {self.current_passkey} - Confirm on phone!", - ) - logging.info( - f"[bt-tether] πŸ”‘ PASSKEY: {self.current_passkey} captured from pair command" - ) - - # Update status message so it shows prominently in web UI - with self.lock: - self.status = self.STATE_PAIRING - self.message = f"πŸ”‘ PASSKEY: {self.current_passkey}\n\nVerify this matches on your phone, then tap PAIR!" - - elif ( - "Confirm passkey" in clean_line - or "DisplayPasskey" in clean_line - ): - # Try alternative patterns - display_match = re.search(r"(\d{6})", clean_line) - if display_match: - self.current_passkey = display_match.group(1) - passkey_found_in_output = True - self._log( - "WARNING", - f"πŸ”‘ PASSKEY: {self.current_passkey} - Confirm on phone!", - ) - logging.info( - f"[bt-tether] πŸ”‘ PASSKEY: {self.current_passkey} captured from pair command" - ) - - # Update status message so it shows prominently in web UI - with self.lock: - self.status = self.STATE_PAIRING - self.message = f"πŸ”‘ PASSKEY: {self.current_passkey}\n\nVerify this matches on your phone, then tap PAIR!" - - # Wait for process to complete with passkey timeout - # Phone usually times out after 30-45s if passkey not confirmed - returncode = process.wait(timeout=self.PAIRING_PASSKEY_TIMEOUT) - output = "".join(output_lines) - clean_output = self._strip_ansi_codes(output) - - # Check if pairing succeeded - if ( - "Pairing successful" in clean_output - or "AlreadyExists" in clean_output - ): - logging.info(f"[bt-tether] βœ“ Pairing successful!") - # Clear passkey after successful pairing - self.current_passkey = None - return True - elif returncode == 0: - # Command succeeded but output unclear - check status - time.sleep(self.DEVICE_OPERATION_LONGER_DELAY) - pair_status = self._check_pair_status(mac) - if pair_status["paired"]: - logging.info(f"[bt-tether] βœ“ Pairing successful!") - # Clear passkey after successful pairing - self.current_passkey = None - return True - - # Diagnose specific failure types - error_hints = "" - if ( - "Authentication failed" in clean_output - or "0x05" in clean_output - ): - error_hints = "\nπŸ’‘ IMPORTANT: Go to your phone's Bluetooth settings and FORGET/UNPAIR this device first!\n Then try pairing again. (0x05 = phone has stale cached credentials)" - elif "Connection refused" in clean_output: - error_hints = "\nπŸ’‘ Hint: Device not found. Make sure phone's Bluetooth is ON and discoverable." - elif ( - "AlreadyExists" not in clean_output - and "Pairing successful" not in clean_output - ): - # Passkey on phone not confirmed or timed out - if not passkey_found_in_output: - error_hints = "\nπŸ’‘ Hint: No passkey appeared. Check Bluetooth permissions or restart phone's Bluetooth." - else: - error_hints = f"\nπŸ’‘ Hint: Passkey {self.current_passkey} was shown but not confirmed on phone." - - logging.error( - f"[bt-tether] Pairing failed: {clean_output}{error_hints}" - ) - self._log("ERROR", f"Pairing failed. {error_hints.lstrip()}") - return False - - finally: - # Ensure process stdout is closed to prevent resource leak - try: - if process.stdout: - process.stdout.close() - except Exception: - pass - # Kill process if still running - try: - if process.poll() is None: - process.kill() - process.wait(timeout=self.SUBPROCESS_TIMEOUT_MEDIUM) - except Exception: - pass - - except subprocess.TimeoutExpired: - logging.error( - f"[bt-tether] Pairing timeout ({self.PAIRING_PASSKEY_TIMEOUT}s) - phone didn't respond or confirm passkey" - ) - self._log( - "ERROR", - f"Pairing timeout - Try: 1) Confirm passkey on phone, or 2) Forget device in phone's Bluetooth settings, then retry", - ) - return False - - except Exception as e: - logging.error(f"[bt-tether] Pairing error: {e}") - return False - - def _get_current_ip(self): - """Get the current IP address from the Bluetooth PAN interface only""" - try: - # Only get IP from bluetooth interface - don't fall back to LAN/WiFi - # since we're advertising the BT tethering IP - pan_iface = self._get_pan_interface() - if pan_iface: - ip = self._get_interface_ip(pan_iface) - if ip and not ip.startswith("169.254."): # Exclude link-local - self._log("DEBUG", f"Found BT IP {ip} on {pan_iface}") - return ip - - # Also check bnep0 explicitly in case _get_pan_interface missed it - ip = self._get_interface_ip("bnep0") - if ip and not ip.startswith("169.254."): - self._log("DEBUG", f"Found BT IP {ip} on bnep0") - return ip - - self._log("DEBUG", "No IP address found on Bluetooth interface") - return None - except Exception as e: - self._log("ERROR", f"Failed to get BT IP: {e}") - return None - - def _get_pwnagotchi_name(self): - """Get pwnagotchi name""" - try: - return pwnagotchi.name() - except Exception as e: - self._log("DEBUG", f"Failed to get pwnagotchi name: {e}") - return "pwnagotchi" - - def _set_device_name(self): - """Set the Bluetooth device name via bluetoothctl""" - try: - pwnagotchi_name = self._get_pwnagotchi_name() - cmd = ["bluetoothctl", "set-alias", pwnagotchi_name] - self._run_cmd(cmd, timeout=5) - self._log("INFO", f"Set Bluetooth device name to: {pwnagotchi_name}") - except Exception as e: - self._log("WARNING", f"Failed to set device name: {e}") - - def _connect_nap_dbus(self, mac): - """Connect to NAP service using DBus directly""" - try: - if not DBUS_AVAILABLE: - logging.error("[bt-tether] dbus module not available") - return False - - logging.info("[bt-tether] Connecting to system bus...") - bus = dbus.SystemBus() - manager = dbus.Interface( - bus.get_object("org.bluez", "/"), "org.freedesktop.DBus.ObjectManager" - ) - logging.info("[bt-tether] System bus connected") - - # Find the device object path - logging.info("[bt-tether] Searching for device in BlueZ...") - objects = manager.GetManagedObjects() - device_path = None - for path, interfaces in objects.items(): - if "org.bluez.Device1" in interfaces: - props = interfaces["org.bluez.Device1"] - if props.get("Address") == mac: - device_path = path - logging.info(f"[bt-tether] Found device at path: {device_path}") - break - - if not device_path: - logging.error( - f"[bt-tether] Device {mac} not found in BlueZ managed objects" - ) - return False - - # Connect to NAP service UUID - logging.info( - f"[bt-tether] Connecting to NAP profile (UUID: {self.NAP_UUID})..." - ) - device = dbus.Interface( - bus.get_object("org.bluez", device_path), "org.bluez.Device1" - ) - - # Set a timeout for the ConnectProfile call to prevent hanging - try: - device.ConnectProfile(self.NAP_UUID, timeout=30) - logging.info( - f"[bt-tether] βœ“ NAP profile connected successfully via DBus" - ) - return True - except dbus.exceptions.DBusException as dbus_err: - error_msg = str(dbus_err) - logging.error(f"[bt-tether] DBus NAP connection failed: {dbus_err}") - - # Check for authentication/pairing errors - if phone was unpaired, remove pairing on Pwnagotchi side too - # BUT: Don't remove for tethering-disabled errors (br-connection-create-socket, br-connection-profile-unavailable) - # AND: Don't remove for transient errors (page-timeout, host-down) - phone may just be out of range - if ( - "Authentication Rejected" in error_msg - or "Connection refused" in error_msg - ): - self._log( - "WARNING", - "⚠️ Device may have been unpaired from phone - removing stale pairing", - ) - # Remove the pairing to prevent repeated failed connection attempts - try: - self._run_cmd(["bluetoothctl", "remove", mac], timeout=5) - self._log( - "INFO", - "Removed stale pairing - use web UI to re-pair if needed", - ) - # Also clear the phone_mac to force re-scanning - with self.lock: - self.phone_mac = "" - self.options["mac"] = "" - except Exception as e: - logging.debug(f"Failed to remove pairing: {e}") - elif ( - "br-connection-page-timeout" in error_msg - or "br-connection-unknown" in error_msg - or "Host is down" in error_msg - ): - # Transient errors - phone may be out of range or BT off, don't remove pairing - self._log( - "WARNING", - "⚠️ Phone not reachable (out of range or BT off) - will retry later", - ) - - # Check for common errors and provide helpful hints - if ( - "br-connection-create-socket" in error_msg - or "br-connection-profile-unavailable" in error_msg - ): - self._log( - "ERROR", - "⚠️ Bluetooth tethering is NOT enabled on your phone!", - ) - self._log( - "ERROR", - "Go to Settings β†’ Network & internet β†’ Hotspot & tethering β†’ Enable 'Bluetooth tethering'", - ) - elif "NoReply" in error_msg or "Did not receive a reply" in error_msg: - self._log( - "ERROR", - "⚠️ Phone's Bluetooth is not responding to connection requests", - ) - self._log( - "ERROR", - "πŸ“± On your phone: Forget/unpair this device in Bluetooth settings", - ) - self._log( - "ERROR", - "πŸ”„ Then toggle Bluetooth tethering OFF and back ON", - ) - self._log( - "ERROR", - "πŸ”Œ Finally, reconnect from the web UI to re-pair", - ) - elif "br-connection-busy" in error_msg or "InProgress" in error_msg: - self._log( - "ERROR", - "⚠️ Bluetooth connection is busy, wait a moment and try again", - ) - - return False - - except ImportError as e: - logging.error(f"[bt-tether] python3-dbus not installed: {e}") - logging.error("[bt-tether] Run: sudo apt-get install -y python3-dbus") - return False - except Exception as e: - error_msg = str(e) - logging.error(f"[bt-tether] NAP connection error: {type(e).__name__}: {e}") - - logging.error(f"[bt-tether] Traceback: {traceback.format_exc()}") - return False \ No newline at end of file + abort(404) diff --git a/pwnagotchi/plugins/default/logtail.py b/pwnagotchi/plugins/default/logtail.py index 71cd9eb6..ddf93f8b 100644 --- a/pwnagotchi/plugins/default/logtail.py +++ b/pwnagotchi/plugins/default/logtail.py @@ -1,18 +1,13 @@ -import os import logging import threading -from itertools import islice from time import sleep -from datetime import datetime,timedelta from pwnagotchi import plugins -from pwnagotchi.utils import StatusFile from flask import render_template_string -from flask import jsonify from flask import abort from flask import Response -TEMPLATE = """ +INDEX = """ {% extends "base.html" %} {% set active_page = "plugins" %} {% block title %} @@ -20,83 +15,267 @@ TEMPLATE = """ {% endblock %} {% block styles %} - {{ super() }} - + } + {% endblock %} {% block script %} - var table = document.getElementById('table'); - var filter = document.getElementById('filter'); + var table = document.getElementById("table").querySelector("tbody"); + var filter = document.getElementById("filter"); var filterVal = filter.value.toUpperCase(); var xhr = new XMLHttpRequest(); - xhr.open('GET', '{{ url_for('plugins') }}/logtail/stream'); + xhr.open("GET", "logtail/stream"); xhr.send(); var position = 0; var data; @@ -110,39 +289,39 @@ TEMPLATE = """ filterVal = filter.value.toUpperCase(); messages.slice(position, -1).forEach(function(value) { - if (value.charAt(0) != '[') { + if (value.charAt(0) != "[") { msg = value; - time = ''; - level = ''; + time = ""; + level = ""; } else { - data = value.split(']'); - time = data.shift() + ']'; - level = data.shift() + ']'; - msg = data.join(']'); + data = value.split("]"); + time = data.shift() + "]"; + level = data.shift() + "]"; + msg = data.join("]"); switch(level) { - case ' [INFO]': - colorClass = 'info'; + case " [INFO]": + colorClass = "info"; break; - case ' [WARNING]': - colorClass = 'warning'; + case " [WARNING]": + colorClass = "warning"; break; - case ' [ERROR]': - colorClass = 'error'; + case " [ERROR]": + colorClass = "error"; break; - case ' [DEBUG]': - colorClass = 'debug'; + case " [DEBUG]": + colorClass = "debug"; break; default: - colorClass = 'default'; + colorClass = "default"; break; } } - var tr = document.createElement('tr'); - var td1 = document.createElement('td'); - var td2 = document.createElement('td'); - var td3 = document.createElement('td'); + var tr = document.createElement("tr"); + var td1 = document.createElement("td"); + var td2 = document.createElement("td"); + var td3 = document.createElement("td"); td1.textContent = time; td2.textContent = level; @@ -169,7 +348,7 @@ TEMPLATE = """ } var timer; - var scrollElm = document.getElementById('autoscroll'); + var scrollElm = document.getElementById("autoscroll"); timer = setInterval(function() { handleNewData(); if (scrollElm.checked) { @@ -181,7 +360,7 @@ TEMPLATE = """ }, 1000); var typingTimer; - var doneTypingInterval = 1000; + var doneTypingInterval = 500; filter.onkeyup = function() { clearTimeout(typingTimer); @@ -193,50 +372,56 @@ TEMPLATE = """ } function doneTyping() { - document.body.style.cursor = 'progress'; var tr, tds, td, i, txtValue; filterVal = filter.value.toUpperCase(); tr = table.getElementsByTagName("tr"); - for (i = 1; i < tr.length; i++) { + for (i = 0; i < tr.length; i++) { txtValue = tr[i].textContent || tr[i].innerText; - if (txtValue.toUpperCase().indexOf(filterVal) > -1) { + if (filterVal.length === 0 || txtValue.toUpperCase().indexOf(filterVal) > -1) { tr[i].style.display = "table-row"; } else { tr[i].style.display = "none"; } } - document.body.style.cursor = 'default'; } {% endblock %} {% block content %} -
- - -
+
+

System Log

+

Real-time log viewer with filtering and auto-scroll capabilities

+
+ +
+ + + + + +
+ +
+ + + + + + + + + + +
TimeLevelMessage
- - - - - - -
- Time - - Level - - Message -
{% endblock %} """ class Logtail(plugins.Plugin): - __author__ = '33197631+dadav@users.noreply.github.com' - __version__ = '0.1.0' - __license__ = 'GPL3' - __description__ = 'This plugin tails the logfile.' + __author__ = "33197631+dadav@users.noreply.github.com" + __version__ = "0.1.0" + __license__ = "GPL3" + __description__ = "This plugin tails the logfile." def __init__(self): self.lock = threading.Lock() @@ -258,15 +443,16 @@ class Logtail(plugins.Plugin): return "Plugin not ready" if not path or path == "/": - return render_template_string(TEMPLATE) + return render_template_string(INDEX) + + if path == "stream": - if path == 'stream': def generate(): - with open(self.config['main']['log']['path']) as f: - yield ''.join(f.readlines()[-self.options.get('max-lines', 4096):]) + with open(self.config["main"]["log"]["path"]) as f: + yield "".join(f.readlines()[-self.options.get("max-lines", 4096) :]) while True: yield f.readline() - return Response(generate(), mimetype='text/plain') + return Response(generate(), mimetype="text/plain") abort(404) diff --git a/pwnagotchi/plugins/default/session-stats.py b/pwnagotchi/plugins/default/session-stats.py index 06ab1687..ff32908b 100644 --- a/pwnagotchi/plugins/default/session-stats.py +++ b/pwnagotchi/plugins/default/session-stats.py @@ -2,7 +2,7 @@ import os import logging import threading from time import sleep -from datetime import datetime,timedelta +from datetime import datetime, timedelta from pwnagotchi import plugins from pwnagotchi.utils import StatusFile from flask import render_template_string @@ -12,252 +12,728 @@ TEMPLATE = """ {% extends "base.html" %} {% set active_page = "plugins" %} {% block title %} - Session stats + Session Stats {% endblock %} {% block styles %} {{ super() }} - - {% endblock %} {% block scripts %} {{ super() }} - - - - - - - - + {% endblock %} {% block script %} - $(document).ready(function(){ - var ajaxDataRenderer = function(url, plot, options) { - var ret = null; - $.ajax({ - async: false, - url: url, - dataType:"json", - success: function(data) { - ret = data; - } - }); - return ret; - }; + const charts = {}; - function loadFiles(url, elm) { - var data = ajaxDataRenderer(url); - var x = document.getElementById(elm); - $.each(data['files'], function( index, value ) { - var option = document.createElement("option"); - option.text = value; - x.add(option); - }); - } - - function loadData(url, elm, title, fill) { - var data = ajaxDataRenderer(url); - var plot_os = $.jqplot(elm, data.values,{ - title: title, - stackSeries: fill, - seriesDefaults: { - showMarker: !fill, - fill: fill, - fillAndStroke: fill - }, - legend: { - show: true, - renderer: $.jqplot.EnhancedLegendRenderer, - placement: 'outsideGrid', - labels: data.labels, - location: 's', - rendererOptions: { - numberRows: '2', - }, - rowSpacing: '0px' - }, - axes:{ - xaxis:{ - renderer:$.jqplot.DateAxisRenderer, - tickOptions:{formatString:'%H:%M:%S'} - }, - yaxis:{ - tickOptions:{formatString:'%.2f'} - } - }, - highlighter: { - show: true, - sizeAdjust: 7.5 - }, - cursor:{ - show: true, - tooltipLocation:'sw' + async function fetchData(url) { + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return await response.json(); + } catch (error) { + console.error(`Failed to fetch ${url}:`, error); + return { values: [], labels: [] }; } - }).replot({ - axes:{ - xaxis:{ - renderer:$.jqplot.DateAxisRenderer, - tickOptions:{formatString:'%H:%M:%S'} - }, - yaxis:{ - tickOptions:{formatString:'%.2f'} - } + } + + function getTransparentColor(color) { + // Convert rgb() to rgba() with 0.2 opacity, or append hex opacity + if (color.startsWith('rgb(')) { + return color.replace('rgb(', 'rgba(').replace(')', ', 0.2)'); } + return color + '33'; // hex format + } + + function createChart(elementId, title, data) { + const container = document.getElementById(elementId); + if (!container || !data.values || data.values.length === 0) return; + + if (charts[elementId]) charts[elementId].destroy(); + + const allLabels = new Set(); + data.values.forEach(values => { + values.forEach(([ts]) => allLabels.add(ts)); }); - } + const labels = Array.from(allLabels).sort(); - function loadSessionFiles() { - loadFiles('/plugins/session-stats/session', 'session'); - $("#session").change(function() { - loadSessionData(); + const datasets = data.values.map((values, index) => { + const color = getChartColor(index); + const valueMap = Object.fromEntries(values); + const chartData = labels.map(ts => valueMap[ts] ?? null); + + return { + label: data.labels[index], + data: chartData, + borderColor: color, + backgroundColor: getTransparentColor(color), + borderWidth: 2, + fill: true, + tension: 0.1, + pointRadius: 1, + pointHoverRadius: 4 + }; }); + + let canvas = container.querySelector('canvas'); + if (!canvas) { + canvas = document.createElement('canvas'); + container.appendChild(canvas); + } + + // Calculate required width based on number of data points + const dataPointCount = labels.length; + const minPixelsPerPoint = 50; // minimum pixels for each data point + const calculatedWidth = Math.max(container.clientWidth, dataPointCount * minPixelsPerPoint); + + // Set canvas dimensions explicitly + canvas.width = calculatedWidth; + canvas.height = 250; + + charts[elementId] = new Chart(canvas, { + type: 'line', + data: { labels, datasets }, + options: { + responsive: false, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: title, + font: { size: 16, family: 'var(--font-pixel)', weight: 'bold' }, + color: '#fff', + padding: 20 + }, + legend: { + display: true, + position: 'bottom', + labels: { + color: '#fff', + font: { family: 'var(--font-main)', size: 12, weight: 'bold' }, + padding: 15, + boxHeight: 4 + } + }, + tooltip: { + backgroundColor: '#000', + titleColor: '#fff', + bodyColor: '#fff', + borderColor: 'var(--accent)', + borderWidth: 1 + } + }, + scales: { + x: { + grid: { + color: '#333', + display: true + }, + ticks: { + color: '#fff', + font: { family: 'var(--font-main)', size: 11, weight: 'bold' }, + maxTicksLimit: 8 + } + }, + y: { + grid: { + color: '#333', + display: true + }, + ticks: { + color: '#fff', + font: { family: 'var(--font-main)', size: 11, weight: 'bold' } + } + } + } + } + }); + + // Add hint text if it doesn't exist + if (!container.querySelector('.chart-hint')) { + const hint = document.createElement('div'); + hint.className = 'chart-hint'; + hint.textContent = 'Scroll left/right to view more data'; + container.appendChild(hint); + } } - function loadSessionData() { - var x = document.getElementById("session"); - var session = x.options[x.selectedIndex].text; - loadData('/plugins/session-stats/os' + '?session=' + session, 'chart_os', 'OS', false) - loadData('/plugins/session-stats/temp' + '?session=' + session, 'chart_temp', 'Temp', false) - loadData('/plugins/session-stats/wifi' + '?session=' + session, 'chart_wifi', 'Wifi', true) - loadData('/plugins/session-stats/duration' + '?session=' + session, 'chart_duration', 'Sleeping', true) - loadData('/plugins/session-stats/reward' + '?session=' + session, 'chart_reward', 'Reward', false) - loadData('/plugins/session-stats/epoch' + '?session=' + session, 'chart_epoch', 'Epochs', false) + function getChartColor(index) { + // Get accent color from CSS root variables + const root = document.documentElement; + const r = getComputedStyle(root).getPropertyValue('--accent-r').trim(); + const g = getComputedStyle(root).getPropertyValue('--accent-g').trim(); + const b = getComputedStyle(root).getPropertyValue('--accent-b').trim(); + const accentColor = `rgb(${r},${g},${b})`; + // Use accent color as first chart color, then secondary colors + const colors = [accentColor, '#ff9800', '#2196f3', '#f44336', '#9c27b0', '#00bcd4']; + return colors[index % colors.length]; } + async function updateStats() { + const sessionSelect = document.getElementById("session"); + const session = sessionSelect?.options[sessionSelect.selectedIndex]?.text || 'Current'; + const params = session === 'Current' ? '' : '?session=' + encodeURIComponent(session); - loadSessionFiles(); - loadSessionData(); - setInterval(loadSessionData, 60000); + // Fetch summary stats + const summary = await fetchData('/plugins/session-stats/summary' + params); + if (summary.networks !== undefined) { + document.getElementById('stat_networks').textContent = summary.networks; + document.getElementById('stat_handshakes').textContent = summary.handshakes; + document.getElementById('stat_deauths').textContent = summary.deauths; + document.getElementById('stat_duration').textContent = summary.duration; + document.getElementById('stat_temp').textContent = summary.temp || '0Β°C'; + document.getElementById('stat_mem').textContent = summary.mem || '0%'; + document.getElementById('stat_cpu').textContent = summary.cpu || '0%'; + } + + // Fetch chart data + const chartConfigs = [ + { endpoint: 'networks', id: 'chart_networks', title: 'Networks Captured' }, + { endpoint: 'handshakes', id: 'chart_handshakes', title: 'Handshakes Captured' }, + { endpoint: 'deauths', id: 'chart_deauths', title: 'Deauthentications Sent' }, + { endpoint: 'temp', id: 'chart_temp', title: 'Temperature (Β°C)' }, + { endpoint: 'mem', id: 'chart_mem', title: 'Memory Usage (%)' }, + { endpoint: 'cpu', id: 'chart_cpu', title: 'CPU Load (%)' } + ]; + + for (const config of chartConfigs) { + const data = await fetchData('/plugins/session-stats/' + config.endpoint + params); + createChart(config.id, config.title, data); + } + } + + async function loadSessionFiles() { + const data = await fetchData('/plugins/session-stats/sessions'); + const select = document.getElementById("session"); + data.files?.forEach(file => { + const option = document.createElement("option"); + option.text = file; + select.appendChild(option); + }); + select.addEventListener('change', updateStats); + } + + document.addEventListener('DOMContentLoaded', () => { + loadSessionFiles(); + updateStats(); + setInterval(updateStats, 30000); }); {% endblock %} {% block content %} - -
-
-
-
-
-
+
+

Session Statistics

+

Real-time monitoring of WiFi capture metrics and system performance

+
+ +
+ + +
+ +
+
+
Networks Captured
+
0
+
+
+
Handshakes
+
0
+
+
+
Deauths Sent
+
0
+
+
+
Session Duration
+
0s
+
+
+
Temperature
+
0Β°C
+
+
+
Memory Usage
+
0%
+
+
+
CPU Load
+
0%
+
+
+ +
+

Trend Charts

+
+
+
+
+
+
+
+
+
{% endblock %} """ -class GhettoClock: - def __init__(self): - self.lock = threading.Lock() - self._track = datetime.now() - self._counter_thread = threading.Thread(target=self.counter) - self._counter_thread.daemon = True - self._counter_thread.start() - - def counter(self): - while True: - with self.lock: - self._track += timedelta(seconds=1) - sleep(1) - - def now(self): - with self.lock: - return self._track - class SessionStats(plugins.Plugin): - __author__ = '33197631+dadav@users.noreply.github.com' - __version__ = '0.1.0' - __license__ = 'GPL3' - __description__ = 'This plugin displays stats of the current session.' + __author__ = "33197631+dadav@users.noreply.github.com modified by wsvdmeer" + __version__ = "0.2.0" + __license__ = "GPL3" + __description__ = ( + "Displays WiFi capture stats including networks, handshakes, and deauths." + ) + DEFAULT_UPDATE_INTERVAL = 15 # RPi-friendly: 15 sec = 4 disk writes/min + DEFAULT_SAVE_PATH = ( + "/home/pi/pwnagotchi/sessions/" # Standard location for user data + ) def __init__(self): self.lock = threading.Lock() self.options = dict() self.stats = dict() - self.clock = GhettoClock() + self.initialized = False + self.running = False + self.agent = None + self.realtime_thread = None def on_loaded(self): - """ - Gets called when the plugin gets loaded - """ - # this has to happen in "loaded" because the options are not yet - # available in the __init__ - os.makedirs(self.options['save_directory'], exist_ok=True) - self.session_name = "stats_{}.json".format(self.clock.now().strftime("%Y_%m_%d_%H_%M")) - self.session = StatusFile(os.path.join(self.options['save_directory'], - self.session_name), - data_format='json') - logging.info("Session-stats plugin loaded.") + # Use default save path if not configured + save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH) + os.makedirs(save_dir, exist_ok=True) + self.session_name = "stats_{}.json".format( + datetime.now().strftime("%Y_%m_%d_%H_%M") + ) + self.session = StatusFile( + os.path.join(save_dir, self.session_name), + data_format="json", + ) + + logging.info(f"Session-stats plugin loaded. Saving to: {save_dir}") + + # Try to load historical data from the most recent previous session + try: + session_files = sorted( + [ + f + for f in os.listdir(save_dir) + if f.startswith("stats_") and f.endswith(".json") + ] + ) + if len(session_files) > 1: # More than just the current session + last_session_file = session_files[ + -2 + ] # Second to last is the previous session + last_session_path = os.path.join(save_dir, last_session_file) + last_session = StatusFile(last_session_path, data_format="json") + historical_data = last_session.data_field_or("data", default=dict()) + if historical_data: + self.stats.update(historical_data) + logging.info( + f"Loaded {len(historical_data)} historical data points from {last_session_file}" + ) + except Exception as e: + logging.warning(f"Could not load historical session data: {e}") + + self.running = True + self.realtime_thread = threading.Thread( + target=self._realtime_loop, daemon=True, name="session-stats-realtime" + ) + self.realtime_thread.start() + logging.info("Session-stats realtime collection thread started.") + + def on_ready(self, agent): + """Called when the agent is ready - store reference for realtime stats""" + self.agent = agent + logging.debug("Session-stats agent reference captured") + + def on_ui_setup(self, ui): + """Get agent reference from UI when UI is set up""" + if hasattr(ui, "_agent") and not self.agent: + self.agent = ui._agent + logging.debug("Session-stats agent reference captured from UI") + + def on_unload(self): + self.running = False + if self.realtime_thread and self.realtime_thread.is_alive(): + self.realtime_thread.join(timeout=5) + logging.info("Session-stats plugin unloaded.") + + def _collect_stats(self): + """Collect current stats from agent (called both from realtime loop and epochs)""" + if not self.agent: + return None + + try: + networks = len(self.agent._access_points) + handshakes = len(self.agent._handshakes) + + stats_entry = { + "num_peers": networks, + "num_handshakes": handshakes, + "num_deauths": 0, # Will be updated if on_epoch is called + "temperature": 0, # Will be updated from system or epoch data + "mem_usage": 0, + "cpu_load": 0, + } + return stats_entry + except Exception as e: + logging.warning(f"Could not collect stats: {e}") + return None + + def _realtime_loop(self): + """Background thread that collects stats periodically without waiting for epochs""" + update_interval = self.options.get( + "update_interval", self.DEFAULT_UPDATE_INTERVAL + ) + agent_acquired = False + + while self.running: + try: + sleep(update_interval) + + if not self.agent: + if not agent_acquired: + logging.debug( + "Session-stats realtime loop: waiting for agent reference..." + ) + continue + + if not agent_acquired: + logging.info( + "Session-stats realtime loop: agent acquired, starting stats collection" + ) + agent_acquired = True + + with self.lock: + stats_entry = self._collect_stats() + if stats_entry: + # Use high-resolution timestamp + current_time = datetime.now() + timestamp = current_time.strftime("%H:%M:%S.%f")[:-3] + + # Only update if this is new data or initialized + if not self.initialized: + self.stats[timestamp] = stats_entry + self.initialized = True + self.session.update(data={"data": self.stats}) + logging.info( + f"Session-stats initialized (realtime): {stats_entry['num_peers']} networks, " + f"{stats_entry['num_handshakes']} handshakes" + ) + else: + # Add to stats if data changed + last_stats = ( + list(self.stats.values())[-1] if self.stats else None + ) + if last_stats and ( + stats_entry["num_peers"] + != last_stats.get("num_peers", 0) + or stats_entry["num_handshakes"] + != last_stats.get("num_handshakes", 0) + ): + self.stats[timestamp] = stats_entry + self.session.update(data={"data": self.stats}) + + except Exception as e: + logging.warning(f"Error in realtime stats loop: {e}") def on_epoch(self, agent, epoch, epoch_data): - """ - Save the epoch_data to self.stats - """ - with self.lock: - self.stats[self.clock.now().strftime("%H:%M:%S")] = epoch_data - self.session.update(data={'data': self.stats}) + # Store agent reference if not already set + if not self.agent: + self.agent = agent + logging.debug("Session-stats agent reference captured from epoch callback") + else: + self.agent = agent - @staticmethod - def extract_key_values(data, subkeys): - result = dict() - result['values'] = list() - result['labels'] = subkeys - for plot_key in subkeys: - v = [ [ts,d[plot_key]] for ts, d in data.items()] - result['values'].append(v) - return result + with self.lock: + # Collect epoch-specific system metrics + stats_entry = { + "num_peers": len(agent._access_points), + "num_handshakes": len(agent._handshakes), + "num_deauths": epoch_data.get("num_deauths", 0), + "temperature": epoch_data.get("temperature", 0), + "mem_usage": epoch_data.get("mem_usage", 0), + "cpu_load": epoch_data.get("cpu_load", 0), + } + + # Add epoch data with high-resolution timestamp + current_time = datetime.now() + timestamp = current_time.strftime("%H:%M:%S.%f")[:-3] + self.stats[timestamp] = stats_entry + self.session.update(data={"data": self.stats}) + + if not self.initialized: + self.initialized = True + logging.info( + f"Session-stats epoch update: {len(agent._access_points)} networks, " + f"{len(agent._handshakes)} handshakes" + ) def on_webhook(self, path, request): if not path or path == "/": return render_template_string(TEMPLATE) - session_param = request.args.get('session') - - if path == "os": - extract_keys = ['cpu_load','mem_usage',] - elif path == "temp": - extract_keys = ['temperature'] - elif path == "wifi": - extract_keys = [ - 'missed_interactions', - 'num_hops', - 'num_peers', - 'tot_bond', - 'avg_bond', - 'num_deauths', - 'num_associations', - 'num_handshakes', - ] - elif path == "duration": - extract_keys = [ - 'duration_secs', - 'slept_for_secs', - ] - elif path == "reward": - extract_keys = [ - 'reward', - ] - elif path == "epoch": - extract_keys = [ - 'active_for_epochs', - ] - elif path == "session": - return jsonify({'files': os.listdir(self.options['save_directory'])}) + session_param = request.args.get("session") + save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH) with self.lock: data = self.stats - if session_param and session_param != 'Current': - file_stats = StatusFile(os.path.join(self.options['save_directory'], session_param), data_format='json') - data = file_stats.data_field_or('data', default=dict()) - return jsonify(SessionStats.extract_key_values(data, extract_keys)) + if session_param and session_param != "Current": + file_stats = StatusFile( + os.path.join(save_dir, session_param), + data_format="json", + ) + data = file_stats.data_field_or("data", default=dict()) + + if path == "summary": + total_networks = len(set(d.get("num_peers", 0) for d in data.values())) + total_handshakes = sum(d.get("num_handshakes", 0) for d in data.values()) + total_deauths = sum(d.get("num_deauths", 0) for d in data.values()) + duration = len(data) if data else 0 + temp = max([d.get("temperature", 0) for d in data.values()], default=0) + mem = max([d.get("mem_usage", 0) for d in data.values()], default=0) + cpu = max([d.get("cpu_load", 0) for d in data.values()], default=0) + + return jsonify( + { + "networks": total_networks, + "handshakes": total_handshakes, + "deauths": total_deauths, + "duration": f"{duration}s", + "temp": f"{temp:.1f}Β°C", + "mem": f"{mem:.1f}%", + "cpu": f"{cpu:.1f}%", + } + ) + + elif path == "networks": + return jsonify(self._extract_key_values(data, ["num_peers"])) + elif path == "handshakes": + return jsonify(self._extract_key_values(data, ["num_handshakes"])) + elif path == "deauths": + return jsonify(self._extract_key_values(data, ["num_deauths"])) + elif path == "temp": + return jsonify(self._extract_key_values(data, ["temperature"])) + elif path == "mem": + return jsonify(self._extract_key_values(data, ["mem_usage"])) + elif path == "cpu": + return jsonify(self._extract_key_values(data, ["cpu_load"])) + elif path == "sessions": + return jsonify({"files": os.listdir(save_dir)}) + + return jsonify({"error": "Unknown path"}) + + @staticmethod + def _extract_key_values(data, subkeys): + result = {"values": [], "labels": subkeys} + for plot_key in subkeys: + v = [[ts, d.get(plot_key, 0)] for ts, d in data.items()] + result["values"].append(v) + return result diff --git a/pwnagotchi/plugins/default/webcfg.py b/pwnagotchi/plugins/default/webcfg.py index 2bf018a6..939cfbfe 100644 --- a/pwnagotchi/plugins/default/webcfg.py +++ b/pwnagotchi/plugins/default/webcfg.py @@ -1,7 +1,7 @@ import logging import json import toml -import _thread +import threading # FIX B5: replaced _thread with threading import pwnagotchi from pwnagotchi import restart, plugins from pwnagotchi.utils import save_config, merge_config @@ -18,174 +18,315 @@ INDEX = """ {% block meta %} -{% endblock %} - -{% block styles %} +{% endblock %}{% block styles %} {{ super() }} {% endblock %} {% block content %} +
+

Configuration Manager

+

Edit your Pwnagotchi configuration settings

+
+
- +
+ +
+
- - + +
-
{% endblock %} {% block script %} @@ -204,7 +345,7 @@ INDEX = """ td = document.createElement("td"); td.setAttribute("data-label", ""); btnDel = document.createElement("Button"); - btnDel.innerHTML = "X"; + btnDel.innerHTML = ""; btnDel.onclick = function(){ delRow(this);}; btnDel.className = "remove"; divDelBtn.appendChild(btnDel); @@ -390,7 +531,7 @@ INDEX = """ td = document.createElement("td"); td.setAttribute("data-label", ""); btnDel = document.createElement("Button"); - btnDel.innerHTML = "X"; + btnDel.innerHTML = ""; btnDel.onclick = function(){ delRow(this);}; btnDel.className = "remove"; divDelBtn.appendChild(btnDel); @@ -427,7 +568,8 @@ INDEX = """ input.type = 'text'; input.value = '[]'; }else{ - input.type = typeof(json[key]); + var valType = typeof(json[key]); + input.type = valType === 'string' ? 'text' : valType; input.value = json[key]; } td.appendChild(input); @@ -487,14 +629,14 @@ def serializer(obj): class WebConfig(plugins.Plugin): - __author__ = '33197631+dadav@users.noreply.github.com' - __version__ = '1.0.0' - __license__ = 'GPL3' - __description__ = 'This plugin allows the user to make runtime changes.' + __author__ = "33197631+dadav@users.noreply.github.com modified by wsvdmeer" + __version__ = "1.0.0" + __license__ = "GPL3" + __description__ = "This plugin allows the user to make runtime changes." def __init__(self): self.ready = False - self.mode = 'MANU' + self.mode = "MANU" self._agent = None def on_config_changed(self, config): @@ -503,11 +645,11 @@ class WebConfig(plugins.Plugin): def on_ready(self, agent): self._agent = agent - self.mode = 'MANU' if agent.mode == 'manual' else 'AUTO' + self.mode = "MANU" if agent.mode == "manual" else "AUTO" def on_internet_available(self, agent): self._agent = agent - self.mode = 'MANU' if agent.mode == 'manual' else 'AUTO' + self.mode = "MANU" if agent.mode == "manual" else "AUTO" def on_loaded(self): """ @@ -534,7 +676,7 @@ class WebConfig(plugins.Plugin): if path == "save-config": try: save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test - _thread.start_new_thread(restart, (self.mode,)) + threading.Thread(target=restart, args=(self.mode,), daemon=True).start() # FIX B5 return "success" except Exception as ex: logging.error(ex) @@ -542,13 +684,21 @@ class WebConfig(plugins.Plugin): elif path == "merge-save-config": try: self.config = merge_config(request.get_json(), self.config) - pwnagotchi.config = merge_config(request.get_json(), pwnagotchi.config) + pwnagotchi.config = merge_config( + request.get_json(), pwnagotchi.config + ) logging.debug("PWNAGOTCHI CONFIG:\n%s" % repr(pwnagotchi.config)) if self._agent: - self._agent._config = merge_config(request.get_json(), self._agent._config) - logging.debug(" Agent CONFIG:\n%s" % repr(self._agent._config)) + self._agent._config = merge_config( + request.get_json(), self._agent._config + ) + logging.debug( + " Agent CONFIG:\n%s" % repr(self._agent._config) + ) logging.debug(" Updated CONFIG:\n%s" % request.get_json()) - save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test + save_config( + request.get_json(), "/etc/pwnagotchi/config.toml" + ) # test return "success" except Exception as ex: logging.error("[webcfg mergesave] %s" % ex) diff --git a/pwnagotchi/ui/web/handler.py b/pwnagotchi/ui/web/handler.py index 6e75cb02..3a2189a8 100644 --- a/pwnagotchi/ui/web/handler.py +++ b/pwnagotchi/ui/web/handler.py @@ -1,7 +1,7 @@ import logging import os import base64 -import _thread +import threading # FIX B5: replaced _thread with threading import secrets import json from functools import wraps @@ -9,8 +9,8 @@ from functools import wraps import flask # https://stackoverflow.com/questions/14888799/disable-console-messages-in-flask-server -logging.getLogger('werkzeug').setLevel(logging.ERROR) -os.environ['WERKZEUG_RUN_MAIN'] = 'false' +logging.getLogger("werkzeug").setLevel(logging.ERROR) +os.environ["WERKZEUG_RUN_MAIN"] = "false" import pwnagotchi import pwnagotchi.grid as grid @@ -32,21 +32,45 @@ class Handler: self._agent = agent self._app = app - self._app.add_url_rule('/', 'index', self.with_auth(self.index)) - self._app.add_url_rule('/ui', 'ui', self.with_auth(self.ui)) + # Dynamic theme CSS route + self._app.add_url_rule("/css/theme.css", "dynamic_theme", self.dynamic_theme) - self._app.add_url_rule('/shutdown', 'shutdown', self.with_auth(self.shutdown), methods=['POST']) - self._app.add_url_rule('/reboot', 'reboot', self.with_auth(self.reboot), methods=['POST']) - self._app.add_url_rule('/restart', 'restart', self.with_auth(self.restart), methods=['POST']) + self._app.add_url_rule("/", "index", self.with_auth(self.index)) + self._app.add_url_rule("/ui", "ui", self.with_auth(self.ui)) + + self._app.add_url_rule( + "/shutdown", "shutdown", self.with_auth(self.shutdown), methods=["POST"] + ) + self._app.add_url_rule( + "/reboot", "reboot", self.with_auth(self.reboot), methods=["POST"] + ) + self._app.add_url_rule( + "/restart", "restart", self.with_auth(self.restart), methods=["POST"] + ) # inbox - self._app.add_url_rule('/inbox', 'inbox', self.with_auth(self.inbox)) - self._app.add_url_rule('/inbox/profile', 'inbox_profile', self.with_auth(self.inbox_profile)) - self._app.add_url_rule('/inbox/peers', 'inbox_peers', self.with_auth(self.inbox_peers)) - self._app.add_url_rule('/inbox/', 'show_message', self.with_auth(self.show_message)) - self._app.add_url_rule('/inbox//', 'mark_message', self.with_auth(self.mark_message)) - self._app.add_url_rule('/inbox/new', 'new_message', self.with_auth(self.new_message)) - self._app.add_url_rule('/inbox/send', 'send_message', self.with_auth(self.send_message), methods=['POST']) + self._app.add_url_rule("/inbox", "inbox", self.with_auth(self.inbox)) + self._app.add_url_rule( + "/inbox/profile", "inbox_profile", self.with_auth(self.inbox_profile) + ) + self._app.add_url_rule( + "/inbox/peers", "inbox_peers", self.with_auth(self.inbox_peers) + ) + self._app.add_url_rule( + "/inbox/", "show_message", self.with_auth(self.show_message) + ) + self._app.add_url_rule( + "/inbox//", "mark_message", self.with_auth(self.mark_message) + ) + self._app.add_url_rule( + "/inbox/new", "new_message", self.with_auth(self.new_message) + ) + self._app.add_url_rule( + "/inbox/send", + "send_message", + self.with_auth(self.send_message), + methods=["POST"], + ) # plugins plugins_with_auth = self.with_auth(self.plugins) @@ -58,52 +82,57 @@ class Handler: def _check_creds(self, u, p): # trying to be timing attack safe - return secrets.compare_digest(u, self._config['username']) and \ - secrets.compare_digest(p, self._config['password']) + return secrets.compare_digest( + u, self._config["username"] + ) and secrets.compare_digest(p, self._config["password"]) def with_auth(self, f): @wraps(f) def wrapper(*args, **kwargs): - if not self._config['auth']: + if not self._config["auth"]: return f(*args, **kwargs) else: auth = request.authorization - if not auth or not auth.username or not auth.password or not self._check_creds(auth.username, - auth.password): - return Response('Unauthorized', 401, {'WWW-Authenticate': 'Basic realm="Unauthorized"'}) + if ( + not auth + or not auth.username + or not auth.password + or not self._check_creds(auth.username, auth.password) + ): + return Response( + "Unauthorized", + 401, + {"WWW-Authenticate": 'Basic realm="Unauthorized"'}, + ) return f(*args, **kwargs) return wrapper def index(self): - return render_template('index.html', - title=pwnagotchi.name(), - other_mode='AUTO' if self._agent.mode == 'manual' else 'MANU', - fingerprint=self._agent.fingerprint()) + return render_template( + "index.html", + title=pwnagotchi.name(), + other_mode="AUTO" if self._agent.mode == "manual" else "MANU", + fingerprint=self._agent.fingerprint(), + ) def inbox(self): page = request.args.get("p", default=1, type=int) - inbox = { - "pages": 1, - "records": 0, - "messages": [] - } + inbox = {"pages": 1, "records": 0, "messages": []} error = None try: if not grid.is_connected(): - raise Exception('not connected') + raise Exception("not connected") inbox = grid.inbox(page, with_pager=True) except Exception as e: - logging.exception('error while reading pwnmail inbox') + logging.exception("error while reading pwnmail inbox") error = str(e) - return render_template('inbox.html', - name=pwnagotchi.name(), - page=page, - error=error, - inbox=inbox) + return render_template( + "inbox.html", name=pwnagotchi.name(), page=page, error=error, inbox=inbox + ) def inbox_profile(self): data = {} @@ -112,14 +141,16 @@ class Handler: try: data = grid.get_advertisement_data() except Exception as e: - logging.exception('error while reading pwngrid data') + logging.exception("error while reading pwngrid data") error = str(e) - return render_template('profile.html', - name=pwnagotchi.name(), - fingerprint=self._agent.fingerprint(), - data=json.dumps(data, indent=2), - error=error) + return render_template( + "profile.html", + name=pwnagotchi.name(), + fingerprint=self._agent.fingerprint(), + data=json.dumps(data, indent=2), + error=error, + ) def inbox_peers(self): peers = {} @@ -128,13 +159,12 @@ class Handler: try: peers = grid.memory() except Exception as e: - logging.exception('error while reading pwngrid peers') + logging.exception("error while reading pwngrid peers") error = str(e) - return render_template('peers.html', - name=pwnagotchi.name(), - peers=peers, - error=error) + return render_template( + "peers.html", name=pwnagotchi.name(), peers=peers, error=error + ) def show_message(self, id): message = {} @@ -142,23 +172,22 @@ class Handler: try: if not grid.is_connected(): - raise Exception('not connected') + raise Exception("not connected") message = grid.inbox_message(id) - if message['data']: - message['data'] = base64.b64decode(message['data']).decode("utf-8") + if message["data"]: + message["data"] = base64.b64decode(message["data"]).decode("utf-8") except Exception as e: - logging.exception('error while reading pwnmail message %d' % int(id)) + logging.exception("error while reading pwnmail message %d" % int(id)) error = str(e) - return render_template('message.html', - name=pwnagotchi.name(), - error=error, - message=message) + return render_template( + "message.html", name=pwnagotchi.name(), error=error, message=message + ) def new_message(self): to = request.args.get("to", default="") - return render_template('new_message.html', to=to) + return render_template("new_message.html", to=to) def send_message(self): to = request.form["to"] @@ -167,7 +196,7 @@ class Handler: try: if not grid.is_connected(): - raise Exception('not connected') + raise Exception("not connected") grid.send_message(to, message) except Exception as e: @@ -185,18 +214,41 @@ class Handler: def plugins(self, name, subpath): if name is None: - return render_template('plugins.html', loaded=plugins.loaded, database=plugins.database) + # Determine which plugins are from the default folder + default_plugins = set() + default_path = os.path.join( + os.path.dirname(os.path.realpath(plugins.__file__)), "default" + ) + for plugin_name, plugin_path in plugins.database.items(): + if plugin_path.startswith(default_path): + default_plugins.add(plugin_name) + return render_template( + "plugins.html", + loaded=plugins.loaded, + database=plugins.database, + default_plugins=default_plugins, + ) - if name == 'toggle' and request.method == 'POST': - checked = True if 'enabled' in request.form else False - return 'success' if plugins.toggle_plugin(request.form['plugin'], checked) else 'failed' + if name == "toggle" and request.method == "POST": + checked = True if "enabled" in request.form else False + return ( + "success" + if plugins.toggle_plugin(request.form["plugin"], checked) + else "failed" + ) - if name == 'upgrade' and request.method == 'POST': + if name == "upgrade" and request.method == "POST": logging.info(f"Upgrading plugin: {request.form['plugin']}") - os.system(f"pwnagotchi plugins update && pwnagotchi plugins upgrade {request.form['plugin']}") + os.system( + f"pwnagotchi plugins update && pwnagotchi plugins upgrade {request.form['plugin']}" + ) return redirect("/plugins") - if name in plugins.loaded and plugins.loaded[name] is not None and hasattr(plugins.loaded[name], 'on_webhook'): + if ( + name in plugins.loaded + and plugins.loaded[name] is not None + and hasattr(plugins.loaded[name], "on_webhook") + ): try: return plugins.loaded[name].on_webhook(subpath, request) except Exception: @@ -207,32 +259,60 @@ class Handler: # serve a message and shuts down the unit def shutdown(self): try: - return render_template('status.html', title=pwnagotchi.name(), go_back_after=60, - message='Shutting down ...') + return render_template( + "status.html", + title=pwnagotchi.name(), + go_back_after=60, + message="Shutting down ...", + ) finally: - _thread.start_new_thread(pwnagotchi.shutdown, ()) + # FIX B5: replaced _thread.start_new_thread with threading.Thread + threading.Thread(target=pwnagotchi.shutdown, daemon=True).start() # serve a message and reboot the unit def reboot(self): - try: - return render_template('status.html', title=pwnagotchi.name(), go_back_after=60, - message='Rebooting ...') - finally: - _thread.start_new_thread(pwnagotchi.reboot, ()) + try: + return render_template( + "status.html", + title=pwnagotchi.name(), + go_back_after=60, + message="Rebooting ...", + ) + finally: + # FIX B5: replaced _thread.start_new_thread with threading.Thread + threading.Thread(target=pwnagotchi.reboot, daemon=True).start() # serve a message and restart the unit in the other mode def restart(self): - mode = request.form['mode'] - if mode not in ('AUTO', 'MANU'): - mode = 'MANU' + mode = request.form["mode"] + if mode not in ("AUTO", "MANU"): + mode = "MANU" try: - return render_template('status.html', title=pwnagotchi.name(), go_back_after=30, - message='Restarting in %s mode ...' % mode) + return render_template( + "status.html", + title=pwnagotchi.name(), + go_back_after=30, + message="Restarting in %s mode ..." % mode, + ) finally: - _thread.start_new_thread(pwnagotchi.restart, (mode,)) + # FIX B5: replaced _thread.start_new_thread with threading.Thread + threading.Thread( + target=pwnagotchi.restart, args=(mode,), daemon=True + ).start() + + # serve dynamic CSS with accent color from config + def dynamic_theme(self): + """Generate CSS accent RGB variables from config [ui.web.theme] section""" + # Get RGB values from already-loaded config, fallback to default green + r = self._config.get("theme", {}).get("accent_r", 76) + g = self._config.get("theme", {}).get("accent_g", 175) + b = self._config.get("theme", {}).get("accent_b", 80) + + css = f":root {{\n --accent: rgb({r}, {g}, {b});\n --accent-r: {r};\n --accent-g: {g};\n --accent-b: {b};\n}}" + return Response(css, mimetype="text/css") # serve the PNG file with the display image def ui(self): with web.frame_lock: - return send_file(web.frame_path, mimetype='image/png') + return send_file(web.frame_path, mimetype="image/png") diff --git a/pwnagotchi/ui/web/static/css/style.css b/pwnagotchi/ui/web/static/css/style.css index 1ed13fa5..4f0e73e8 100644 --- a/pwnagotchi/ui/web/static/css/style.css +++ b/pwnagotchi/ui/web/static/css/style.css @@ -1,82 +1,1415 @@ +/* ============================================ + Pwnagotchi Web UI - Modern Dark Theme + Color Scheme: Terminal Green Accent + ============================================ */ + +/* VT323 Font - Load locally */ +@font-face { + font-family: 'VT323'; + src: url('../fonts/VT323-Regular.woff2') format('woff2'), + url('../fonts/VT323-Regular.woff') format('woff'), + url('../fonts/VT323-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +:root { + /* Colors - Default */ + --bg-color: #121212; + --card-bg: #1e1e1e; + --text-main: #e0e0e0; + --text-bright: #ffffff; + --text-body: #bfbfbf; + --text-muted: #888; + --accent: rgb(var(--accent-r), var(--accent-g), var(--accent-b)); + --danger: #ff5555; + --danger-hover: #ff7777; + --info: #4fc3f7; + --border-color: #333; + --bg-hover: #252525; + --bg-secondary: #161616; + + /* Fonts */ + --font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-pixel: "VT323", monospace; + + /* Spacing & Effects */ + --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1); + --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4); +} + +* { + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html, +body { + margin: 0; + padding: 0; + height: 100vh; + font-family: var(--font-main); + font-size: 0.95rem; + line-height: 1.6; + color: var(--text-main); + background-color: var(--bg-color); + overflow-x: hidden; + word-wrap: break-word; + overflow-wrap: break-word; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; +} + +body { + display: flex; + flex-direction: column; + transition: background-color 0.3s, color 0.3s; +} + +a { + color: var(--accent); + text-decoration: none; + transition: color 0.2s; +} + +a:hover { + color: var(--accent); + filter: brightness(1.3); +} + +/* ============================================ + Global Text Wrapping + ============================================ */ + +h1, h2, h3, h4, h5, h6 { + word-wrap: break-word; + overflow-wrap: break-word; +} + +strong, b { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + display: inline; +} + +.messagebody-meta strong, +.messagebody-meta b { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; +} + +/* ============================================ + Scrollbars + ============================================ */ + +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: #444 transparent; +} + +/* Webkit (Chrome, Safari, Edge) */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #444; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #666; +} + +/* ============================================ + Layout Structure + ============================================ */ + +.page-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: auto; +} + +.page-content { + flex: 1; + padding: 16px; + width: 100%; + overflow-y: auto; + overflow-x: hidden; + background-color: var(--bg-color); +} + +.page-footer { + margin-top: auto; + background-color: #0d0d0d; + padding: 0; + overflow: hidden; +} + +.page-header { + padding: 40px 20px; + text-align: center; + margin-bottom: 40px; + background: linear-gradient(to bottom, rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05), transparent); + border-bottom: 1px solid #2a2a2a; +} + +.page-header h1 { + font-family: var(--font-pixel); + font-weight: 400; + font-size: 3.5rem; + color: #fff; + margin: 0; + line-height: 0.9; + text-transform: uppercase; + letter-spacing: 2px; + text-shadow: 0 0 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3); +} + +/* ============================================ + Navigation Bar + ============================================ */ + +.navbar { + display: flex; + list-style: none; + margin: 0; + padding: 0; + gap: 0; + background-color: #0d0d0d; + border-top: 1px solid #333; +} + +.navbar-item { + flex: 1; + text-align: center; + margin-bottom: 0; +} + +.navbar-item a { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0; + color: #b0b0b0; + font-size: 0.65rem; + border-bottom: 4px solid transparent; + transition: var(--transition); + white-space: nowrap; + height: 100%; + flex: 1; + min-height: 60px; + font-weight: 400; + text-transform: uppercase; + letter-spacing: 0.3px; + gap: 0.25rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.navbar-item a:hover { + color: #ffffff; + background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.08); + text-decoration: none; +} + +.navbar-item a.active { + color: var(--accent); + border-bottom-color: var(--accent); + background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1); +} + +.navbar-icon { + display: flex; + width: 1.5rem; + height: 1.5rem; + align-items: center; + justify-content: center; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; +} + +/* Navigation Icons - Using mask-image with currentColor via background-color */ +#home .navbar-icon { + -webkit-mask-image: url("../svg/home.svg"); + mask-image: url("../svg/home.svg"); + background-color: rgb(176,176,176); +} + +#inbox .navbar-icon { + -webkit-mask-image: url("../svg/inbox.svg"); + mask-image: url("../svg/inbox.svg"); + background-color: rgb(176,176,176); +} + +#new .navbar-icon { + -webkit-mask-image: url("../svg/new.svg"); + mask-image: url("../svg/new.svg"); + background-color: rgb(176,176,176); +} + +#profile .navbar-icon { + -webkit-mask-image: url("../svg/profile.svg"); + mask-image: url("../svg/profile.svg"); + background-color: rgb(176,176,176); +} + +#peers .navbar-icon { + -webkit-mask-image: url("../svg/peers.svg"); + mask-image: url("../svg/peers.svg"); + background-color: rgb(176,176,176); +} + +#plugins .navbar-icon { + -webkit-mask-image: url("../svg/plugins.svg"); + mask-image: url("../svg/plugins.svg"); + background-color: rgb(176,176,176); +} + +/* Active state - Icons use accent color */ +#home.active .navbar-icon { + background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b)); +} + +#inbox.active .navbar-icon { + background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b)); +} + +#new.active .navbar-icon { + background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b)); +} + +#profile.active .navbar-icon { + background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b)); +} + +#peers.active .navbar-icon { + background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b)); +} + +#plugins.active .navbar-icon { + background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b)); +} + +/* ============================================ + Typography + ============================================ */ + +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 0.5rem; + font-weight: 400; + line-height: 1.25; + color: var(--text-main); +} + +h1 { + font-size: 3.5rem; + font-family: var(--font-pixel); + letter-spacing: 2px; + color: #ffffff; + margin: 1.5rem 0 1rem 0; + line-height: 0.9; + text-transform: uppercase; + text-shadow: 0 0 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3); + font-weight: 400; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +h2 { + font-size: 1.8rem; + color: var(--accent); + font-family: var(--font-pixel); + font-weight: 400; + letter-spacing: 1px; + margin: 1.25rem 0 0.75rem 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +h3 { + font-size: 1.4rem; + color: var(--accent); + font-family: var(--font-pixel); + font-weight: 400; + letter-spacing: 1px; + margin: 1rem 0 0.5rem 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +h4 { + font-family: var(--font-pixel); + font-size: 1.2rem; + color: var(--text-main); + font-weight: 400; + margin: 1rem 0 0.75rem 0; + letter-spacing: 0.5px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +h5 { + font-family: var(--font-pixel); + font-size: 1.1rem; + color: var(--text-main); + font-weight: 400; + margin: 0.75rem 0 0.5rem 0; + letter-spacing: 0.5px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +h6 { + font-family: var(--font-pixel); + font-size: 1rem; + color: var(--text-main); + font-weight: 400; + margin: 0.75rem 0 0.5rem 0; + letter-spacing: 0.5px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +p { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-body); + line-height: 1.6; + font-size: 0.9rem; +} + +p::first-letter { + text-transform: uppercase; +} + +small { + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* ============================================ + Forms + ============================================ */ + +form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.control-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: flex-start; + align-items: stretch; +} + +.control-buttons form { + flex: 1 1 calc(33% - 0.5rem); + min-width: 140px; + display: flex; + margin: 0; + gap: 0; +} + +.control-buttons button { + flex: 1; + min-width: 100px; + margin: 0; + font-size: 0.95rem; + padding: 10px 16px; +} + +@media (max-width: 768px) { + .control-buttons form { + flex: 1 1 calc(50% - 0.375rem); + min-width: 100px; + } + + .control-buttons button { + font-size: 0.9rem; + padding: 8px 12px; + } +} + +@media (max-width: 480px) { + .control-buttons form { + flex: 1 1 calc(50% - 0.375rem); + min-width: 80px; + } + + .control-buttons button { + font-size: 0.85rem; + padding: 8px 12px; + } +} + +label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + font-size: 0.9rem; + letter-spacing: 0.5px; + font-family: var(--font-pixel); + transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +label:hover { + color: var(--accent); +} + +input[type="text"], +input[type="email"], +input[type="password"], +input[type="number"], +textarea, +select { + width: 100%; + padding: 12px 16px; + font-size: 0.95rem; + font-family: var(--font-main); + line-height: 1.5; + color: var(--text-main); + background-color: #1a1a1a; + border: 1px solid var(--border-color); + border-radius: 6px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +input[type="text"]:focus, +input[type="email"]:focus, +input[type="password"]:focus, +input[type="number"]:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1); + background-color: #1e1e1e; +} + +/* Select Box Styling - chevron-down.svg as mask with accent color */ +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + -webkit-mask-image: url("../svg/chevron-down.svg"); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: right 10px center; + -webkit-mask-size: 20px; + mask-image: url("../svg/chevron-down.svg"); + mask-repeat: no-repeat; + mask-position: right 10px center; + mask-size: 20px; + padding-right: 36px; + cursor: pointer; + color: rgb(var(--accent-r), var(--accent-g), var(--accent-b)); + background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b)); +} + +select::-ms-expand { + display: none; +} + +/* Placeholder text styling */ +input::placeholder, +textarea::placeholder { + color: var(--text-muted); +} + +/* Textarea improvements */ +textarea { + font-family: var(--font-main); + resize: vertical; + min-height: 6rem; +} + +/* ============================================ + Buttons + ============================================ */ + +button, +input[type="submit"], +input[type="button"], +input[type="reset"], +.btn { + width: auto; + min-width: 120px; + padding: 12px 28px; + background: var(--accent); + color: #000; + border: none; + border-radius: 50px; + font-family: var(--font-pixel); + font-size: 1.2rem; + cursor: pointer; + box-shadow: 0 4px 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.4); + text-transform: uppercase; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; + font-weight: 400; + letter-spacing: 0.5px; +} + +button:hover, +input[type="submit"]:hover, +input[type="button"]:hover, +input[type="reset"]:hover, +.btn:hover { + color: #000; + background-color: #ffffff; + box-shadow: 0 6px 20px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.5); + transform: translateY(-2px) scale(1.02); + text-decoration: none; +} + +button:active, +input[type="submit"]:active, +input[type="button"]:active, +input[type="reset"]:active, +.btn:active { + transform: translateY(0) scale(0.98); + box-shadow: 0 2px 8px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3); +} + +button:disabled, +input[type="submit"]:disabled, +input[type="button"]:disabled, +input[type="reset"]:disabled, +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn.danger { + background-color: var(--danger); + box-shadow: 0 4px 12px rgba(255, 85, 85, 0.3); + color: #fff; +} + +.btn.danger:hover { + background-color: var(--danger-hover); + box-shadow: 0 6px 16px rgba(255, 85, 85, 0.4); +} + +.btn.info { + background-color: var(--info); + color: #000; + box-shadow: 0 4px 12px rgba(79, 195, 247, 0.3); +} + +.btn.info:hover { + background-color: #29b6f6; + box-shadow: 0 6px 16px rgba(79, 195, 247, 0.4); +} + +.btn.secondary { + background-color: var(--text-muted); + color: #000; + box-shadow: 0 4px 12px rgba(136, 136, 136, 0.3); +} + +.btn.secondary:hover { + background-color: #aaa; + box-shadow: 0 6px 16px rgba(136, 136, 136, 0.4); +} + + +/* ============================================ + Toggle Switch (Checkbox) + ============================================ */ + +.switch { + position: relative; + display: inline-block; + width: 2.8rem; + height: 1.6rem; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #333; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 2rem; + border: 1px solid #4a4a4a; +} + +.slider:before { + position: absolute; + content: ""; + height: 1.1rem; + width: 1.1rem; + left: 0.2rem; + bottom: 0.2rem; + background-color: var(--text-main); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 50%; +} + +input:checked + .slider { + background-color: var(--accent); + border-color: var(--accent); +} + +input:checked + .slider:before { + transform: translateX(1.2rem); + background-color: #ffffff; + box-shadow: 0 0 8px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3); +} + +.switch-label { + display: inline-block; + margin-left: 0.5rem; + font-weight: 500; +} + +/* ============================================ + Lists + ============================================ */ + +ul, ol { + margin-top: 0; + margin-bottom: 1rem; + padding-left: 1.5rem; +} + +li { + margin-bottom: 0.5rem; +} + +.list-group { + display: flex; + flex-direction: column; + gap: 0; + margin-bottom: 1rem; + border: 1px solid #333; + border-radius: 6px; + overflow: hidden; +} + +.list-item { + padding: 0.75rem 1rem; + background-color: var(--card-bg); + border-bottom: 1px solid #333; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + color: var(--text-body); +} + +.list-item:last-child { + border-bottom: none; +} + +.list-item:hover { + background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05); + border-left: 4px solid var(--accent); + padding-left: calc(1rem - 4px); + color: var(--text-bright); +} + +.list-item a { + display: block; + color: inherit; + text-decoration: none; +} + +.list-item a:hover { + text-decoration: none; +} + +.list-item-primary { + font-weight: 500; + margin-bottom: 0.25rem; + color: var(--accent); + font-size: 0.95rem; +} + +.list-item-secondary { + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.4; +} + +/* ============================================ + Cards & Boxes + ============================================ */ + +.card { + background-color: var(--card-bg); + border: 1px solid #2a2a2a; + border-radius: 16px; + padding: 16px; + position: relative; + display: flex; + flex-direction: column; + transition: all 0.3s ease; + box-shadow: var(--shadow-md); +} + +.card:hover { + border-color: var(--accent); + box-shadow: 0 10px 30px rgba(0,0,0,0.4); + transform: translateY(-5px); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + font-weight: 400; + color: var(--accent); + font-family: var(--font-pixel); + text-transform: uppercase; + letter-spacing: 1px; + font-size: 1.4rem; +} + +.card-body { + font-size: 0.9rem; + color: var(--text-main); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 999; + line-clamp: 999; + -webkit-box-orient: vertical; + flex-grow: 1; + background-color: var(--card-bg); +} + +.card-body::first-letter { + text-transform: uppercase; +} + +.card-footer { + margin-top: auto; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* Plugin-specific button styles */ +.plugin-upgrade-btn { + width: 100%; +} + +.plugin-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.plugins-box > div:not(.tooltip) { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* ============================================ + Badges + ============================================ */ + +.badge { + background: #2a2a2a; + font-size: 0.65rem; + padding: 2px 6px; + border-radius: 4px; + color: #777; + font-family: var(--font-pixel); + text-transform: uppercase; + letter-spacing: 0.3px; + border: 1px solid #444; +} + +.badge.version { + flex-shrink: 0; +} + +.badge.default { + background: #262626; + color: #888; + border-color: #333; +} + +.tooltip { + position: relative; + display: inline-block; + cursor: help; +} + +.tooltip .tooltiptext { + visibility: hidden; + width: 180px; + background-color: rgba(0, 0, 0, 0.8); + color: #fff; + text-align: center; + border-radius: 4px; + padding: 0.4rem 0.6rem; + position: absolute; + z-index: 1; + top: 100%; + left: 50%; + margin-left: -90px; + margin-top: 0.4rem; + font-size: 0.75rem; + opacity: 0; + transition: opacity 0.3s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.tooltip:hover .tooltiptext { + visibility: visible; + opacity: 1; +} + +/* ============================================ + Messages & Alerts + ============================================ */ + +/* Toast Notifications */ +.toast { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%) translateY(120px); + background-color: #1e1e1e; + color: var(--text-main); + padding: 1rem 1.5rem; + border-radius: 8px; + border-left: 4px solid var(--danger); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + z-index: 9999; + max-width: 90%; + width: auto; + transition: transform 0.3s ease; + font-size: 0.95rem; + line-height: 1.5; +} + +.toast.show { + transform: translateX(-50%) translateY(0); +} + +.toast-error { + border-left-color: var(--danger); + background: linear-gradient(135deg, rgba(255, 85, 85, 0.1), rgba(255, 85, 85, 0.05)); +} + +.toast-success { + border-left-color: var(--accent); + background: linear-gradient(135deg, rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1), rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05)); +} + +.toast-info { + border-left-color: var(--info); + background: linear-gradient(135deg, rgba(79, 195, 247, 0.1), rgba(79, 195, 247, 0.05)); +} + +.alert { + padding: 1.25rem 1.5rem; + margin-bottom: 1rem; + border-left: 4px solid; + border-radius: 4px; + font-weight: 500; + display: flex; + align-items: flex-start; + gap: 1rem; + animation: slideInDown 0.3s ease-out; + color: var(--text-bright); + position: fixed; + top: 0; + left: 0; + right: 0; + margin: 0; + border-radius: 0; + z-index: 1000; + max-height: 100px; +} + +.alert-error, +.error { + background: linear-gradient(135deg, rgba(255, 85, 85, 0.15), rgba(255, 85, 85, 0.08)); + border-left-color: #ff5555; + color: #ffb0b0; +} + +.alert-success { + background: linear-gradient(135deg, rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.15), rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.08)); + border-left-color: var(--accent); + color: var(--accent); + filter: brightness(1.2); +} + +.alert-info { + background: linear-gradient(135deg, rgba(79, 195, 247, 0.15), rgba(79, 195, 247, 0.08)); + border-left-color: var(--info); + color: #4fc3f7; +} + +@keyframes slideInDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.status { + padding: 2rem 1rem; + text-align: center; +} + +/* ============================================ + Images & Media + ============================================ */ + +img { + max-width: 100%; + height: auto; + display: block; + border-radius: 6px; +} + +pre { + overflow-x: auto; + background-color: #0a0a0a; + padding: 1rem; + border-radius: 6px; + border: 1px solid #333; + max-width: 100%; + word-break: break-all; + white-space: pre-wrap; + font-family: var(--font-pixel); + color: var(--accent); + font-size: 0.9rem; + line-height: 1.4; +} + +code { + word-break: break-all; + max-width: 100%; + font-family: var(--font-pixel); + color: var(--accent); +} + .ui-image { - width: 100%; + width: 100%; + border-radius: 6px; + box-shadow: var(--shadow-md); + margin-bottom: 16px; } .pixelated { - image-rendering: optimizeSpeed; /* Legal fallback */ - image-rendering: -moz-crisp-edges; /* Firefox */ - image-rendering: -o-crisp-edges; /* Opera */ - image-rendering: -webkit-optimize-contrast; /* Safari */ - image-rendering: optimize-contrast; /* CSS3 Proposed */ - image-rendering: crisp-edges; /* CSS4 Proposed */ - image-rendering: pixelated; /* CSS4 Proposed */ - -ms-interpolation-mode: nearest-neighbor; /* IE8+ */ + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: optimize-contrast; + image-rendering: crisp-edges; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + margin-bottom: 1rem; } -.image-wrapper { - flex: 1; - position: relative; +/* ============================================ + Tooltips + ============================================ */ + +.btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border-radius: 50px; + border: none; + font-weight: 400; + text-transform: uppercase; + font-size: 0.85rem; + min-height: 36px; + white-space: nowrap; + text-decoration: none; + transition: var(--transition); + cursor: pointer; } -div.status { - position: absolute; - top: 0; +.btn.primary { + background-color: var(--accent); + color: #000; +} + +.btn.primary:hover { + background-color: var(--accent); + filter: brightness(1.2); + text-decoration: none; + transform: translateY(-2px); +} + +.btn.danger { + background-color: var(--danger); + color: #fff; +} + +.btn.danger:hover { + background-color: var(--danger-hover); + text-decoration: none; + transform: translateY(-2px); +} + +.read { + color: var(--text-secondary) !important; +} + +/* ============================================ + Utility Classes + ============================================ */ + +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.text-left { + text-align: left; +} + +.text-muted { + color: var(--text-secondary); +} + +.mt-1 { + margin-top: 0.5rem; +} + +.mt-2 { + margin-top: 1rem; +} + +.mt-3 { + margin-top: 1.5rem; +} + +.mb-1 { + margin-bottom: 0.5rem; +} + +.mb-2 { + margin-bottom: 1rem; +} + +.mb-3 { + margin-bottom: 1.5rem; +} + +.p-1 { + padding: 0.5rem; +} + +.p-2 { + padding: 1rem; +} + +.p-3 { + padding: 1.5rem; +} + +.hidden { + display: none !important; +} + +/* ============================================ + Responsive Design + ============================================ */ + +@media (max-width: 1024px) { + .plugins-box { + flex: 1 1 calc(50% - 1rem); + } +} + +@media (max-width: 768px) { + body { + font-size: 0.95rem; + } + + h1 { + font-size: 1.5rem; + } + + h2 { + font-size: 1.25rem; + } + + .page-content { + padding: 16px; + } + + .navbar-item a { + padding: 0.5rem; + font-size: 0.65rem; + min-height: 60px; + gap: 0.2rem; + } + + .navbar-icon { + width: 1.3rem; + height: 1.3rem; + } + + button, + input[type="submit"], + input[type="button"], + input[type="reset"], + .btn { + padding: 10px 20px; + font-size: 0.9rem; + min-width: 100px; + } + + .switch { + width: 2.5rem; + height: 1.5rem; + } + + .slider:before { + height: 1rem; + width: 1rem; + } + + input:checked + .slider:before { + transform: translateX(1rem); + } + + .plugins-box { + flex: 1 1 calc(50% - 0.75rem); + margin: 0.375rem; + padding: 0.75rem; + } + + #container { + gap: 0; + } + + input[type="text"], + input[type="email"], + input[type="password"], + input[type="number"], + textarea, + select { + font-size: 16px; + } +} + +@media (max-width: 480px) { + body { + font-size: 0.9rem; + } + + h1 { + font-size: 1.25rem; + } + + h2 { + font-size: 1.1rem; + } + + h3 { + font-size: 1rem; + } + + h4 { + font-size: 1rem; + } + + .page-header { + padding: 20px 15px; + margin-bottom: 15px; + } + + .page-footer { + position: fixed; + bottom: 0; left: 0; + right: 0; + margin: 0; + overflow: hidden; + } + + .page-content { + padding: 12px 12px 80px 12px; + } + + .toast { + bottom: 70px; + } + + .navbar-item a { + padding: 0.4rem 0.25rem; + font-size: 0.6rem; + min-height: 50px; + gap: 0.1rem; + } + + .navbar-icon { + width: 1.1rem; + height: 1.1rem; + } + + button, + input[type="submit"], + input[type="button"], + input[type="reset"], + .btn { + padding: 8px 16px; + font-size: 0.85rem; + min-width: 80px; width: 100%; + margin: 0.5rem 0; + } + + .switch { + width: 2.5rem; + height: 1.4rem; + } + + .slider:before { + height: 0.9rem; + width: 0.9rem; + bottom: 0.2rem; + } + + input:checked + .slider:before { + transform: translateX(1rem); + } + + .plugins-box { + flex: 1 1 100%; + min-height: auto; + margin: 0.5rem 0; + padding: 0.5rem; + } + + .field-inline { + flex-direction: column; + gap: 0.5rem; + } + + input[type="text"], + input[type="email"], + input[type="password"], + input[type="number"], + textarea, + select, + label { + font-size: 0.9rem; + } + + .message-input-area { + flex-direction: column; + gap: 0.5rem; + } + + .message-input-area input { + width: 100%; + } + + .message-input-area button { + width: 100%; + } + + #container { + gap: 0; + } + + .message-actions { + flex-direction: column; + } + + .message-actions a { + width: 100%; + } + + form { + gap: 0.75rem; + } } -a.read { - color: #777 !important; -} +/* ============================================ + Print Styles + ============================================ */ -p.messagebody { - padding: 1em; -} +@media print { + body { + background-color: white; + color: black; + } -li.navitem { - width: 16.66% !important; - clear: none !important; -} + .page-footer { + display: none; + } -/* Custom indentations are needed because the length of custom labels differs from - the length of the standard labels */ -.custom-size-flipswitch.ui-flipswitch .ui-btn.ui-flipswitch-on { - text-indent: -5.9em; -} - -.custom-size-flipswitch.ui-flipswitch .ui-flipswitch-off { - text-indent: 0.5em; -} - -/* Custom widths are needed because the length of custom labels differs from - the length of the standard labels */ -.custom-size-flipswitch.ui-flipswitch { - width: 8.875em; -} - -.custom-size-flipswitch.ui-flipswitch.ui-flipswitch-active { - padding-left: 7em; - width: 1.875em; -} - -@media (min-width: 28em) { - /*Repeated from rule .ui-flipswitch above*/ - .ui-field-contain > label + .custom-size-flipswitch.ui-flipswitch { - width: 1.875em; - } -} - -#container { - display: flex; - flex-wrap: wrap; - justify-content: space-around; -} - -.plugins-box { - margin: 0.5rem; - padding: 0.2rem; - border-style: groove; - border-radius: 0.5rem; - background-color: lightgrey; - text-align: center; + a { + color: inherit; + text-decoration: underline; + } } diff --git a/pwnagotchi/ui/web/templates/base.html b/pwnagotchi/ui/web/templates/base.html index 921dbaa8..6f92b210 100644 --- a/pwnagotchi/ui/web/templates/base.html +++ b/pwnagotchi/ui/web/templates/base.html @@ -1,86 +1,73 @@ - - -{% block head %} - + + + {% block head %} + {% block meta %} - - - + + + + {% endblock %} - - {% block title %} - {% endblock %} - + {% block title %}{% endblock %} {% block styles %} - - - - - {% endblock %} - - -{% endblock %} - -{% block body %} - -
- - {% if error %} -
-

{{ error }}

-
- + + + {% if active_page == 'profile' %} + + {% elif active_page in ['inbox', 'new'] %} + + {% elif active_page == 'plugins' %} + {% endif %} + + + {% endblock %} + - {% set navigation = [ - ( '/', 'home', 'eye', 'Home' ), - ( '/inbox', 'inbox', 'bars', 'Inbox' ), - ( '/inbox/new', 'new', 'mail', 'New' ), - ( '/inbox/profile', 'profile', 'info', 'Profile' ), - ( '/inbox/peers', 'peers', 'user', 'Peers' ), - ( '/plugins', 'plugins', 'grid', 'Plugins' ), - ] %} - {% set active_page = active_page|default('inbox') %} + {% endblock %} {% block body %} + +
+ {% if error %} + + {% endif %} {% set navigation = [ ( '/', 'home', 'Home' ), ( '/inbox', + 'inbox', 'Inbox' ), ( '/inbox/new', 'new', 'New' ), ( '/inbox/profile', + 'profile', 'Profile' ), ( '/inbox/peers', 'peers', 'Peers' ), ( + '/plugins', 'plugins', 'Plugins' ), ] %} {% set active_page = + active_page|default('inbox') %} -
-
-
    - {% for href, id, icon, caption in navigation %} - - {% endfor %} -
-
+
{% block content %} {% endblock %}
+ +
- {% block content %} + {% block scripts %} + + + {% endblock %} - -
-{% block scripts %} - - - - - -{% endblock %} - -{% endblock %} + + {% endblock %}