Add BLE layer: firmware, coordinator support, dashboard tab
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user