Moved cache plugin to core functionality of Pwnagotchi.

Moved bt-tether to core functionality of Pwnagotchi.
Moved auto-tune to core functionality of Pwnagotchi.

They are no longer plugins, except for bt-tether which can still be disabled if you don't want it.

Signed-off-by: Jeroen Oudshoorn <oudshoorn.jeroen@gmail.com>
This commit is contained in:
Jeroen Oudshoorn
2026-07-27 21:09:38 +02:00
parent a6e283c25b
commit 39341f6525
10 changed files with 1753 additions and 267 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4">
<component name="SshConfigs">
<configs>
<sshConfig authType="PASSWORD" connectionConfig="{&quot;hostKeyVerifier&quot;:{&quot;hashKnownHosts&quot;:true},&quot;serverAliveInterval&quot;:300}" host="pwnagotchi.local" id="8b69df7d-cec5-421f-8edf-53ed6233f6b6" port="22" customName="pwnagotchi" nameFormat="CUSTOM" username="pi" useOpenSSHConfig="true">
<sshConfig authType="PASSWORD" connectionConfig="{&quot;hostKeyVerifier&quot;:{&quot;hashKnownHosts&quot;:true,&quot;stringHostKeyChecking&quot;:&quot;ASK&quot;},&quot;serverAliveInterval&quot;:300}" host="10.12.194.1" id="8b69df7d-cec5-421f-8edf-53ed6233f6b6" port="22" customName="pwnagotchi" nameFormat="CUSTOM" username="pi" useOpenSSHConfig="true">
<option name="customName" value="pwnagotchi" />
</sshConfig>
</configs>
+1 -1
View File
@@ -3,7 +3,7 @@
<component name="WebServers">
<option name="servers">
<webServer id="cf2a1148-a103-4472-a782-7debdc6dabf9" name="pwnagotchi">
<fileTransfer accessType="SFTP" host="pwnagotchi.local" port="22" sshConfigId="8b69df7d-cec5-421f-8edf-53ed6233f6b6" sshConfig="pwnagotchi">
<fileTransfer accessType="SFTP" host="10.12.194.1" port="22" sshConfigId="8b69df7d-cec5-421f-8edf-53ed6233f6b6" sshConfig="pwnagotchi">
<advancedOptions>
<advancedOptions dataProtectionLevel="Private" passiveMode="true" shareSSLContext="true" isUseSudo="true" />
</advancedOptions>
+28
View File
@@ -17,6 +17,7 @@ from pwnagotchi.log import LastSession
from pwnagotchi.bettercap import Client
from pwnagotchi.mesh.utils import AsyncAdvertiser
from pwnagotchi.strategy import Strategy
from pwnagotchi.cache import CacheManager
RECOVERY_DATA_FILE = '/root/.pwnagotchi-recovery'
@@ -44,6 +45,9 @@ class Agent(Client, Automata, AsyncAdvertiser):
# Initialize channel selection strategy (core infrastructure, not optional)
self._strategy = Strategy(config)
# Initialize cache manager (core infrastructure, not optional)
self._cache = CacheManager(config)
self._access_points = []
self._last_pwnd = None
self._history = {}
@@ -138,6 +142,7 @@ class Agent(Client, Automata, AsyncAdvertiser):
self.set_starting()
self.start_monitor_mode()
self.start_event_polling()
self.start_cache_cleanup()
self.start_session_fetcher()
# print initial stats
self.next_epoch()
@@ -170,6 +175,8 @@ class Agent(Client, Automata, AsyncAdvertiser):
def set_access_points(self, aps):
self._access_points = aps
plugins.on('wifi_update', self, aps)
# Cache APs in core
self._cache.on_wifi_update(aps)
# Update strategy with latest access points
self._strategy.on_wifi_update(self, aps)
# Select next channels to scan based on strategy
@@ -183,6 +190,8 @@ class Agent(Client, Automata, AsyncAdvertiser):
try:
s = self.session()
plugins.on("unfiltered_ap_list", self, s['wifi']['aps'])
# Cache unfiltered APs
self._cache.on_unfiltered_ap_list(s['wifi']['aps'])
for ap in s['wifi']['aps']:
if ap['encryption'] == '' or ap['encryption'] == 'OPEN':
continue
@@ -377,6 +386,8 @@ class Agent(Client, Automata, AsyncAdvertiser):
"!!! captured new handshake on channel %d, %d dBm: %s (%s) -> %s [%s (%s)] !!!",
ap['channel'], ap['rssi'], sta['mac'], sta['vendor'], ap['hostname'], ap['mac'], ap['vendor'])
plugins.on('handshake', self, filename, ap, sta)
# Cache AP on handshake
self._cache.on_handshake(filename, ap, sta)
self._strategy.on_handshake(self, filename, ap, sta)
found_handshake = True
self._update_handshakes(1 if found_handshake else 0)
@@ -399,6 +410,19 @@ class Agent(Client, Automata, AsyncAdvertiser):
#_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 _cache_cleanup_loop(self):
"""Periodic cache cleanup loop"""
while True:
try:
self._cache.periodic_cleanup()
time.sleep(30)
except Exception as e:
logging.debug(f"[agent] Cache cleanup error: {e}")
def start_cache_cleanup(self):
"""Start periodic cache cleanup thread"""
threading.Thread(target=self._cache_cleanup_loop, name="Cache Cleanup", daemon=True).start()
def is_module_running(self, module):
s = self.session()
for m in s['modules']:
@@ -450,6 +474,8 @@ class Agent(Client, Automata, AsyncAdvertiser):
self._on_error(ap['mac'], e)
plugins.on('association', self, ap)
# Cache AP on association
self._cache.on_association(ap)
self._strategy.on_association(self, ap)
if throttle > 0:
time.sleep(throttle)
@@ -476,6 +502,8 @@ class Agent(Client, Automata, AsyncAdvertiser):
self._on_error(sta['mac'], e)
plugins.on('deauthentication', self, ap, sta)
# Cache AP on deauthentication
self._cache.on_deauthentication(ap, sta)
self._strategy.on_deauthentication(self, ap, sta)
if throttle > 0:
time.sleep(throttle)
+223 -23
View File
@@ -3,6 +3,7 @@
import logging
import threading
import time
import subprocess
from .device import BluetoothDevice
from .connection import ConnectionManager
from .network import NetworkManager
@@ -49,6 +50,11 @@ class BluetoothService:
self._initialized = False
self._event_handlers = {}
# Scan state tracking
self._scanning = False
self._scan_devices = []
self._scan_start_time = None
def start(self):
"""Initialize and start the Bluetooth service."""
try:
@@ -122,26 +128,52 @@ class BluetoothService:
return devices[0] if devices else None
def scan_devices(self, duration=30):
"""Scan for Bluetooth devices."""
"""Scan for Bluetooth devices and track progress."""
with self._lock:
self._status = self.STATE_SCANNING
self._message = f"Scanning for {duration}s..."
self._scanning = True
self._scan_devices = []
self._scan_start_time = time.time()
try:
devices = self.connection.scan(duration)
self._emit_event("bt:scan_complete", {"devices": devices})
# Merge final results with what we've collected
with self._lock:
self._scan_devices = devices if devices else self.connection.get_scan_results()
self._scanning = False
self._status = self.STATE_IDLE
return devices
self._emit_event("bt:scan_complete", {"devices": self._scan_devices})
return self._scan_devices
except Exception as e:
self.logger.error(f"Scan failed: {e}")
with self._lock:
self._scanning = False
self._status = self.STATE_ERROR
self._message = f"Scan failed: {e}"
return []
def get_scan_progress(self):
"""Get current scan progress and discovered devices."""
with self._lock:
# During active scan, also check connection's real-time results
scan_devices = self._scan_devices.copy()
if self._scanning:
# Merge with real-time results from connection
real_time_devices = self.connection.get_scan_results()
if real_time_devices:
scan_devices = real_time_devices
return {
"scanning": self._scanning,
"devices": scan_devices,
"elapsed": time.time() - self._scan_start_time if self._scan_start_time else 0,
}
def connect(self, mac, name=""):
"""Initiate connection to a device."""
"""Initiate full connection to a device with pairing, trusting, NAP, and PAN setup."""
if not BluetoothDevice.validate_mac(mac):
self.logger.error(f"Invalid MAC: {mac}")
return False
@@ -152,38 +184,194 @@ class BluetoothService:
def connect_thread():
try:
# Pair if needed
self.logger.info(f"Starting connection to {name} ({mac})...")
# Check if Bluetooth is responsive
if not self.connection.is_responsive():
self.logger.warning("Bluetooth not responsive, attempting restart...")
self.connection.restart_if_needed()
time.sleep(3)
# Make Pwnagotchi discoverable and pairable
self.logger.info("Making Pwnagotchi discoverable...")
with self._lock:
self._message = f"Making Pwnagotchi discoverable for {name}..."
self.connection._run_cmd(["bluetoothctl", "discoverable", "on"], capture=True)
self.connection._run_cmd(["bluetoothctl", "pairable", "on"], capture=True)
time.sleep(2)
# Check current pairing status
with self._lock:
self._message = f"Checking pairing status with {name}..."
status = self.connection.get_status(mac)
if not status or not status["paired"]:
self.logger.info(f"Pairing with {mac}...")
# If device is not paired, pair first
if not status or not status.get("paired"):
self.logger.info(f"Device not paired. Starting pairing process with {name}...")
with self._lock:
self._status = self.STATE_PAIRING
self.connection.pair_interactive(mac, name)
with self._lock:
self._status = self.STATE_TRUSTING
self.connection.trust_device(mac)
self._message = f"Pairing with {name}..."
# Connect NAP
time.sleep(0.5)
# Attempt pairing
if not self.connection.pair_interactive(mac, name):
self.logger.error(f"Pairing with {name} failed!")
with self._lock:
self._status = self.STATE_ERROR
self._message = f"Pairing with {name} failed. Did you accept the dialog?"
self._emit_event("bt:connect_failed", {"mac": mac, "error": "Pairing failed"})
return False
self.logger.info(f"Pairing with {name} successful!")
else:
self.logger.info(f"Device {name} already paired")
with self._lock:
self._message = f"Device {name} already paired ✓"
# Trust the device
self.logger.info(f"Trusting device {name}...")
with self._lock:
self._status = self.STATE_TRUSTING
self._message = f"Trusting {name}..."
time.sleep(0.5)
self.connection.trust_device(mac)
# Wait for NAP UUID to appear
NAP_UUID = "00001116"
NAP_WAIT_TIMEOUT = 15
self.logger.info(f"Waiting for {name} NAP service to be ready...")
with self._lock:
self._message = f"Waiting for {name} to be ready..."
nap_ready = False
nap_wait_start = time.time()
while time.time() - nap_wait_start < NAP_WAIT_TIMEOUT:
info = self.connection._run_cmd(
["bluetoothctl", "info", mac],
capture=True,
timeout=3,
)
if info and NAP_UUID in info:
elapsed = time.time() - nap_wait_start
self.logger.info(f"NAP service ready after {elapsed:.1f}s")
nap_ready = True
break
time.sleep(1)
if not nap_ready:
self.logger.warning(f"NAP UUID not seen after {NAP_WAIT_TIMEOUT}s - proceeding anyway")
# Connect to NAP profile
self.logger.info("Connecting to NAP profile...")
with self._lock:
self._status = self.STATE_CONNECTING
if self.connection.connect_nap(mac):
time.sleep(self.network.SUBPROCESS_TIMEOUT_MEDIUM)
if self.network.is_pan_active():
with self._lock:
self._status = self.STATE_CONNECTED
self._emit_event("bt:connect_success", {"mac": mac, "name": name})
return True
self._message = "Connecting to NAP profile for internet..."
time.sleep(0.5)
# Try NAP connection with retries
nap_connected = False
for retry in range(3):
if retry > 0:
self.logger.info(f"Retrying NAP connection (attempt {retry + 1}/3)...")
with self._lock:
self._message = f"NAP retry {retry + 1}/3..."
time.sleep(3)
nap_connected = self.connection.connect_nap(mac)
if nap_connected:
break
else:
self.logger.warning(f"NAP attempt {retry + 1} failed")
with self._lock:
self._message = f"NAP attempt {retry + 1}/3 failed..."
if nap_connected:
self.logger.info("NAP connection successful!")
# Check if PAN interface is up
time.sleep(2)
iface = self.network.get_pan_interface()
if iface:
self.logger.info(f"✓ PAN interface active: {iface}")
# Setup network with DHCP
self.logger.info(f"Setting up {iface} for DHCP...")
self.network.setup_dhcp(iface)
# Wait for interface initialization
self.logger.info("Waiting for interface initialization...")
time.sleep(2)
# Setup network with DHCP
if self.network.setup_dhcp(iface):
self.logger.info("✓ Network setup successful")
else:
self.logger.warning("Network setup may have failed, continuing...")
# Wait a bit for network to stabilize
time.sleep(2)
# Verify internet connectivity
self.logger.info("Checking internet connectivity...")
with self._lock:
self._message = "Verifying internet connection..."
if self.network.check_internet_connectivity():
self.logger.info("✓ Internet connectivity verified!")
# Get current IP
ip = self.network.get_current_ip()
if ip:
self.logger.info(f"Current IP address: {ip}")
with self._lock:
self._status = self.STATE_CONNECTED
self._message = f"✓ Connected! Internet via {iface}"
self._emit_event("bt:connect_success", {
"mac": mac,
"name": name,
"ip": ip,
"interface": iface,
})
return True
else:
self.logger.warning("No internet connectivity detected")
# Still report as connected if we have an IP
ip = self.network.get_current_ip()
if ip:
self.logger.info(f"Connected via {iface} but no internet access")
with self._lock:
self._status = self.STATE_CONNECTED
self._message = f"Connected via {iface} but no internet access"
self._emit_event("bt:connect_success", {
"mac": mac,
"name": name,
"ip": ip,
"interface": iface,
})
return True
else:
self.logger.warning("NAP connected but no interface detected")
# If we got here, connection partially succeeded or failed
with self._lock:
self._status = self.STATE_ERROR
self._emit_event("bt:connect_failed", {"mac": mac, "error": "Failed to establish NAP"})
self._status = self.STATE_CONNECTED
self._message = "Bluetooth connected but tethering setup incomplete"
self._emit_event("bt:connect_failed", {
"mac": mac,
"error": "NAP/PAN setup incomplete - enable tethering on phone"
})
return False
except Exception as e:
self.logger.error(f"Connection failed: {e}")
self.logger.error(f"Connection thread error: {e}")
with self._lock:
self._status = self.STATE_ERROR
self._message = f"Connection failed: {e}"
self._message = f"Connection error: {str(e)}"
self._emit_event("bt:connect_failed", {"mac": mac, "error": str(e)})
return False
@@ -191,6 +379,18 @@ class BluetoothService:
thread.start()
return True
def _check_internet(self):
"""Check internet connectivity via ping."""
try:
subprocess.run(
["ping", "-c", "1", "8.8.8.8"],
capture_output=True,
timeout=5,
)
return True
except Exception:
return False
def disconnect(self, mac):
"""Disconnect from a device."""
with self._lock:
+595 -77
View File
@@ -40,6 +40,7 @@ class ConnectionManager:
self._lock = threading.Lock()
self.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})")
self.scan_ansi_pattern = re.compile(r"(\x1b\[[0-9;]*m|\x08)")
self._scan_results = {} # Track scan results in real-time
def _strip_ansi_codes(self, text):
"""Remove ANSI escape codes from text."""
@@ -62,19 +63,23 @@ class ConnectionManager:
cmd, capture_output=True, text=True, timeout=timeout, env=env
)
output = result.stdout + result.stderr
if not output and result.returncode != 0:
self.logger.warning(f"Command returned error code {result.returncode}: {' '.join(cmd)}")
return self._strip_ansi_codes(output)
else:
subprocess.run(
result = subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=timeout,
env=env,
)
if result.returncode != 0:
self.logger.warning(f"Command returned error code {result.returncode}: {' '.join(cmd)}")
return None
except subprocess.TimeoutExpired:
self.logger.error(f"Command timeout ({timeout}s): {' '.join(cmd)}")
if cmd and cmd[0] == "bluetoothctl":
if cmd and len(cmd) > 0 and cmd[0] == "bluetoothctl":
try:
subprocess.run(
["pkill", "-9", "bluetoothctl"],
@@ -93,7 +98,9 @@ class ConnectionManager:
def is_responsive(self):
"""Check if Bluetooth service is responsive."""
result = self._run_cmd(["bluetoothctl", "show"], capture=True, timeout=self.SUBPROCESS_TIMEOUT_NORMAL)
return result is not None and result != "Timeout"
is_resp = result is not None and result != "Timeout"
self.logger.debug(f"Bluetooth responsive: {is_resp}, result preview: {repr(result[:100] if result else result)}")
return is_resp
def restart_if_needed(self):
"""Restart Bluetooth service if it appears hung."""
@@ -116,56 +123,87 @@ class ConnectionManager:
def get_status(self, mac):
"""Get basic connection status for a device."""
if not BluetoothDevice.validate_mac(mac):
return None
return {
"paired": False,
"trusted": False,
"connected": False,
}
info = self._run_cmd(
["bluetoothctl", "info", mac],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_NORMAL,
)
if not info:
return None
try:
info = self._run_cmd(
["bluetoothctl", "info", mac],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_NORMAL,
)
if not info:
return {
"paired": False,
"trusted": False,
"connected": False,
}
status = {
"paired": "Paired: yes" in info,
"trusted": "Trusted: yes" in info,
"connected": "Connected: yes" in info,
}
return status
status = {
"paired": "Paired: yes" in info,
"trusted": "Trusted: yes" in info,
"connected": "Connected: yes" in info,
}
return status
except Exception as e:
self.logger.debug(f"Error getting status for {mac}: {e}")
return {
"paired": False,
"trusted": False,
"connected": False,
}
def get_full_status(self, mac):
"""Get complete connection status including network details."""
status = self.get_status(mac)
if not status:
try:
status = self.get_status(mac)
if not status:
status = {
"paired": False,
"trusted": False,
"connected": False,
}
status["pan_active"] = False
status["interface"] = None
status["ip_address"] = None
# Check if PAN interface is active
result = self._run_cmd(
["ip", "link", "show"],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
)
if result and ("bnep" in result or "bt-pan" in result):
status["pan_active"] = True
match = re.search(r"(\d+):\s+(bnep\d+|bt-pan\d*)", result)
if match:
iface = match.group(2)
status["interface"] = iface
ip_result = self._run_cmd(
["ip", "addr", "show", iface],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
)
if ip_result:
ip_match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", ip_result)
if ip_match:
status["ip_address"] = ip_match.group(1)
return status
status["pan_active"] = False
status["interface"] = None
status["ip_address"] = None
# Check if PAN interface is active
result = self._run_cmd(
["ip", "link", "show"],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
)
if result and ("bnep" in result or "bt-pan" in result):
status["pan_active"] = True
match = re.search(r"(\d+):\s+(bnep\d+|bt-pan\d*)", result)
if match:
iface = match.group(2)
status["interface"] = iface
ip_result = self._run_cmd(
["ip", "addr", "show", iface],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
)
if ip_result:
ip_match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", ip_result)
if ip_match:
status["ip_address"] = ip_match.group(1)
return status
except Exception as e:
self.logger.debug(f"Error getting full status for {mac}: {e}")
return {
"paired": False,
"trusted": False,
"connected": False,
"pan_active": False,
"interface": None,
"ip_address": None,
}
def disconnect(self, mac):
"""Disconnect from a device."""
@@ -177,60 +215,338 @@ class ConnectionManager:
self._run_cmd(["bluetoothctl", "remove", mac], timeout=self.SUBPROCESS_TIMEOUT_LONG)
time.sleep(self.OPERATION_SHORT_DELAY)
def scan(self, duration=30):
"""Scan for Bluetooth devices."""
def scan_old_broken(self, duration=30):
"""Scan for Bluetooth devices using interactive bluetoothctl."""
self.logger.info(f"Starting Bluetooth scan ({duration}s)...")
devices = []
# Clear previous scan results
with self._lock:
self._scan_results = {}
import select
import subprocess as sp
import os
try:
self._run_cmd(["bluetoothctl", "scan", "on"], timeout=self.SUBPROCESS_TIMEOUT_NORMAL)
time.sleep(duration)
self._run_cmd(["bluetoothctl", "scan", "off"], timeout=self.SUBPROCESS_TIMEOUT_NORMAL)
# Ensure Bluetooth is powered on first
self.logger.debug("Powering on Bluetooth...")
self._run_cmd(["bluetoothctl", "power", "on"], timeout=self.SUBPROCESS_TIMEOUT_NORMAL)
time.sleep(0.5)
# Pre-load all currently known devices (paired, trusted, or cached)
self.logger.debug("Pre-loading known devices...")
# Try with sudo first for system dbus access
result = self._run_cmd(
["bluetoothctl", "devices"],
["sudo", "bluetoothctl", "devices"],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_NORMAL,
)
if result:
for line in result.split("\n"):
if "Device" in line:
parts = line.split()
if len(parts) >= 3:
mac = parts[1]
name = " ".join(parts[2:])
devices.append({"mac": mac, "name": name})
if not result or result == "Timeout":
self.logger.debug("Sudo bluetoothctl failed, trying direct bluetoothctl...")
result = self._run_cmd(
["bluetoothctl", "devices"],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_NORMAL,
)
# If bluetoothctl failed, try dbus directly
if not result or result == "Timeout":
self.logger.debug("Bluetoothctl failed, trying dbus...")
dbus_devices = self._get_devices_via_dbus()
with self._lock:
self._scan_results = dbus_devices
self.logger.info(f"Pre-loaded {len(self._scan_results)} known devices via dbus")
else:
self.logger.debug(f"bluetoothctl devices output: {repr(result)}")
if result:
with self._lock:
for line in result.split("\n"):
line = line.strip()
if line and line.startswith("Device"):
parts = line.split(None, 2)
if len(parts) >= 3:
mac = parts[1]
name = parts[2]
self._scan_results[mac] = {"mac": mac, "name": name}
self.logger.info(f"Pre-loaded: {name} ({mac})")
self.logger.info(f"Pre-loaded {len(self._scan_results)} known devices")
# Start interactive bluetoothctl process to read [NEW] Device events
env = dict(os.environ)
env["NO_COLOR"] = "1"
env["TERM"] = "dumb"
# Try with sudo first for dbus access
self.logger.debug("Starting bluetoothctl with sudo...")
try:
scan_process = sp.Popen(
["sudo", "bluetoothctl"],
stdin=sp.PIPE,
stdout=sp.PIPE,
stderr=sp.PIPE,
text=True,
bufsize=1,
env=env,
)
except Exception as e:
self.logger.debug(f"Sudo bluetoothctl failed: {e}, trying direct...")
scan_process = sp.Popen(
["bluetoothctl"],
stdin=sp.PIPE,
stdout=sp.PIPE,
stderr=sp.PIPE,
text=True,
bufsize=1,
env=env,
)
# Send scan on command
scan_process.stdin.write("scan on\n")
scan_process.stdin.flush()
self.logger.debug("Scan started, reading discovery events...")
# Read [NEW] Device events in real-time for the duration
scan_end_time = time.time() + duration
lines_read = 0
try:
while time.time() < scan_end_time:
try:
# Use select to read available output with 0.5s timeout
ready = select.select([scan_process.stdout], [], [], 0.5)
if ready[0]:
line = scan_process.stdout.readline()
if not line:
break
line = line.strip()
lines_read += 1
# Parse "[NEW] Device MAC Name" format
if "[NEW]" in line and "Device" in line:
# Extract MAC and name from line like: [NEW] Device AB:CD:EF:12:34:56 Phone Name
parts = line.split()
mac_idx = None
for i, part in enumerate(parts):
if ":" in part and len(part) == 17: # Valid MAC format
mac_idx = i
break
if mac_idx is not None:
mac = parts[mac_idx]
name = " ".join(parts[mac_idx + 1:]) if mac_idx + 1 < len(parts) else "(unnamed)"
with self._lock:
self._scan_results[mac] = {"mac": mac, "name": name}
self.logger.info(f"[NEW] {name} ({mac})")
except select.error:
pass
except Exception as e:
self.logger.debug(f"Error parsing line: {e}")
finally:
# Stop scan
self.logger.debug("Stopping scan...")
try:
self._run_cmd(["bluetoothctl", "scan", "off"], timeout=self.SUBPROCESS_TIMEOUT_NORMAL)
except Exception:
pass
time.sleep(0.5)
# Send quit to bluetoothctl
try:
scan_process.stdin.write("quit\n")
scan_process.stdin.flush()
scan_process.wait(timeout=self.SUBPROCESS_TIMEOUT_MEDIUM)
except Exception as e:
self.logger.debug(f"Error closing bluetoothctl: {e}")
try:
scan_process.terminate()
scan_process.wait(timeout=1)
except Exception:
pass
except Exception as e:
self.logger.error(f"Scan failed: {e}")
return devices
with self._lock:
device_count = len(self._scan_results)
self.logger.info(f"Scan complete: found {device_count} total devices")
return list(self._scan_results.values())
def get_scan_results(self):
"""Get current scan results (for real-time progress during scan)."""
with self._lock:
return list(self._scan_results.values())
def connect_nap(self, mac):
"""Connect to device's NAP (Network Access Point) profile."""
"""Connect to device's NAP (Network Access Point) profile via DBus."""
try:
import dbus
from dbus.exceptions import DBusException
self.logger.info(f"Connecting to NAP profile for {mac}...")
result = self._run_cmd(
["bluetoothctl", "connect", mac],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_STANDARD,
bus = dbus.SystemBus()
manager = dbus.Interface(
bus.get_object("org.bluez", "/"), "org.freedesktop.DBus.ObjectManager"
)
if result and "successful" in result.lower():
# Find the device object path
self.logger.info("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"]
device_mac = props.get("Address", "").upper()
if device_mac == mac.upper():
device_path = path
self.logger.info(f"Found device at path: {device_path}")
break
if not device_path:
self.logger.error(f"Device {mac} not found in BlueZ managed objects")
return False
# Connect to NAP service UUID
NAP_UUID = "00001116-0000-1000-8000-00805f9b34fb"
self.logger.info(f"Connecting to NAP profile (UUID: {NAP_UUID})...")
device = dbus.Interface(
bus.get_object("org.bluez", device_path), "org.bluez.Device1"
)
try:
device.ConnectProfile(NAP_UUID, timeout=30)
self.logger.info("✓ NAP profile connected successfully via DBus")
return True
except DBusException as dbus_err:
error_msg = str(dbus_err)
self.logger.error(f"DBus NAP connection failed: {dbus_err}")
# Check for authentication/pairing errors
if (
"Authentication Rejected" in error_msg
or "Connection refused" in error_msg
):
self.logger.warning(
"Device may have been unpaired from phone - removing stale pairing"
)
try:
self._run_cmd(["bluetoothctl", "remove", mac], timeout=5)
self.logger.info("Removed stale pairing")
except Exception as e:
self.logger.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
):
self.logger.warning(
"Phone not reachable (out of range or BT off) - will retry later"
)
# Provide helpful error messages
if (
"br-connection-create-socket" in error_msg
or "br-connection-profile-unavailable" in error_msg
):
self.logger.error(
"⚠️ Bluetooth tethering is NOT enabled on your phone!"
)
self.logger.error(
"Enable 'Bluetooth tethering' in phone Settings → Network & internet → Hotspot & tethering"
)
elif "NoReply" in error_msg or "Did not receive a reply" in error_msg:
self.logger.error(
"⚠️ Phone's Bluetooth is not responding to connection requests"
)
elif "br-connection-busy" in error_msg or "InProgress" in error_msg:
self.logger.error(
"⚠️ Bluetooth connection is busy, wait a moment and try again"
)
return False
except ImportError:
self.logger.error("python3-dbus not installed - run: sudo apt-get install -y python3-dbus")
return False
except Exception as e:
self.logger.error(f"NAP connection failed: {e}")
self.logger.error(f"NAP connection error: {type(e).__name__}: {e}")
return False
def pair_interactive(self, mac, name=""):
"""Pair with a device interactively."""
"""Pair with device - persistent agent will handle the dialog."""
try:
self.logger.info(f"Starting pair for {mac} ({name})...")
self._run_cmd(["bluetoothctl", "pair", mac], timeout=self.PAIRING_PASSKEY_TIMEOUT)
self.logger.info(f"Starting pairing with {mac}...")
# First ensure Bluetooth is powered on and in pairable mode
self._run_cmd(["bluetoothctl", "power", "on"], capture=True)
time.sleep(self.DEVICE_OPERATION_DELAY)
return True
self._run_cmd(["bluetoothctl", "pairable", "on"], capture=True)
self._run_cmd(["bluetoothctl", "discoverable", "on"], capture=True)
time.sleep(self.DEVICE_OPERATION_DELAY)
# Initiate pairing
self.logger.info(f"Running: bluetoothctl pair {mac}")
try:
env = dict(os.environ)
env["NO_COLOR"] = "1"
env["TERM"] = "dumb"
process = subprocess.Popen(
["bluetoothctl", "pair", mac],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
bufsize=1,
)
output = ""
start_time = time.time()
timeout = self.PAIRING_PASSKEY_TIMEOUT
# Read output with timeout
while time.time() - start_time < timeout:
try:
line = process.stdout.readline()
if not line:
break
output += line
if "Pairing successful" in line or "Paired: yes" in line:
self.logger.info(f"Pairing successful for {mac}")
return True
except Exception as e:
self.logger.debug(f"Error reading pairing output: {e}")
break
# Check final status
process.wait(timeout=5)
if "Pairing successful" in output or "Paired: yes" in output:
self.logger.info(f"Pairing successful for {mac}")
return True
else:
self.logger.warning(f"Pairing unclear - output: {output[:200]}")
# Even if output is unclear, the pairing may have succeeded
# Check pair status to confirm
time.sleep(1)
status = self.get_status(mac)
if status and status.get("paired"):
self.logger.info(f"Pairing confirmed for {mac}")
return True
self.logger.error(f"Pairing failed for {mac}")
return False
except subprocess.TimeoutExpired:
self.logger.error(f"Pairing timed out for {mac}")
return False
except Exception as e:
self.logger.error(f"Pairing error: {e}")
return False
except Exception as e:
self.logger.error(f"Pairing failed: {e}")
self.logger.error(f"Pair setup error: {e}")
return False
def trust_device(self, mac):
@@ -238,8 +554,188 @@ class ConnectionManager:
self._run_cmd(["bluetoothctl", "trust", mac], timeout=self.SUBPROCESS_TIMEOUT_NORMAL)
time.sleep(self.DEVICE_OPERATION_DELAY)
def scan(self, duration=30):
"""Scan for Bluetooth devices using interactive bluetoothctl session - working implementation from backup."""
try:
self.logger.info("[bt-tether] Starting device scan...")
self._scan_results = {}
discovered_devices = {}
device_types = {}
# Pre-populate with cached paired devices
self.logger.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.logger.debug(f"Pre-loaded paired device: {name} ({mac})")
except Exception as e:
self.logger.debug(f"Error pre-loading paired devices: {e}")
# Update _scan_results with cached devices
with self._lock:
for mac in discovered_devices:
self._scan_results[mac] = {
"mac": mac,
"name": discovered_devices[mac]
}
lines_read = 0
try:
# Ensure Bluetooth is powered on
self.logger.debug("Ensuring Bluetooth is powered on...")
self._run_cmd(
["bluetoothctl", "power", "on"],
timeout=self.SUBPROCESS_TIMEOUT_STANDARD,
)
time.sleep(self.OPERATION_SHORT_DELAY)
self.logger.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,
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.logger.error(f"Failed to start scan: {e}")
scan_process = None
if scan_process:
self.logger.debug(f"Scanning for {duration} seconds...")
self.logger.debug(f"Process started, PID: {scan_process.pid}")
scan_end_time = time.time() + duration
try:
while time.time() < scan_end_time:
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 = self.scan_ansi_pattern.sub("", line)
# Parse discovery events: "[NEW] Device MAC Name"
if "[NEW]" in clean_line and "Device" in clean_line:
mac_match = self.scan_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.logger.info(f"[NEW] {name} ({mac})")
# Update real-time list
with self._lock:
self._scan_results[mac] = {
"mac": mac,
"name": name
}
except select.error:
pass
finally:
# Stop scan and close bluetoothctl
self.logger.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)
self.logger.info("Bluetoothctl process exited cleanly")
except subprocess.TimeoutExpired:
self.logger.info("Force killing bluetoothctl after timeout")
scan_process.kill()
scan_process.wait(timeout=self.SUBPROCESS_TIMEOUT_SHORT)
except Exception as e:
self.logger.debug(f"Error stopping scan: {e}")
try:
scan_process.kill()
except Exception:
pass
elapsed = time.time() - scan_start
self.logger.info(
f"Scan completed in {elapsed:.1f}s, found {len(discovered_devices)} device(s)"
)
except Exception as e:
self.logger.error(f"Error during scan: {e}")
# Pick up any devices that were paired during the scan
self.logger.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._scan_results[mac] = {
"mac": mac,
"name": name
}
self.logger.info(f"Found device paired during scan: {name} ({mac})")
except Exception as e:
self.logger.debug(f"Error checking for newly paired devices: {e}")
except Exception as e:
self.logger.error(f"Scan error: {e}")
with self._lock:
result = list(self._scan_results.values())
self.logger.info(f"Scan complete: {len(result)} devices total")
return result
def get_trusted_devices(self):
"""Get list of trusted Bluetooth devices."""
"""Get list of trusted Bluetooth devices with NAP support info."""
devices = []
result = self._run_cmd(
["bluetoothctl", "devices"],
@@ -257,6 +753,28 @@ class ConnectionManager:
name = " ".join(parts[2:])
status = self.get_status(mac)
if status and status.get("trusted"):
devices.append(BluetoothDevice(mac, name, paired=status["paired"], trusted=True, connected=status["connected"]))
# Check if device supports NAP
has_nap = self._check_nap_support(mac)
devices.append(BluetoothDevice(
mac, name,
paired=status["paired"],
trusted=True,
connected=status["connected"],
has_nap=has_nap
))
return devices
def _check_nap_support(self, mac):
"""Check if device supports NAP (Network Access Point) for tethering."""
try:
info = self._run_cmd(
["bluetoothctl", "info", mac],
capture=True,
timeout=self.SUBPROCESS_TIMEOUT_NORMAL,
)
if info and BluetoothDevice.NAP_UUID in info:
return True
except Exception as e:
self.logger.debug(f"Error checking NAP support for {mac}: {e}")
return False
+6 -1
View File
@@ -5,6 +5,9 @@ import logging
class BluetoothDevice:
"""Represents a Bluetooth device with connection state."""
# Bluetooth NAP profile UUID
NAP_UUID = "00001116-0000-1000-8000-00805f9b34fb"
def __init__(self, mac, name, paired=False, trusted=False, connected=False, has_nap=False):
self.mac = mac
self.name = name
@@ -38,5 +41,7 @@ class BluetoothDevice:
paired = info_dict.get("Paired", "no").lower() == "yes"
trusted = info_dict.get("Trusted", "no").lower() == "yes"
connected = info_dict.get("Connected", "no").lower() == "yes"
has_nap = "PNP" in info_dict.get("UUID", "") or info_dict.get("Has_NAP", False)
# Check if device supports NAP (Network Access Point) for tethering
uuids = info_dict.get("UUID", "")
has_nap = BluetoothDevice.NAP_UUID in uuids
return BluetoothDevice(mac, name, paired, trusted, connected, has_nap)
+434 -10
View File
@@ -2,6 +2,8 @@ import subprocess
import re
import logging
import time
import os
import socket
class NetworkManager:
@@ -105,25 +107,172 @@ class NetworkManager:
return False
def setup_dhcp(self, iface):
"""Setup DHCP on interface."""
"""Setup network for interface using DHCP."""
try:
self.logger.info(f"Setting up DHCP for {iface}")
self.logger.info(f"Setting up network for {iface}...")
# Ensure interface is up
self.logger.info(f"Ensuring {iface} is up...")
subprocess.run(
["dhclient", iface],
timeout=self.SUBPROCESS_TIMEOUT_STANDARD,
capture_output=True,
["sudo", "ip", "link", "set", iface, "up"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=5,
)
time.sleep(1)
return True
except Exception as e:
self.logger.warning(f"DHCP setup failed: {e}")
return self._setup_dhclient_internal(iface)
except subprocess.TimeoutExpired:
self.logger.error("Network setup timed out")
return False
except Exception as e:
self.logger.error(f"Network setup error: {e}")
return False
def _setup_dhclient_internal(self, iface):
"""Request DHCP on interface using available client."""
try:
self.logger.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.logger.info(f"Requesting DHCP on {iface}...")
dhcp_success = False
if has_dhcpcd:
self.logger.info("Using dhcpcd...")
# Release any existing lease first
subprocess.run(
["sudo", "dhcpcd", "-k", iface],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
time.sleep(1)
# 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.logger.info(f"dhcpcd: {result.stdout.strip()}")
if result.returncode == 0:
dhcp_success = True
else:
self.logger.warning(f"dhcpcd failed: {result.stderr.strip()}")
elif has_dhclient:
self.logger.info("Using dhclient...")
# Kill any existing dhclient for this interface
self._kill_dhclient_for_interface(iface)
time.sleep(0.5)
# Request new lease
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 errors
if "Network error: Software caused connection abort" in combined:
self.logger.warning("dhclient: Connection aborted by phone")
self.logger.warning("📱 Ensure Bluetooth tethering is ENABLED on your phone!")
elif "DHCPDISCOVER" in combined and "No DHCPOFFERS" in combined:
self.logger.warning("dhclient: No DHCP response from phone")
self.logger.warning("📱 Phone is not providing DHCP - enable Bluetooth tethering!")
if result.returncode == 0:
dhcp_success = True
else:
self.logger.warning(f"dhclient returned {result.returncode}")
except subprocess.TimeoutExpired:
self.logger.warning("dhclient timed out after 30s")
try:
self._kill_dhclient_for_interface(iface)
except Exception:
pass
else:
self.logger.error("No DHCP client available (dhcpcd or dhclient)")
return dhcp_success
except Exception as e:
self.logger.error(f"DHCP setup error: {e}")
return False
def _kill_dhclient_for_interface(self, iface):
"""Kill dhclient processes specifically managing the given interface."""
try:
result = subprocess.run(
["pidof", "dhclient"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=3,
)
if result.returncode != 0 or not result.stdout.strip():
return
pids = result.stdout.strip().split()
for pid in pids:
try:
ps_result = subprocess.run(
["ps", "-p", pid, "-o", "args="],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=2,
)
if ps_result.returncode == 0:
cmdline = ps_result.stdout.strip()
args = cmdline.split()
# The interface must be the LAST argument and match EXACTLY
if args and args[-1] == iface:
self.logger.debug(f"Killing dhclient PID {pid} for {iface}")
subprocess.run(
["sudo", "kill", pid],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=3,
)
except Exception as e:
self.logger.debug(f"Error checking PID {pid}: {e}")
except Exception as e:
self.logger.debug(f"Error killing dhclient: {e}")
def stop_dhclient(self, iface):
"""Stop dhclient for interface."""
try:
self._kill_dhclient_for_interface(iface)
subprocess.run(
["dhclient", "-r", iface],
["sudo", "dhclient", "-r", iface],
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
capture_output=True,
)
@@ -194,3 +343,278 @@ class NetworkManager:
pass
return result
def check_internet_connectivity(self):
"""Check if internet is accessible via Bluetooth interface specifically"""
try:
bt_iface = self.get_interface_name() or "bnep0"
# First verify interface has an IP
ip_result = subprocess.run(
["ip", "addr", "show", bt_iface],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
if ip_result.returncode != 0:
self.logger.warning(f"{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."):
self.logger.warning(f"{bt_iface} has no valid IP")
return False
bt_ip = ip_match.group(1)
self.logger.info(f"{bt_iface} has IP: {bt_ip}")
# Log current routing table
route_check = subprocess.run(
["ip", "route", "show"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
if route_check.returncode == 0:
self.logger.info(f"Current routes:\n{route_check.stdout}")
# Ping via the Bluetooth interface specifically
self.logger.info(f"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:
self.logger.info("✓ Ping to 8.8.8.8 successful")
return True
else:
self.logger.warning("Ping to 8.8.8.8 failed")
self.logger.warning(f"Ping stderr: {result.stderr}")
self.logger.warning(f"Ping stdout: {result.stdout}")
# Try to ping the gateway
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)
self.logger.info(f"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:
self.logger.warning(
"Gateway ping works, but internet ping failed - possible NAT/firewall issue"
)
else:
self.logger.warning(
"Gateway ping also failed - phone may not be providing internet"
)
return False
except subprocess.TimeoutExpired:
self.logger.warning("Ping timeout - no internet connectivity")
return False
except Exception as e:
self.logger.error(f"Internet check error: {e}")
return False
def is_pan_active(self):
"""Check if any PAN interface (bnep/bt-pan) is active"""
try:
result = subprocess.run(
["ip", "link", "show"],
capture_output=True,
text=True,
timeout=3,
)
has_bnep = "bnep" in result.stdout
has_bt_pan = "bt-pan" in result.stdout
if has_bnep or has_bt_pan:
self.logger.debug(f"Found PAN interface (bnep={has_bnep}, bt-pan={has_bt_pan})")
return True
self.logger.debug("No PAN interface found")
return False
except Exception as e:
self.logger.error(f"Failed to check PAN: {e}")
return False
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)
for line in out.split("\n"):
if "bnep" in line or "bt-pan" in line:
parts = line.split(":")
if len(parts) >= 2:
iface = parts[1].strip()
return iface
return None
except Exception as e:
self.logger.error(f"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
)
match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", result)
if match:
return match.group(1)
return None
except Exception as e:
self.logger.debug(f"Failed to get IP for {iface}: {e}")
return None
def get_current_ip(self):
"""Get the current IP address from the Bluetooth PAN interface only"""
try:
pan_iface = self.get_pan_interface()
if pan_iface:
ip = self.get_interface_ip(pan_iface)
if ip and not ip.startswith("169.254."):
self.logger.debug(f"Found BT IP {ip} on {pan_iface}")
return ip
# Also check bnep0 explicitly
ip = self.get_interface_ip("bnep0")
if ip and not ip.startswith("169.254."):
self.logger.debug(f"Found BT IP {ip} on bnep0")
return ip
self.logger.debug("No IP address found on Bluetooth interface")
return None
except Exception as e:
self.logger.debug(f"Error getting current IP: {e}")
return None
def full_internet_test(self):
"""Test internet connectivity and return detailed results - comprehensive version"""
result = {
"ping_success": False,
"dns_success": False,
"bnep0_ip": None,
"default_route": None,
"dns_servers": None,
"dns_error": None,
"localhost_routes": None,
}
# Test ping
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
self.logger.info(f"Ping test: {'Success' if result['ping_success'] else 'Failed'}")
except Exception as e:
self.logger.warning(f"Ping test error: {e}")
# Test DNS
try:
socket.gethostbyname("google.com")
result["dns_success"] = True
self.logger.info("DNS test: Success")
except socket.gaierror as e:
result["dns_success"] = False
result["dns_error"] = f"DNS resolution failed: {str(e)}"
self.logger.warning(f"DNS test failed: {e}")
except Exception as e:
result["dns_success"] = False
result["dns_error"] = str(e)
self.logger.warning(f"DNS test error: {e}")
# Get DNS servers
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"
self.logger.info(f"DNS servers: {result['dns_servers']}")
except Exception as e:
result["dns_servers"] = f"Error: {str(e)[:50]}"
self.logger.warning(f"Get DNS servers error: {e}")
# Get bnep0 IP
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)
self.logger.info(f"bnep0 IP: {result['bnep0_ip']}")
except Exception as e:
self.logger.warning(f"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()
self.logger.info(f"Default route: {result['default_route']}")
except Exception as e:
self.logger.warning(f"Get default route error: {e}")
# Get localhost route
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()
if "lo" not in result["localhost_routes"] and "local" not in result["localhost_routes"]:
self.logger.warning("⚠️ WARNING: Localhost not routing through 'lo' interface!")
self.logger.warning(f"⚠️ This may prevent bettercap API from working: {result['localhost_routes']}")
else:
self.logger.info(f"Localhost route: {result['localhost_routes']}")
else:
result["localhost_routes"] = "Error getting localhost route"
except Exception as e:
result["localhost_routes"] = f"Error: {str(e)}"
self.logger.warning(f"Get localhost route error: {e}")
return result
@@ -3,61 +3,70 @@ import json
import os
import re
import pathlib
import pwnagotchi.plugins as plugins
from datetime import datetime, UTC
from threading import Lock
def read_ap_cache(cache_dir, file):
cache_filename = os.path.basename(re.sub(r"\.(pcap|gps\.json|geo\.json)$", ".cache", file))
cache_filename = os.path.join(cache_dir, cache_filename)
if not os.path.exists(cache_filename):
logging.info("Cache not exist")
return None
try:
with open(cache_filename, "r") as f:
return json.load(f)
except Exception as e:
logging.info(f"Exception {e}")
return None
class CacheManager:
"""Core cache manager for AP information"""
class Cache(plugins.Plugin):
__author__ = "fmatray"
__version__ = "1.0.0"
__license__ = "GPL3"
__description__ = "A simple plugin to cache AP informations"
def __init__(self):
self.options = dict()
self.ready = False
def __init__(self, config):
self.lock = Lock()
self.ready = False
self.last_clean = None
self.cache_dir = None
def on_loaded(self):
logging.info("[CACHE] plugin loaded.")
def on_config_changed(self, config):
try:
handshake_dir = config["bettercap"].get("handshakes")
self.cache_dir = os.path.join(handshake_dir, "cache")
os.makedirs(self.cache_dir, exist_ok=True)
except Exception:
logging.info(f"[CACHE] Cannot access to the cache directory")
return
self.last_clean = datetime.now(tz=UTC)
self.ready = True
logging.info(f"[CACHE] Cache plugin configured")
self.clean_ap_cache()
self.last_clean = datetime.now(tz=UTC)
self.ready = True
logging.info("[CACHE] Cache manager initialized at %s", self.cache_dir)
except Exception as e:
logging.error(f"[CACHE] Failed to initialize cache: {e}")
def on_unload(self, ui):
self.clean_ap_cache()
def clean_ap_cache(self):
def write_ap_cache(self, access_point):
"""Write AP information to cache"""
if not self.ready:
return
with self.lock:
try:
mac = access_point["mac"].replace(":", "")
hostname = re.sub(r"[^a-zA-Z0-9]", "", access_point["hostname"])
except KeyError:
return
cache_file = os.path.join(self.cache_dir, f"{hostname}_{mac}.apcache")
try:
with open(cache_file, "w") as f:
json.dump(access_point, f)
except Exception as e:
logging.error(f"[CACHE] Cannot write {cache_file}: {e}")
def read_ap_cache(self, cache_dir, file):
"""Read AP cache from disk"""
cache_filename = os.path.basename(re.sub(r"\.(pcap|gps\.json|geo\.json)$", ".cache", file))
cache_filename = os.path.join(cache_dir, cache_filename)
if not os.path.exists(cache_filename):
return None
try:
with open(cache_filename, "r") as f:
return json.load(f)
except Exception as e:
logging.debug(f"[CACHE] Exception reading cache: {e}")
return None
def clean_ap_cache(self):
"""Clean AP cache files older than 5 minutes"""
if not self.ready:
return
with self.lock:
ctime = datetime.now(tz=UTC)
cache_to_delete = list()
for cache_file in pathlib.Path(self.cache_dir).glob("*.apcache"):
try:
mtime = datetime.fromtimestamp(cache_file.lstat().st_mtime, tz=UTC)
@@ -65,54 +74,48 @@ class Cache(plugins.Plugin):
cache_to_delete.append(cache_file)
except FileNotFoundError:
pass
if cache_to_delete:
logging.info(f"[CACHE] Cleaning {len(cache_to_delete)} files")
logging.debug(f"[CACHE] Cleaning {len(cache_to_delete)} files")
for cache_file in cache_to_delete:
try:
cache_file.unlink()
except FileNotFoundError as e:
except FileNotFoundError:
pass
def write_ap_cache(self, access_point):
with self.lock:
try:
mac = access_point["mac"].replace(":", "")
hostname = re.sub(r"[^a-zA-Z0-9]", "", access_point["hostname"])
except KeyError:
return
cache_file = os.path.join(self.cache_dir, f"{hostname}_{mac}.apcache")
try:
with open(cache_file, "w") as f:
json.dump(access_point, f)
except Exception as e:
logging.error(f"[CACHE] Cannot write {cache_file}: {e}")
pass
def on_wifi_update(self, agent, access_points):
def on_wifi_update(self, aps):
"""Cache APs from WiFi update events"""
if self.ready:
for ap in filter(lambda ap: ap["hostname"] not in ["", "<hidden>"], access_points):
for ap in filter(lambda ap: ap.get("hostname") not in ["", "<hidden>"], aps):
self.write_ap_cache(ap)
def on_unfiltered_ap_list(self, agent, aps):
def on_unfiltered_ap_list(self, aps):
"""Cache APs from unfiltered AP list events"""
if self.ready:
for ap in filter(lambda ap: ap["hostname"] not in ["", "<hidden>"], aps):
for ap in filter(lambda ap: ap.get("hostname") not in ["", "<hidden>"], aps):
self.write_ap_cache(ap)
def on_association(self, agent, access_point):
def on_association(self, access_point):
"""Cache AP on association event"""
if self.ready:
self.write_ap_cache(access_point)
def on_deauthentication(self, agent, access_point, client_station):
def on_deauthentication(self, access_point, client_station):
"""Cache AP on deauthentication event"""
if self.ready:
self.write_ap_cache(access_point)
def on_handshake(self, agent, filename, access_point, client_station):
def on_handshake(self, filename, access_point, client_station):
"""Cache AP on handshake event"""
if self.ready:
self.write_ap_cache(access_point)
def on_ui_update(self, ui):
if not self.ready:
def periodic_cleanup(self):
"""Check if cleanup is needed (call periodically)"""
if not self.ready or not self.last_clean:
return
current_time = datetime.now(tz=UTC)
if (current_time - self.last_clean).total_seconds() > 60:
self.clean_ap_cache()
-3
View File
@@ -48,9 +48,6 @@ detailed_status_position = [0, 82] # Position for detailed status line
[main.plugins.fix_services]
enabled = true
[main.plugins.cache]
enabled = true
[main.plugins.gpio_buttons]
enabled = false
+398 -87
View File
@@ -14,6 +14,7 @@ Configuration (config.toml):
import logging
import threading
import json
import time
from collections import deque
from pwnagotchi.plugins import Plugin
from pwnagotchi.bluetooth import BluetoothService
@@ -25,7 +26,7 @@ from flask import render_template_string, request, jsonify
class BtTether(Plugin):
__author__ = "wsvdmeer (refactored)"
__version__ = "2.0.0"
__version__ = "2.0.1"
__license__ = "GPL3"
__description__ = "Bluetooth tethering with delegated core operations"
@@ -35,12 +36,15 @@ class BtTether(Plugin):
def on_loaded(self):
"""Initialize plugin configuration and core Bluetooth service."""
self.phone_mac = ""
self._phone_name = ""
self._status = "IDLE"
self._message = "Ready"
self._message = "- -"
self._ui_logs = deque(maxlen=100)
self._ui_log_lock = threading.Lock()
self._ui_reference = None
self._screen_needs_refresh = False
self._connection_time = None
self._show_device_name = False
# Configuration
self.show_on_screen = self.options.get("show_on_screen", True)
@@ -123,19 +127,98 @@ class BtTether(Plugin):
return
try:
status = self.bt.ui_cache.get()
icon = self.bt.ui_renderer.get_status_icon(status)
detailed = self.bt.ui_renderer.format_status(status)
# Try to get status for stored phone_mac first, then check for any connected tethering device
cached_status = None
connected_device = None
if self.phone_mac:
cached_status = self.bt.connection.get_full_status(self.phone_mac)
# Try to get the device name if we have a MAC
if cached_status and not self._phone_name:
trusted_devices = self.bt.connection.get_trusted_devices()
for device in trusted_devices:
if device.mac == self.phone_mac:
self._phone_name = device.name
connected_device = device
break
# If no status for stored MAC, look for any connected device with NAP (tethering)
if not cached_status:
trusted_devices = self.bt.connection.get_trusted_devices()
for device in trusted_devices:
if device.connected and device.has_nap:
cached_status = self.bt.connection.get_full_status(device.mac)
if cached_status:
self.phone_mac = device.mac
self._phone_name = device.name
connected_device = device
break
cached_status = cached_status or {}
# Update internal status if we detected a connected device
current_time = time.time()
if cached_status.get("connected", False):
if self._status != "CONNECTED":
# Just connected - start timer
self._status = "CONNECTED"
self._connection_time = current_time
# Toggle between IP and device name every 5 seconds
if self._connection_time:
elapsed = current_time - self._connection_time
# Every 10 seconds: 0-5 show IP, 5-10 show device name, repeat
cycle_position = elapsed % 10
self._show_device_name = cycle_position >= 5
# Store device name if we have a connected device
if connected_device and connected_device.name:
self._phone_name = connected_device.name
# Set message based on current cycle
if self._show_device_name:
self._message = self._phone_name[:20] if self._phone_name else "Unknown"
else:
ip_address = cached_status.get("ip_address", "")
self._message = ip_address if ip_address else "- -"
else:
if self._status == "CONNECTED":
self._status = "IDLE"
self._connection_time = None
self._show_device_name = False
self._message = "- -"
# Determine display character based on connection state
if cached_status.get("pan_active", False):
# Pan active - tethering is working
display = "C"
elif cached_status.get("connected", False) and cached_status.get("trusted", False):
# Connected and trusted
display = "T"
elif cached_status.get("connected", False):
# Just connected (not trusted)
display = "N"
elif cached_status.get("paired", False):
# Paired but not connected
display = "P"
else:
# No device or not paired
display = "X"
if self.show_mini_status:
ui.set("bt-status", icon)
ui.set("bt-status", display)
if self.show_detailed_status:
detailed = f"BT:{self._message}" if self._message else "BT:- -"
ui.set("bt-detail", detailed)
except Exception as e:
logging.debug(f"UI update error: {e}")
if self.show_mini_status:
ui.set("bt-status", "?")
if self.show_detailed_status:
ui.set("bt-detail", "BT:Error")
def on_webhook(self, path, request):
"""Handle webhook requests for web UI."""
@@ -230,32 +313,46 @@ class BtTether(Plugin):
return jsonify({"success": result})
def _scan_devices(self):
"""Start device scan."""
self._scanned_devices = []
"""Start device scan in background."""
threading.Thread(target=self._scan_thread, daemon=True).start()
return jsonify({"success": True})
def _scan_thread(self):
"""Background thread for scanning."""
self._scanned_devices = self.bt.scan_devices(duration=30)
self._log("INFO", f"Scan complete: found {len(self._scanned_devices)} devices")
try:
self._log("INFO", "Starting Bluetooth device scan...")
self.bt.scan_devices(duration=30)
progress = self.bt.get_scan_progress()
self._log("INFO", f"Scan complete: found {len(progress['devices'])} devices")
except Exception as e:
self._log("ERROR", f"Scan failed: {e}")
def _get_scan_progress(self):
"""Get scan progress."""
"""Get scan progress and discovered devices."""
progress = self.bt.get_scan_progress()
return jsonify({
"scanning": False,
"scanning": progress["scanning"],
"devices": [
{"mac": d.get("mac", ""), "name": d.get("name", "")}
for d in getattr(self, "_scanned_devices", [])
for d in progress["devices"]
]
})
def _get_status(self):
"""Get current plugin status."""
# Auto-detect connected device if phone_mac not set
current_mac = self.phone_mac
if not current_mac:
trusted_devices = self.bt.connection.get_trusted_devices()
for device in trusted_devices:
if device.connected and device.has_nap:
current_mac = device.mac
break
return jsonify({
"status": self._status,
"message": self._message,
"mac": self.phone_mac,
"mac": current_mac,
"initialized": self.bt.initialized,
"scanning": False,
"connection_in_progress": False,
@@ -265,24 +362,28 @@ class BtTether(Plugin):
})
def _get_connection_status(self, mac):
"""Get connection status for a device."""
"""Get full connection status for a device."""
if not mac:
return jsonify({"success": False})
status = self.bt.get_status(mac)
if not status:
return jsonify({"success": False})
try:
status = self.bt.connection.get_full_status(mac)
if not status:
return jsonify({"success": False})
return jsonify({
"success": True,
"paired": status.get("paired", False),
"trusted": status.get("trusted", False),
"connected": status.get("connected", False),
"pan_active": status.get("pan_active", False),
"interface": status.get("interface"),
"ip_address": status.get("ip_address"),
"default_route_interface": self.bt.network.get_default_route_interface(),
})
return jsonify({
"success": True,
"paired": status.get("paired", False),
"trusted": status.get("trusted", False),
"connected": status.get("connected", False),
"pan_active": status.get("pan_active", False),
"interface": status.get("interface"),
"ip_address": status.get("ip_address"),
"default_route_interface": self.bt.network.get_default_route_interface(),
})
except Exception as e:
self._log("ERROR", f"Failed to get connection status: {e}")
return jsonify({"success": False, "error": str(e)})
def _test_internet(self):
"""Test internet connectivity."""
@@ -325,6 +426,7 @@ class BtTether(Plugin):
self._log("INFO", f"Connected to {name}")
self._status = "CONNECTED"
self._message = f"Connected to {name}"
self._screen_needs_refresh = True
def _on_connect_failed(self, data):
"""Handle failed connection event."""
@@ -333,6 +435,7 @@ class BtTether(Plugin):
self._log("ERROR", f"Connection failed: {error}")
self._status = "ERROR"
self._message = f"Connection failed: {error}"
self._screen_needs_refresh = True
def _on_disconnect_success(self, data):
"""Handle successful disconnection event."""
@@ -340,10 +443,11 @@ class BtTether(Plugin):
self._log("INFO", f"Disconnected from {mac}")
self._status = "IDLE"
self._message = "Ready"
self._screen_needs_refresh = True
def _get_html_template(self):
"""Get the original full-featured HTML template."""
# This template is extracted from the original bt-tether.py
# This template is extracted from the original bt-tether.py.disabled
# It provides the full UI with device discovery, status monitoring, and internet testing
template = """<!DOCTYPE html>
<html>
@@ -448,6 +552,7 @@ class BtTether(Plugin):
</button>
<div id="scanResults" style="display: none;">
<h5 style="margin: 0 0 8px 0; color: #8b949e;">Discovered Devices:</h5>
<div id="scanStatus" style="color: #8b949e; margin: 8px 0; font-size: 13px;">Scanning...</div>
<div id="deviceList"></div>
</div>
</div>
@@ -468,71 +573,194 @@ class BtTether(Plugin):
let statusInterval = null;
let logInterval = null;
// Show initializing state first
setInitializingStatus();
// Load trusted devices on page load
loadTrustedDevicesSummary();
// Then check actual connection status
setTimeout(checkConnectionStatus, 1000);
// Start log polling immediately
refreshLogs();
startLogPolling();
function setInitializingStatus() {
document.getElementById("statusPaired").innerHTML =
`📱 Paired: <span style="color: #8b949e;">🔄 Initializing...</span>`;
document.getElementById("statusTrusted").innerHTML =
`🔐 Trusted: <span style="color: #8b949e;">🔄 Initializing...</span>`;
document.getElementById("statusConnected").innerHTML =
`🔵 Connected: <span style="color: #8b949e;">🔄 Initializing...</span>`;
document.getElementById("statusInternet").innerHTML =
`🌐 Internet: <span style="color: #8b949e;">🔄 Initializing...</span>`;
document.getElementById('statusIP').style.display = 'none';
const connectBtn = document.getElementById('quickConnectBtn');
connectBtn.disabled = true;
connectBtn.innerHTML = '<span class="spinner"></span> Initializing...';
}
async function checkConnectionStatus() {
const mac = macInput.value.trim();
let mac = macInput.value.trim();
// If no MAC in input, try to get it from backend status
if (!mac || !/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/i.test(mac)) {
const connectBtn = document.getElementById('quickConnectBtn');
connectBtn.style.display = 'none';
return;
try {
const statusResponse = await fetch(`/plugins/bt-tether/status`);
const statusData = await statusResponse.json();
// If backend has a current MAC, use it
if (statusData.mac && /^([0-9A-F]{2}:){5}[0-9A-F]{2}$/i.test(statusData.mac)) {
mac = statusData.mac;
macInput.value = mac;
} else {
// No valid MAC - show disconnected state
document.getElementById("statusPaired").innerHTML =
`📱 Paired: <span style="color: #f48771;"> No</span>`;
document.getElementById("statusTrusted").innerHTML =
`🔐 Trusted: <span style="color: #f48771;"> No</span>`;
document.getElementById("statusConnected").innerHTML =
`🔵 Connected: <span style="color: #f48771;"> No</span>`;
document.getElementById("statusInternet").innerHTML =
`🌐 Internet: <span style="color: #f48771;"> Not Active</span>`;
const connectBtn = document.getElementById('quickConnectBtn');
connectBtn.style.display = 'none';
document.getElementById('disconnectSection').style.display = 'none';
return;
}
} catch (err) {
console.error('Failed to get backend status:', err);
// Still show buttons even if status fetch fails
const connectBtn = document.getElementById('quickConnectBtn');
connectBtn.disabled = false;
connectBtn.innerHTML = '⚡ Connect to Phone';
return;
}
}
try {
// Fetch both backend status and connection status
const statusResponse = await fetch(`/plugins/bt-tether/status`);
const statusData = await statusResponse.json();
const response = await fetch(`/plugins/bt-tether/connection-status?mac=${encodeURIComponent(mac)}`);
const data = await response.json();
updateStatusDisplay(data);
updateStatusDisplay(statusData, data);
} catch (error) {
console.error('Status check failed:', error);
}
}
function updateStatusDisplay(data) {
document.getElementById("statusPaired").innerHTML =
`📱 Paired: <span style="color: ${data.paired ? '#4ec9b0' : '#f48771'};">${data.paired ? '✓ Yes' : '✗ No'}</span>`;
document.getElementById("statusTrusted").innerHTML =
`🔐 Trusted: <span style="color: ${data.trusted ? '#4ec9b0' : '#f48771'};">${data.trusted ? '✓ Yes' : '✗ No'}</span>`;
document.getElementById("statusConnected").innerHTML =
`🔵 Connected: <span style="color: ${data.connected ? '#4ec9b0' : '#f48771'};">${data.connected ? '✓ Yes' : '✗ No'}</span>`;
document.getElementById("statusInternet").innerHTML =
`🌐 Internet: <span style="color: ${data.pan_active ? '#4ec9b0' : '#f48771'};">${data.pan_active ? '✓ Active' : '✗ Not Active'}</span>`;
function updateStatusDisplay(statusData, data) {
try {
// Ensure data objects exist with defaults
statusData = statusData || {};
data = data || {};
const statusIPElement = document.getElementById('statusIP');
if (data.ip_address && data.pan_active) {
statusIPElement.style.display = 'block';
statusIPElement.innerHTML = `🔢 IP Address: <span style="color: #4ec9b0;">${data.ip_address}</span>`;
} else {
statusIPElement.style.display = 'none';
}
const paired = data.paired || false;
const trusted = data.trusted || false;
const connected = data.connected || false;
const pan_active = data.pan_active || false;
const ip_address = data.ip_address || null;
const testInternetCard = document.getElementById('testInternetCard');
if (data.pan_active) {
testInternetCard.style.display = 'block';
} else {
testInternetCard.style.display = 'none';
}
document.getElementById("statusPaired").innerHTML =
`📱 Paired: <span style="color: ${paired ? '#4ec9b0' : '#f48771'};">${paired ? '✓ Yes' : '✗ No'}</span>`;
document.getElementById("statusTrusted").innerHTML =
`🔐 Trusted: <span style="color: ${trusted ? '#4ec9b0' : '#f48771'};">${trusted ? '✓ Yes' : '✗ No'}</span>`;
document.getElementById("statusConnected").innerHTML =
`🔵 Connected: <span style="color: ${connected ? '#4ec9b0' : '#f48771'};">${connected ? '✓ Yes' : '✗ No'}</span>`;
document.getElementById("statusInternet").innerHTML =
`🌐 Internet: <span style="color: ${pan_active ? '#4ec9b0' : '#f48771'};">${pan_active ? '✓ Active' : '✗ Not Active'}</span>${data.interface ? ` <span style="color: #888;">(${data.interface})</span>` : ''}`;
const connectBtn = document.getElementById('quickConnectBtn');
const disconnectSection = document.getElementById('disconnectSection');
const statusIPElement = document.getElementById('statusIP');
if (ip_address && pan_active) {
statusIPElement.style.display = 'block';
statusIPElement.innerHTML = `🔢 IP Address: <span style="color: #4ec9b0;">${ip_address}</span>`;
} else {
statusIPElement.style.display = 'none';
}
if (data.connected) {
connectBtn.style.display = 'none';
disconnectSection.style.display = 'block';
} else if (data.paired) {
connectBtn.style.display = 'block';
disconnectSection.style.display = 'block';
} else {
connectBtn.style.display = 'block';
disconnectSection.style.display = 'none';
}
const testInternetCard = document.getElementById('testInternetCard');
if (pan_active) {
testInternetCard.style.display = 'block';
} else {
testInternetCard.style.display = 'none';
}
if (!statusInterval || statusInterval._interval !== 10000) {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(checkConnectionStatus, 10000);
statusInterval._interval = 10000;
const connectBtn = document.getElementById('quickConnectBtn');
const disconnectSection = document.getElementById('disconnectSection');
// Check if operation is in progress
const operationInProgress = statusData.status === 'PAIRING' || statusData.status === 'CONNECTING' || statusData.status === 'RECONNECTING';
if (operationInProgress) {
// Show connecting state
connectBtn.disabled = true;
connectBtn.innerHTML = '<span class="spinner"></span> Connecting...';
connectBtn.style.display = 'block';
disconnectSection.style.display = 'none';
} else {
connectBtn.disabled = false;
connectBtn.innerHTML = '⚡ Connect to Phone';
// Show/hide based on connection status
if (connected) {
connectBtn.style.display = 'none';
disconnectSection.style.display = 'block';
} else if (paired && trusted) {
connectBtn.style.display = 'block';
disconnectSection.style.display = 'block';
} else if (paired) {
connectBtn.style.display = 'none';
disconnectSection.style.display = 'block';
} else {
connectBtn.style.display = 'none';
disconnectSection.style.display = 'none';
}
}
// Manage polling based on connection state
if (operationInProgress) {
if (!statusInterval || statusInterval._interval !== 2000) {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(checkConnectionStatus, 2000);
statusInterval._interval = 2000;
}
} else if (connected || paired) {
if (!statusInterval || statusInterval._interval !== 10000) {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(checkConnectionStatus, 10000);
statusInterval._interval = 10000;
}
} else {
if (!statusInterval || statusInterval._interval !== 30000) {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(checkConnectionStatus, 30000);
statusInterval._interval = 30000;
}
}
// Refresh trusted devices summary if connection state changed
if (!window.lastStatusUpdate ||
(window.lastStatusUpdate.connected !== (statusData.mac && connected))) {
loadTrustedDevicesSummary();
window.lastStatusUpdate = {
connected: statusData.mac && connected
};
}
} catch (error) {
console.error('Error updating status display:', error);
}
}
@@ -542,26 +770,101 @@ class BtTether(Plugin):
alert("Enter valid MAC address");
return;
}
await fetch(`/plugins/bt-tether/connect?mac=${encodeURIComponent(mac)}`);
setTimeout(checkConnectionStatus, 1000);
try {
const response = await fetch(`/plugins/bt-tether/connect?mac=${encodeURIComponent(mac)}`);
const data = await response.json();
if (data.success) {
// Start fast polling to show connection progress
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(checkConnectionStatus, 2000);
statusInterval._interval = 2000;
}
} catch (error) {
console.error('Connection request failed:', error);
}
}
async function scanDevices() {
const scanBtn = document.getElementById('scanBtn');
const scanResults = document.getElementById('scanResults');
const scanStatus = document.getElementById('scanStatus');
const deviceList = document.getElementById('deviceList');
scanBtn.disabled = true;
scanBtn.innerHTML = '<span class="spinner"></span> Scanning...';
scanResults.style.display = 'block';
deviceList.innerHTML = '';
await fetch('/plugins/bt-tether/scan');
await new Promise(r => setTimeout(r, 1000));
const response = await fetch('/plugins/bt-tether/scan-progress');
const data = await response.json();
deviceList.innerHTML = data.devices.map(d =>
`<div class="device-item"><div><b>${d.name}</b><br><small style="color: #888;">${d.mac}</small></div>
<button class="success" onclick="pairAndConnectDevice('${d.mac}', '${d.name}'); return false;">Pair</button></div>`
).join('');
scanBtn.disabled = false;
scanBtn.innerHTML = '🔍 Scan';
scanStatus.innerHTML = '<span class="spinner"></span> Scanning for devices...';
try {
await fetch('/plugins/bt-tether/scan', { method: 'GET' });
// Poll /scan-progress every 1 second to show devices as they appear
let pollCount = 0;
const maxPolls = 30;
let lastDeviceCount = 0;
let scanProgressInterval = setInterval(async () => {
pollCount++;
try {
const progressResponse = await fetch('/plugins/bt-tether/scan-progress');
const progressData = await progressResponse.json();
if (progressData && progressData.devices) {
const deviceCount = progressData.devices.length;
// Update if device count changed or first poll
if (deviceCount > lastDeviceCount) {
lastDeviceCount = deviceCount;
deviceList.innerHTML = '';
progressData.devices.forEach(device => {
const div = document.createElement('div');
div.className = 'device-item';
div.innerHTML = `
<div style="flex: 1; font-family: 'Courier New', monospace; font-size: 12px;">
<b>${device.name}</b><br>
<small style="color: #888;">${device.mac}</small>
</div>
<button onclick="pairAndConnectDevice('${device.mac}', '${device.name.replace(/'/g, "\\'")}'); return false;" class="success" style="margin: 0; padding: 6px 12px; font-size: 12px;">Pair</button>
`;
deviceList.appendChild(div);
});
}
// Update status
if (progressData.scanning) {
scanStatus.innerHTML = `<span class="spinner"></span> Found ${deviceCount} device(s)... still scanning`;
} else {
clearInterval(scanProgressInterval);
if (deviceCount > 0) {
scanStatus.textContent = `Scan complete - Found ${deviceCount} device(s):`;
} else {
scanStatus.textContent = 'Scan complete - No devices found';
deviceList.innerHTML = '';
}
scanBtn.disabled = false;
scanBtn.innerHTML = '🔍 Scan';
}
}
if (pollCount >= maxPolls) {
clearInterval(scanProgressInterval);
if (lastDeviceCount === 0) {
scanStatus.textContent = 'Scan timeout - No devices found';
}
scanBtn.disabled = false;
scanBtn.innerHTML = '🔍 Scan';
}
} catch (e) {
console.error('Scan progress poll error:', e);
}
}, 1000);
} catch (error) {
scanStatus.textContent = 'Scan failed: ' + error.message;
scanBtn.disabled = false;
scanBtn.innerHTML = '🔍 Scan';
console.error('Scan failed:', error);
}
}
async function pairAndConnectDevice(mac, name) {
@@ -578,15 +881,23 @@ class BtTether(Plugin):
const deviceDiscoverySection = document.getElementById('deviceDiscoverySection');
if (data.devices && data.devices.length > 0) {
deviceDiscoverySection.style.display = 'none';
const napDevices = data.devices.filter(d => d.has_nap);
if (napDevices.length > 0) {
const connectedDevice = napDevices.find(d => d.connected);
// Hide device discovery section only if a device is actively connected
if (connectedDevice) {
deviceDiscoverySection.style.display = 'none';
summaryDiv.innerHTML = `<span style="color: #3fb950;">🔵 Connected to ${connectedDevice.name}</span><br><small style="color: #888;">${connectedDevice.mac}</small>`;
} else if (napDevices.length > 0) {
// Show device list and discovery section if not connected
deviceDiscoverySection.style.display = 'block';
summaryDiv.innerHTML = napDevices.map(d =>
`<div style="margin: 4px 0;">📱 ${d.name}<br><small style="color: #888;">${d.mac}</small></div>`
).join('');
} else {
summaryDiv.innerHTML = `<span style="color: #f85149;">${data.devices.length} paired but no tethering support</span>`;
// No tethering support
deviceDiscoverySection.style.display = 'block';
summaryDiv.innerHTML = `<span style="color: #f85149;">${data.devices.length} paired but no tethering support</span>`;
}
} else {
deviceDiscoverySection.style.display = 'block';