Add BLE layer: firmware, coordinator support, dashboard tab

This commit is contained in:
bot
2026-04-27 14:21:02 +03:00
parent 2ebafc5584
commit 1ca2dc21f5
8 changed files with 807 additions and 13 deletions
+110
View File
@@ -131,6 +131,49 @@ def _node_status(last_seen: str | None, threshold: int = ONLINE_SECS) -> str:
# ─── API data builders ────────────────────────────────────────────────────────
# ─── BLE classification ──────────────────────────────────────────────────────
BLE_COMPANIES: dict[int, str] = {
0x004C: "Apple",
0x0075: "Samsung",
0x0006: "Microsoft",
0x00E0: "Google",
0x0059: "Nordic Semiconductor",
0x0138: "Garmin",
0x0499: "Ruuvi Innovations",
0x0157: "Polar Electro",
0x00BD: "Fitbit",
0x0171: "Amazon",
0x004F: "MediaTek",
0x001D: "Qualcomm",
}
# Apple manufacturer data: after the 2-byte company ID, byte 0 of mfr_data is the type.
APPLE_TYPES: dict[str, str] = {
"02": "iBeacon",
"05": "AirDrop",
"07": "HomeKit",
"09": "AirPods",
"0a": "AirPods Pro",
"0b": "AirPods 2nd gen",
"0c": "AirPods case / Watch",
"0f": "AirPods 3rd gen",
"12": "FindMy / AirTag",
"13": "AirPods 4th gen",
"15": "Proximity Pair",
"1e": "AirPods Pro 2nd gen",
}
def ble_device_type(mfr_id: int | None, mfr_data: str | None) -> str:
if mfr_id is None:
return "Unknown"
if mfr_id == 0x004C and mfr_data and len(mfr_data) >= 2:
return APPLE_TYPES.get(mfr_data[:2].lower(), "Apple device")
vendor = BLE_COMPANIES.get(mfr_id)
return f"{vendor} device" if vendor else f"Unknown (0x{mfr_id:04X})"
_nodes_cache: tuple[float, list] | None = None
_networks_cache: tuple[float, list] | None = None
_clients_cache: tuple[float, list] | None = None
@@ -138,6 +181,7 @@ _cross_node_cache: tuple[float, dict] | None = None
_presence_cache: tuple[float, dict] | None = None
_alerts_cache: tuple[float, dict] | None = None
_sessions_cache: tuple[float, list] | None = None
_ble_cache: tuple[float, dict] | None = None
_NODES_CACHE_TTL = 10 # seconds
_NETWORKS_CACHE_TTL = 30 # seconds
_CLIENTS_CACHE_TTL = 30 # seconds
@@ -145,6 +189,7 @@ _CROSS_NODE_CACHE_TTL = 60 # seconds
_PRESENCE_CACHE_TTL = 60 # seconds
_ALERTS_CACHE_TTL = 60 # seconds
_SESSIONS_CACHE_TTL = 60 # seconds
_BLE_CACHE_TTL = 30 # seconds
def build_nodes() -> list[dict]:
global _nodes_cache
@@ -1190,6 +1235,12 @@ async def api_node_detail(node_id: str):
return detail
@app.get("/api/ble")
async def api_ble():
data = await asyncio.to_thread(build_ble)
return data
@app.get("/api/stream")
async def api_stream():
"""SSE stream — polls DB every 2 s, pushes new events to the browser."""
@@ -1225,6 +1276,65 @@ async def api_stream():
)
def build_ble() -> dict:
global _ble_cache
now = datetime.datetime.utcnow().timestamp()
if _ble_cache and (now - _ble_cache[0]) < _BLE_CACHE_TTL:
return _ble_cache[1]
cutoff = _hot_cutoff()
devices = query("""
SELECT
mac, addr_type,
MAX(name) AS name,
mfr_id,
MAX(mfr_data) AS mfr_data,
MAX(rssi) AS best_rssi,
COUNT(*) AS times_seen,
MIN(received_at) AS first_seen,
MAX(received_at) AS last_seen,
COUNT(DISTINCT node_id) AS node_count
FROM ble_events
WHERE received_at >= ?
GROUP BY mac
ORDER BY times_seen DESC
LIMIT 300
""", (cutoff,))
for d in devices:
d["type"] = ble_device_type(d["mfr_id"], d["mfr_data"])
d["vendor"] = BLE_COMPANIES.get(d["mfr_id"], "Unknown") if d["mfr_id"] is not None else "Unknown"
feed = query("""
SELECT received_at, node_id, mac, addr_type, name, rssi, mfr_id, mfr_data
FROM ble_events
WHERE received_at >= ?
ORDER BY id DESC
LIMIT 100
""", (cutoff,))
for f in feed:
f["type"] = ble_device_type(f["mfr_id"], f["mfr_data"])
# Manufacturer breakdown
breakdown = query("""
SELECT mfr_id, COUNT(DISTINCT mac) AS unique_devices
FROM ble_events
WHERE received_at >= ?
GROUP BY mfr_id
ORDER BY unique_devices DESC
LIMIT 20
""", (cutoff,))
for b in breakdown:
b["vendor"] = BLE_COMPANIES.get(b["mfr_id"], "Unknown") if b["mfr_id"] is not None else "No mfr data"
result = {"devices": devices, "feed": feed, "breakdown": breakdown}
_ble_cache = (now, result)
return result
# ─── Entry point ─────────────────────────────────────────────────────────────
if __name__ == "__main__":