From 01c6ab856dab5a3037df32161d94e58f34ae098b Mon Sep 17 00:00:00 2001 From: Jeroen Oudshoorn Date: Wed, 25 Mar 2026 19:01:56 +0100 Subject: [PATCH] Revert to 2.9.5.4 Signed-off-by: Jeroen Oudshoorn --- pwnagotchi/_version.py | 2 +- pwnagotchi/agent.py | 6 +- pwnagotchi/ai/epoch.py | 71 +- pwnagotchi/bettercap.py | 64 +- pwnagotchi/cli.py | 129 + pwnagotchi/defaults.toml | 78 +- pwnagotchi/fs/__init__.py | 2 + pwnagotchi/grid.py | 18 +- pwnagotchi/identity.py | 2 +- pwnagotchi/log.py | 2 +- pwnagotchi/mesh/utils.py | 2 + pwnagotchi/plugins/default/auto-tune.py | 2 +- pwnagotchi/plugins/default/auto-update.py | 4 +- pwnagotchi/plugins/default/auto_backup.py | 459 +- pwnagotchi/plugins/default/bt-tether.py | 5235 +----------------- pwnagotchi/plugins/default/fix_services.py | 84 +- pwnagotchi/plugins/default/logtail.py | 420 +- pwnagotchi/plugins/default/session-stats.py | 874 +-- pwnagotchi/plugins/default/webcfg.py | 372 +- pwnagotchi/plugins/default/wpa-sec.py | 8 +- pwnagotchi/ui/components.py | 27 +- pwnagotchi/ui/display.py | 3 - pwnagotchi/ui/faces.py | 1 - pwnagotchi/ui/hw/__init__.py | 4 - pwnagotchi/ui/hw/waveshare2in13b_V3.py | 30 +- pwnagotchi/ui/hw/waveshare2in13b_V4.py | 28 +- pwnagotchi/ui/view.py | 8 +- pwnagotchi/ui/web/handler.py | 240 +- pwnagotchi/ui/web/server.py | 1 + pwnagotchi/ui/web/static/css/style.css | 1468 +---- pwnagotchi/ui/web/templates/base.html | 131 +- pwnagotchi/ui/web/templates/inbox.html | 91 +- pwnagotchi/ui/web/templates/index.html | 81 +- pwnagotchi/ui/web/templates/message.html | 74 +- pwnagotchi/ui/web/templates/new_message.html | 71 +- pwnagotchi/ui/web/templates/peers.html | 39 +- pwnagotchi/ui/web/templates/plugins.html | 175 +- pwnagotchi/ui/web/templates/profile.html | 60 +- pwnagotchi/ui/web/templates/status.html | 13 +- pwnagotchi/utils.py | 2 - 40 files changed, 1708 insertions(+), 8673 deletions(-) diff --git a/pwnagotchi/_version.py b/pwnagotchi/_version.py index c9af5365..5fb05872 100644 --- a/pwnagotchi/_version.py +++ b/pwnagotchi/_version.py @@ -1 +1 @@ -__version__ = '2.9.5.5' +__version__ = '2.9.5.4' diff --git a/pwnagotchi/agent.py b/pwnagotchi/agent.py index 7d0410a1..6f6dbecf 100644 --- a/pwnagotchi/agent.py +++ b/pwnagotchi/agent.py @@ -4,6 +4,7 @@ import os import re import logging import asyncio +#import _thread import threading import subprocess @@ -297,10 +298,12 @@ class Agent(Client, Automata, AsyncAdvertiser): if delete: logging.info("deleting %s", RECOVERY_DATA_FILE) os.unlink(RECOVERY_DATA_FILE) - except Exception: # FIX B4: was bare except, now catches Exception only + except: + if not no_exceptions: raise def start_session_fetcher(self): + #_thread.start_new_thread(self._fetch_stats, ()) threading.Thread(target=self._fetch_stats, args=(), name="Session Fetcher", daemon=True).start() def _fetch_stats(self): @@ -384,6 +387,7 @@ class Agent(Client, Automata, AsyncAdvertiser): def start_event_polling(self): # start a thread and pass in the mainloop + #_thread.start_new_thread(self._event_poller, (asyncio.get_event_loop(),)) threading.Thread(target=self._event_poller, args=(asyncio.get_event_loop(),), name="Event Polling", daemon=True).start() def is_module_running(self, module): diff --git a/pwnagotchi/ai/epoch.py b/pwnagotchi/ai/epoch.py index ea9581d3..2ba47212 100644 --- a/pwnagotchi/ai/epoch.py +++ b/pwnagotchi/ai/epoch.py @@ -4,10 +4,9 @@ import logging import pwnagotchi import pwnagotchi.utils as utils +import pwnagotchi.mesh.wifi as wifi -# REMOVED: from pwnagotchi.ai.reward import RewardFunction -# The RewardFunction computed a reward score that was logged but never consumed -# by any decision-making logic after AI removal. Removed to eliminate dead CPU work. +from pwnagotchi.ai.reward import RewardFunction class Epoch(object): @@ -45,7 +44,7 @@ class Epoch(object): # number of peers seen during this epoch self.num_peers = 0 # cumulative bond factor - self.tot_bond_factor = 0.0 + self.tot_bond_factor = 0.0 # cum_bond_factor sounded worse ... # average bond factor self.avg_bond_factor = 0.0 # any activity at all during this epoch? @@ -56,27 +55,29 @@ class Epoch(object): self.epoch_duration = 0 # https://www.metageek.com/training/resources/why-channels-1-6-11.html self.non_overlapping_channels = {1: 0, 6: 0, 11: 0} - # REMOVED: observation histogram vectors (_observation, _observation_ready) - # These were 14-element float arrays per channel computed every observe() call, - # feeding a neural network that no longer exists. + # observation vectors + self._observation = { + 'aps_histogram': [0.0] * wifi.NumChannels, + 'sta_histogram': [0.0] * wifi.NumChannels, + 'peers_histogram': [0.0] * wifi.NumChannels + } + self._observation_ready = threading.Event() self._epoch_data = {} self._epoch_data_ready = threading.Event() - # REMOVED: self._reward = RewardFunction() + self._reward = RewardFunction() def wait_for_epoch_data(self, with_observation=True, timeout=None): - # REMOVED: observation wait (was already commented out in original) + # if with_observation: + # self._observation_ready.wait(timeout) + # self._observation_ready.clear() self._epoch_data_ready.wait(timeout) self._epoch_data_ready.clear() - # REMOVED: merging histogram observation vectors - just return epoch data - return self._epoch_data + return self._epoch_data if with_observation is False else {**self._observation, **self._epoch_data} def data(self): return self._epoch_data def observe(self, aps, peers): - # REMOVED: histogram computation (aps_histogram, sta_histogram, peers_histogram) - # These were normalised channel observation vectors for the neural network. - # Kept: the non-histogram peer/AP accounting the mood system depends on. num_aps = len(aps) if num_aps == 0: self.blind_for += 1 @@ -91,8 +92,38 @@ class Epoch(object): self.tot_bond_factor = sum((peer.encounters for peer in peers)) / bond_unit_scale self.avg_bond_factor = self.tot_bond_factor / num_peers - # REMOVED: per-channel histogram normalisation loops - # REMOVED: self._observation update and self._observation_ready.set() + num_aps = len(aps) + 1e-10 + num_sta = sum(len(ap['clients']) for ap in aps) + 1e-10 + aps_per_chan = [0.0] * wifi.NumChannels + sta_per_chan = [0.0] * wifi.NumChannels + peers_per_chan = [0.0] * wifi.NumChannels + + for ap in aps: + ch_idx = ap['channel'] - 1 + try: + aps_per_chan[ch_idx] += 1.0 + sta_per_chan[ch_idx] += len(ap['clients']) + except IndexError: + logging.error("got data on channel %d, we can store %d channels" % (ap['channel'], wifi.NumChannels)) + + for peer in peers: + try: + peers_per_chan[peer.last_channel - 1] += 1.0 + except IndexError: + logging.error( + "got peer data on channel %d, we can store %d channels" % (peer.last_channel, wifi.NumChannels)) + + # normalize + aps_per_chan = [e / num_aps for e in aps_per_chan] + sta_per_chan = [e / num_sta for e in sta_per_chan] + peers_per_chan = [e / num_peers for e in peers_per_chan] + + self._observation = { + 'aps_histogram': aps_per_chan, + 'sta_histogram': sta_per_chan, + 'peers_histogram': peers_per_chan + } + self._observation_ready.set() def track(self, deauth=False, assoc=False, handshake=False, hop=False, sleep=False, miss=False, inc=1): if deauth: @@ -174,13 +205,12 @@ class Epoch(object): 'temperature': temp } - # REMOVED: self._epoch_data['reward'] = self._reward(self.epoch + 1, self._epoch_data) - + self._epoch_data['reward'] = self._reward(self.epoch + 1, self._epoch_data) self._epoch_data_ready.set() logging.info("[epoch %d] duration=%s slept_for=%s blind=%d sad=%d bored=%d inactive=%d active=%d peers=%d tot_bond=%.2f " "avg_bond=%.2f hops=%d missed=%d deauths=%d assocs=%d handshakes=%d cpu=%d%% mem=%d%% " - "temperature=%dC" % ( + "temperature=%dC reward=%s" % ( self.epoch, utils.secs_to_hhmmss(self.epoch_duration), utils.secs_to_hhmmss(self.num_slept), @@ -199,7 +229,8 @@ class Epoch(object): self.num_shakes, cpu * 100, mem * 100, - temp)) + temp, + self._epoch_data['reward'])) self.epoch += 1 self.epoch_started = now diff --git a/pwnagotchi/bettercap.py b/pwnagotchi/bettercap.py index a55a1b6f..def18802 100644 --- a/pwnagotchi/bettercap.py +++ b/pwnagotchi/bettercap.py @@ -18,13 +18,6 @@ max_queue = 10000 min_sleep = 0.5 max_sleep = 5.0 -# FIX B2: constants for run() retry logic -MAX_RETRIES = 10 -BACKOFF_BASE = 2.0 - -# FIX B3: consecutive websocket OSError failures before triggering restart -MAX_WS_ERRORS = 5 - def decode(r, verbose_errors=True): try: @@ -60,8 +53,20 @@ class Client(object): async def start_websocket(self, consumer): s = "%s/events" % self.websocket - # FIX B3: track consecutive OSError failures before escalating to restart - oserror_count = 0 + # More modern version of the approach below + # logging.info("Creating new websocket...") + # async for ws in websockets.connect(s): + # try: + # async for msg in ws: + # try: + # await consumer(msg) + # except Exception as ex: + # logging.debug("Error while parsing event (%s)", ex) + # except websockets.exceptions.ConnectionClosedError: + # sleep_time = max_sleep*random.random() + # logging.warning('Retrying websocket connection in {} sec'.format(sleep_time)) + # await asyncio.sleep(sleep_time) + # continue # restarted every time the connection fails while True: @@ -69,8 +74,6 @@ class Client(object): try: async with websockets.connect(s, ping_interval=ping_interval, ping_timeout=ping_timeout, max_queue=max_queue) as ws: - # reset error counter on successful connect - oserror_count = 0 # listener loop while True: try: @@ -85,44 +88,31 @@ class Client(object): await asyncio.wait_for(pong, timeout=ping_timeout) logging.warning('[bettercap] ping OK, keeping connection alive...') continue - except Exception: - # FIX B4: replaced bare except with except Exception - sleep_time = min_sleep + max_sleep * random.random() + except: + sleep_time = min_sleep + max_sleep*random.random() logging.warning('[bettercap] ping error - retrying connection in {} sec'.format(sleep_time)) await asyncio.sleep(sleep_time) break except ConnectionRefusedError: - sleep_time = min_sleep + max_sleep * random.random() + sleep_time = min_sleep + max_sleep*random.random() logging.warning('[bettercap] nobody seems to be listening at the bettercap endpoint...') logging.warning('[bettercap] retrying connection in {} sec'.format(sleep_time)) await asyncio.sleep(sleep_time) continue except OSError: - # FIX B3: count consecutive failures, only restart after MAX_WS_ERRORS - oserror_count += 1 - logging.warning('[bettercap] connection to the bettercap endpoint failed (failure %d/%d)...', - oserror_count, MAX_WS_ERRORS) - if oserror_count >= MAX_WS_ERRORS: - logging.error('[bettercap] too many consecutive websocket failures, restarting...') - pwnagotchi.restart("AUTO") - else: - sleep_time = min_sleep + max_sleep * random.random() - logging.warning('[bettercap] retrying websocket in %.1fs', sleep_time) - await asyncio.sleep(sleep_time) - continue + logging.warning('connection to the bettercap endpoint failed...') + pwnagotchi.restart("AUTO") def run(self, command, verbose_errors=True): - # FIX B2: replace infinite while True loop with bounded retry + exponential backoff - for attempt in range(MAX_RETRIES): + while True: try: r = requests.post("%s/session" % self.url, auth=self.auth, json={'cmd': command}) - return decode(r, verbose_errors=verbose_errors) - except requests.exceptions.ConnectionError: - sleep_time = min(BACKOFF_BASE ** attempt, 30) - logging.warning( - "[bettercap] can't run my request... connection failed (attempt %d/%d), retrying in %.1fs", - attempt + 1, MAX_RETRIES, sleep_time) + except requests.exceptions.ConnectionError as e: + sleep_time = min_sleep + max_sleep*random.random() + logging.warning("[bettercap] can't run my request... connection to the bettercap endpoint failed...") + logging.warning('[bettercap] retrying run in {} sec'.format(sleep_time)) sleep(sleep_time) + else: + break - logging.critical('[bettercap] unreachable after %d attempts, restarting...', MAX_RETRIES) - pwnagotchi.restart('AUTO') + return decode(r, verbose_errors=verbose_errors) diff --git a/pwnagotchi/cli.py b/pwnagotchi/cli.py index 6b7c2e86..b2b1bb12 100644 --- a/pwnagotchi/cli.py +++ b/pwnagotchi/cli.py @@ -6,9 +6,11 @@ import sys import tomlkit import requests import os +import re import pwnagotchi from pwnagotchi import utils +from pwnagotchi.google import cmd as google_cmd from pwnagotchi.plugins import cmd as plugins_cmd from pwnagotchi import log from pwnagotchi import fs @@ -102,6 +104,9 @@ def pwnagotchi_cli(): # Add parsers from plugins_cmd plugins_cmd.add_parsers(subparsers) + # Add parsers from google_cmd + google_cmd.add_parsers(subparsers) + parser = argparse.ArgumentParser(prog="pwnagotchi") # pwnagotchi --help parser.add_argument('-C', '--config', action='store', dest='config', default='/etc/pwnagotchi/default.toml', @@ -126,6 +131,8 @@ def pwnagotchi_cli(): help="Print the configuration.") # Jayofelony added these + parser.add_argument('--wizard', dest="wizard", action="store_true", default=False, + help="Interactive installation of your personal configuration.") parser.add_argument('--check-update', dest="check_update", action="store_true", default=False, help="Check for updates on Pwnagotchi. And tells current version.") parser.add_argument('--donate', dest="donate", action="store_true", default=False, @@ -140,11 +147,133 @@ def pwnagotchi_cli(): log.setup_logging(args, config) rc = plugins_cmd.handle_cmd(args, config) sys.exit(rc) + if google_cmd.used_google_cmd(args): + config = utils.load_config(args) + log.setup_logging(args, config) + rc = google_cmd.handle_cmd(args) + sys.exit(rc) if args.version: print(pwnagotchi.__version__) sys.exit(0) + if args.wizard: + def is_valid_hostname(hostname): + if len(hostname) > 255: + return False + if hostname[-1] == ".": + hostname = hostname[:-1] # strip exactly one dot from the right, if present + allowed = re.compile("(?!-)[A-Z\d-]{1,63}(? 0: + f.write("whitelist = [\n") + for x in range(int(pwn_whitelist)): + ssid = input("SSID (Name): ") + bssid = input("BSSID (MAC): ") + f.write(f"\t\"{ssid}\",\n") + if bssid != "": + f.write(f"\t\"{bssid}\",\n") + f.write("]\n") + # set bluetooth tether + pwn_bluetooth = input("Do you want to enable BT-Tether?\n\n" + "[Y/N] ") + if pwn_bluetooth.lower() in ('y', 'yes'): + f.write("[main.plugins.bt-tether]\n" + "enabled = true\n\n") + pwn_bluetooth_phone_name = input("What name uses your phone, check settings?\n\n") + if pwn_bluetooth_phone_name != "": + f.write(f"phone-name = \"{pwn_bluetooth_phone_name}\"\n") + pwn_bluetooth_device = input("What device do you use? android or ios?\n\n" + "Device: ") + if pwn_bluetooth_device != "": + if pwn_bluetooth_device != "android" and pwn_bluetooth_device != "ios": + print("You have chosen an invalid device. Please start over.") + exit() + f.write(f"phone = \"{pwn_bluetooth_device.lower()}\"\n") + if pwn_bluetooth_device == "android": + f.write("ip = \"192.168.44.44\"\n") + elif pwn_bluetooth_device == "ios": + f.write("ip = \"172.20.10.6\"\n") + pwn_bluetooth_mac = input("What is the bluetooth MAC of your device?\n\n" + "MAC: ") + if pwn_bluetooth_mac != "": + f.write(f"mac = \"{pwn_bluetooth_mac}\"\n") + # set up display settings + pwn_display_enabled = input("Do you want to enable a display?\n\n" + "[Y/N]: ") + if pwn_display_enabled.lower() in ('y', 'yes'): + f.write("[ui.display]\n" + "enabled = true\n") + pwn_display_type = input("What display do you use?\n\n" + "Be sure to check for the correct display type @ \n" + "https://github.com/jayofelony/pwnagotchi/blob/master/pwnagotchi/utils.py#L240-L501\n\n" + "Display type: ") + if pwn_display_type != "": + f.write(f"type = \"{pwn_display_type}\"\n") + pwn_display_invert = input("Do you want to invert the display colors?\n" + "N = Black background\n" + "Y = White background\n\n" + "[Y/N]: ") + if pwn_display_invert.lower() in ('y', 'yes'): + f.write("[ui]\n" + "invert = true\n") + f.close() + if pwn_bluetooth.lower() in ('y', 'yes'): + if pwn_bluetooth_device.lower == "android": + print("To visit the webui when connected with your phone, visit: http://192.168.44.44:8080\n" + "Be sure to run `sudo bluetoothctl` to set-up the bluetooth connection for the first time. And read the wiki step 4.\n" + "Your configuration is done, and I will restart in 5 seconds.") + + elif pwn_bluetooth_device.lower == "ios": + print("To visit the webui when connected with your phone, visit: http://172.20.10.6:8080\n" + "Your configuration is done, and I will restart in 5 seconds.") + else: + print("Your configuration is done, and I will restart in 5 seconds.") + time.sleep(5) + os.system("service pwnagotchi restart") + else: + print("Ok, doing nothing.") + sys.exit(0) + if args.donate: print("Donations can be made @ \n " "https://github.com/sponsors/jayofelony \n\n" diff --git a/pwnagotchi/defaults.toml b/pwnagotchi/defaults.toml index 963a26bb..13ab0312 100644 --- a/pwnagotchi/defaults.toml +++ b/pwnagotchi/defaults.toml @@ -21,14 +21,34 @@ custom_plugin_repos = [ "https://github.com/wpa-2/Pwnagotchi-Plugins/archive/master.zip", "https://github.com/cyberartemio/wardriver-pwnagotchi-plugin/archive/main.zip" ] -custom_plugins = "/etc/pwnagotchi/custom-plugins/" +custom_plugins = "/usr/local/share/pwnagotchi/custom-plugins/" [main.plugins.auto-tune] enabled = true -[main.plugins.auto_backup] #More options availble https://github.com/wpa-2/Pwnagotchi-Plugins/blob/main/auto_backup_config.md -enabled = true -backup_location = "/etc/pwnagotchi/backups" +[main.plugins.auto_backup] +enabled = false +interval = "daily" # or "hourly", or a number (minutes) +max_tries = 0 +backup_location = "/home/pi/" +files = [ + "/root/settings.yaml", + "/root/client_secrets.json", + "/root/.api-report.json", + "/root/.ssh", + "/root/.bashrc", + "/root/.profile", + "/home/pi/handshakes", + "/root/peers", + "/etc/pwnagotchi/", + "/usr/local/share/pwnagotchi/custom-plugins", + "/etc/ssh/", + "/home/pi/.bashrc", + "/home/pi/.profile", + "/home/pi/.wpa_sec_uploads" +] +exclude = [ "/etc/pwnagotchi/logs/*"] +commands = [ "tar cf {backup_file} {files}"] [main.plugins.auto-update] enabled = true @@ -37,21 +57,13 @@ interval = 1 token = "" # Create a personal access token (classic) with scope set to public_repo to use the GitHub API [main.plugins.bt-tether] -enabled = false # Enable the plugin -# Display Settings -show_on_screen = true # Master switch: enable/disable all on-screen display (default: true) -show_mini_status = true # Show compact mini status indicator (single letter) (default: true) -mini_status_position = [110, 0] # Position [x, y] for mini status (default: [110, 0]) -show_detailed_status = true # Show detailed status line with IP (default: true) -detailed_status_position = [0, 82] # Position for detailed status (default: [0, 82]) - -# Auto-Reconnect Settings -auto_reconnect = true # Automatically reconnect when connection drops (default: true) -reconnect_interval = 60 # Check connection every N seconds (default: 60) -reconnect_failure_cooldown = 300 # Cooldown after max failures in seconds (default: 300 = 5 minutes) - -# Discord Notifications (Optional) -discord_webhook_url = "https://discord.com/api/webhooks/YOUR_WEBHOOK_URL" # Send IP notifications to Discord (optional) +enabled = false +phone-name = "" # name as shown on the phone i.e. "Pwnagotchi's Phone" +mac = "" +phone = "" # android or ios +ip = "" # optional, default : 192.168.44.2 if android or 172.20.10.2 if ios +gateway = "" #optional, default : 192.168.44.1 if android or 172.20.10.2 if ios +dns = "8.8.8.8 1.1.1.1" # optional, default (google): "8.8.8.8 1.1.1.1". Consider using anonymous DNS like OpenNic :-) [main.plugins.fix_services] enabled = true @@ -59,6 +71,12 @@ enabled = true [main.plugins.cache] enabled = true +[main.plugins.gdrivesync] +enabled = false +backupfiles = [""] +backup_folder = "PwnagotchiBackups" +interval = 1 + [main.plugins.gpio_buttons] enabled = false @@ -124,9 +142,6 @@ shutdown = 2 [main.plugins.webcfg] enabled = true -[main.plugins.pwnstore_ui] -enabled = true - [main.plugins.webgpsmap] enabled = false @@ -198,18 +213,17 @@ cool = ["(⌐■_■)", "(단__단)"] happy = ["(•‿‿•)", "(^‿‿^)", "(^◡◡^)"] excited = ["(ᵔ◡◡ᵔ)", "(✜‿‿✜)"] grateful = ["(^‿‿^)"] -motivated = ["(☼‿‿☼)", "(★‿★)", "(•̀ᴗ•́)"] -demotivated = ["(≖__≖)", "( ̄ヘ ̄)", "(¬、¬)"] +motivated = ["(☼‿‿☼)"] +demotivated = ["(≖__≖)"] smart = ["(✜‿‿✜)"] -lonely = ["(ب__ب)", "(。•́︿•̀。)", "(︶︹︺)"] -sad = ["(╥☁╥ )", "(╥﹏╥)", "(ಥ﹏ಥ)"] +lonely = ["(ب__ب)"] +sad = ["(╥☁╥ )"] angry = ["(-_-')", "(⇀__⇀)", "(`___´)"] friend = ["(♥‿‿♥)", "(♡‿‿♡)", "(♥‿♥ )", "(♥ω♥ )"] broken = ["(☓‿‿☓)"] debug = ["(#__#)"] upload = ["(1__0)", "(1__1)", "(0__1)"] png = false -scale = 1 position_x = 0 position_y = 34 @@ -223,19 +237,13 @@ 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 type = "waveshare_4" [bettercap] -handshakes = "/etc/pwnagotchi/handshakes" +handshakes = "/home/pi/handshakes" silence = [ "ble.device.new", "ble.device.lost", @@ -269,4 +277,4 @@ mount = "/var/tmp/pwnagotchi" size = "10M" sync = 3600 zram = true -rsync = true \ No newline at end of file +rsync = true diff --git a/pwnagotchi/fs/__init__.py b/pwnagotchi/fs/__init__.py index e0c56801..5205ff92 100644 --- a/pwnagotchi/fs/__init__.py +++ b/pwnagotchi/fs/__init__.py @@ -3,6 +3,7 @@ import re import tempfile import contextlib import shutil +#import _thread import threading import logging @@ -86,6 +87,7 @@ def setup_mounts(config): logging.debug("[FS] Starting thread to sync %s (interval: %d)", options['mount'], interval) threading.Thread(target=m.daemonize, args=(interval,),name="File Sys", daemon=True).start() + #_thread.start_new_thread(m.daemonize, (interval,)) else: logging.debug("[FS] Not syncing %s, because interval is 0", options['mount']) diff --git a/pwnagotchi/grid.py b/pwnagotchi/grid.py index 4e5aea2f..a8ea8560 100644 --- a/pwnagotchi/grid.py +++ b/pwnagotchi/grid.py @@ -1,5 +1,6 @@ import subprocess import requests +import json import logging import pwnagotchi @@ -16,7 +17,7 @@ def is_connected(): r = requests.get(host, headers=headers, timeout=(30.0, 60.0)) if r.json().get('isUp'): return True - except Exception: + except: pass return False @@ -36,8 +37,7 @@ def call(path, obj=None): def advertise(enabled=True): - # FIX B1: parentheses around ternary ensure correct string interpolation - return call("/mesh/%s" % ('true' if enabled else 'false')) + return call("/mesh/%s" % 'true' if enabled else 'false') def set_advertisement_data(data): @@ -62,8 +62,12 @@ def closest_peer(): def update_data(last_session): - # REMOVED: brain.json loading - file is never created by the noai fork - # REMOVED: AI session fields (train_epochs, avg_reward, min_reward, max_reward) - always zero without AI + brain = {} + try: + with open('/root/brain.json') as fp: + brain = json.load(fp) + except: + pass enabled = [name for name, options in pwnagotchi.config['main']['plugins'].items() if 'enabled' in options and options['enabled']] language = pwnagotchi.config['main']['lang'] @@ -73,6 +77,10 @@ def update_data(last_session): 'session': { 'duration': last_session.duration, 'epochs': last_session.epochs, + 'train_epochs': last_session.train_epochs, + 'avg_reward': last_session.avg_reward, + 'min_reward': last_session.min_reward, + 'max_reward': last_session.max_reward, 'deauthed': last_session.deauthed, 'associated': last_session.associated, 'handshakes': last_session.handshakes, diff --git a/pwnagotchi/identity.py b/pwnagotchi/identity.py index dbd7b0d6..f553b08d 100644 --- a/pwnagotchi/identity.py +++ b/pwnagotchi/identity.py @@ -56,7 +56,7 @@ class KeyPair(object): try: os.remove(self.priv_path) os.remove(self.pub_path) - except Exception: # FIX B4: was bare except + except: pass # no exception, keys loaded correctly. diff --git a/pwnagotchi/log.py b/pwnagotchi/log.py index 1fa9bfd4..bd55ff6c 100644 --- a/pwnagotchi/log.py +++ b/pwnagotchi/log.py @@ -54,7 +54,7 @@ class LastSession(object): try: with open(LAST_SESSION_FILE, 'rt') as fp: saved = fp.read().strip() - except Exception: # FIX B4: was bare except, swallowed KeyboardInterrupt + except: saved = '' return saved diff --git a/pwnagotchi/mesh/utils.py b/pwnagotchi/mesh/utils.py index 4d4aa2dc..7f90f2c4 100644 --- a/pwnagotchi/mesh/utils.py +++ b/pwnagotchi/mesh/utils.py @@ -1,3 +1,4 @@ +#import _thread import threading import logging import time @@ -41,6 +42,7 @@ class AsyncAdvertiser(object): def start_advertising(self): if self._config['personality']['advertise']: + #_thread.start_new_thread(self._adv_poller, ()) threading.Thread(target=self._adv_poller,args=(), name="Grid", daemon=True).start() grid.set_advertisement_data(self._advertisement) diff --git a/pwnagotchi/plugins/default/auto-tune.py b/pwnagotchi/plugins/default/auto-tune.py index f6a42fe7..488eb373 100644 --- a/pwnagotchi/plugins/default/auto-tune.py +++ b/pwnagotchi/plugins/default/auto-tune.py @@ -57,7 +57,7 @@ class auto_tune(plugins.Plugin): "sta_ttl": "Clients older than this will ignored", } self.options = dict() - self.presets_dir = os.path.expanduser("/etc/pwnagotchi/auto-tune-presets") + self.presets_dir = os.path.expanduser("~/auto-tune-presets") self._ensure_presets_dir() def _ensure_presets_dir(self): diff --git a/pwnagotchi/plugins/default/auto-update.py b/pwnagotchi/plugins/default/auto-update.py index 97bafe4c..246cd97d 100644 --- a/pwnagotchi/plugins/default/auto-update.py +++ b/pwnagotchi/plugins/default/auto-update.py @@ -72,7 +72,7 @@ def check(version, repo, native=True, token=""): def make_path_for(name): - path = os.path.join("/opt/", name) + path = os.path.join("/home/pi/", name) if os.path.exists(path): logging.debug("[update] deleting %s" % path) shutil.rmtree(path, ignore_errors=True, onerror=None) @@ -155,7 +155,7 @@ def install(display, update): try: # Activate the virtual environment and install the package subprocess.run( - ["bash", "-c", f"source /opt/.pwn/bin/activate && pip install {source_path}"], + ["bash", "-c", f"source /home/pi/.pwn/bin/activate && pip install {source_path}"], check=True ) diff --git a/pwnagotchi/plugins/default/auto_backup.py b/pwnagotchi/plugins/default/auto_backup.py index 4199f717..2ed124e5 100644 --- a/pwnagotchi/plugins/default/auto_backup.py +++ b/pwnagotchi/plugins/default/auto_backup.py @@ -5,383 +5,148 @@ 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__ = "2.2" - __license__ = "GPL3" - __description__ = ( - "Backs up Pwnagotchi configuration and data, keeping recent backups." - ) - - # Hardcoded defaults for Pwnagotchi - DEFAULT_FILES = [ - "/root/.api-report.json", - "/root/.ssh", - "/root/.bashrc", - "/root/.profile", - "/root/peers", - "/etc/pwnagotchi/", - "/etc/ssh/", - ] - - DEFAULT_INTERVAL_SECONDS = 60 * 60 # 60 minutes - DEFAULT_MAX_BACKUPS = 3 - DEFAULT_EXCLUDE = [ - "/etc/pwnagotchi/logs/*", - "*.bak", - "*.tmp", - ] + __author__ = 'WPA2' + __version__ = '1.1.3' + __license__ = 'GPL3' + __description__ = 'Backs up files when internet is available, with support for excludes.' 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 - self.status_file = "/root/.auto-backup" + # Store the status file path separately. + 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): - """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 - - self.hostname = socket.gethostname() - - # 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: - 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" - ) + 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 + # 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 - 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}" - ) + logging.info("AUTO-BACKUP: Successfully loaded.") + + 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 + else: + logging.error("AUTO-BACKUP: Unrecognized type for interval. Defaulting to daily interval.") + return 24 * 60 * 60 def is_backup_due(self): - """Check if backup is due based on interval.""" + """ + 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() try: last_backup = os.path.getmtime(self.status_file) except OSError: + # Status file doesn't exist—backup is due. return True - return (time.time() - last_backup) >= self.interval_seconds + now = time.time() + return (now - last_backup) >= interval_sec - 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.""" + def on_internet_available(self, agent): if not self.ready: return - 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: + if self.options['max_tries'] and self.tries >= self.options['max_tries']: + logging.info("AUTO-BACKUP: Maximum tries reached, skipping backup.") 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 - existing_files = self._get_backup_files() + # Only include files/directories that exist to prevent errors. + existing_files = list(filter(lambda f: os.path.exists(f), self.options['files'])) if not existing_files: - logging.warning("AUTO-BACKUP: No files to backup exist") + logging.warning("AUTO-BACKUP: No files found to backup.") return + files_to_backup = " ".join(existing_files) - 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") + # 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}'" - def manual_backup(self, agent): - """Manually trigger a backup.""" - if self.backup_in_progress: - return {"status": "Backup already in progress"} + # Get the backup location from config. + backup_location = self.options['backup_location'] - existing_files = self._get_backup_files() - if not existing_files: - return {"status": "No files to backup"} + # 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") - 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"} + 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() diff --git a/pwnagotchi/plugins/default/bt-tether.py b/pwnagotchi/plugins/default/bt-tether.py index fa5eec63..e12d4b6c 100644 --- a/pwnagotchi/plugins/default/bt-tether.py +++ b/pwnagotchi/plugins/default/bt-tether.py @@ -1,5041 +1,328 @@ -""" -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...
-
-
- -
- - -
- -
- - - - - - -
- - - - - - - + } +{% endblock %} +{% block content %} + + + + + + + + + + + + + + + + + + +
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 my fmatray" + __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) - - # UI and buffer constants - UI_LOG_MAXLEN = 100 # Maximum number of log messages in UI buffer - - # 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) - - # UI polling intervals (milliseconds) - UI_STATUS_POLL_INTERVAL = 2000 # Connection status check interval - UI_LOG_POLL_INTERVAL = 5000 # Log refresh interval - - # 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 + def nmcli(self, args, pattern=None): + return self.exec_cmd("nmcli", args, pattern) 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.options["phone-name"] + " Network" + self.mac = self.options["mac"] + 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, - ) - 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 = self.nmcli(["-w", "0", "connection", "show", self.phone_name]) + 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 + abort(404) diff --git a/pwnagotchi/plugins/default/fix_services.py b/pwnagotchi/plugins/default/fix_services.py index 9f6f3eb0..519d3b11 100644 --- a/pwnagotchi/plugins/default/fix_services.py +++ b/pwnagotchi/plugins/default/fix_services.py @@ -126,35 +126,25 @@ class FixServices(plugins.Plugin): def on_bcap_sys_log(self, agent, event): if self.is_disabled: return - message = event.get('data', {}).get('Message', '') - if not isinstance(message, str): - return - if 'wifi error while hopping to channel' not in message: - return - # Cooldown: don't spam recon flips when bettercap is unstable - if time.time() - self.LASTTRY < 30: - return - logging.debug("[Fix_Services]SYSLOG MATCH: %s" % message) - logging.debug("[Fix_Services]**** restarting wifi.recon") - try: - result = agent.run("wifi.recon off; wifi.recon on") - if result.get("success"): - logging.debug("[Fix_Services] wifi.recon flip: success!") - self.LASTTRY = time.time() - if hasattr(agent, 'view'): - display = agent.view() - if display: - display.update(force=True, new_data={"status": "Wifi recon flipped!", "face": faces.COOL}) + if re.search('wifi error while hopping to channel', event['data']['Message']): + logging.debug("[Fix_Services]SYSLOG MATCH: %s" % event['data']['Message']) + logging.debug("[Fix_Services]**** restarting wifi.recon") + try: + result = agent.run("wifi.recon off; wifi.recon on") + if result["success"]: + logging.debug("[Fix_Services] wifi.recon flip: success!") + if hasattr(agent, 'view'): + display = agent.view() + if display: + display.update(force=True, new_data={"status": "Wifi recon flipped!", "face": faces.COOL}) + else: + print("Wifi recon flipped") else: - logging.debug("Wifi recon flipped") - else: - logging.warning("[Fix_Services] wifi.recon flip: FAILED: %s" % repr(result)) - self.LASTTRY = time.time() + logging.warning("[Fix_Services] wifi.recon flip: FAILED: %s" % repr(result)) + self._tryTurningItOffAndOnAgain(agent) + except Exception as err: + logging.error("[Fix_Services]SYSLOG wifi.recon flip fail: %s" % err) self._tryTurningItOffAndOnAgain(agent) - except Exception as err: - logging.error("[Fix_Services]SYSLOG wifi.recon flip fail: %s" % err) - self.LASTTRY = time.time() - self._tryTurningItOffAndOnAgain(agent) def on_epoch(self, agent, epoch, epoch_data): if self.is_disabled: @@ -190,13 +180,13 @@ class FixServices(plugins.Plugin): try: result = agent.run("wifi.recon off; wifi.recon on") - if result.get("success"): + if result["success"]: logging.debug("[Fix_Services] wifi.recon flip: success!") if display: display.update(force=True, new_data={"status": "Wifi recon flipped!", "face": faces.COOL}) else: - logging.debug("Wifi recon flipped") + print("Wifi recon flipped\nthat was easy!") else: logging.warning("[Fix_Services] wifi.recon flip: FAILED: %s" % repr(result)) @@ -235,7 +225,7 @@ class FixServices(plugins.Plugin): if hasattr(agent, 'view'): display.set('status', 'Restarting pwnagotchi!') display.update(force=True) - subprocess.run(["systemctl", "restart", "bettercap"], timeout=30) + os.system("systemctl restart bettercap") pwnagotchi.restart("AUTO") # Look for pattern 6 @@ -244,7 +234,7 @@ class FixServices(plugins.Plugin): if hasattr(agent, 'view'): display.set('status', 'Restarting pwnagotchi!') display.update(force=True) - subprocess.run(["systemctl", "restart", "bettercap"], timeout=30) + os.system("systemctl restart bettercap") pwnagotchi.restart("AUTO") # Look for pattern 7 @@ -252,20 +242,20 @@ class FixServices(plugins.Plugin): logging.debug("[Fix_Services] Monitor mode failed!") try: result = agent.run("wifi.recon off; wifi.recon on") - if result.get("success"): + if result["success"]: logging.debug("[Fix_Services] wifi.recon flip: success!") if display: display.update(force=True, new_data={"status": "Wifi recon flipped!", "face": faces.COOL}) else: - logging.debug("Wifi recon flipped") + print("Wifi recon flipped\nthat was easy!") else: logging.warning("[Fix_Services] wifi.recon flip: FAILED: %s" % repr(result)) except Exception as err: logging.error("[Fix_Services wifi.recon flip] %s" % repr(err)) else: - logging.debug("[Fix_Services] logs look good") + print("logs look good") def logPrintView(self, level, message, ui=None, displayData=None, force=True): try: @@ -281,7 +271,9 @@ class FixServices(plugins.Plugin): if ui: ui.update(force=force, new_data=displayData) elif displayData and "status" in displayData: - logging.debug(displayData["status"]) + print(displayData["status"]) + else: + print("[%s] %s" % (level, message)) except Exception as err: logging.error("[logPrintView] ERROR %s" % repr(err)) @@ -328,7 +320,7 @@ class FixServices(plugins.Plugin): try: result = connection.run("wifi.recon off") - if result.get("success"): + if "success" in result: self.logPrintView("info", "[Fix_Services] wifi.recon off: %s!" % repr(result), display, {"status": "Wifi recon paused!", "face": faces.COOL}) time.sleep(2) @@ -378,28 +370,36 @@ class FixServices(plugins.Plugin): try: # try accessing mon0 in bettercap result = connection.run("set wifi.interface wlan0mon") - if result.get("success"): + if "success" in result: logging.debug("[Fix_Services set wifi.interface wlan0mon worked!") # stop looping and get back to recon break else: logging.debug( - "[Fix_Services set wifi.interface wlan0mon] failed? %s" % repr(result)) + "[Fix_Services set wifi.interfaceface wlan0mon] failed? %s" % repr(result)) except Exception as err: logging.debug( "[Fix_Services set wifi.interface wlan0mon] except: %s" % repr(err)) except Exception as cerr: # - logging.error("failed loading wlan0mon attempt #%s: %s" % (tries, repr(cerr))) + if not display: + print("failed loading wlan0mon attempt #%s: %s" % (tries, repr(cerr))) except Exception as err: # from modprobe + if not display: + print("Failed reloading brcmfmac") logging.error("[Fix_Services] Failed reloading brcmfmac %s" % repr(err)) except Exception as nope: # from modprobe -r # fails if already unloaded, so probably fine logging.error("[Fix_Services #%s modprobe -r] %s" % (tries, repr(nope))) + if not display: + print("[Fix_Services #%s modprobe -r] %s" % (tries, repr(nope))) + pass tries = tries + 1 if tries < 3: logging.debug("[Fix_Services] wlan0mon didn't make it. trying again") + if not display: + print(" wlan0mon didn't make it. trying again") else: logging.debug("[Fix_Services] wlan0mon loading failed, no choice but to reboot ..") pwnagotchi.reboot() @@ -410,7 +410,7 @@ class FixServices(plugins.Plugin): display.update(force=True, new_data={"status": "And back on again...", "face": faces.INTENSE}) else: - logging.debug("And back on again...") + print("And back on again...") logging.debug("[Fix_Services] wlan0mon back up") else: self.LASTTRY = time.time() @@ -422,12 +422,12 @@ class FixServices(plugins.Plugin): try: result = connection.run("wifi.clear; wifi.recon on") - if result.get("success"): + if "success" in result: # and result["success"] is True: if display: display.update(force=True, new_data={"status": "I can see again! (probably)", "face": faces.HAPPY}) else: - logging.debug("I can see again") + print("I can see again") logging.debug("[Fix_Services] wifi.recon on") self.LASTTRY = time.time() + 120 # 2-minute pause until next time. else: diff --git a/pwnagotchi/plugins/default/logtail.py b/pwnagotchi/plugins/default/logtail.py index ddf93f8b..71cd9eb6 100644 --- a/pwnagotchi/plugins/default/logtail.py +++ b/pwnagotchi/plugins/default/logtail.py @@ -1,13 +1,18 @@ +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 -INDEX = """ +TEMPLATE = """ {% extends "base.html" %} {% set active_page = "plugins" %} {% block title %} @@ -15,267 +20,83 @@ INDEX = """ {% endblock %} {% block styles %} -{{ super() }} - + tr.info { + color: black; + } + tr.warning { + color: darkorange; + } + tr.error { + color: crimson; + } + tr.debug { + color: blueviolet; + } + .ui-mobile .ui-page-active { + overflow: visible; + overflow-x: visible; + } + {% endblock %} {% block script %} - var table = document.getElementById("table").querySelector("tbody"); - var filter = document.getElementById("filter"); + var table = document.getElementById('table'); + var filter = document.getElementById('filter'); var filterVal = filter.value.toUpperCase(); var xhr = new XMLHttpRequest(); - xhr.open("GET", "logtail/stream"); + xhr.open('GET', '{{ url_for('plugins') }}/logtail/stream'); xhr.send(); var position = 0; var data; @@ -289,39 +110,39 @@ INDEX = """ 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; @@ -348,7 +169,7 @@ INDEX = """ } var timer; - var scrollElm = document.getElementById("autoscroll"); + var scrollElm = document.getElementById('autoscroll'); timer = setInterval(function() { handleNewData(); if (scrollElm.checked) { @@ -360,7 +181,7 @@ INDEX = """ }, 1000); var typingTimer; - var doneTypingInterval = 500; + var doneTypingInterval = 1000; filter.onkeyup = function() { clearTimeout(typingTimer); @@ -372,56 +193,50 @@ INDEX = """ } function doneTyping() { + document.body.style.cursor = 'progress'; var tr, tds, td, i, txtValue; filterVal = filter.value.toUpperCase(); tr = table.getElementsByTagName("tr"); - for (i = 0; i < tr.length; i++) { + for (i = 1; i < tr.length; i++) { txtValue = tr[i].textContent || tr[i].innerText; - if (filterVal.length === 0 || txtValue.toUpperCase().indexOf(filterVal) > -1) { + if (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() @@ -443,16 +258,15 @@ class Logtail(plugins.Plugin): return "Plugin not ready" if not path or path == "/": - return render_template_string(INDEX) - - if path == "stream": + return render_template_string(TEMPLATE) + 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 b1fdc83e..06ab1687 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,728 +12,252 @@ 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 %} - const charts = {}; - - 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: [] }; - } - } - - 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(); - - 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' } - } - } - } + $(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; + }; - // 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 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); - - // 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); + 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); }); - select.addEventListener('change', updateStats); } - document.addEventListener('DOMContentLoaded', () => { - loadSessionFiles(); - updateStats(); - setInterval(updateStats, 30000); + 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' + } + }).replot({ + axes:{ + xaxis:{ + renderer:$.jqplot.DateAxisRenderer, + tickOptions:{formatString:'%H:%M:%S'} + }, + yaxis:{ + tickOptions:{formatString:'%.2f'} + } + } + }); + } + + function loadSessionFiles() { + loadFiles('/plugins/session-stats/session', 'session'); + $("#session").change(function() { + loadSessionData(); + }); + } + + 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) + } + + + loadSessionFiles(); + loadSessionData(); + setInterval(loadSessionData, 60000); }); {% 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 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 = ( - "/etc/pwnagotchi/sessions/" # Standard location for user data - ) + __author__ = '33197631+dadav@users.noreply.github.com' + __version__ = '0.1.0' + __license__ = 'GPL3' + __description__ = 'This plugin displays stats of the current session.' def __init__(self): self.lock = threading.Lock() self.options = dict() self.stats = dict() - self.initialized = False - self.running = False - self.agent = None - self.realtime_thread = None + self.clock = GhettoClock() def on_loaded(self): - # 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}") + """ + 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.") def on_epoch(self, agent, epoch, epoch_data): - # 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 - + """ + Save the epoch_data to self.stats + """ 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), - } + self.stats[self.clock.now().strftime("%H:%M:%S")] = epoch_data + self.session.update(data={'data': self.stats}) - # 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" - ) + @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 def on_webhook(self, path, request): if not path or path == "/": return render_template_string(TEMPLATE) - session_param = request.args.get("session") - save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH) + 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'])}) with self.lock: data = self.stats - 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 + 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)) diff --git a/pwnagotchi/plugins/default/webcfg.py b/pwnagotchi/plugins/default/webcfg.py index 939cfbfe..2bf018a6 100644 --- a/pwnagotchi/plugins/default/webcfg.py +++ b/pwnagotchi/plugins/default/webcfg.py @@ -1,7 +1,7 @@ import logging import json import toml -import threading # FIX B5: replaced _thread with threading +import _thread import pwnagotchi from pwnagotchi import restart, plugins from pwnagotchi.utils import save_config, merge_config @@ -18,315 +18,174 @@ INDEX = """ {% block meta %} -{% endblock %}{% block styles %} +{% endblock %} + +{% block styles %} {{ super() }} {% endblock %} {% block content %} -
-

Configuration Manager

-

Edit your Pwnagotchi configuration settings

-
-
- +
- -
-
- - + +
+
{% endblock %} {% block script %} @@ -345,7 +204,7 @@ INDEX = """ td = document.createElement("td"); td.setAttribute("data-label", ""); btnDel = document.createElement("Button"); - btnDel.innerHTML = ""; + btnDel.innerHTML = "X"; btnDel.onclick = function(){ delRow(this);}; btnDel.className = "remove"; divDelBtn.appendChild(btnDel); @@ -531,7 +390,7 @@ INDEX = """ td = document.createElement("td"); td.setAttribute("data-label", ""); btnDel = document.createElement("Button"); - btnDel.innerHTML = ""; + btnDel.innerHTML = "X"; btnDel.onclick = function(){ delRow(this);}; btnDel.className = "remove"; divDelBtn.appendChild(btnDel); @@ -568,8 +427,7 @@ INDEX = """ input.type = 'text'; input.value = '[]'; }else{ - var valType = typeof(json[key]); - input.type = valType === 'string' ? 'text' : valType; + input.type = typeof(json[key]); input.value = json[key]; } td.appendChild(input); @@ -629,14 +487,14 @@ def serializer(obj): class WebConfig(plugins.Plugin): - __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." + __author__ = '33197631+dadav@users.noreply.github.com' + __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): @@ -645,11 +503,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): """ @@ -676,7 +534,7 @@ class WebConfig(plugins.Plugin): if path == "save-config": try: save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test - threading.Thread(target=restart, args=(self.mode,), daemon=True).start() # FIX B5 + _thread.start_new_thread(restart, (self.mode,)) return "success" except Exception as ex: logging.error(ex) @@ -684,21 +542,13 @@ 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/plugins/default/wpa-sec.py b/pwnagotchi/plugins/default/wpa-sec.py index f776d228..e2aeced8 100644 --- a/pwnagotchi/plugins/default/wpa-sec.py +++ b/pwnagotchi/plugins/default/wpa-sec.py @@ -34,7 +34,7 @@ class WpaSec(plugins.Plugin): self._init_db() def _init_db(self): - db_conn = sqlite3.connect('/etc/pwnagotchi/.wpa_sec_db') + db_conn = sqlite3.connect('/home/pi/.wpa_sec_db') db_conn.execute('pragma journal_mode=wal') with db_conn: db_conn.execute(''' @@ -72,7 +72,7 @@ class WpaSec(plugins.Plugin): if not remove_whitelisted([filename], config['main']['whitelist']): return - db_conn = sqlite3.connect('/etc/pwnagotchi/.wpa_sec_db') + db_conn = sqlite3.connect('/home/pi/.wpa_sec_db') with db_conn: db_conn.execute(''' INSERT INTO handshakes (path, status) @@ -93,7 +93,7 @@ class WpaSec(plugins.Plugin): display = agent.view() try: - db_conn = sqlite3.connect('/etc/pwnagotchi/.wpa_sec_db') + db_conn = sqlite3.connect('/home/pi/.wpa_sec_db') cursor = db_conn.cursor() cursor.execute('SELECT path FROM handshakes WHERE status = ?', (self.Status.TOUPLOAD.value,)) @@ -264,7 +264,7 @@ class WpaSec(plugins.Plugin): def on_ui_update(self, ui): if 'show_pwd' in self.options and self.options['show_pwd'] and 'download_results' in self.options and self.options['download_results']: - file_path = '/etc/pwnagotchi/handshakes/wpa-sec.cracked.potfile' + file_path = '/home/pi/handshakes/wpa-sec.cracked.potfile' try: with open(file_path, 'r') as file: # Read all lines and extract the required fields diff --git a/pwnagotchi/ui/components.py b/pwnagotchi/ui/components.py index d60d02e8..8537cddf 100644 --- a/pwnagotchi/ui/components.py +++ b/pwnagotchi/ui/components.py @@ -44,7 +44,7 @@ class FilledRect(Widget): class Text(Widget): - def __init__(self, value="", position=(0, 0), font=None, color=0, wrap=False, max_length=0, png=False, scale=1): + def __init__(self, value="", position=(0, 0), font=None, color=0, wrap=False, max_length=0, png=False): super().__init__(position, color) self.value = value self.font = font @@ -52,7 +52,6 @@ class Text(Widget): self.max_length = max_length self.wrapper = TextWrapper(width=self.max_length, replace_whitespace=False) if wrap else None self.png = png - self.scale = scale def draw(self, canvas, drawer): if self.value is not None: @@ -68,30 +67,16 @@ class Text(Widget): self.pixels = self.image.load() for y in range(self.image.size[1]): for x in range(self.image.size[0]): - if self.pixels[x, y][3] < 255: - self.pixels[x, y] = (255, 255, 255, 255) + if self.pixels[x,y][3] < 255: # check alpha + self.pixels[x,y] = (255, 255, 255, 255) if self.color == 255: - self._image = ImageOps.colorize(self.image.convert('L'), black="white", white="black") + self._image = ImageOps.colorize(self.image.convert('L'), black = "white", white = "black") else: self._image = self.image - if self.scale != 1: - width, height = self._image.size - new_width = int(width * self.scale) - new_height = int(height * self.scale) - scaled_image = Image.new('RGBA', (new_width, new_height)) - original_pixels = self._image.load() - scaled_pixels = scaled_image.load() - for y in range(new_height): - for x in range(new_width): - original_x = x // self.scale - original_y = y // self.scale - scaled_pixels[x, y] = original_pixels[original_x, original_y] - self.image = scaled_image - else: - self.image = self._image - self.image = self.image.convert('1') + self.image = self._image.convert('1') canvas.paste(self.image, self.xy) + class LabeledValue(Widget): def __init__(self, label, value="", position=(0, 0), label_font=None, text_font=None, color=0, label_spacing=5): super().__init__(position, color) diff --git a/pwnagotchi/ui/display.py b/pwnagotchi/ui/display.py index bb243380..f8e7baa8 100644 --- a/pwnagotchi/ui/display.py +++ b/pwnagotchi/ui/display.py @@ -26,9 +26,6 @@ class Display(View): ) self._render_thread_instance.start() - def is_whisplay(self): - return self._implementation.name == 'whisplay' - def is_lcdhat(self): return self._implementation.name == 'lcdhat' diff --git a/pwnagotchi/ui/faces.py b/pwnagotchi/ui/faces.py index 053fffdb..a81966c5 100644 --- a/pwnagotchi/ui/faces.py +++ b/pwnagotchi/ui/faces.py @@ -23,7 +23,6 @@ DEBUG = '(#__#)' UPLOAD = '(1__0)' UPLOAD1 = '(1__1)' UPLOAD2 = '(0__1)' -SCALE = 1 PNG = False POSITION_X = 0 POSITION_Y = 40 diff --git a/pwnagotchi/ui/hw/__init__.py b/pwnagotchi/ui/hw/__init__.py index 91b66032..f6b9ddad 100644 --- a/pwnagotchi/ui/hw/__init__.py +++ b/pwnagotchi/ui/hw/__init__.py @@ -4,10 +4,6 @@ def display_for(config): from pwnagotchi.ui.hw.inky import Inky return Inky(config) - elif config['ui']['display']['type'] == 'whisplay': - from pwnagotchi.ui.hw.whisplay import Whisplay - return Whisplay(config) - elif config['ui']['display']['type'] == 'inkyv2': from pwnagotchi.ui.hw.inkyv2 import InkyV2 return InkyV2(config) diff --git a/pwnagotchi/ui/hw/waveshare2in13b_V3.py b/pwnagotchi/ui/hw/waveshare2in13b_V3.py index bb683993..c3e8d5cf 100644 --- a/pwnagotchi/ui/hw/waveshare2in13b_V3.py +++ b/pwnagotchi/ui/hw/waveshare2in13b_V3.py @@ -10,22 +10,22 @@ class Waveshare2in13bV3(DisplayImpl): super(Waveshare2in13bV3, self).__init__(config, 'waveshare2in13b_v3') def layout(self): - fonts.setup(10, 8, 10, 25, 25, 9) - self._layout['width'] = 212 - self._layout['height'] = 104 - self._layout['face'] = (0, 26) - self._layout['name'] = (5, 15) + fonts.setup(10, 9, 10, 35, 25, 9) + self._layout['width'] = 250 + self._layout['height'] = 122 + self._layout['face'] = (0, 40) + self._layout['name'] = (5, 20) self._layout['channel'] = (0, 0) self._layout['aps'] = (28, 0) - self._layout['uptime'] = (147, 0) - self._layout['line1'] = [0, 12, 212, 12] - self._layout['line2'] = [0, 92, 212, 92] - self._layout['friend_face'] = (0, 76) - self._layout['friend_name'] = (40, 78) - self._layout['shakes'] = (0, 93) - self._layout['mode'] = (187, 93) + self._layout['uptime'] = (185, 0) + self._layout['line1'] = [0, 14, 250, 14] + self._layout['line2'] = [0, 108, 250, 108] + self._layout['friend_face'] = (0, 92) + self._layout['friend_name'] = (40, 94) + self._layout['shakes'] = (0, 109) + self._layout['mode'] = (225, 109) self._layout['status'] = { - 'pos': (91, 15), + 'pos': (125, 20), 'font': fonts.status_font(fonts.Medium), 'max': 20 } @@ -39,10 +39,8 @@ class Waveshare2in13bV3(DisplayImpl): self._display.Clear() def render(self, canvasBlack=None, canvasRed=None): - # Match V4 behavior: if only one canvas is passed, show it on black channel buffer = self._display.getbuffer - # Create blank image matching the display dimensions - image = Image.new('1', (self._layout['height'], self._layout['width']), 255) + image = Image.new('1', (self._layout['height'], self._layout['width'])) imageBlack = image if canvasBlack is None else canvasBlack imageRed = image if canvasRed is None else canvasRed self._display.display(buffer(imageBlack), buffer(imageRed)) diff --git a/pwnagotchi/ui/hw/waveshare2in13b_V4.py b/pwnagotchi/ui/hw/waveshare2in13b_V4.py index d1d47c2b..3bd00a92 100644 --- a/pwnagotchi/ui/hw/waveshare2in13b_V4.py +++ b/pwnagotchi/ui/hw/waveshare2in13b_V4.py @@ -11,21 +11,21 @@ class Waveshare213bV4(DisplayImpl): def layout(self): fonts.setup(10, 9, 10, 35, 25, 9) - self._layout['width'] = 212 - self._layout['height'] = 104 - self._layout['face'] = (0, 26) - self._layout['name'] = (5, 15) + self._layout['width'] = 250 + self._layout['height'] = 122 + self._layout['face'] = (0, 40) + self._layout['name'] = (5, 20) self._layout['channel'] = (0, 0) self._layout['aps'] = (28, 0) - self._layout['uptime'] = (147, 0) - self._layout['line1'] = [0, 12, 212, 12] - self._layout['line2'] = [0, 92, 212, 92] - self._layout['friend_face'] = (0, 76) - self._layout['friend_name'] = (40, 78) - self._layout['shakes'] = (0, 93) - self._layout['mode'] = (187, 93) + self._layout['uptime'] = (185, 0) + self._layout['line1'] = [0, 14, 250, 14] + self._layout['line2'] = [0, 108, 250, 108] + self._layout['friend_face'] = (0, 92) + self._layout['friend_name'] = (40, 94) + self._layout['shakes'] = (0, 109) + self._layout['mode'] = (225, 109) self._layout['status'] = { - 'pos': (91, 15), + 'pos': (125, 20), 'font': fonts.status_font(fonts.Medium), 'max': 20 } @@ -39,10 +39,8 @@ class Waveshare213bV4(DisplayImpl): self._display.Clear() def render(self, canvasBlack=None, canvasRed=None): - # Match V4 behavior: if only one canvas is passed, show it on black channel buffer = self._display.getbuffer - # Create blank image matching the display dimensions - image = Image.new('1', (self._layout['height'], self._layout['width']), 255) + image = Image.new('1', (self._layout['height'], self._layout['width'])) imageBlack = image if canvasBlack is None else canvasBlack imageRed = image if canvasRed is None else canvasRed self._display.display(buffer(imageBlack), buffer(imageRed)) diff --git a/pwnagotchi/ui/view.py b/pwnagotchi/ui/view.py index 50e02e0b..09354ce7 100644 --- a/pwnagotchi/ui/view.py +++ b/pwnagotchi/ui/view.py @@ -1,3 +1,4 @@ +# import _thread import threading import logging import random @@ -72,11 +73,8 @@ class View(object): 'line2': Line(self._layout['line2'], color=BLACK), 'face': Text(value=faces.SLEEP, - position=(config['ui']['faces']['position_x'], config['ui']['faces']['position_y']), - color=BLACK, font=fonts.Huge, - png=config['ui']['faces']['png'], - scale = config['ui']['faces'].get('scale', 1) - ), + position=(config['ui']['faces']['position_x'], config['ui']['faces']['position_y']), + color=BLACK, font=fonts.Huge, png=config['ui']['faces']['png']), # 'friend_face': Text(value=None, position=self._layout['friend_face'], font=fonts.Bold, color=BLACK), 'friend_name': Text(value=None, position=self._layout['friend_face'], font=fonts.BoldSmall, color=BLACK), diff --git a/pwnagotchi/ui/web/handler.py b/pwnagotchi/ui/web/handler.py index 3a2189a8..6e75cb02 100644 --- a/pwnagotchi/ui/web/handler.py +++ b/pwnagotchi/ui/web/handler.py @@ -1,7 +1,7 @@ import logging import os import base64 -import threading # FIX B5: replaced _thread with threading +import _thread 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,45 +32,21 @@ class Handler: self._agent = agent self._app = app - # Dynamic theme CSS route - self._app.add_url_rule("/css/theme.css", "dynamic_theme", self.dynamic_theme) + 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("/", "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"] - ) + 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) @@ -82,57 +58,52 @@ 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 = {} @@ -141,16 +112,14 @@ 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 = {} @@ -159,12 +128,13 @@ 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 = {} @@ -172,22 +142,23 @@ 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"] @@ -196,7 +167,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: @@ -214,41 +185,18 @@ class Handler: def plugins(self, name, subpath): if name is None: - # 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, - ) + return render_template('plugins.html', loaded=plugins.loaded, database=plugins.database) - 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: @@ -259,60 +207,32 @@ 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: - # FIX B5: replaced _thread.start_new_thread with threading.Thread - threading.Thread(target=pwnagotchi.shutdown, daemon=True).start() + _thread.start_new_thread(pwnagotchi.shutdown, ()) # 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: - # FIX B5: replaced _thread.start_new_thread with threading.Thread - threading.Thread(target=pwnagotchi.reboot, daemon=True).start() + try: + return render_template('status.html', title=pwnagotchi.name(), go_back_after=60, + message='Rebooting ...') + finally: + _thread.start_new_thread(pwnagotchi.reboot, ()) # 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: - # 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") + _thread.start_new_thread(pwnagotchi.restart, (mode,)) # 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/server.py b/pwnagotchi/ui/web/server.py index 8d95d09d..32aeef8b 100644 --- a/pwnagotchi/ui/web/server.py +++ b/pwnagotchi/ui/web/server.py @@ -1,3 +1,4 @@ +#import _thread import threading import secrets import logging diff --git a/pwnagotchi/ui/web/static/css/style.css b/pwnagotchi/ui/web/static/css/style.css index 05cc3b95..1ed13fa5 100644 --- a/pwnagotchi/ui/web/static/css/style.css +++ b/pwnagotchi/ui/web/static/css/style.css @@ -1,1412 +1,82 @@ -/* ============================================ - 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; -} - -/* ============================================ - Tooltips - ============================================ */ - -.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%; - transform: translateX(-50%); - 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; -} - -/* ============================================ - 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; -} - -/* ============================================ - 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%; - border-radius: 6px; - box-shadow: var(--shadow-md); - margin-bottom: 16px; + width: 100%; } .pixelated { - 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-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+ */ } -/* ============================================ - 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; +.image-wrapper { + flex: 1; + position: relative; } -.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; +div.status { + position: absolute; + top: 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; - } } -/* ============================================ - Print Styles - ============================================ */ - -@media print { - body { - background-color: white; - color: black; - } - - .page-footer { - display: none; - } - - a { - color: inherit; - text-decoration: underline; - } +a.read { + color: #777 !important; +} + +p.messagebody { + padding: 1em; +} + +li.navitem { + width: 16.66% !important; + clear: none !important; +} + +/* 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; } diff --git a/pwnagotchi/ui/web/templates/base.html b/pwnagotchi/ui/web/templates/base.html index 6f92b210..921dbaa8 100644 --- a/pwnagotchi/ui/web/templates/base.html +++ b/pwnagotchi/ui/web/templates/base.html @@ -1,73 +1,86 @@ - - - {% block head %} - + + +{% block head %} + {% block meta %} - - - - + + + {% endblock %} - {% block title %}{% endblock %} + + {% block title %} + {% endblock %} + {% block styles %} - - - {% if active_page == 'profile' %} - - {% elif active_page in ['inbox', 'new'] %} - - {% elif active_page == 'plugins' %} - - {% endif %} - - + + + + {% endblock %} - - {% 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') %} + + {% endif %} -
{% block content %} {% 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') %} - +
+
+
    + {% for href, id, icon, caption in navigation %} + + {% endfor %} +
+
- {% block scripts %} - - - + {% block content %} {% endblock %} - - {% endblock %} + +
+{% block scripts %} + + + + + +{% endblock %} + +{% endblock %} diff --git a/pwnagotchi/ui/web/templates/inbox.html b/pwnagotchi/ui/web/templates/inbox.html index 664a134a..ca0b76ef 100644 --- a/pwnagotchi/ui/web/templates/inbox.html +++ b/pwnagotchi/ui/web/templates/inbox.html @@ -1,55 +1,46 @@ -{% extends "base.html" %} {% set active_page = "inbox" %} {% block title %} {{ -name }} Inbox {% endblock %} {% block script %} autoRefresh(15000); -filterList('.search-box input', '.inbox li'); {% endblock %} {% block content %} - +{% extends "base.html" %} +{% set active_page = "inbox" %} -
    - {% for message in inbox.messages %} -
  • - -
    - {{ message.sender_name }}@{{ message.sender }} -
    -
    - Received - - {% if message.seen_at %}, seen - {% endif %} -
    -
    -
  • - {% endfor %} +{% block title %} +{{ name }} Inbox +{% endblock %} + +{% block script %} +$(function() { + setTimeout(function(){ + window.location.reload(true); + }, 15000); +}); +{% endblock %} + +{% block content %} + {% if inbox.pages > 1 %} -