198 lines
7.6 KiB
Python
198 lines
7.6 KiB
Python
#!/usr/bin/python3
|
|
"""Linux observer: только живые BlueZ manufacturer-события + отдельный GATT echo-клиент.
|
|
|
|
Запуск: /usr/bin/python3 ble_dual_observer.py --peer /path/to/ble_dual_peer
|
|
Зависимости: python3-dbus, python3-gi. Windows-проба уже должна работать.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
SERVER_ID = 0x56434455414C0001
|
|
NAME = "VC-dual-probe"
|
|
VERSION = 5
|
|
MAX_GAP = 10.0
|
|
|
|
|
|
class Evidence:
|
|
"""Отдельные часы VC-маяка и GATT: кеш/другой ID/неправильное имя не подтверждают эфир."""
|
|
|
|
def __init__(self):
|
|
self.names = []
|
|
self.addresses = set()
|
|
self.link_up = None
|
|
self.peer_pass = None
|
|
self.echoes = 0
|
|
self.endpoint = False
|
|
|
|
def advertisement(self, data, address, now):
|
|
if len(data) < 11 or len(data) > 24 or data[:3] != b"VC" + bytes([VERSION]):
|
|
return False
|
|
if int.from_bytes(data[3:11], "big") != SERVER_ID or data[11:] != NAME.encode():
|
|
return False
|
|
self.names.append(now)
|
|
self.addresses.add(address)
|
|
return True
|
|
|
|
def peer_line(self, line, now):
|
|
if "DUAL_ENDPOINT " in line:
|
|
self.endpoint = True
|
|
if "DUAL_LINK_UP " in line:
|
|
self.link_up = now
|
|
if "DUAL_ECHO " in line:
|
|
self.echoes += 1
|
|
if "DUAL_PEER_PASS " in line:
|
|
self.peer_pass = now
|
|
|
|
def result(self, returncode):
|
|
during = [] if self.link_up is None or self.peer_pass is None else [
|
|
t for t in self.names if self.link_up <= t <= self.peer_pass
|
|
]
|
|
gaps = []
|
|
if self.link_up is not None and self.peer_pass is not None:
|
|
checkpoints = [self.link_up] + during + [self.peer_pass]
|
|
gaps = [b - a for a, b in zip(checkpoints, checkpoints[1:])]
|
|
max_gap = max(gaps, default=float("inf"))
|
|
held = 0 if self.link_up is None or self.peer_pass is None else self.peer_pass - self.link_up
|
|
ok = (returncode == 0 and self.endpoint and self.echoes >= 10 and held >= 29.5
|
|
and len(during) >= 3 and max_gap <= MAX_GAP)
|
|
return {"ok": ok, "peer_exit": returncode, "service_endpoint_seen": self.endpoint,
|
|
"echoes": self.echoes, "connected_seconds": round(held, 3), "name_events": len(self.names),
|
|
"name_events_during_connection": len(during),
|
|
"max_name_gap_seconds": round(max_gap, 3) if gaps else None,
|
|
"name_addresses": sorted(self.addresses)}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--peer", required=True, type=Path)
|
|
parser.add_argument("--seconds", type=int, default=120)
|
|
parser.add_argument("--log", type=Path, default=Path("ble_dual_observer.log"))
|
|
args = parser.parse_args()
|
|
if not args.peer.is_file() or not 45 <= args.seconds <= 600:
|
|
parser.error("--peer должен указывать на собранный ble_dual_peer; --seconds: 45..600")
|
|
|
|
import dbus
|
|
from dbus.mainloop.glib import DBusGMainLoop
|
|
from gi.repository import GLib
|
|
|
|
evidence = Evidence()
|
|
started = time.monotonic()
|
|
output = args.log.open("w", encoding="utf-8")
|
|
|
|
def log(message):
|
|
text = f"{time.monotonic() - started:8.3f} {message}"
|
|
print(text, flush=True)
|
|
output.write(text + "\n")
|
|
output.flush()
|
|
|
|
DBusGMainLoop(set_as_default=True)
|
|
bus = dbus.SystemBus()
|
|
|
|
def properties(interface, changed, invalidated, path=None):
|
|
if str(interface) != "org.bluez.Device1":
|
|
return
|
|
manufacturer = changed.get("ManufacturerData", {})
|
|
for company, data in manufacturer.items():
|
|
if int(company) != 0xFFFF:
|
|
continue
|
|
now = time.monotonic()
|
|
if evidence.advertisement(bytes(data), str(path), now):
|
|
log(f"VC_NAME node={SERVER_ID:016x} name={NAME} endpoint={path} "
|
|
f"rssi={changed.get('RSSI', 'unknown')} count={len(evidence.names)} source=live_signal")
|
|
|
|
def added(path, interfaces):
|
|
properties("org.bluez.Device1", interfaces.get("org.bluez.Device1", {}), [], path)
|
|
|
|
# Никакого GetManagedObjects: начальные кешированные данные не участвуют в доказательстве.
|
|
bus.add_signal_receiver(properties, signal_name="PropertiesChanged", dbus_interface="org.freedesktop.DBus.Properties",
|
|
bus_name="org.bluez", path_keyword="path")
|
|
bus.add_signal_receiver(added, signal_name="InterfacesAdded", dbus_interface="org.freedesktop.DBus.ObjectManager",
|
|
bus_name="org.bluez")
|
|
loop = GLib.MainLoop()
|
|
process = None
|
|
io_errors = []
|
|
try:
|
|
log(f"START expected_node={SERVER_ID:016x} expected_name={NAME} max_name_gap={MAX_GAP}s")
|
|
process = subprocess.Popen([str(args.peer.resolve()), str(args.seconds)], stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT)
|
|
os.set_blocking(process.stdout.fileno(), False)
|
|
buffered = b""
|
|
|
|
def drain_peer():
|
|
nonlocal buffered
|
|
while True:
|
|
try:
|
|
data = os.read(process.stdout.fileno(), 65536)
|
|
except BlockingIOError:
|
|
return True
|
|
if not data:
|
|
if buffered:
|
|
log("PEER " + buffered.decode("utf-8", errors="replace"))
|
|
buffered = b""
|
|
return False
|
|
buffered += data
|
|
while b"\n" in buffered:
|
|
line, buffered = buffered.split(b"\n", 1)
|
|
text = line.decode("utf-8", errors="replace").rstrip()
|
|
evidence.peer_line(text, time.monotonic())
|
|
log("PEER " + text)
|
|
|
|
def peer_output(stream, condition):
|
|
try:
|
|
return drain_peer()
|
|
except Exception as error:
|
|
io_errors.append(str(error))
|
|
log(f"ERROR peer output: {error}")
|
|
loop.quit()
|
|
return False
|
|
|
|
GLib.io_add_watch(process.stdout, GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR, peer_output)
|
|
|
|
def finished():
|
|
if process.poll() is None:
|
|
return True
|
|
drain_peer()
|
|
loop.quit()
|
|
return False
|
|
|
|
GLib.timeout_add(100, finished)
|
|
|
|
def timeout():
|
|
io_errors.append("observer deadline")
|
|
log("ERROR observer deadline")
|
|
loop.quit()
|
|
return False
|
|
|
|
GLib.timeout_add_seconds(args.seconds + 10, timeout)
|
|
loop.run()
|
|
except KeyboardInterrupt:
|
|
io_errors.append("interrupted")
|
|
log("ERROR interrupted")
|
|
except Exception as error:
|
|
io_errors.append(str(error))
|
|
log(f"ERROR {error}")
|
|
finally:
|
|
if process is not None and process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
log("ERROR peer did not stop; killing probe child")
|
|
process.kill()
|
|
process.wait()
|
|
result = evidence.result(process.returncode if process is not None else -1)
|
|
result["errors"] = io_errors
|
|
result["ok"] = result["ok"] and not io_errors
|
|
log(("DUAL_OBSERVER_PASS " if result["ok"] else "DUAL_OBSERVER_FAIL ") + json.dumps(result, ensure_ascii=False))
|
|
output.close()
|
|
return 0 if result["ok"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|