feat: add safe BLE discovery and network baseline tools
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""Return a stable UTC timestamp for manifests and capture artifacts."""
|
||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def write_json_atomic(path: Path, payload: Any) -> None:
|
||||
"""Write JSON without exposing a partially written artifact."""
|
||||
path = path.expanduser()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
serialized = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
temp_name: str | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as stream:
|
||||
temp_name = stream.name
|
||||
stream.write(serialized)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
Path(temp_name).replace(path)
|
||||
finally:
|
||||
if temp_name is not None:
|
||||
Path(temp_name).unlink(missing_ok=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""Bluetooth Low Energy discovery tools."""
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from importlib.metadata import version
|
||||
from typing import TypedDict
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
|
||||
class DescriptorRecord(TypedDict):
|
||||
uuid: str
|
||||
handle: int
|
||||
description: str
|
||||
|
||||
|
||||
class CharacteristicRecord(TypedDict):
|
||||
uuid: str
|
||||
handle: int
|
||||
description: str
|
||||
properties: list[str]
|
||||
descriptors: list[DescriptorRecord]
|
||||
|
||||
|
||||
class ServiceRecord(TypedDict):
|
||||
uuid: str
|
||||
handle: int
|
||||
description: str
|
||||
characteristics: list[CharacteristicRecord]
|
||||
|
||||
|
||||
class GattDumpResult(TypedDict):
|
||||
schema_version: int
|
||||
started_at_utc: str
|
||||
completed_at_utc: str
|
||||
adapter: str
|
||||
bleak_version: str
|
||||
device_macos_uuid: str
|
||||
device_name: str
|
||||
metadata_only: bool
|
||||
services: list[ServiceRecord]
|
||||
|
||||
|
||||
async def dump_metadata(device_macos_uuid: str, timeout_seconds: float) -> GattDumpResult:
|
||||
"""Connect and enumerate GATT metadata without characteristic reads or writes."""
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
|
||||
started_at = utc_now_iso()
|
||||
async with asyncio.timeout(timeout_seconds):
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
services: list[ServiceRecord] = []
|
||||
for service in client.services:
|
||||
characteristics: list[CharacteristicRecord] = []
|
||||
for characteristic in service.characteristics:
|
||||
descriptors: list[DescriptorRecord] = []
|
||||
for descriptor in characteristic.descriptors:
|
||||
descriptors.append(
|
||||
{
|
||||
"uuid": descriptor.uuid,
|
||||
"handle": descriptor.handle,
|
||||
"description": descriptor.description,
|
||||
}
|
||||
)
|
||||
characteristics.append(
|
||||
{
|
||||
"uuid": characteristic.uuid,
|
||||
"handle": characteristic.handle,
|
||||
"description": characteristic.description,
|
||||
"properties": sorted(characteristic.properties),
|
||||
"descriptors": descriptors,
|
||||
}
|
||||
)
|
||||
services.append(
|
||||
{
|
||||
"uuid": service.uuid,
|
||||
"handle": service.handle,
|
||||
"description": service.description,
|
||||
"characteristics": characteristics,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": client.name,
|
||||
"metadata_only": True,
|
||||
"services": services,
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version
|
||||
from typing import TypedDict
|
||||
|
||||
from bleak import BleakScanner
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
|
||||
class BleDeviceRecord(TypedDict):
|
||||
macos_uuid: str
|
||||
id_kind: str
|
||||
name: str | None
|
||||
local_name: str | None
|
||||
rssi: int
|
||||
tx_power: int | None
|
||||
service_uuids: list[str]
|
||||
manufacturer_data_hex: dict[str, str]
|
||||
service_data_hex: dict[str, str]
|
||||
k1_name_candidate: bool
|
||||
|
||||
|
||||
class BleScanResult(TypedDict):
|
||||
schema_version: int
|
||||
started_at_utc: str
|
||||
completed_at_utc: str
|
||||
duration_seconds: float
|
||||
adapter: str
|
||||
bleak_version: str
|
||||
device_count: int
|
||||
devices: list[BleDeviceRecord]
|
||||
|
||||
|
||||
def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) -> BleDeviceRecord:
|
||||
name = advertisement.local_name or device.name
|
||||
normalized_name = (name or "").casefold()
|
||||
return {
|
||||
"macos_uuid": device.address,
|
||||
"id_kind": "corebluetooth_uuid",
|
||||
"name": device.name,
|
||||
"local_name": advertisement.local_name,
|
||||
"rssi": advertisement.rssi,
|
||||
"tx_power": advertisement.tx_power,
|
||||
"service_uuids": sorted(advertisement.service_uuids),
|
||||
"manufacturer_data_hex": {
|
||||
str(company_id): data.hex()
|
||||
for company_id, data in sorted(advertisement.manufacturer_data.items())
|
||||
},
|
||||
"service_data_hex": {
|
||||
service_uuid: data.hex()
|
||||
for service_uuid, data in sorted(advertisement.service_data.items())
|
||||
},
|
||||
"k1_name_candidate": any(marker in normalized_name for marker in ("lixel", "xgrids", "k1")),
|
||||
}
|
||||
|
||||
|
||||
async def scan(duration_seconds: float) -> BleScanResult:
|
||||
if duration_seconds <= 0:
|
||||
raise ValueError("duration_seconds must be positive")
|
||||
|
||||
started_at = utc_now_iso()
|
||||
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
|
||||
devices = [
|
||||
advertisement_record(device, advertisement) for device, advertisement in discovered.values()
|
||||
]
|
||||
devices.sort(
|
||||
key=lambda item: (not item["k1_name_candidate"], -item["rssi"], item["macos_uuid"])
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"duration_seconds": duration_seconds,
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_count": len(devices),
|
||||
"devices": devices,
|
||||
}
|
||||
+92
-1
@@ -1,18 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
from typing import Annotated, TypedDict
|
||||
|
||||
import typer
|
||||
from bleak.exc import BleakError
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from k1link import __version__
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.ble.gatt import dump_metadata
|
||||
from k1link.ble.scanner import scan
|
||||
from k1link.net.snapshot import snapshot
|
||||
|
||||
app = typer.Typer(
|
||||
name="k1link",
|
||||
@@ -20,6 +26,10 @@ app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
)
|
||||
console = Console()
|
||||
ble_app = typer.Typer(help="Bluetooth LE discovery and metadata commands.", no_args_is_help=True)
|
||||
net_app = typer.Typer(help="Passive local network observation commands.", no_args_is_help=True)
|
||||
app.add_typer(ble_app, name="ble")
|
||||
app.add_typer(net_app, name="net")
|
||||
|
||||
|
||||
class ToolStatus(TypedDict):
|
||||
@@ -168,5 +178,86 @@ def doctor(
|
||||
console.print(f"- {note}")
|
||||
|
||||
|
||||
@ble_app.command("scan")
|
||||
def ble_scan(
|
||||
out: Annotated[
|
||||
Path,
|
||||
typer.Option(help="Ignored session JSON path; parent directories are created."),
|
||||
],
|
||||
duration: Annotated[
|
||||
float,
|
||||
typer.Option(min=1.0, max=300.0, help="Scan duration in seconds."),
|
||||
] = 30.0,
|
||||
) -> None:
|
||||
"""Discover BLE advertisements without connecting or changing device configuration."""
|
||||
try:
|
||||
result = asyncio.run(scan(duration))
|
||||
except (BleakError, OSError, ValueError) as exc:
|
||||
console.print(f"[red]BLE scan failed:[/red] {type(exc).__name__}: {exc}")
|
||||
console.print("Check System Settings → Privacy & Security → Bluetooth.")
|
||||
raise typer.Exit(code=2) from exc
|
||||
|
||||
write_json_atomic(out, result)
|
||||
table = Table(title=f"BLE devices ({result['device_count']})")
|
||||
table.add_column("Candidate")
|
||||
table.add_column("Name")
|
||||
table.add_column("RSSI")
|
||||
table.add_column("macOS UUID")
|
||||
for device in result["devices"]:
|
||||
table.add_row(
|
||||
"K1?" if device["k1_name_candidate"] else "",
|
||||
device["local_name"] or device["name"] or "-",
|
||||
str(device["rssi"]),
|
||||
device["macos_uuid"],
|
||||
)
|
||||
console.print(table)
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
@ble_app.command("gatt-dump")
|
||||
def ble_gatt_dump(
|
||||
device: Annotated[str, typer.Option(help="CoreBluetooth/macOS UUID from ble scan.")],
|
||||
out: Annotated[
|
||||
Path,
|
||||
typer.Option(help="Ignored session JSON path; parent directories are created."),
|
||||
],
|
||||
timeout: Annotated[
|
||||
float,
|
||||
typer.Option(min=5.0, max=120.0, help="Connection timeout in seconds."),
|
||||
] = 45.0,
|
||||
) -> None:
|
||||
"""Enumerate GATT metadata only: no characteristic reads, subscriptions or writes."""
|
||||
console.print(
|
||||
"Connecting for service discovery only; no characteristic values will be read or written."
|
||||
)
|
||||
try:
|
||||
result = asyncio.run(dump_metadata(device, timeout))
|
||||
except (BleakError, OSError, ValueError) as exc:
|
||||
console.print(f"[red]GATT metadata failed:[/red] {type(exc).__name__}: {exc}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
|
||||
write_json_atomic(out, result)
|
||||
characteristic_count = sum(len(service["characteristics"]) for service in result["services"])
|
||||
console.print(
|
||||
f"Device: {result['device_name']}; services: {len(result['services'])}; "
|
||||
f"characteristics: {characteristic_count}"
|
||||
)
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
@net_app.command("snapshot")
|
||||
def net_snapshot(
|
||||
out: Annotated[
|
||||
Path,
|
||||
typer.Option(help="Ignored session JSON path; parent directories are created."),
|
||||
],
|
||||
) -> None:
|
||||
"""Save routes, interfaces and the existing neighbor table without scanning the LAN."""
|
||||
result = snapshot()
|
||||
write_json_atomic(out, result)
|
||||
console.print("Saved a sensitive local network snapshot; do not commit the output.")
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Passive network observation tools."""
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import TypedDict
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
|
||||
class CommandRecord(TypedDict):
|
||||
argv: list[str]
|
||||
returncode: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
error: str | None
|
||||
|
||||
|
||||
class NetworkSnapshot(TypedDict):
|
||||
schema_version: int
|
||||
created_at_utc: str
|
||||
sensitivity: str
|
||||
commands: list[CommandRecord]
|
||||
|
||||
|
||||
def command_record(argv: list[str]) -> CommandRecord:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return {
|
||||
"argv": argv,
|
||||
"returncode": None,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
return {
|
||||
"argv": argv,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def snapshot() -> NetworkSnapshot:
|
||||
"""Collect a local read-only network snapshot; no packets are transmitted intentionally."""
|
||||
commands = [
|
||||
["route", "-n", "get", "default"],
|
||||
["networksetup", "-listallhardwareports"],
|
||||
["scutil", "--nwi"],
|
||||
["arp", "-an"],
|
||||
]
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"created_at_utc": utc_now_iso(),
|
||||
"sensitivity": (
|
||||
"contains local interface, route, IP/MAC and neighbor metadata; do not commit"
|
||||
),
|
||||
"commands": [command_record(command) for command in commands],
|
||||
}
|
||||
Reference in New Issue
Block a user