534 lines
31 KiB
Python
534 lines
31 KiB
Python
"""Run the single real Android WiFi switch matrix with a timed board recovery path.
|
|
|
|
The hotspot and saved WiFi secrets stay in this process and in short-lived private
|
|
test files. Never print raw ADB, NetworkManager, SSH, or instrumentation output.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import importlib.util
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "测试设备登记"))
|
|
from prepare_test_device import check_connected, load_registration
|
|
|
|
logging.getLogger("paramiko").setLevel(logging.CRITICAL)
|
|
|
|
|
|
def hotspot_configuration() -> dict:
|
|
script = (
|
|
'$ErrorActionPreference="Stop"; '
|
|
'$n=[Windows.Networking.Connectivity.NetworkInformation,Windows.Networking.Connectivity,ContentType=WindowsRuntime]; '
|
|
'$t=[Windows.Networking.NetworkOperators.NetworkOperatorTetheringManager,Windows.Networking.NetworkOperators,ContentType=WindowsRuntime]; '
|
|
'$m=$t::CreateFromConnectionProfile($n::GetInternetConnectionProfile()); '
|
|
'if($m.TetheringOperationalState.ToString() -ne "On"){throw "hotspot off"}; '
|
|
'$c=$m.GetCurrentAccessPointConfiguration(); '
|
|
'$a=@(Get-NetAdapter -IncludeHidden|Where-Object {$_.InterfaceDescription -like "*Wi-Fi Direct Virtual Adapter*"}); '
|
|
'$ips=@($a|ForEach-Object {Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue}|Where-Object {$_.PrefixLength -eq 24}); '
|
|
'if($ips.Count -ne 1){throw "hotspot address ambiguous"}; '
|
|
'$j=@{ssid=$c.Ssid;password=$c.Passphrase;gateway=$ips[0].IPAddress;prefix=$ips[0].PrefixLength}|ConvertTo-Json -Compress; '
|
|
'[Console]::Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($j)))'
|
|
)
|
|
result = subprocess.run(["powershell.exe", "-NoProfile", "-Command", script],
|
|
capture_output=True, timeout=20)
|
|
if result.returncode:
|
|
raise RuntimeError("电脑热点配置无法安全读取")
|
|
values = json.loads(base64.b64decode(result.stdout.strip()))
|
|
if not values["ssid"] or not 8 <= len(values["password"].encode("utf-8")) <= 63:
|
|
raise RuntimeError("电脑热点配置不满足测试条件")
|
|
gateway = ipaddress.ip_address(values["gateway"])
|
|
if not isinstance(gateway, ipaddress.IPv4Address) or values["prefix"] != 24:
|
|
raise RuntimeError("电脑热点 IPv4 参数不满足测试条件")
|
|
return values
|
|
|
|
|
|
def hotspot_client_ips(network: ipaddress.IPv4Network, gateway: str) -> list[str]:
|
|
if network is None:
|
|
return []
|
|
script = (
|
|
'$n=[Windows.Networking.Connectivity.NetworkInformation,Windows.Networking.Connectivity,ContentType=WindowsRuntime];'
|
|
'$t=[Windows.Networking.NetworkOperators.NetworkOperatorTetheringManager,Windows.Networking.NetworkOperators,ContentType=WindowsRuntime];'
|
|
'$m=$t::CreateFromConnectionProfile($n::GetInternetConnectionProfile());'
|
|
'$x=@();foreach($c in $m.GetTetheringClients()){foreach($h in $c.HostNames){$x+=$h.CanonicalName}};'
|
|
'$j=$x|ConvertTo-Json -Compress;'
|
|
'[Console]::Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($j)))'
|
|
)
|
|
result = subprocess.run(["powershell.exe", "-NoProfile", "-Command", script],
|
|
capture_output=True, timeout=20)
|
|
if result.returncode or not result.stdout.strip():
|
|
return []
|
|
names = json.loads(base64.b64decode(result.stdout.strip()))
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
addresses = []
|
|
for name in names or []:
|
|
try:
|
|
address = ipaddress.ip_address(name)
|
|
except ValueError:
|
|
continue
|
|
if address in network and str(address) != gateway:
|
|
addresses.append(str(address))
|
|
return addresses
|
|
|
|
|
|
def original_configuration(client, fields: dict) -> dict:
|
|
remote = r'''python3 - <<'PY'
|
|
import base64,configparser,json,pathlib,subprocess
|
|
u=json.load(open('/var/lib/matrix-screen-controller/wifi_config.json'))['managed_connection_uuid']
|
|
assert u
|
|
names=['802-11-wireless.ssid','802-11-wireless-security.psk','ipv4.method','ipv4.addresses','ipv4.gateway','ipv4.dns']
|
|
d={name:subprocess.check_output(['nmcli','-s','-g',name,'connection','show',u],text=True).strip() for name in names}
|
|
d['uuid']=u
|
|
for directory in ('/etc/NetworkManager/system-connections','/run/NetworkManager/system-connections'):
|
|
for path in pathlib.Path(directory).glob('*'):
|
|
if not path.is_file(): continue
|
|
cfg=configparser.ConfigParser(interpolation=None,strict=False)
|
|
try: cfg.read(path,encoding='utf-8')
|
|
except Exception: continue
|
|
if cfg.get('connection','uuid',fallback='')==u: d['path']=str(path)
|
|
if cfg.get('connection','uuid',fallback='')==u and not d['802-11-wireless-security.psk']:
|
|
d['802-11-wireless-security.psk']=cfg.get('wifi-security','psk',fallback='')
|
|
identity=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json'))
|
|
d['board_name']='QMS-'+identity['device_id'].replace('-','')[:8]
|
|
assert d.get('path')
|
|
print(base64.b64encode(json.dumps(d,ensure_ascii=False).encode()).decode())
|
|
PY'''
|
|
code, out, _ = execute(client, remote, password=fields["密码"], timeout=50)
|
|
if code:
|
|
raise RuntimeError("不能安全读取受管网络恢复配置")
|
|
values = json.loads(base64.b64decode(out.strip()))
|
|
if not values["802-11-wireless-security.psk"]:
|
|
raise RuntimeError("原网络缺少可恢复的密钥")
|
|
if values["ipv4.method"] not in ("manual", "auto"):
|
|
raise RuntimeError("原网络 IPv4 模式不受测试恢复器支持")
|
|
return values
|
|
|
|
|
|
def resume_configuration(client, fields: dict) -> tuple[dict, str, str]:
|
|
current = original_configuration(client, fields)
|
|
remote = r'''python3 - <<'PY'
|
|
import base64,configparser,json,pathlib
|
|
roots=list(pathlib.Path('/var/lib/matrix-screen-controller').glob('.wifi-test-restore-*'))
|
|
assert len(roots)==1
|
|
root=roots[0]
|
|
cfg=configparser.ConfigParser(interpolation=None)
|
|
assert cfg.read(root/'original.nmconnection',encoding='utf-8')
|
|
address=cfg.get('ipv4','address1',fallback='')
|
|
parts=address.split(',',1)
|
|
d={
|
|
'root':str(root),'unit':'qms-wifi-test-restore-'+root.name.rsplit('-',1)[-1],
|
|
'ssid':cfg['wifi']['ssid'],'psk':cfg['wifi-security']['psk'],
|
|
'mode':cfg['ipv4']['method'],'address':parts[0],
|
|
'gateway':parts[1] if len(parts)>1 else '',
|
|
'dns':cfg.get('ipv4','dns',fallback='').replace(';',',').strip(','),
|
|
'uuid':cfg['connection']['uuid'],
|
|
}
|
|
print(base64.b64encode(json.dumps(d,ensure_ascii=False).encode()).decode())
|
|
PY'''
|
|
code, out, _ = execute(client, remote, password=fields["密码"])
|
|
if code:
|
|
raise RuntimeError("无法读取上次测试的受保护恢复副本")
|
|
backup = json.loads(base64.b64decode(out.strip()))
|
|
if backup["uuid"] != current["uuid"] or not backup["psk"]:
|
|
raise RuntimeError("恢复副本与受管网络不匹配")
|
|
current.update({
|
|
"802-11-wireless.ssid": backup["ssid"],
|
|
"802-11-wireless-security.psk": backup["psk"],
|
|
"ipv4.method": backup["mode"],
|
|
"ipv4.addresses": backup["address"],
|
|
"ipv4.gateway": backup["gateway"],
|
|
"ipv4.dns": backup["dns"],
|
|
})
|
|
return current, backup["root"], backup["unit"]
|
|
|
|
|
|
def checked_remote(client, command: str, fields: dict, *, timeout=35) -> str:
|
|
code, out, _ = execute(client, command, password=fields["密码"], timeout=timeout)
|
|
if code:
|
|
raise RuntimeError("板端测试保护操作失败")
|
|
return out.strip()
|
|
|
|
|
|
def adb_call(adb: Path, serial: str, *arguments: str, timeout=40) -> str:
|
|
result = subprocess.run([str(adb), "-s", serial, *arguments],
|
|
capture_output=True, timeout=timeout)
|
|
if result.returncode:
|
|
raise RuntimeError("登记手机的 ADB 操作失败")
|
|
return result.stdout.decode("utf-8", "replace")
|
|
|
|
|
|
def connect_at(ip: str, expected_key, fields: dict):
|
|
import paramiko
|
|
class ExactKey(paramiko.MissingHostKeyPolicy):
|
|
def missing_host_key(self, ssh, hostname, key):
|
|
if key.get_name() != expected_key.get_name() or key.asbytes() != expected_key.asbytes():
|
|
raise RuntimeError("切网后 SSH 主机密钥不匹配")
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(ExactKey())
|
|
try:
|
|
port = int(re.search(r"(?:^|\s)-p\s+(\d+)", fields["SSH"]).group(1)) if re.search(r"(?:^|\s)-p\s+(\d+)", fields["SSH"]) else 22
|
|
client.connect(ip, port=port, username=fields["用户名"], password=fields["密码"],
|
|
look_for_keys=False, allow_agent=False, timeout=8,
|
|
banner_timeout=8, auth_timeout=8)
|
|
return client
|
|
except Exception:
|
|
client.close()
|
|
raise RuntimeError("切网后无法通过已核验的 SSH 主机密钥连接") from None
|
|
|
|
|
|
def upload_text(client, text: str, destination: str) -> None:
|
|
import io
|
|
with client.open_sftp() as sftp:
|
|
sftp.putfo(io.BytesIO(text.encode("utf-8")), destination)
|
|
|
|
|
|
def prepare_recovery(client, fields: dict, original: dict, *, minutes: int = 15) -> tuple[str, str]:
|
|
suffix = secrets.token_hex(5)
|
|
root = f"/var/lib/matrix-screen-controller/.wifi-test-restore-{suffix}"
|
|
unit = f"qms-wifi-test-restore-{suffix}"
|
|
source = original["path"]
|
|
if not source.startswith(("/etc/NetworkManager/system-connections/", "/run/NetworkManager/system-connections/")):
|
|
raise RuntimeError("受管网络文件不在预期目录")
|
|
checked_remote(client, f"install -d -m 700 '{root}' && install -m 600 '{source}' '{root}/original.nmconnection'", fields)
|
|
script = ("#!/bin/sh\nset -eu\n"
|
|
f"install -m 600 '{root}/original.nmconnection' '{source}'\n"
|
|
"nmcli connection reload\n"
|
|
f"nmcli --wait 30 connection up uuid '{original['uuid']}' >/dev/null 2>&1 || true\n")
|
|
upload_text(client, script, f"/tmp/{unit}.sh")
|
|
checked_remote(client, f"install -m 700 '/tmp/{unit}.sh' '{root}/restore.sh' && rm -f '/tmp/{unit}.sh' && sh -n '{root}/restore.sh'", fields)
|
|
checked_remote(client, f"systemd-run --unit='{unit}' --on-active={minutes}m /bin/sh '{root}/restore.sh' >/dev/null && systemctl is-active '{unit}.timer'", fields)
|
|
return root, unit
|
|
|
|
|
|
def prepare_boot_recovery(client, fields: dict, root: str, unit: str) -> None:
|
|
unit += "-boot"
|
|
service = f"[Unit]\nDescription=Temporary Android WiFi test recovery\n[Service]\nType=oneshot\nExecStart=/bin/sh {root}/restore.sh\n"
|
|
timer = f"[Unit]\nDescription=Temporary Android WiFi test recovery after reboot\n[Timer]\nOnBootSec=4min\nUnit={unit}.service\n[Install]\nWantedBy=timers.target\n"
|
|
upload_text(client, service, f"/tmp/{unit}.service")
|
|
upload_text(client, timer, f"/tmp/{unit}.timer")
|
|
checked_remote(client, f"install -m 644 '/tmp/{unit}.service' '/etc/systemd/system/{unit}.service' && install -m 644 '/tmp/{unit}.timer' '/etc/systemd/system/{unit}.timer' && rm -f '/tmp/{unit}.service' '/tmp/{unit}.timer' && systemctl daemon-reload && systemctl enable '{unit}.timer' >/dev/null", fields)
|
|
|
|
|
|
def clean_recovery(client, fields: dict, root: str, unit: str) -> None:
|
|
# These exact paths are created by this runner. Leave them if cleanup fails.
|
|
command = (f"systemctl disable --now '{unit}-boot.timer' >/dev/null 2>&1 || true; "
|
|
f"systemctl stop '{unit}.timer' '{unit}-continued.timer' >/dev/null 2>&1 || true; "
|
|
f"systemctl stop '{unit}.service' >/dev/null 2>&1 || true; "
|
|
f"rm -f '/etc/systemd/system/{unit}-boot.service' '/etc/systemd/system/{unit}-boot.timer'; "
|
|
"systemctl daemon-reload; "
|
|
f"rm -f '{root}/restore.sh' '{root}/original.nmconnection'; rmdir '{root}'")
|
|
checked_remote(client, command, fields)
|
|
|
|
|
|
def verify_restored(client, fields: dict, root: str) -> bool:
|
|
remote = f'''python3 - <<'PY'
|
|
import configparser,hashlib,pathlib,subprocess
|
|
backup=pathlib.Path('{root}/original.nmconnection')
|
|
cfg=configparser.ConfigParser(interpolation=None);cfg.read(backup,encoding='utf-8')
|
|
u=cfg['connection']['uuid']
|
|
matches=[]
|
|
for directory in ('/etc/NetworkManager/system-connections','/run/NetworkManager/system-connections'):
|
|
for path in pathlib.Path(directory).glob('*'):
|
|
if not path.is_file(): continue
|
|
current=configparser.ConfigParser(interpolation=None)
|
|
try: current.read(path,encoding='utf-8')
|
|
except Exception: continue
|
|
if current.get('connection','uuid',fallback='')==u: matches.append(path)
|
|
active=subprocess.check_output(['nmcli','-g','GENERAL.CONNECTION','device','show','wlan0'],text=True).strip()
|
|
saved=subprocess.check_output(['nmcli','-g','802-11-wireless.ssid','connection','show',u],text=True).strip()
|
|
print(int(len(matches)==1 and hashlib.sha256(matches[0].read_bytes()).digest()==hashlib.sha256(backup.read_bytes()).digest() and saved==cfg['wifi']['ssid'] and active==cfg['connection']['id']))
|
|
PY'''
|
|
return checked_remote(client, remote, fields) == "1"
|
|
|
|
|
|
def wifi_snapshot(client, fields: dict) -> dict:
|
|
remote = "cd /opt/matrix-screen-controller && .venv/bin/python -c 'import json; from app.network.manager import NmcliNetworkManager; u=json.load(open(\"/var/lib/matrix-screen-controller/wifi_config.json\"))[\"managed_connection_uuid\"]; n=NmcliNetworkManager(); print(json.dumps({\"saved\":n.read_saved(u),\"active\":n.read_active()},ensure_ascii=False))'"
|
|
return json.loads(checked_remote(client, remote, fields, timeout=30))
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--adb", type=Path)
|
|
parser.add_argument("--list-scenarios", action="store_true")
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
parser.add_argument("--build", type=Path)
|
|
parser.add_argument("--resume", action="store_true", help="Continue after the saved recovery profile is active on the computer hotspot")
|
|
parser.add_argument("--root-swipe", action="store_true", help="Requires explicit root permission for scrolling only the foreground App settings list")
|
|
parser.add_argument("--skip-wrong", action="store_true", help="Use only when the wrong-password branch has been separately observed and archived")
|
|
parser.add_argument("--wrong-only", action="store_true", help="Verify the failure branch and restore the original profile")
|
|
parser.add_argument("--validate-only", action="store_true", help="Verify form and invalid IPv4 without switching networks")
|
|
parser.add_argument("--final-check", action="store_true", help="Verify App, BLE, board, and recovery cleanup on the original network")
|
|
args = parser.parse_args()
|
|
modes = ['validate_only', 'wrong_only', 'skip_wrong', 'final_check']
|
|
if args.list_scenarios:
|
|
print('validate-only: form only; wrong-only: failed activation with timed restore; skip-wrong: cross-network DHCP/static roundtrip; final-check: restored state checks')
|
|
return 0
|
|
selected = [m for m in modes if getattr(args, m)]
|
|
if len(selected) != 1: parser.error('Select exactly one explicit WiFi mode')
|
|
if args.resume and not args.skip_wrong: parser.error('--resume requires --skip-wrong')
|
|
if args.dry_run:
|
|
print(json.dumps({'scenario': selected[0], 'root_phone': True, 'board': True, 'network_change': selected[0] in ('wrong_only', 'skip_wrong')}))
|
|
return 0
|
|
if not args.adb or not args.build: parser.error('--adb and --build required')
|
|
global connect, execute
|
|
from remote_support import connect, execute
|
|
args.root_swipe = True
|
|
if not (args.wrong_only or args.skip_wrong or args.resume or args.validate_only or args.final_check):
|
|
raise RuntimeError("请选择 --validate-only、--wrong-only、--final-check 或已完成错误密码单测后的 --skip-wrong")
|
|
phone = next(d for d in load_registration() if d["alias"] == "android-test-a")
|
|
check_connected(str(args.adb), phone)
|
|
root_check = subprocess.run([str(args.adb), '-s', phone['serial'], 'shell', 'su', '-c', 'id -u'], capture_output=True, text=True, timeout=15)
|
|
if root_check.returncode or root_check.stdout.strip() != '0':
|
|
raise RuntimeError('测试手机 root 权限不可用;停止测试,等待用户处理')
|
|
local_only = args.validate_only or args.final_check or args.wrong_only
|
|
hotspot = dict(ssid="", password="", gateway="", prefix=24) if local_only else hotspot_configuration()
|
|
network = None
|
|
static_address = ""
|
|
if not local_only:
|
|
network = ipaddress.ip_network(f"{hotspot['gateway']}/{hotspot['prefix']}", strict=False)
|
|
static_address = str(network.network_address + 250)
|
|
if ipaddress.ip_address(static_address) not in network or static_address == hotspot["gateway"]:
|
|
raise RuntimeError("电脑热点静态地址候选不满足安全条件")
|
|
network_check = subprocess.run([
|
|
"powershell.exe", "-NoProfile", "-Command",
|
|
f"$g=Get-NetIPAddress -IPAddress '{hotspot['gateway']}' -AddressFamily IPv4 -ErrorAction SilentlyContinue; "
|
|
f"$n=Get-NetNeighbor -IPAddress '{static_address}' -ErrorAction SilentlyContinue; "
|
|
"if($g.PrefixLength -eq 24 -and ($null -eq $n -or $n.State -eq 'Unreachable')){exit 0}else{exit 1}",
|
|
], capture_output=True, timeout=15)
|
|
if network_check.returncode:
|
|
raise RuntimeError("热点网段或静态地址候选不满足安全条件")
|
|
if args.resume:
|
|
gate_path = Path(__file__).resolve().parents[3] / "测试相关资料/核桃派的用户名和密码和ip/prepare_credentials.py"
|
|
spec = importlib.util.spec_from_file_location("qms_credentials", gate_path)
|
|
gate = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(gate)
|
|
if not gate.ensure_credentials(gate_path.parent):
|
|
raise RuntimeError("设备凭据门禁未通过")
|
|
fields = gate._parse_fields(gate_path.with_name(gate.PRIVATE_NAME))
|
|
current_ip = adb_call(args.adb, phone["serial"], "shell", "run-as", "org.qimiaoscreen.controller", "cat", "files/qms-wifi-test-ip.txt").strip()
|
|
if ipaddress.ip_address(current_ip) not in network:
|
|
raise RuntimeError("当前设备地址不是电脑热点网段")
|
|
import paramiko
|
|
keys = paramiko.HostKeys()
|
|
from local_test_paths import SSH_KNOWN_HOSTS
|
|
keys.load(str(SSH_KNOWN_HOSTS))
|
|
expected_key = next(iter(keys.lookup(fields["IP"].strip()).values()))
|
|
board = connect_at(current_ip, expected_key, fields)
|
|
original, backup_root, backup_unit = resume_configuration(board, fields)
|
|
else:
|
|
board, fields = connect()
|
|
expected_key = board.get_transport().get_remote_server_key()
|
|
original = original_configuration(board, fields)
|
|
current_ip = fields["IP"].strip()
|
|
backup_root = backup_unit = None
|
|
if not local_only and hotspot["ssid"] == original["802-11-wireless.ssid"]:
|
|
raise RuntimeError("电脑热点与原网络相同,不能进行切换测试")
|
|
host_secret = None
|
|
completed: list[str] = []
|
|
try:
|
|
addresses = original["ipv4.addresses"].splitlines()
|
|
if original["ipv4.method"] == "manual" and not addresses:
|
|
raise RuntimeError("原静态地址无法恢复")
|
|
original_address, _, original_prefix = (addresses[0] if addresses else "").partition("/")
|
|
if args.wrong_only:
|
|
hotspot = dict(ssid=original['802-11-wireless.ssid'], password=original['802-11-wireless-security.psk'],
|
|
gateway=original['ipv4.gateway'], prefix=int(original_prefix or '24'))
|
|
values = {
|
|
"board_name": original["board_name"],
|
|
"hotspot_ssid": hotspot["ssid"], "hotspot_password": hotspot["password"],
|
|
"original_ssid": original["802-11-wireless.ssid"],
|
|
"original_password": original["802-11-wireless-security.psk"],
|
|
"original_mode": original["ipv4.method"], "original_address": original_address,
|
|
"original_prefix": original_prefix or "24",
|
|
"original_gateway": original["ipv4.gateway"],
|
|
"original_dns": ",".join(re.split(r"[,\n]+", original["ipv4.dns"])),
|
|
"static_address": static_address, "hotspot_gateway": hotspot["gateway"],
|
|
"wrong_password": "qms-invalid-password-2026",
|
|
}
|
|
from local_test_paths import private_directory
|
|
private_tmp = private_directory()
|
|
path = private_tmp / ("qms-wifi-test-" + secrets.token_hex(8) + ".json")
|
|
path.write_text(json.dumps(values, ensure_ascii=False), encoding="utf-8")
|
|
host_secret = path
|
|
test_apk = args.build / "androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk"
|
|
if not test_apk.is_file():
|
|
raise RuntimeError("专项 Android 测试 APK 不存在")
|
|
adb_call(args.adb, phone["serial"], "install", "-r", str(test_apk), timeout=120)
|
|
adb_call(args.adb, phone["serial"], "push", str(path), "/data/local/tmp/qms-wifi-test-private.json")
|
|
adb_call(args.adb, phone["serial"], "shell", "chmod", "600", "/data/local/tmp/qms-wifi-test-private.json")
|
|
helper = Path(__file__).with_name("authorized_app_input.sh")
|
|
adb_call(args.adb, phone["serial"], "push", str(helper), "/data/local/tmp/qms-authorized-input.sh")
|
|
|
|
def run_stage(stage: str) -> None:
|
|
adb_call(args.adb, phone["serial"], "shell", "am", "force-stop", "org.qimiaoscreen.controller")
|
|
output = adb_call(args.adb, phone["serial"], "shell", "am", "instrument", "-w", "-r",
|
|
"-e", "wifi_stage", stage, "-e", "root_swipe", str(args.root_swipe).lower(), "-e", "class",
|
|
"org.qimiaoscreen.controller.WifiSwitchTest",
|
|
"org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner",
|
|
timeout=180)
|
|
if "OK (1 test)" not in output:
|
|
safe = output
|
|
for private in sorted({*map(str, values.values()), *map(str, fields.values()), phone["serial"]}, key=len, reverse=True):
|
|
if len(private) >= 4:
|
|
safe = safe.replace(private, "[private]")
|
|
lines = [line for line in safe.splitlines() if any(marker in line for marker in
|
|
("UI tap", "Exception", "Error", "Assertion", "WifiSwitchTest.kt", "INSTRUMENTATION_CODE", "Failure", "java.lang", "androidx.test"))]
|
|
print("脱敏诊断:" + " | ".join((lines[:5] + lines[-8:]))[:1800], flush=True)
|
|
raise RuntimeError(f"App WiFi 专项阶段失败:{stage};原始日志已抑制")
|
|
if stage == 'wrong':
|
|
print('认证错误明确分类:' + str('AUTH_FAILED_CONFIRMED' in output), flush=True)
|
|
completed.append(stage)
|
|
print(f"阶段通过:{stage}", flush=True)
|
|
|
|
def reconnect_hotspot(preferred: str | None = None):
|
|
for _ in range(12):
|
|
candidates = ([preferred] if preferred else []) + hotspot_client_ips(network, hotspot["gateway"])
|
|
for address in dict.fromkeys(x for x in candidates if x):
|
|
try:
|
|
return address, connect_at(address, expected_key, fields)
|
|
except Exception:
|
|
pass
|
|
time.sleep(5)
|
|
raise RuntimeError("热点切换后无法找到已核验主机密钥的核桃派")
|
|
|
|
if args.final_check:
|
|
run_stage("verify")
|
|
snapshot = wifi_snapshot(board, fields)
|
|
if (snapshot["saved"]["ssid"] != original["802-11-wireless.ssid"]
|
|
or snapshot["active"]["ssid"] != original["802-11-wireless.ssid"]
|
|
or not snapshot["active"]["connected"]):
|
|
raise RuntimeError("最终板端原网络状态不一致")
|
|
checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields)
|
|
residue = checked_remote(board, "find /var/lib/matrix-screen-controller -maxdepth 1 -name '.wifi-test-restore-*' -print | wc -l", fields)
|
|
if residue != "0":
|
|
raise RuntimeError("本轮 WiFi 恢复材料尚未清理")
|
|
print("App、BLE、板端原网络与恢复材料最终核验通过", flush=True)
|
|
return 0
|
|
if not args.resume:
|
|
run_stage("validate")
|
|
run_stage("validate_invalid")
|
|
if args.validate_only:
|
|
print("App 字段校验通过,原网络未切换", flush=True)
|
|
return 0
|
|
run_stage("scan")
|
|
backup_root, backup_unit = prepare_recovery(board, fields, original, minutes=3 if args.wrong_only else 15)
|
|
print("板端受保护备份与定时恢复已就绪", flush=True)
|
|
else:
|
|
checked_remote(board, f"systemd-run --unit='{backup_unit}-continued' --on-active=15m /bin/sh '{backup_root}/restore.sh' >/dev/null && systemctl is-active '{backup_unit}-continued.timer' && systemctl stop '{backup_unit}.timer'", fields)
|
|
print("已核验原备份并续设恢复定时器", flush=True)
|
|
if args.wrong_only:
|
|
run_stage("wrong")
|
|
run_stage("inspect_failure")
|
|
print("错误密码任务失败已由 App 中文状态确认;正在恢复原网络", flush=True)
|
|
return 0
|
|
stages = ("manual", "dhcp_return") if args.resume else ("dhcp", "manual", "dhcp_return")
|
|
for stage in stages:
|
|
run_stage(stage)
|
|
if stage != "wrong":
|
|
current_ip, new_board = reconnect_hotspot(values["static_address"] if stage == "manual" else None)
|
|
board.close()
|
|
board = new_board
|
|
checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields)
|
|
snapshot = wifi_snapshot(board, fields)
|
|
if snapshot["saved"]["ssid"] != hotspot["ssid"] or snapshot["active"]["ssid"] != hotspot["ssid"] or not snapshot["active"]["connected"]:
|
|
raise RuntimeError(f"板端网络状态不符合阶段 {stage}")
|
|
expected_mode = "manual" if stage == "manual" else "dhcp"
|
|
if snapshot["saved"]["ipv4_mode"] != expected_mode:
|
|
raise RuntimeError(f"板端 IPv4 模式不符合阶段 {stage}")
|
|
if stage == "manual" and snapshot["active"]["ipv4_address"] != values["static_address"]:
|
|
raise RuntimeError("静态 IPv4 地址未实际生效")
|
|
run_stage("restore")
|
|
board.close()
|
|
board = None
|
|
for _ in range(24):
|
|
time.sleep(3)
|
|
try:
|
|
board = connect_at(fields["IP"].strip(), expected_key, fields)
|
|
break
|
|
except RuntimeError:
|
|
pass
|
|
if board is None:
|
|
raise RuntimeError("立即恢复原网络后 SSH 未恢复;板端定时恢复仍启用")
|
|
checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields)
|
|
run_stage("verify")
|
|
snapshot = wifi_snapshot(board, fields)
|
|
if (snapshot["saved"]["ssid"] != original["802-11-wireless.ssid"]
|
|
or snapshot["active"]["ssid"] != original["802-11-wireless.ssid"]
|
|
or not snapshot["active"]["connected"]):
|
|
raise RuntimeError("立即生效后未恢复原网络")
|
|
code, journal, _ = execute(board, "journalctl -u matrix-screen-controller.service --since '-30 min' --no-pager -o cat", password=fields["密码"], timeout=30)
|
|
if code or any(secret in journal for secret in (hotspot["password"], original["802-11-wireless-security.psk"], values["wrong_password"])):
|
|
raise RuntimeError("板端日志检查失败或含有测试密码")
|
|
checked_remote(board, f"/bin/sh '{backup_root}/restore.sh'", fields, timeout=50)
|
|
if not verify_restored(board, fields, backup_root):
|
|
raise RuntimeError("原网络未完全恢复")
|
|
clean_recovery(board, fields, backup_root, backup_unit)
|
|
backup_root = backup_unit = None
|
|
print("原网络恢复、密码日志检查和恢复材料清理通过", flush=True)
|
|
return 0
|
|
finally:
|
|
if host_secret is not None:
|
|
host_secret.unlink(missing_ok=True)
|
|
for target in ("/data/local/tmp/qms-wifi-test-private.json", "/data/local/tmp/qms-authorized-input.sh"):
|
|
try: adb_call(args.adb, phone["serial"], "shell", "rm", "-f", target)
|
|
except Exception: pass
|
|
try: adb_call(args.adb, phone["serial"], "shell", "run-as", "org.qimiaoscreen.controller", "rm", "-f", "files/qms-wifi-test-ip.txt")
|
|
except Exception: pass
|
|
if backup_root and backup_unit:
|
|
for ip in dict.fromkeys([current_ip, *hotspot_client_ips(network, hotspot["gateway"]), fields["IP"].strip()]):
|
|
try:
|
|
if board is None: board = connect_at(ip, expected_key, fields)
|
|
try:
|
|
checked_remote(board, f"/bin/sh '{backup_root}/restore.sh'", fields, timeout=50)
|
|
except Exception:
|
|
# The connection can close while the restore switches WiFi.
|
|
pass
|
|
break
|
|
except Exception:
|
|
if board is not None: board.close()
|
|
board = None
|
|
if board is not None:
|
|
board.close()
|
|
board = None
|
|
for _ in range(40 if args.wrong_only else 12):
|
|
try:
|
|
board = connect_at(fields["IP"].strip(), expected_key, fields)
|
|
checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields)
|
|
if not verify_restored(board, fields, backup_root):
|
|
raise RuntimeError("原网络恢复尚未核验")
|
|
if args.wrong_only:
|
|
code, journal, _ = execute(board, "journalctl -u matrix-screen-controller.service --since '-10 min' --no-pager -o cat", password=fields["密码"], timeout=30)
|
|
if code or any(secret in journal for secret in (hotspot["password"], original["802-11-wireless-security.psk"], values["wrong_password"])):
|
|
raise RuntimeError("错误密码阶段日志检查失败或含有测试密码")
|
|
clean_recovery(board, fields, backup_root, backup_unit)
|
|
print("失败后已核对原网络并清理保护材料", flush=True)
|
|
break
|
|
except Exception:
|
|
if board is not None: board.close()
|
|
board = None
|
|
time.sleep(5)
|
|
if board is None:
|
|
print("即时恢复无法连接;板端定时恢复仍已启用", flush=True)
|
|
if args.wrong_only:
|
|
raise RuntimeError("错误密码单测的原网络恢复尚未核验")
|
|
if board is not None: board.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except Exception as error:
|
|
print(f"WiFi 专项测试未通过:{error}", file=sys.stderr)
|
|
raise SystemExit(1)
|