mirror of
https://github.com/jayofelony/pwnagotchi.git
synced 2026-07-28 14:37:00 -07:00
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:
Generated
+1
-1
@@ -2,7 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="SshConfigs">
|
||||
<configs>
|
||||
<sshConfig authType="PASSWORD" connectionConfig="{"hostKeyVerifier":{"hashKnownHosts":true},"serverAliveInterval":300}" host="pwnagotchi.local" id="8b69df7d-cec5-421f-8edf-53ed6233f6b6" port="22" customName="pwnagotchi" nameFormat="CUSTOM" username="pi" useOpenSSHConfig="true">
|
||||
<sshConfig authType="PASSWORD" connectionConfig="{"hostKeyVerifier":{"hashKnownHosts":true,"stringHostKeyChecking":"ASK"},"serverAliveInterval":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>
|
||||
|
||||
Generated
+1
-1
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user