feat: add safe BLE discovery and network baseline tools

This commit is contained in:
DCCONSTRUCTIONS
2026-07-15 15:26:14 +03:00
parent 4c401bc59b
commit faa442eefc
16 changed files with 666 additions and 6 deletions
+39
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Bluetooth Low Energy discovery tools."""
+104
View File
@@ -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,
}
+81
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Passive network observation tools."""
+65
View File
@@ -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],
}