164 lines
7.1 KiB
Python
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())
|