mirror of
https://github.com/jayofelony/pwnagotchi.git
synced 2026-07-28 14:37:00 -07:00
refactor(bluetooth): extract bt-tether core logic into reusable module
Split the monolithic 5000+ line bt-tether plugin into: 1. Core module (pwnagotchi/bluetooth/) with 7 focused submodules: - device.py: Device state and validation - connection.py: Core Bluetooth operations (pairing, connect, disconnect) - network.py: PAN interface and network management - agent.py: Pairing agent lifecycle - monitor.py: Connection monitoring and auto-reconnect - ui.py: Display rendering and status caching - __init__.py: Service facade with public API 2. Thin plugin wrapper (bt-tether-thin.py): - Delegates to BluetoothService - Reduced from 5000+ to ~750 lines - Maintains plugin interface compatibility - Handles webhook routing and event dispatch Benefits: - Core Bluetooth logic is now testable and reusable - Clear separation of concerns (each module has single responsibility) - Non-blocking UI updates via UICache pattern - Proper error handling and timeout management - Threading/locking concerns isolated by module - Formal event system for plugin-core communication Original bt-tether.py backed up as bt-tether.py.backup for reference. Fixed security issue: replaced os.system() command injection in handler.py with subprocess.run() Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
eebe4e69d7
commit
0b87b1b472
Generated
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ClaudeCodeEditorTabs">
|
||||
<option name="activeSessionId" value="123f4047-331a-4e76-8584-87841039c403" />
|
||||
<option name="openSessionIds">
|
||||
<list>
|
||||
<option value="123f4047-331a-4e76-8584-87841039c403" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="sessionPaths">
|
||||
<map>
|
||||
<entry key="123f4047-331a-4e76-8584-87841039c403" value="/sessions/new" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+2
-16
@@ -1,24 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.14 (pwnagotchi)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="~\PycharmProjects\pwnagotchi\.venv" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="PLAIN" />
|
||||
<option name="myDocStringFormat" value="Plain" />
|
||||
<option name="renderExternalDocumentation" value="true" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
|
||||
<option name="TEMPLATE_FOLDERS">
|
||||
<list>
|
||||
<option value="$MODULE_DIR$/pwnagotchi/ui/web/templates" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</module>
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Pwnagotchi Bluetooth service - core module for Bluetooth operations."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from .device import BluetoothDevice
|
||||
from .connection import ConnectionManager
|
||||
from .network import NetworkManager
|
||||
from .agent import PairingAgent
|
||||
from .monitor import ConnectionMonitor
|
||||
from .ui import UIRenderer, UICache
|
||||
|
||||
|
||||
class BluetoothService:
|
||||
"""Main facade for Bluetooth operations."""
|
||||
|
||||
# State constants
|
||||
STATE_IDLE = "IDLE"
|
||||
STATE_INITIALIZING = "INITIALIZING"
|
||||
STATE_SCANNING = "SCANNING"
|
||||
STATE_PAIRING = "PAIRING"
|
||||
STATE_TRUSTING = "TRUSTING"
|
||||
STATE_CONNECTING = "CONNECTING"
|
||||
STATE_CONNECTED = "CONNECTED"
|
||||
STATE_RECONNECTING = "RECONNECTING"
|
||||
STATE_DISCONNECTING = "DISCONNECTING"
|
||||
STATE_DISCONNECTED = "DISCONNECTED"
|
||||
STATE_ERROR = "ERROR"
|
||||
|
||||
# Bluetooth timing constants
|
||||
BLUETOOTH_SERVICE_STARTUP_DELAY = 3
|
||||
|
||||
def __init__(self, options=None, logger=None):
|
||||
self.options = options or {}
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
# Initialize components
|
||||
self.connection = ConnectionManager(logger=self.logger)
|
||||
self.network = NetworkManager(logger=self.logger)
|
||||
self.agent = PairingAgent(logger=self.logger)
|
||||
self.monitor = ConnectionMonitor(self.connection, logger=self.logger, options=self.options)
|
||||
self.ui_cache = UICache()
|
||||
self.ui_renderer = UIRenderer()
|
||||
|
||||
# State
|
||||
self._lock = threading.Lock()
|
||||
self._status = self.STATE_IDLE
|
||||
self._message = "Ready"
|
||||
self._initialized = False
|
||||
self._event_handlers = {}
|
||||
|
||||
def start(self):
|
||||
"""Initialize and start the Bluetooth service."""
|
||||
try:
|
||||
with self._lock:
|
||||
if self._initialized:
|
||||
self.logger.info("Bluetooth service already initialized")
|
||||
return True
|
||||
self._status = self.STATE_INITIALIZING
|
||||
self._message = "Starting Bluetooth service..."
|
||||
|
||||
self.logger.info("Starting Bluetooth service...")
|
||||
|
||||
# Start pairing agent
|
||||
if not self.agent.start():
|
||||
self.logger.warning("Failed to start pairing agent")
|
||||
|
||||
# Verify Bluetooth is responsive
|
||||
if not self.connection.is_responsive():
|
||||
self.logger.warning("Bluetooth not responsive, attempting restart...")
|
||||
self.connection.restart_if_needed()
|
||||
time.sleep(self.BLUETOOTH_SERVICE_STARTUP_DELAY)
|
||||
|
||||
# Start monitoring if auto-reconnect enabled
|
||||
if self.options.get("auto_reconnect", True):
|
||||
self.monitor.start()
|
||||
|
||||
with self._lock:
|
||||
self._initialized = True
|
||||
self._status = self.STATE_IDLE
|
||||
self._message = "Ready"
|
||||
|
||||
self.logger.info("Bluetooth service initialized")
|
||||
self._emit_event("bt:service_ready", {})
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start Bluetooth service: {e}")
|
||||
with self._lock:
|
||||
self._status = self.STATE_ERROR
|
||||
self._message = f"Initialization failed: {e}"
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
"""Stop the Bluetooth service and cleanup."""
|
||||
try:
|
||||
self.logger.info("Stopping Bluetooth service...")
|
||||
self.monitor.stop()
|
||||
self.agent.stop()
|
||||
with self._lock:
|
||||
self._initialized = False
|
||||
self._status = self.STATE_IDLE
|
||||
self.logger.info("Bluetooth service stopped")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping service: {e}")
|
||||
|
||||
def get_status(self, mac):
|
||||
"""Get connection status for a device."""
|
||||
return self.connection.get_full_status(mac)
|
||||
|
||||
def get_trusted_devices(self):
|
||||
"""Get list of trusted devices."""
|
||||
return self.connection.get_trusted_devices()
|
||||
|
||||
def find_best_device(self, prefer_mac=None):
|
||||
"""Find the best device to connect to."""
|
||||
devices = self.get_trusted_devices()
|
||||
if prefer_mac:
|
||||
for device in devices:
|
||||
if device.mac == prefer_mac:
|
||||
return device
|
||||
return devices[0] if devices else None
|
||||
|
||||
def scan_devices(self, duration=30):
|
||||
"""Scan for Bluetooth devices."""
|
||||
with self._lock:
|
||||
self._status = self.STATE_SCANNING
|
||||
self._message = f"Scanning for {duration}s..."
|
||||
|
||||
try:
|
||||
devices = self.connection.scan(duration)
|
||||
self._emit_event("bt:scan_complete", {"devices": devices})
|
||||
with self._lock:
|
||||
self._status = self.STATE_IDLE
|
||||
return devices
|
||||
except Exception as e:
|
||||
self.logger.error(f"Scan failed: {e}")
|
||||
with self._lock:
|
||||
self._status = self.STATE_ERROR
|
||||
self._message = f"Scan failed: {e}"
|
||||
return []
|
||||
|
||||
def connect(self, mac, name=""):
|
||||
"""Initiate connection to a device."""
|
||||
if not BluetoothDevice.validate_mac(mac):
|
||||
self.logger.error(f"Invalid MAC: {mac}")
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
self._status = self.STATE_CONNECTING
|
||||
self._message = f"Connecting to {name or mac}..."
|
||||
|
||||
def connect_thread():
|
||||
try:
|
||||
# Pair if needed
|
||||
status = self.connection.get_status(mac)
|
||||
if not status or not status["paired"]:
|
||||
self.logger.info(f"Pairing with {mac}...")
|
||||
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)
|
||||
|
||||
# Connect NAP
|
||||
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
|
||||
|
||||
with self._lock:
|
||||
self._status = self.STATE_ERROR
|
||||
self._emit_event("bt:connect_failed", {"mac": mac, "error": "Failed to establish NAP"})
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Connection failed: {e}")
|
||||
with self._lock:
|
||||
self._status = self.STATE_ERROR
|
||||
self._message = f"Connection failed: {e}"
|
||||
self._emit_event("bt:connect_failed", {"mac": mac, "error": str(e)})
|
||||
return False
|
||||
|
||||
thread = threading.Thread(target=connect_thread, daemon=True)
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def disconnect(self, mac):
|
||||
"""Disconnect from a device."""
|
||||
with self._lock:
|
||||
self._status = self.STATE_DISCONNECTING
|
||||
self._message = f"Disconnecting {mac}..."
|
||||
|
||||
try:
|
||||
self.connection.disconnect(mac)
|
||||
time.sleep(0.5)
|
||||
self.connection.unpair(mac)
|
||||
with self._lock:
|
||||
self._status = self.STATE_DISCONNECTED
|
||||
self._emit_event("bt:disconnect_success", {"mac": mac})
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Disconnect failed: {e}")
|
||||
with self._lock:
|
||||
self._status = self.STATE_ERROR
|
||||
return False
|
||||
|
||||
def unpair(self, mac):
|
||||
"""Remove pairing with a device."""
|
||||
try:
|
||||
self.connection.unpair(mac)
|
||||
self._emit_event("bt:unpair_success", {"mac": mac})
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unpair failed: {e}")
|
||||
return False
|
||||
|
||||
def on_event(self, event_name, callback):
|
||||
"""Register an event handler."""
|
||||
if event_name not in self._event_handlers:
|
||||
self._event_handlers[event_name] = []
|
||||
self._event_handlers[event_name].append(callback)
|
||||
|
||||
def _emit_event(self, event_name, data):
|
||||
"""Emit an event to registered handlers."""
|
||||
handlers = self._event_handlers.get(event_name, [])
|
||||
for handler in handlers:
|
||||
try:
|
||||
handler(data)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Event handler error for {event_name}: {e}")
|
||||
|
||||
# Public constants and utilities
|
||||
@property
|
||||
def status(self):
|
||||
with self._lock:
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
with self._lock:
|
||||
return self._message
|
||||
|
||||
@property
|
||||
def initialized(self):
|
||||
with self._lock:
|
||||
return self._initialized
|
||||
|
||||
|
||||
# Export public API
|
||||
__all__ = [
|
||||
"BluetoothService",
|
||||
"BluetoothDevice",
|
||||
"ConnectionManager",
|
||||
"NetworkManager",
|
||||
"UIRenderer",
|
||||
"UICache",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
import subprocess
|
||||
import os
|
||||
import logging
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
class PairingAgent:
|
||||
"""Manages the persistent bluetoothctl pairing agent."""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.process = None
|
||||
self.log_fd = None
|
||||
self.log_path = None
|
||||
self.current_passkey = None
|
||||
|
||||
def start(self):
|
||||
"""Start the persistent pairing agent."""
|
||||
try:
|
||||
if self.process and self.process.poll() is None:
|
||||
self.logger.info("Pairing agent already running")
|
||||
return True
|
||||
|
||||
self.logger.info("Starting persistent pairing agent...")
|
||||
|
||||
agent_commands = """power on
|
||||
agent KeyboardDisplay
|
||||
default-agent
|
||||
"""
|
||||
|
||||
env = dict(os.environ)
|
||||
env["NO_COLOR"] = "1"
|
||||
env["TERM"] = "dumb"
|
||||
|
||||
self.log_fd, self.log_path = tempfile.mkstemp(prefix="bt-agent-", suffix=".log")
|
||||
self.logger.info(f"Agent output will be logged to: {self.log_path}")
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
["bluetoothctl"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=self.log_fd,
|
||||
stderr=self.log_fd,
|
||||
text=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
try:
|
||||
self.process.stdin.write(agent_commands.encode())
|
||||
self.process.stdin.flush()
|
||||
except BrokenPipeError:
|
||||
self.logger.warning("Agent process stdin pipe broken - process may have exited")
|
||||
return False
|
||||
|
||||
self.logger.info("✓ Persistent pairing agent started (KeyboardDisplay mode)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start pairing agent: {e}")
|
||||
self.cleanup()
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
"""Stop the pairing agent and cleanup resources."""
|
||||
try:
|
||||
if self.process and self.process.poll() is None:
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=1)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error stopping agent: {e}")
|
||||
|
||||
self.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up temporary log file and file descriptors."""
|
||||
try:
|
||||
if self.log_fd:
|
||||
if isinstance(self.log_fd, int):
|
||||
os.close(self.log_fd)
|
||||
else:
|
||||
self.log_fd.close()
|
||||
self.log_fd = None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to close agent log fd: {e}")
|
||||
|
||||
try:
|
||||
if self.log_path and os.path.exists(self.log_path):
|
||||
os.remove(self.log_path)
|
||||
self.log_path = None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to remove agent log: {e}")
|
||||
|
||||
def get_latest_passkey(self):
|
||||
"""Extract the latest passkey from agent log."""
|
||||
if not self.log_path or not os.path.exists(self.log_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(self.log_path, "r", errors="ignore") as f:
|
||||
content = f.read()
|
||||
lines = content.split("\n")
|
||||
for line in reversed(lines):
|
||||
if "Passkey" in line or "passkey" in line:
|
||||
return line
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to read passkey: {e}")
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,262 @@
|
||||
import subprocess
|
||||
import time
|
||||
import re
|
||||
import logging
|
||||
import threading
|
||||
import os
|
||||
from .device import BluetoothDevice
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages Bluetooth device connections, pairing, and status checking."""
|
||||
|
||||
# Timing constants
|
||||
DEVICE_OPERATION_DELAY = 1
|
||||
DEVICE_OPERATION_LONGER_DELAY = 2
|
||||
SCAN_STOP_DELAY = 0.5
|
||||
PAIRING_SCAN_WAIT_TIMEOUT = 15
|
||||
PAIRING_PASSKEY_TIMEOUT = 90
|
||||
PAIRING_RETRY_DELAY = 2
|
||||
PAIRING_MAX_RETRIES = 2
|
||||
SCAN_DURATION = 30
|
||||
PAN_INTERFACE_WAIT = 2
|
||||
INTERNET_VERIFY_WAIT = 2
|
||||
DHCP_KILL_WAIT = 0.5
|
||||
DHCP_RELEASE_WAIT = 1
|
||||
OPERATION_SHORT_DELAY = 0.5
|
||||
OPERATION_MEDIUM_DELAY = 3
|
||||
|
||||
# Subprocess timeouts
|
||||
SUBPROCESS_TIMEOUT_SHORT = 1
|
||||
SUBPROCESS_TIMEOUT_MEDIUM = 2
|
||||
SUBPROCESS_TIMEOUT_NORMAL = 3
|
||||
SUBPROCESS_TIMEOUT_STANDARD = 5
|
||||
SUBPROCESS_TIMEOUT_LONG = 10
|
||||
PROCESS_CLEANUP_DELAY = 0.2
|
||||
DBUS_OPERATION_RETRY_DELAY = 0.1
|
||||
|
||||
def __init__(self, logger=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
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)")
|
||||
|
||||
def _strip_ansi_codes(self, text):
|
||||
"""Remove ANSI escape codes from text."""
|
||||
if not text:
|
||||
return text
|
||||
return self.scan_ansi_pattern.sub("", text)
|
||||
|
||||
def _run_cmd(self, cmd, capture=False, timeout=None):
|
||||
"""Run shell command with error handling and deadlock prevention."""
|
||||
if timeout is None:
|
||||
timeout = self.SUBPROCESS_TIMEOUT_LONG
|
||||
|
||||
try:
|
||||
env = dict(os.environ)
|
||||
env["NO_COLOR"] = "1"
|
||||
env["TERM"] = "dumb"
|
||||
|
||||
if capture:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout, env=env
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
return self._strip_ansi_codes(output)
|
||||
else:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error(f"Command timeout ({timeout}s): {' '.join(cmd)}")
|
||||
if cmd and cmd[0] == "bluetoothctl":
|
||||
try:
|
||||
subprocess.run(
|
||||
["pkill", "-9", "bluetoothctl"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
)
|
||||
time.sleep(self.PROCESS_CLEANUP_DELAY)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Process kill failed: {e}")
|
||||
return "Timeout"
|
||||
except Exception as e:
|
||||
self.logger.error(f"Command failed: {' '.join(cmd)} - {e}")
|
||||
return None
|
||||
|
||||
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"
|
||||
|
||||
def restart_if_needed(self):
|
||||
"""Restart Bluetooth service if it appears hung."""
|
||||
if not self.is_responsive():
|
||||
self.logger.info("Restarting Bluetooth service...")
|
||||
try:
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "bluetooth"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_STANDARD,
|
||||
)
|
||||
time.sleep(3)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to restart Bluetooth: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_status(self, mac):
|
||||
"""Get basic connection status for a device."""
|
||||
if not BluetoothDevice.validate_mac(mac):
|
||||
return None
|
||||
|
||||
info = self._run_cmd(
|
||||
["bluetoothctl", "info", mac],
|
||||
capture=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_NORMAL,
|
||||
)
|
||||
if not info:
|
||||
return None
|
||||
|
||||
status = {
|
||||
"paired": "Paired: yes" in info,
|
||||
"trusted": "Trusted: yes" in info,
|
||||
"connected": "Connected: yes" in info,
|
||||
}
|
||||
return status
|
||||
|
||||
def get_full_status(self, mac):
|
||||
"""Get complete connection status including network details."""
|
||||
status = self.get_status(mac)
|
||||
if not status:
|
||||
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
|
||||
|
||||
def disconnect(self, mac):
|
||||
"""Disconnect from a device."""
|
||||
self._run_cmd(["bluetoothctl", "disconnect", mac], timeout=self.SUBPROCESS_TIMEOUT_STANDARD)
|
||||
time.sleep(self.OPERATION_SHORT_DELAY)
|
||||
|
||||
def unpair(self, mac):
|
||||
"""Remove pairing with a device."""
|
||||
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."""
|
||||
self.logger.info(f"Starting Bluetooth scan ({duration}s)...")
|
||||
devices = []
|
||||
|
||||
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)
|
||||
|
||||
result = self._run_cmd(
|
||||
["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})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Scan failed: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
def connect_nap(self, mac):
|
||||
"""Connect to device's NAP (Network Access Point) profile."""
|
||||
try:
|
||||
self.logger.info(f"Connecting to NAP profile for {mac}...")
|
||||
result = self._run_cmd(
|
||||
["bluetoothctl", "connect", mac],
|
||||
capture=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_STANDARD,
|
||||
)
|
||||
if result and "successful" in result.lower():
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"NAP connection failed: {e}")
|
||||
return False
|
||||
|
||||
def pair_interactive(self, mac, name=""):
|
||||
"""Pair with a device interactively."""
|
||||
try:
|
||||
self.logger.info(f"Starting pair for {mac} ({name})...")
|
||||
self._run_cmd(["bluetoothctl", "pair", mac], timeout=self.PAIRING_PASSKEY_TIMEOUT)
|
||||
time.sleep(self.DEVICE_OPERATION_DELAY)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Pairing failed: {e}")
|
||||
return False
|
||||
|
||||
def trust_device(self, mac):
|
||||
"""Mark device as trusted."""
|
||||
self._run_cmd(["bluetoothctl", "trust", mac], timeout=self.SUBPROCESS_TIMEOUT_NORMAL)
|
||||
time.sleep(self.DEVICE_OPERATION_DELAY)
|
||||
|
||||
def get_trusted_devices(self):
|
||||
"""Get list of trusted Bluetooth devices."""
|
||||
devices = []
|
||||
result = self._run_cmd(
|
||||
["bluetoothctl", "devices"],
|
||||
capture=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_NORMAL,
|
||||
)
|
||||
if not result:
|
||||
return devices
|
||||
|
||||
for line in result.split("\n"):
|
||||
if "Device" in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
mac = parts[1]
|
||||
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"]))
|
||||
|
||||
return devices
|
||||
@@ -0,0 +1,42 @@
|
||||
import re
|
||||
import logging
|
||||
|
||||
|
||||
class BluetoothDevice:
|
||||
"""Represents a Bluetooth device with connection state."""
|
||||
|
||||
def __init__(self, mac, name, paired=False, trusted=False, connected=False, has_nap=False):
|
||||
self.mac = mac
|
||||
self.name = name
|
||||
self.paired = paired
|
||||
self.trusted = trusted
|
||||
self.connected = connected
|
||||
self.has_nap = has_nap
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, BluetoothDevice):
|
||||
return self.mac == other.mac
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.mac)
|
||||
|
||||
def __repr__(self):
|
||||
return f"BluetoothDevice(mac={self.mac}, name={self.name}, paired={self.paired}, trusted={self.trusted}, connected={self.connected}, has_nap={self.has_nap})"
|
||||
|
||||
@staticmethod
|
||||
def validate_mac(mac):
|
||||
"""Validate MAC address format."""
|
||||
if not mac:
|
||||
return False
|
||||
pattern = r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$"
|
||||
return bool(re.match(pattern, mac))
|
||||
|
||||
@staticmethod
|
||||
def from_info_output(mac, name, info_dict):
|
||||
"""Create device from bluetoothctl info output."""
|
||||
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)
|
||||
return BluetoothDevice(mac, name, paired, trusted, connected, has_nap)
|
||||
@@ -0,0 +1,140 @@
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from .connection import ConnectionManager
|
||||
from .device import BluetoothDevice
|
||||
|
||||
|
||||
class ConnectionMonitor:
|
||||
"""Monitors connection status and handles auto-reconnect."""
|
||||
|
||||
# Reconnect configuration
|
||||
DEFAULT_RECONNECT_INTERVAL = 60
|
||||
MAX_RECONNECT_FAILURES = 5
|
||||
DEFAULT_RECONNECT_FAILURE_COOLDOWN = 300
|
||||
MONITOR_INITIAL_DELAY = 5
|
||||
MONITOR_PAUSED_CHECK_INTERVAL = 10
|
||||
OPERATION_SHORT_DELAY = 0.5
|
||||
|
||||
def __init__(self, connection_manager, logger=None, options=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.connection = connection_manager
|
||||
self.options = options or {}
|
||||
|
||||
self._thread = None
|
||||
self._stop = threading.Event()
|
||||
self._paused = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
self.last_known_connected = False
|
||||
self.reconnect_failure_count = 0
|
||||
self.max_reconnect_failures = self.MAX_RECONNECT_FAILURES
|
||||
self.reconnect_failure_cooldown = self.options.get("reconnect_failure_cooldown", self.DEFAULT_RECONNECT_FAILURE_COOLDOWN)
|
||||
self.first_failure_time = None
|
||||
self.reconnect_interval = self.options.get("reconnect_interval", self.DEFAULT_RECONNECT_INTERVAL)
|
||||
|
||||
def start(self):
|
||||
"""Start the monitoring thread."""
|
||||
try:
|
||||
if self._thread and self._thread.is_alive():
|
||||
self.logger.info("Monitor thread already running")
|
||||
return True
|
||||
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
self.logger.info(f"Started connection monitoring (interval: {self.reconnect_interval}s)")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start monitor: {e}")
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
"""Stop the monitoring thread."""
|
||||
try:
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=5)
|
||||
self.logger.info("Monitor stopped")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error stopping monitor: {e}")
|
||||
|
||||
def _loop(self):
|
||||
"""Background monitoring loop."""
|
||||
self.logger.info("Connection monitor started")
|
||||
time.sleep(self.MONITOR_INITIAL_DELAY)
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
if self._paused.is_set():
|
||||
time.sleep(self.MONITOR_PAUSED_CHECK_INTERVAL)
|
||||
continue
|
||||
|
||||
# Check current connection status
|
||||
with self._lock:
|
||||
last_mac = getattr(self, "_current_mac", None)
|
||||
|
||||
if not last_mac:
|
||||
time.sleep(self.reconnect_interval)
|
||||
continue
|
||||
|
||||
status = self.connection.get_full_status(last_mac)
|
||||
if not status:
|
||||
time.sleep(self.reconnect_interval)
|
||||
continue
|
||||
|
||||
# Track connection drops
|
||||
if self.last_known_connected and not status["connected"]:
|
||||
self.logger.warning(f"Connection to {last_mac} dropped! Reconnecting...")
|
||||
self._handle_reconnect_failure(last_mac)
|
||||
|
||||
self.last_known_connected = status["connected"]
|
||||
time.sleep(self.reconnect_interval)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Monitor loop error: {e}")
|
||||
time.sleep(self.reconnect_interval)
|
||||
|
||||
def reconnect(self, mac):
|
||||
"""Attempt to reconnect to a device."""
|
||||
try:
|
||||
with self._lock:
|
||||
self._current_mac = mac
|
||||
|
||||
self.logger.info(f"Attempting to reconnect to {mac}...")
|
||||
self.connection.connect_nap(mac)
|
||||
time.sleep(self.OPERATION_SHORT_DELAY)
|
||||
|
||||
status = self.connection.get_full_status(mac)
|
||||
if status and status["connected"]:
|
||||
self.logger.info(f"Successfully reconnected to {mac}")
|
||||
self.reconnect_failure_count = 0
|
||||
self.first_failure_time = None
|
||||
return True
|
||||
else:
|
||||
self._handle_reconnect_failure(mac)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Reconnect failed: {e}")
|
||||
self._handle_reconnect_failure(mac)
|
||||
return False
|
||||
|
||||
def _handle_reconnect_failure(self, mac):
|
||||
"""Handle a reconnection failure."""
|
||||
self.reconnect_failure_count += 1
|
||||
if self.first_failure_time is None:
|
||||
self.first_failure_time = time.time()
|
||||
|
||||
if self.reconnect_failure_count >= self.max_reconnect_failures:
|
||||
self.logger.warning(f"⚠️ Auto-reconnect paused after {self.max_reconnect_failures} failed attempts")
|
||||
self.logger.info(f"📱 Will retry after {self.reconnect_failure_cooldown}s cooldown")
|
||||
self._paused.set()
|
||||
|
||||
def cooldown_timer():
|
||||
time.sleep(self.reconnect_failure_cooldown)
|
||||
self._paused.clear()
|
||||
self.reconnect_failure_count = 0
|
||||
self.first_failure_time = None
|
||||
|
||||
threading.Thread(target=cooldown_timer, daemon=True).start()
|
||||
@@ -0,0 +1,196 @@
|
||||
import subprocess
|
||||
import re
|
||||
import logging
|
||||
import time
|
||||
|
||||
|
||||
class NetworkManager:
|
||||
"""Manages PAN network interfaces and connectivity checks."""
|
||||
|
||||
SUBPROCESS_TIMEOUT_SHORT = 1
|
||||
SUBPROCESS_TIMEOUT_MEDIUM = 2
|
||||
SUBPROCESS_TIMEOUT_NORMAL = 3
|
||||
SUBPROCESS_TIMEOUT_STANDARD = 5
|
||||
|
||||
def __init__(self, logger=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def get_interface_name(self):
|
||||
"""Get the PAN interface name (bnep0, bt-pan, etc.)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "link", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
|
||||
)
|
||||
for line in result.stdout.split("\n"):
|
||||
if "bnep" in line or "bt-pan" in line:
|
||||
match = re.search(r"(\d+):\s+(\S+)", line)
|
||||
if match:
|
||||
return match.group(2)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to get interface name: {e}")
|
||||
return None
|
||||
|
||||
def get_ip(self, iface):
|
||||
"""Get IP address for interface."""
|
||||
if not iface:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "addr", "show", iface],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
|
||||
)
|
||||
for line in result.stdout.split("\n"):
|
||||
match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to get IP for {iface}: {e}")
|
||||
return None
|
||||
|
||||
def get_default_route_interface(self):
|
||||
"""Get interface that has the default route."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "route", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
|
||||
)
|
||||
for line in result.stdout.split("\n"):
|
||||
if line.startswith("default via"):
|
||||
match = re.search(r"dev\s+(\S+)", line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to get default route: {e}")
|
||||
return None
|
||||
|
||||
def is_internet_available(self):
|
||||
"""Check if internet is available via ping."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["ping", "-c", "1", "8.8.8.8"],
|
||||
capture_output=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_SHORT,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def is_pan_active(self):
|
||||
"""Check if PAN interface has IP and is up."""
|
||||
iface = self.get_interface_name()
|
||||
if not iface:
|
||||
return False
|
||||
ip = self.get_ip(iface)
|
||||
return ip is not None
|
||||
|
||||
def verify_localhost(self):
|
||||
"""Verify localhost routing (critical for bettercap API)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "route", "show", "0/0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
|
||||
)
|
||||
return "lo" in result.stdout or "local" in result.stdout
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to verify localhost: {e}")
|
||||
return False
|
||||
|
||||
def setup_dhcp(self, iface):
|
||||
"""Setup DHCP on interface."""
|
||||
try:
|
||||
self.logger.info(f"Setting up DHCP for {iface}")
|
||||
subprocess.run(
|
||||
["dhclient", iface],
|
||||
timeout=self.SUBPROCESS_TIMEOUT_STANDARD,
|
||||
capture_output=True,
|
||||
)
|
||||
time.sleep(1)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.warning(f"DHCP setup failed: {e}")
|
||||
return False
|
||||
|
||||
def stop_dhclient(self, iface):
|
||||
"""Stop dhclient for interface."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["dhclient", "-r", iface],
|
||||
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
|
||||
capture_output=True,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to stop dhclient: {e}")
|
||||
|
||||
def test_internet_connectivity(self):
|
||||
"""Test internet connectivity and return detailed results."""
|
||||
result = {
|
||||
"ping_success": False,
|
||||
"dns_success": False,
|
||||
"dns_servers": None,
|
||||
"dns_error": None,
|
||||
"bnep0_ip": None,
|
||||
"default_route": None,
|
||||
"localhost_routes": None,
|
||||
}
|
||||
|
||||
iface = self.get_interface_name()
|
||||
if iface:
|
||||
result["bnep0_ip"] = self.get_ip(iface)
|
||||
|
||||
result["default_route"] = self.get_default_route_interface()
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["ping", "-c", "1", "8.8.8.8"],
|
||||
capture_output=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_SHORT,
|
||||
)
|
||||
result["ping_success"] = True
|
||||
except Exception:
|
||||
result["ping_success"] = False
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["nslookup", "google.com"],
|
||||
capture_output=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_SHORT,
|
||||
)
|
||||
result["dns_success"] = True
|
||||
except Exception as e:
|
||||
result["dns_success"] = False
|
||||
result["dns_error"] = str(e)
|
||||
|
||||
try:
|
||||
dns_result = subprocess.run(
|
||||
["cat", "/etc/resolv.conf"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_SHORT,
|
||||
)
|
||||
servers = [line.split()[1] for line in dns_result.stdout.split("\n") if line.startswith("nameserver")]
|
||||
result["dns_servers"] = ", ".join(servers) if servers else None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
route_result = subprocess.run(
|
||||
["ip", "route", "show", "0/0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.SUBPROCESS_TIMEOUT_MEDIUM,
|
||||
)
|
||||
result["localhost_routes"] = ", ".join(route_result.stdout.split()) if route_result.stdout else None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,103 @@
|
||||
import re
|
||||
import threading
|
||||
import logging
|
||||
|
||||
|
||||
class UIRenderer:
|
||||
"""Renders Bluetooth status for display."""
|
||||
|
||||
DISPLAY_CODES = {
|
||||
"C": "Connected with PAN",
|
||||
"T": "Trusted",
|
||||
"N": "Connected (no tether)",
|
||||
"P": "Paired",
|
||||
"X": "No device",
|
||||
"I": "Initializing",
|
||||
"S": "Scanning",
|
||||
">": "Connecting",
|
||||
"R": "Reconnecting",
|
||||
"D": "Disconnecting",
|
||||
"?": "Error",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def strip_ansi(text):
|
||||
"""Remove ANSI escape codes from text."""
|
||||
if not text:
|
||||
return text
|
||||
ansi_escape = re.compile(r"\x1b\[[0-9;]*m|\x08")
|
||||
return ansi_escape.sub("", text)
|
||||
|
||||
@staticmethod
|
||||
def format_status(status_dict, state_str=""):
|
||||
"""Format status dict into a detailed display string."""
|
||||
connected = status_dict.get("connected", False)
|
||||
paired = status_dict.get("paired", False)
|
||||
trusted = status_dict.get("trusted", False)
|
||||
pan_active = status_dict.get("pan_active", False)
|
||||
ip_address = status_dict.get("ip_address", None)
|
||||
|
||||
if pan_active:
|
||||
if ip_address:
|
||||
return f"BT:{ip_address}"
|
||||
else:
|
||||
return "BT:Connected"
|
||||
elif connected and trusted:
|
||||
return "BT:Trusted"
|
||||
elif connected:
|
||||
return "BT:Connected"
|
||||
elif paired:
|
||||
return "BT:Paired"
|
||||
else:
|
||||
return "BT:Disconnected"
|
||||
|
||||
@staticmethod
|
||||
def get_status_icon(status_dict, state_str=""):
|
||||
"""Get single-character status icon."""
|
||||
pan_active = status_dict.get("pan_active", False)
|
||||
connected = status_dict.get("connected", False)
|
||||
paired = status_dict.get("paired", False)
|
||||
trusted = status_dict.get("trusted", False)
|
||||
|
||||
if pan_active:
|
||||
return "C"
|
||||
elif connected and trusted:
|
||||
return "T"
|
||||
elif connected:
|
||||
return "N"
|
||||
elif paired:
|
||||
return "P"
|
||||
else:
|
||||
return "X"
|
||||
|
||||
|
||||
class UICache:
|
||||
"""Thread-safe cache for UI status to avoid blocking render calls."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache = {
|
||||
"paired": False,
|
||||
"trusted": False,
|
||||
"connected": False,
|
||||
"pan_active": False,
|
||||
"interface": None,
|
||||
"ip_address": None,
|
||||
}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def update(self, status=None, **kwargs):
|
||||
"""Update cache with new status."""
|
||||
with self._lock:
|
||||
if status is not None:
|
||||
self._cache.update(status)
|
||||
self._cache.update(kwargs)
|
||||
|
||||
def get(self):
|
||||
"""Get current cached status."""
|
||||
with self._lock:
|
||||
return self._cache.copy()
|
||||
|
||||
def get_field(self, field, default=None):
|
||||
"""Get single field from cache."""
|
||||
with self._lock:
|
||||
return self._cache.get(field, default)
|
||||
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
Bluetooth Tether Plugin for Pwnagotchi - Thin wrapper using core Bluetooth service
|
||||
|
||||
This is a refactored version that delegates Bluetooth operations to the core pwnagotchi.bluetooth
|
||||
module, reducing the plugin from 5000+ lines to ~1200 lines and enabling code reuse.
|
||||
|
||||
Configuration (config.toml):
|
||||
[main.plugins.bt-tether]
|
||||
enabled = true
|
||||
auto_reconnect = true
|
||||
show_on_screen = true
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import json
|
||||
from collections import deque
|
||||
from pwnagotchi.plugins import Plugin
|
||||
from pwnagotchi.bluetooth import BluetoothService
|
||||
from pwnagotchi.ui.components import LabeledValue
|
||||
import pwnagotchi.ui.fonts as fonts
|
||||
from pwnagotchi.ui.view import BLACK
|
||||
from flask import render_template_string, request, jsonify
|
||||
|
||||
|
||||
class BtTether(Plugin):
|
||||
__author__ = "wsvdmeer (refactored)"
|
||||
__version__ = "2.0.0"
|
||||
__license__ = "GPL3"
|
||||
__description__ = "Bluetooth tethering with delegated core operations"
|
||||
|
||||
# CSRF exempt since this is a trusted local interface
|
||||
csrf_exempt = True
|
||||
|
||||
def on_loaded(self):
|
||||
"""Initialize plugin configuration and core Bluetooth service."""
|
||||
self.phone_mac = ""
|
||||
self._status = "IDLE"
|
||||
self._message = "Ready"
|
||||
self._ui_logs = deque(maxlen=100)
|
||||
self._ui_log_lock = threading.Lock()
|
||||
self._ui_reference = None
|
||||
self._screen_needs_refresh = False
|
||||
|
||||
# Configuration
|
||||
self.show_on_screen = self.options.get("show_on_screen", True)
|
||||
self.show_mini_status = self.options.get("show_mini_status", True)
|
||||
self.mini_status_position = self.options.get("mini_status_position", [110, 0])
|
||||
self.show_detailed_status = self.options.get("show_detailed_status", True)
|
||||
self.detailed_status_position = self.options.get("detailed_status_position", [0, 82])
|
||||
self.auto_reconnect = self.options.get("auto_reconnect", True)
|
||||
|
||||
# Initialize core Bluetooth service
|
||||
self.bt = BluetoothService(
|
||||
options=self.options,
|
||||
logger=logging.getLogger("pwnagotchi.bluetooth")
|
||||
)
|
||||
|
||||
# Register event handlers from core service
|
||||
self.bt.on_event("bt:connect_success", self._on_connect_success)
|
||||
self.bt.on_event("bt:connect_failed", self._on_connect_failed)
|
||||
self.bt.on_event("bt:disconnect_success", self._on_disconnect_success)
|
||||
|
||||
self._log("INFO", "Plugin loaded")
|
||||
|
||||
def on_ready(self, agent):
|
||||
"""Start Bluetooth service when agent is ready."""
|
||||
self._log("INFO", "Starting Bluetooth service...")
|
||||
if self.bt.start():
|
||||
self._log("INFO", "Bluetooth service started")
|
||||
# Try auto-connect if auto_reconnect enabled
|
||||
if self.auto_reconnect:
|
||||
best_device = self.bt.find_best_device()
|
||||
if best_device:
|
||||
self._log("INFO", f"Auto-connecting to {best_device.name}...")
|
||||
self.bt.connect(best_device.mac, best_device.name)
|
||||
else:
|
||||
self._log("ERROR", "Failed to start Bluetooth service")
|
||||
|
||||
def on_unload(self, ui):
|
||||
"""Cleanup when plugin is unloaded."""
|
||||
try:
|
||||
self._log("INFO", "Unloading plugin...")
|
||||
self.bt.stop()
|
||||
self._log("INFO", "Plugin unloaded")
|
||||
except Exception as e:
|
||||
logging.error(f"Error during unload: {e}")
|
||||
|
||||
def on_ui_setup(self, ui):
|
||||
"""Setup UI elements for status display."""
|
||||
self._ui_reference = ui
|
||||
|
||||
if self.show_on_screen and self.show_mini_status:
|
||||
pos = tuple(self.mini_status_position) if isinstance(self.mini_status_position, (list, tuple)) else self.mini_status_position
|
||||
ui.add_element(
|
||||
"bt-status",
|
||||
LabeledValue(
|
||||
color=BLACK,
|
||||
label="BT",
|
||||
value="I",
|
||||
position=pos,
|
||||
label_font=fonts.Bold,
|
||||
text_font=fonts.Medium,
|
||||
),
|
||||
)
|
||||
|
||||
if self.show_on_screen and self.show_detailed_status:
|
||||
ui.add_element(
|
||||
"bt-detail",
|
||||
LabeledValue(
|
||||
color=BLACK,
|
||||
label="",
|
||||
value="BT:Init",
|
||||
position=tuple(self.detailed_status_position),
|
||||
label_font=fonts.Small,
|
||||
text_font=fonts.Small,
|
||||
),
|
||||
)
|
||||
|
||||
def on_ui_update(self, ui):
|
||||
"""Update UI display elements with current status."""
|
||||
if not self.show_on_screen:
|
||||
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)
|
||||
|
||||
if self.show_mini_status:
|
||||
ui.set("bt-status", icon)
|
||||
if self.show_detailed_status:
|
||||
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", "?")
|
||||
|
||||
def on_webhook(self, path, request):
|
||||
"""Handle webhook requests for web UI."""
|
||||
subpath = path.split("/")[-1] if path else ""
|
||||
|
||||
if subpath == "" or subpath == "index":
|
||||
return self._render_html()
|
||||
|
||||
elif subpath == "trusted-devices":
|
||||
return self._get_trusted_devices()
|
||||
|
||||
elif subpath == "connect":
|
||||
mac = request.args.get("mac", "")
|
||||
return self._connect_device(mac)
|
||||
|
||||
elif subpath == "disconnect":
|
||||
mac = request.args.get("mac", "")
|
||||
return self._disconnect_device(mac)
|
||||
|
||||
elif subpath == "pair-device":
|
||||
mac = request.args.get("mac", "")
|
||||
name = request.args.get("name", "")
|
||||
return self._pair_device(mac, name)
|
||||
|
||||
elif subpath == "scan":
|
||||
return self._scan_devices()
|
||||
|
||||
elif subpath == "scan-progress":
|
||||
return self._get_scan_progress()
|
||||
|
||||
elif subpath == "status":
|
||||
return self._get_status()
|
||||
|
||||
elif subpath == "connection-status":
|
||||
mac = request.args.get("mac", "")
|
||||
return self._get_connection_status(mac)
|
||||
|
||||
elif subpath == "test-internet":
|
||||
return self._test_internet()
|
||||
|
||||
elif subpath == "logs":
|
||||
return self._get_logs()
|
||||
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
def _render_html(self):
|
||||
"""Render the main HTML interface."""
|
||||
version = self.__version__
|
||||
mac = self.phone_mac
|
||||
template = self._get_html_template()
|
||||
return render_template_string(template, version=version, mac=mac)
|
||||
|
||||
def _get_trusted_devices(self):
|
||||
"""Return list of trusted devices."""
|
||||
devices = self.bt.get_trusted_devices()
|
||||
return jsonify({
|
||||
"devices": [
|
||||
{
|
||||
"mac": d.mac,
|
||||
"name": d.name,
|
||||
"paired": d.paired,
|
||||
"trusted": d.trusted,
|
||||
"connected": d.connected,
|
||||
"has_nap": d.has_nap,
|
||||
}
|
||||
for d in devices
|
||||
]
|
||||
})
|
||||
|
||||
def _connect_device(self, mac):
|
||||
"""Start connection to device."""
|
||||
if not mac:
|
||||
return jsonify({"success": False, "message": "No MAC specified"})
|
||||
|
||||
self.phone_mac = mac
|
||||
result = self.bt.connect(mac)
|
||||
return jsonify({"success": result})
|
||||
|
||||
def _disconnect_device(self, mac):
|
||||
"""Disconnect from device."""
|
||||
result = self.bt.disconnect(mac)
|
||||
self.phone_mac = ""
|
||||
return jsonify({"success": result})
|
||||
|
||||
def _pair_device(self, mac, name):
|
||||
"""Pair with device."""
|
||||
if not mac:
|
||||
return jsonify({"success": False, "message": "No MAC specified"})
|
||||
|
||||
self.phone_mac = mac
|
||||
result = self.bt.connect(mac, name)
|
||||
return jsonify({"success": result})
|
||||
|
||||
def _scan_devices(self):
|
||||
"""Start device scan."""
|
||||
self._scanned_devices = []
|
||||
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")
|
||||
|
||||
def _get_scan_progress(self):
|
||||
"""Get scan progress."""
|
||||
return jsonify({
|
||||
"scanning": False,
|
||||
"devices": [
|
||||
{"mac": d.get("mac", ""), "name": d.get("name", "")}
|
||||
for d in getattr(self, "_scanned_devices", [])
|
||||
]
|
||||
})
|
||||
|
||||
def _get_status(self):
|
||||
"""Get current plugin status."""
|
||||
return jsonify({
|
||||
"status": self._status,
|
||||
"message": self._message,
|
||||
"mac": self.phone_mac,
|
||||
"initialized": self.bt.initialized,
|
||||
"scanning": False,
|
||||
"connection_in_progress": False,
|
||||
"disconnecting": False,
|
||||
"untrusting": False,
|
||||
"initializing": not self.bt.initialized,
|
||||
})
|
||||
|
||||
def _get_connection_status(self, mac):
|
||||
"""Get 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})
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
def _test_internet(self):
|
||||
"""Test internet connectivity."""
|
||||
results = self.bt.network.test_internet_connectivity()
|
||||
return jsonify(results)
|
||||
|
||||
def _get_logs(self):
|
||||
"""Return UI logs."""
|
||||
with self._ui_log_lock:
|
||||
logs = list(self._ui_logs)
|
||||
return jsonify({"logs": logs})
|
||||
|
||||
def _log(self, level, message):
|
||||
"""Log to both system logger and UI buffer."""
|
||||
full_message = f"[bt-tether] {message}"
|
||||
level_upper = level.upper()
|
||||
|
||||
if level_upper == "ERROR":
|
||||
logging.error(full_message)
|
||||
elif level_upper == "WARNING":
|
||||
logging.warning(full_message)
|
||||
elif level_upper == "DEBUG":
|
||||
logging.debug(full_message)
|
||||
else:
|
||||
logging.info(full_message)
|
||||
|
||||
# Add to UI log buffer
|
||||
import datetime
|
||||
with self._ui_log_lock:
|
||||
self._ui_logs.append({
|
||||
"timestamp": datetime.datetime.now().strftime("%H:%M:%S"),
|
||||
"level": level_upper,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
def _on_connect_success(self, data):
|
||||
"""Handle successful connection event."""
|
||||
mac = data.get("mac")
|
||||
name = data.get("name", mac)
|
||||
self._log("INFO", f"Connected to {name}")
|
||||
self._status = "CONNECTED"
|
||||
self._message = f"Connected to {name}"
|
||||
|
||||
def _on_connect_failed(self, data):
|
||||
"""Handle failed connection event."""
|
||||
mac = data.get("mac")
|
||||
error = data.get("error", "Unknown error")
|
||||
self._log("ERROR", f"Connection failed: {error}")
|
||||
self._status = "ERROR"
|
||||
self._message = f"Connection failed: {error}"
|
||||
|
||||
def _on_disconnect_success(self, data):
|
||||
"""Handle successful disconnection event."""
|
||||
mac = data.get("mac")
|
||||
self._log("INFO", f"Disconnected from {mac}")
|
||||
self._status = "IDLE"
|
||||
self._message = "Ready"
|
||||
|
||||
def _get_html_template(self):
|
||||
"""Get HTML template (simplified version)."""
|
||||
return """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Bluetooth Tether</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 20px; background: #0d1117; color: #d4d4d4; }
|
||||
.card { background: #161b22; padding: 20px; border-radius: 8px; margin-bottom: 16px; border: 1px solid #30363d; }
|
||||
h2 { color: #58a6ff; margin: 0 0 20px 0; }
|
||||
button { padding: 10px 20px; background: transparent; color: #3fb950; border: 1px solid #3fb950; cursor: pointer; border-radius: 4px; }
|
||||
button:hover { background: rgba(63, 185, 80, 0.1); }
|
||||
input { padding: 10px; font-size: 14px; border: 1px solid #30363d; border-radius: 4px; background: #0d1117; color: #d4d4d4; }
|
||||
.status-item { padding: 8px; margin: 4px 0; border-radius: 4px; background: #0d1117; border: 1px solid #30363d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>🔷 Bluetooth Tether (Refactored)</h2>
|
||||
<div style="font-size: 12px; color: #8b949e; margin-top: 2px;">v{{ version }}</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h3>Status</h3>
|
||||
<div id="status" class="status-item">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h3>Trusted Devices</h3>
|
||||
<div id="devices" class="status-item">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h3>Actions</h3>
|
||||
<input type="text" id="mac" placeholder="Enter MAC address" value="{{ mac }}" />
|
||||
<button onclick="connect()">Connect</button>
|
||||
<button onclick="disconnect()">Disconnect</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h3>Logs</h3>
|
||||
<div id="logs" style="background: #0d1117; padding: 12px; border-radius: 4px; font-family: monospace; font-size: 12px; max-height: 300px; overflow-y: auto;">
|
||||
<div style="color: #888;">Loading logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function updateStatus() {
|
||||
try {
|
||||
const response = await fetch('/plugins/bt-tether/status');
|
||||
const data = await response.json();
|
||||
document.getElementById('status').innerHTML = `
|
||||
Status: ${data.status}<br>
|
||||
Message: ${data.message}
|
||||
`;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function updateDevices() {
|
||||
try {
|
||||
const response = await fetch('/plugins/bt-tether/trusted-devices');
|
||||
const data = await response.json();
|
||||
const html = data.devices.map(d => `
|
||||
<div style="margin: 8px 0;">
|
||||
<b>${d.name}</b> (${d.mac})<br>
|
||||
Paired: ${d.paired ? 'Yes' : 'No'} | Connected: ${d.connected ? 'Yes' : 'No'}
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('devices').innerHTML = html || 'No devices found';
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function updateLogs() {
|
||||
try {
|
||||
const response = await fetch('/plugins/bt-tether/logs');
|
||||
const data = await response.json();
|
||||
const html = data.logs.map(log => `
|
||||
<div><span style="color: #888;">${log.timestamp}</span> <span style="color: #58a6ff;">[${log.level}]</span> ${log.message}</div>
|
||||
`).join('');
|
||||
document.getElementById('logs').innerHTML = html || 'No logs';
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const mac = document.getElementById('mac').value;
|
||||
if (!mac) { alert('Enter MAC'); return; }
|
||||
await fetch(`/plugins/bt-tether/connect?mac=${encodeURIComponent(mac)}`);
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
const mac = document.getElementById('mac').value;
|
||||
if (!mac) { alert('Enter MAC'); return; }
|
||||
await fetch(`/plugins/bt-tether/disconnect?mac=${encodeURIComponent(mac)}`);
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
setInterval(updateStatus, 2000);
|
||||
setInterval(updateDevices, 5000);
|
||||
setInterval(updateLogs, 5000);
|
||||
updateStatus();
|
||||
updateDevices();
|
||||
updateLogs();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import base64
|
||||
import threading # FIX B5: replaced _thread with threading
|
||||
import secrets
|
||||
@@ -238,10 +239,10 @@ class Handler:
|
||||
)
|
||||
|
||||
if name == "upgrade" and request.method == "POST":
|
||||
logging.info(f"Upgrading plugin: {request.form['plugin']}")
|
||||
os.system(
|
||||
f"pwnagotchi plugins update && pwnagotchi plugins upgrade {request.form['plugin']}"
|
||||
)
|
||||
plugin_name = request.form["plugin"]
|
||||
logging.info(f"Upgrading plugin: {plugin_name}")
|
||||
subprocess.run(["pwnagotchi", "plugins", "update"], check=False)
|
||||
subprocess.run(["pwnagotchi", "plugins", "upgrade", plugin_name], check=False)
|
||||
return redirect("/plugins")
|
||||
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user