Autarch/web/routes/hardware.py

417 lines
15 KiB
Python
Raw Normal View History

"""Hardware route - ADB/Fastboot device management and ESP32 serial flashing."""
import json
import time
from flask import Blueprint, render_template, request, jsonify, Response, stream_with_context
from web.auth import login_required
hardware_bp = Blueprint('hardware', __name__, url_prefix='/hardware')
@hardware_bp.route('/')
@login_required
def index():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
status = mgr.get_status()
return render_template('hardware.html', status=status)
@hardware_bp.route('/status')
@login_required
def status():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
return jsonify(mgr.get_status())
# ── ADB Endpoints ──────────────────────────────────────────────────
@hardware_bp.route('/adb/devices')
@login_required
def adb_devices():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
return jsonify({'devices': mgr.adb_devices()})
@hardware_bp.route('/adb/info', methods=['POST'])
@login_required
def adb_info():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
if not serial:
return jsonify({'error': 'No serial provided'})
return jsonify(mgr.adb_device_info(serial))
@hardware_bp.route('/adb/shell', methods=['POST'])
@login_required
def adb_shell():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
command = data.get('command', '').strip()
if not serial:
return jsonify({'error': 'No serial provided'})
if not command:
return jsonify({'error': 'No command provided'})
result = mgr.adb_shell(serial, command)
return jsonify({
'stdout': result.get('output', ''),
'stderr': '',
'exit_code': result.get('returncode', -1),
})
@hardware_bp.route('/adb/reboot', methods=['POST'])
@login_required
def adb_reboot():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
mode = data.get('mode', 'system').strip()
if not serial:
return jsonify({'error': 'No serial provided'})
if mode not in ('system', 'recovery', 'bootloader'):
return jsonify({'error': 'Invalid mode'})
return jsonify(mgr.adb_reboot(serial, mode))
@hardware_bp.route('/adb/sideload', methods=['POST'])
@login_required
def adb_sideload():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
filepath = data.get('filepath', '').strip()
if not serial:
return jsonify({'error': 'No serial provided'})
if not filepath:
return jsonify({'error': 'No filepath provided'})
return jsonify(mgr.adb_sideload(serial, filepath))
@hardware_bp.route('/adb/push', methods=['POST'])
@login_required
def adb_push():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
local_path = data.get('local', '').strip()
remote_path = data.get('remote', '').strip()
if not serial or not local_path or not remote_path:
return jsonify({'error': 'Missing serial, local, or remote path'})
return jsonify(mgr.adb_push(serial, local_path, remote_path))
@hardware_bp.route('/adb/pull', methods=['POST'])
@login_required
def adb_pull():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
remote_path = data.get('remote', '').strip()
if not serial or not remote_path:
return jsonify({'error': 'Missing serial or remote path'})
return jsonify(mgr.adb_pull(serial, remote_path))
@hardware_bp.route('/adb/logcat', methods=['POST'])
@login_required
def adb_logcat():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
lines = int(data.get('lines', 100))
if not serial:
return jsonify({'error': 'No serial provided'})
return jsonify(mgr.adb_logcat(serial, lines))
@hardware_bp.route('/archon/bootstrap', methods=['POST'])
def archon_bootstrap():
"""Bootstrap ArchonServer on a USB-connected Android device.
No auth required this is called by the companion app itself.
Only runs the specific app_process bootstrap command (not arbitrary shell).
"""
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
apk_path = data.get('apk_path', '').strip()
token = data.get('token', '').strip()
port = int(data.get('port', 17321))
if not apk_path or not token:
return jsonify({'ok': False, 'error': 'Missing apk_path or token'}), 400
# Validate inputs to prevent injection
if not apk_path.startswith('/data/app/') or "'" in apk_path or '"' in apk_path:
return jsonify({'ok': False, 'error': 'Invalid APK path'}), 400
if not token.isalnum() or len(token) > 64:
return jsonify({'ok': False, 'error': 'Invalid token'}), 400
if port < 1024 or port > 65535:
return jsonify({'ok': False, 'error': 'Invalid port'}), 400
# Find USB-connected device
devices = mgr.adb_devices()
usb_devices = [d for d in devices if ':' not in d.get('serial', '')]
if not usb_devices:
usb_devices = devices
if not usb_devices:
return jsonify({'ok': False, 'error': 'No ADB devices connected'}), 404
serial = usb_devices[0].get('serial', '')
# Construct the bootstrap command (server-side, safe)
cmd = (
f"TMPDIR=/data/local/tmp "
f"CLASSPATH='{apk_path}' "
f"nohup /system/bin/app_process /system/bin "
f"com.darkhal.archon.server.ArchonServer {token} {port} "
f"> /data/local/tmp/archon_server.log 2>&1 & echo started"
)
result = mgr.adb_shell(serial, cmd)
output = result.get('output', '')
exit_code = result.get('returncode', -1)
if exit_code == 0 or 'started' in output:
return jsonify({'ok': True, 'stdout': output, 'stderr': '', 'exit_code': exit_code})
else:
return jsonify({'ok': False, 'stdout': output, 'stderr': '', 'exit_code': exit_code})
@hardware_bp.route('/adb/setup-tcp', methods=['POST'])
@login_required
def adb_setup_tcp():
"""Enable ADB TCP/IP mode on a USB-connected device.
Called by the Archon companion app to set up remote ADB access.
Finds the first USB-connected device, enables TCP mode on port 5555,
and returns the device's IP address for wireless connection."""
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
port = int(data.get('port', 5555))
serial = data.get('serial', '').strip()
# Find a USB-connected device if no serial specified
if not serial:
devices = mgr.adb_devices()
usb_devices = [d for d in devices if 'usb' in d.get('type', '').lower()
or ':' not in d.get('serial', '')]
if not usb_devices:
# Fall back to any connected device
usb_devices = devices
if not usb_devices:
return jsonify({'ok': False, 'error': 'No ADB devices connected via USB'})
serial = usb_devices[0].get('serial', '')
if not serial:
return jsonify({'ok': False, 'error': 'No device serial available'})
# Get device IP address before switching to TCP mode
ip_result = mgr.adb_shell(serial, 'ip route show default 2>/dev/null | grep -oP "src \\K[\\d.]+"')
device_ip = ip_result.get('stdout', '').strip() if ip_result.get('exit_code', -1) == 0 else ''
# Enable TCP/IP mode
result = mgr.adb_shell(serial, f'setprop service.adb.tcp.port {port}')
if result.get('exit_code', -1) != 0:
return jsonify({'ok': False, 'error': f'Failed to set TCP port: {result.get("stderr", "")}'})
# Restart adbd to apply
mgr.adb_shell(serial, 'stop adbd && start adbd')
return jsonify({
'ok': True,
'serial': serial,
'ip': device_ip,
'port': port,
'message': f'ADB TCP mode enabled on {device_ip}:{port}'
})
# ── Fastboot Endpoints ─────────────────────────────────────────────
@hardware_bp.route('/fastboot/devices')
@login_required
def fastboot_devices():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
return jsonify({'devices': mgr.fastboot_devices()})
@hardware_bp.route('/fastboot/info', methods=['POST'])
@login_required
def fastboot_info():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
if not serial:
return jsonify({'error': 'No serial provided'})
return jsonify(mgr.fastboot_device_info(serial))
@hardware_bp.route('/fastboot/flash', methods=['POST'])
@login_required
def fastboot_flash():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
partition = data.get('partition', '').strip()
filepath = data.get('filepath', '').strip()
if not serial or not partition or not filepath:
return jsonify({'error': 'Missing serial, partition, or filepath'})
return jsonify(mgr.fastboot_flash(serial, partition, filepath))
@hardware_bp.route('/fastboot/reboot', methods=['POST'])
@login_required
def fastboot_reboot():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
mode = data.get('mode', 'system').strip()
if not serial:
return jsonify({'error': 'No serial provided'})
if mode not in ('system', 'bootloader', 'recovery'):
return jsonify({'error': 'Invalid mode'})
return jsonify(mgr.fastboot_reboot(serial, mode))
@hardware_bp.route('/fastboot/unlock', methods=['POST'])
@login_required
def fastboot_unlock():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
serial = data.get('serial', '').strip()
if not serial:
return jsonify({'error': 'No serial provided'})
return jsonify(mgr.fastboot_oem_unlock(serial))
# ── Operation Progress SSE ──────────────────────────────────────────
@hardware_bp.route('/progress/stream')
@login_required
def progress_stream():
"""SSE stream for operation progress (sideload, flash, etc.)."""
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
op_id = request.args.get('op_id', '')
def generate():
while True:
prog = mgr.get_operation_progress(op_id)
yield f'data: {json.dumps(prog)}\n\n'
if prog.get('status') in ('done', 'error', 'unknown'):
break
time.sleep(0.5)
return Response(stream_with_context(generate()), content_type='text/event-stream')
# ── Serial / ESP32 Endpoints ──────────────────────────────────────
@hardware_bp.route('/serial/ports')
@login_required
def serial_ports():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
return jsonify({'ports': mgr.list_serial_ports()})
@hardware_bp.route('/serial/detect', methods=['POST'])
@login_required
def serial_detect():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
port = data.get('port', '').strip()
baud = int(data.get('baud', 115200))
if not port:
return jsonify({'error': 'No port provided'})
return jsonify(mgr.detect_esp_chip(port, baud))
@hardware_bp.route('/serial/flash', methods=['POST'])
@login_required
def serial_flash():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
port = data.get('port', '').strip()
filepath = data.get('filepath', '').strip()
baud = int(data.get('baud', 460800))
if not port or not filepath:
return jsonify({'error': 'Missing port or filepath'})
return jsonify(mgr.flash_esp(port, filepath, baud))
@hardware_bp.route('/serial/monitor/start', methods=['POST'])
@login_required
def monitor_start():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
port = data.get('port', '').strip()
baud = int(data.get('baud', 115200))
if not port:
return jsonify({'error': 'No port provided'})
return jsonify(mgr.serial_monitor_start(port, baud))
@hardware_bp.route('/serial/monitor/stop', methods=['POST'])
@login_required
def monitor_stop():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
return jsonify(mgr.serial_monitor_stop())
@hardware_bp.route('/serial/monitor/send', methods=['POST'])
@login_required
def monitor_send():
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
data = request.get_json(silent=True) or {}
text = data.get('data', '')
return jsonify(mgr.serial_monitor_send(text))
@hardware_bp.route('/serial/monitor/stream')
@login_required
def monitor_stream():
"""SSE stream for serial monitor output."""
from core.hardware import get_hardware_manager
mgr = get_hardware_manager()
def generate():
last_index = 0
while mgr.monitor_running:
result = mgr.serial_monitor_get_output(last_index)
if result['lines']:
for line in result['lines']:
yield f'data: {json.dumps({"type": "data", "line": line["data"]})}\n\n'
last_index = result['total']
yield f'data: {json.dumps({"type": "status", "running": True, "total": result["total"]})}\n\n'
time.sleep(0.3)
yield f'data: {json.dumps({"type": "stopped"})}\n\n'
return Response(stream_with_context(generate()), content_type='text/event-stream')