Files
MeshCore/tools/hil/ble_visibility_probe.py
T

164 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Owned, bounded BlueZ discovery; print only one explicitly selected peer.
Does not pair/connect, power adapters, open USB, or stop shared discovery.
All nearby-device output stays in process memory and is discarded.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
ANSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
MAC = re.compile(r"^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$")
def device_cache_state(output: str, target: str, returncode: int) -> bool | None:
"""True=exact successful info, False=explicit unavailable, None=query error."""
header = re.compile(r"^Device\s+" + re.escape(target) + r"(?:\s+\((?:public|random)\))?\s*$", re.I)
unavailable = re.compile(r"^Device\s+" + re.escape(target) + r"\s+not available\s*$", re.I)
lines = ANSI.sub("", output).splitlines()
headers = sum(bool(header.fullmatch(line)) for line in lines)
absent = sum(bool(unavailable.fullmatch(line)) for line in lines)
if returncode == 0 and headers == 1 and not absent:
return True
if returncode in (0, 1) and absent == 1 and not headers:
return False
return None
def is_cached_device(output: str, target: str, returncode: int) -> bool:
"""Only a successful info header proves a cached record, never its error."""
return device_cache_state(output, target, returncode) is True
def parse_discovery(output: str, target: str) -> dict:
started = False
events = 0
new_events = 0
rssi = []
for line in ANSI.sub("", output).splitlines():
if "Discovery started" in line:
started = True
if not started or not re.search(r"\bDevice " + re.escape(target) + r"(?:\s|$)", line, re.I):
continue
if "[NEW]" in line or "[CHG]" in line:
events += 1
new_events += int("[NEW]" in line)
value = re.search(r"RSSI:\s*(?:0x[0-9a-f]+\s*\()?(-\d+)\)?", line, re.I)
if value:
rssi.append(int(value[1]))
return {
"discovery_started": started,
"target_events": events,
"new_target_events": new_events,
"rssi_updates": len(rssi),
"rssi_dbm": rssi,
# Startup cached-device enumeration is ignored until StartDiscovery succeeds.
"advertisement_evidence": bool(rssi or new_events),
}
def with_cache_evidence(scan: dict, before: bool | None, after: bool | None) -> dict:
"""Do not invent event counts when BlueZ prints its start marker late.
A verified absent -> present transition during successful discovery is
separate evidence of a newly learned target. It does not prove the exact
advertising time/RSSI, nor attribution if another discovery client exists.
Unknown/failed info queries never constitute verified prior absence.
"""
result = dict(scan)
created = bool(scan["discovery_started"] and before is False and after is True)
event_evidence = bool(scan["advertisement_evidence"])
sources = []
if scan["rssi_updates"]:
sources.append("post_start_rssi_update")
if scan["new_target_events"]:
sources.append("post_start_new_target_event")
if created:
sources.append("absent_before_present_after_successful_discovery")
result.update(
cached_before=before is True,
cached_after=after is True,
cache_query_before_verified=before is not None,
cache_query_after_verified=after is not None,
post_start_event_evidence=event_evidence,
new_target_record_during_scan=created,
advertisement_evidence=event_evidence or created,
evidence_sources=sources,
)
return result
def usb_identity(alias: str, expected_serial: str) -> dict:
path = Path(alias)
if path.parent != Path("/dev/serial/by-id") or expected_serial not in path.name:
raise ValueError("Explicit matching USB by-id identity required")
resolved = path.resolve(strict=True)
if not re.fullmatch(r"ttyACM\d+", resolved.name):
raise ValueError("Expected native USB ACM radio")
device = Path("/sys/class/tty") / resolved.name / "device"
for parent in (device.resolve(), *device.resolve().parents):
if (parent / "serial").is_file():
serial = (parent / "serial").read_text().strip()
vid = (parent / "idVendor").read_text().strip().lower()
pid = (parent / "idProduct").read_text().strip().lower()
if (serial, vid, pid) != (expected_serial, "239a", "8029"):
raise ValueError("USB identity mismatch")
return {"serial": serial, "vid": vid, "pid": pid, "port": str(resolved)}
raise ValueError("USB identity unavailable")
def bluetooth(*args: str, timeout: int = 8) -> subprocess.CompletedProcess:
return subprocess.run(
["bluetoothctl", *args], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=timeout,
env={**os.environ, "LC_ALL": "C"}, check=False,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", required=True)
parser.add_argument("--controller", required=True)
parser.add_argument("--usb-by-id", required=True)
parser.add_argument("--usb-serial", required=True)
parser.add_argument("--seconds", type=int, default=20, choices=range(1, 21))
args = parser.parse_args()
target, controller = args.target.upper(), args.controller.upper()
result = {"target": target, "duration_seconds": args.seconds}
try:
if not MAC.fullmatch(target) or not MAC.fullmatch(controller):
raise ValueError("Exact peer and controller MAC required")
result["usb_before"] = usb_identity(args.usb_by_id, args.usb_serial)
adapter = bluetooth("show", controller)
if not re.search(r"Powered:\s*yes\b", adapter.stdout):
raise ValueError("Expected controller is not already powered")
# Verify this is the selected/default controller, rather than changing it.
default = bluetooth("show")
if not re.search(r"Controller\s+" + re.escape(controller) + r"\b", default.stdout, re.I):
raise ValueError("Expected controller is not the default")
before = bluetooth("info", target)
before_state = device_cache_state(before.stdout, target, before.returncode)
scan = bluetooth("--timeout", str(args.seconds), "scan", "on", timeout=args.seconds + 5)
result["scan_exit_code"] = scan.returncode
after = bluetooth("info", target)
after_state = device_cache_state(after.stdout, target, after.returncode)
result.update(with_cache_evidence(parse_discovery(scan.stdout, target), before_state, after_state))
result["usb_after"] = usb_identity(args.usb_by_id, args.usb_serial)
result["success"] = result["discovery_started"]
except (ValueError, OSError, subprocess.SubprocessError):
result["success"] = False
result["error"] = "Identity-bound discovery could not complete; raw output suppressed"
print(json.dumps(result, sort_keys=True))
return 0 if result["success"] else 1
if __name__ == "__main__":
raise SystemExit(main())