mirror of
https://github.com/ruvnet/RuView
synced 2026-07-23 17:33:20 +00:00
feat: ADR-069 ESP32 CSI → Cognitum Seed RVF pipeline (v0.5.4-esp32)
Hardware-validated pipeline connecting ESP32-S3 CSI sensing to Cognitum Seed (Pi Zero 2 W) edge intelligence appliance via 8-dim feature vectors. Firmware: - New 48-byte feature vector packet (magic 0xC5110003) at 1 Hz with normalized presence, motion, breathing, heart rate, phase variance, person count, fall detection, and RSSI - Compressed frame magic reassigned 0xC5110003 → 0xC5110005 - Guard against uninitialized s_top_k read when count=0 Bridge (scripts/seed_csi_bridge.py): - UDP→HTTPS ingest with bearer token, hash-based vector IDs - --validate (kNN), --stats, --compact, --allowed-sources modes - NaN/inf rejection, retry logic, SEED_TOKEN env var support Validated on live hardware: - 941 vectors ingested, 100% kNN exact match - Witness chain SHA-256 verified (1,325 entries) - 1,463 Rust tests passed, Python proof VERDICT: PASS Research: 26 docs covering Arena Physica, Maxwell's equations in WiFi sensing, SOTA survey 2025-2026, GOAP implementation plan Security: removed hardcoded credentials, added NVS patterns to .gitignore, source IP filtering, NaN validation Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Release script for v0.5.4-esp32
|
||||
# Run AFTER firmware build completes and all tests pass
|
||||
#
|
||||
# Prerequisites:
|
||||
# - firmware/esp32-csi-node/build/esp32-csi-node.bin (8MB build)
|
||||
# - All Rust tests passing (1,031+)
|
||||
# - Python proof VERDICT: PASS
|
||||
#
|
||||
# Usage: bash scripts/release-v0.5.4.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TAG="v0.5.4-esp32"
|
||||
BUILD_DIR="firmware/esp32-csi-node/build"
|
||||
DIST_DIR="dist/${TAG}"
|
||||
|
||||
echo "=== Preparing release ${TAG} ==="
|
||||
|
||||
# Verify build artifacts exist
|
||||
for f in \
|
||||
"${BUILD_DIR}/esp32-csi-node.bin" \
|
||||
"${BUILD_DIR}/bootloader/bootloader.bin" \
|
||||
"${BUILD_DIR}/partition_table/partition-table.bin" \
|
||||
"${BUILD_DIR}/ota_data_initial.bin"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "ERROR: Missing build artifact: $f"
|
||||
echo "Run the firmware build first."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Create dist directory
|
||||
mkdir -p "${DIST_DIR}"
|
||||
|
||||
# Copy binaries
|
||||
cp "${BUILD_DIR}/esp32-csi-node.bin" "${DIST_DIR}/"
|
||||
cp "${BUILD_DIR}/bootloader/bootloader.bin" "${DIST_DIR}/"
|
||||
cp "${BUILD_DIR}/partition_table/partition-table.bin" "${DIST_DIR}/"
|
||||
cp "${BUILD_DIR}/ota_data_initial.bin" "${DIST_DIR}/"
|
||||
|
||||
# Generate SHA-256 hashes
|
||||
echo "=== SHA-256 Hashes ==="
|
||||
cd "${DIST_DIR}"
|
||||
sha256sum *.bin > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
cd -
|
||||
|
||||
# Binary sizes
|
||||
echo ""
|
||||
echo "=== Binary Sizes ==="
|
||||
ls -lh "${DIST_DIR}"/*.bin
|
||||
|
||||
echo ""
|
||||
echo "=== Release artifacts ready in ${DIST_DIR} ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Flash to COM9: esptool.py --chip esp32s3 --port COM9 write_flash 0x0 ${DIST_DIR}/bootloader.bin 0x8000 ${DIST_DIR}/partition-table.bin 0xd000 ${DIST_DIR}/ota_data_initial.bin 0x10000 ${DIST_DIR}/esp32-csi-node.bin"
|
||||
echo " 2. Tag: git tag ${TAG}"
|
||||
echo " 3. Push: git push origin ${TAG}"
|
||||
echo " 4. Release: gh release create ${TAG} ${DIST_DIR}/*.bin ${DIST_DIR}/SHA256SUMS.txt --title 'ESP32-S3 CSI Firmware ${TAG} — Cognitum Seed Integration' --notes-file -"
|
||||
@@ -0,0 +1,656 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ADR-069: ESP32 CSI → Cognitum Seed RVF Ingest Bridge
|
||||
|
||||
Listens for CSI feature vectors from ESP32 nodes via UDP, batches them,
|
||||
and ingests into the Cognitum Seed's RVF vector store via HTTPS REST API.
|
||||
|
||||
Usage:
|
||||
# Run bridge (default mode)
|
||||
python scripts/seed_csi_bridge.py \
|
||||
--seed-url https://169.254.42.1:8443 \
|
||||
--token "$SEED_TOKEN" \
|
||||
--udp-port 5006 \
|
||||
--batch-size 10
|
||||
|
||||
# Run with validation (kNN query + PIR comparison after each batch)
|
||||
python scripts/seed_csi_bridge.py \
|
||||
--token TOKEN --validate
|
||||
|
||||
# Print Seed stats
|
||||
python scripts/seed_csi_bridge.py --token TOKEN --stats
|
||||
|
||||
# Trigger store compaction
|
||||
python scripts/seed_csi_bridge.py --token TOKEN --compact
|
||||
|
||||
The bridge also accepts legacy ADR-018 CSI frames (magic 0xC5110001/0xC5110002)
|
||||
and extracts a simplified 8-dim feature vector from the raw data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import math
|
||||
import ssl
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("seed-bridge")
|
||||
|
||||
# Packet magic numbers
|
||||
MAGIC_CSI_RAW = 0xC5110001 # ADR-018 raw CSI frame
|
||||
MAGIC_VITALS = 0xC5110002 # ADR-039 vitals packet
|
||||
MAGIC_FEATURES = 0xC5110003 # ADR-069 feature vector (new)
|
||||
|
||||
# Feature vector packet: 4 + 1 + 1 + 2 + 8 + 32 = 48 bytes
|
||||
FEATURE_PKT_FMT = "<IBBHq8f"
|
||||
FEATURE_PKT_SIZE = struct.calcsize(FEATURE_PKT_FMT) # 48
|
||||
|
||||
# Vitals packet (edge_processing.h edge_vitals_pkt_t, 32 bytes):
|
||||
# magic(4) + node_id(1) + flags(1) + breathing_rate(2) +
|
||||
# heartrate(4) + rssi(1) + n_persons(1) + reserved(2) +
|
||||
# motion_energy(4) + presence_score(4) + timestamp_ms(4) + reserved2(4)
|
||||
VITALS_PKT_FMT = "<IBBHIbBxxffII"
|
||||
VITALS_PKT_SIZE = 32
|
||||
|
||||
# Default flush interval in seconds (time-based batching)
|
||||
DEFAULT_FLUSH_INTERVAL = 10.0
|
||||
|
||||
|
||||
def parse_feature_packet(data: bytes) -> dict | None:
|
||||
"""Parse an ADR-069 feature vector packet."""
|
||||
if len(data) < FEATURE_PKT_SIZE:
|
||||
return None
|
||||
magic, node_id, _, seq, ts, *features = struct.unpack_from(FEATURE_PKT_FMT, data)
|
||||
if magic != MAGIC_FEATURES:
|
||||
return None
|
||||
# Reject NaN/inf in raw feature values before they reach the vector store
|
||||
for i, f in enumerate(features):
|
||||
if math.isnan(f) or math.isinf(f):
|
||||
log.warning("Dropping feature packet: features[%d]=%s (NaN/inf)", i, f)
|
||||
return None
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"seq": seq,
|
||||
"timestamp_us": ts,
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
def parse_vitals_packet(data: bytes) -> dict | None:
|
||||
"""Parse an ADR-039 vitals packet and extract an 8-dim feature vector."""
|
||||
if len(data) < VITALS_PKT_SIZE:
|
||||
return None
|
||||
try:
|
||||
fields = struct.unpack_from(VITALS_PKT_FMT, data)
|
||||
except struct.error:
|
||||
return None
|
||||
magic = fields[0]
|
||||
if magic != MAGIC_VITALS:
|
||||
return None
|
||||
node_id = fields[1]
|
||||
flags = fields[2]
|
||||
breathing_rate_raw = fields[3] # BPM * 100
|
||||
heartrate_raw = fields[4] # BPM * 10000
|
||||
rssi = fields[5] # int8
|
||||
n_persons = fields[6]
|
||||
motion_energy = fields[7] # float
|
||||
presence_score = fields[8] # float
|
||||
timestamp_ms = fields[9]
|
||||
|
||||
# Reject NaN/inf in raw float fields before clamping (clamp masks NaN)
|
||||
if math.isnan(motion_energy) or math.isinf(motion_energy):
|
||||
log.warning("Dropping vitals packet: motion_energy=%s (NaN/inf)", motion_energy)
|
||||
return None
|
||||
if math.isnan(presence_score) or math.isinf(presence_score):
|
||||
log.warning("Dropping vitals packet: presence_score=%s (NaN/inf)", presence_score)
|
||||
return None
|
||||
|
||||
# Convert from fixed-point
|
||||
br_bpm = breathing_rate_raw / 100.0
|
||||
hr_bpm = heartrate_raw / 10000.0
|
||||
presence = (flags & 0x01) != 0
|
||||
fall = (flags & 0x02) != 0
|
||||
motion = (flags & 0x04) != 0
|
||||
|
||||
# Normalize to 0.0-1.0 range for 8-dim RVF vector.
|
||||
# Live readings show presence_score in 0-15 range and motion_energy in 0-10 range,
|
||||
# so divide by their respective maxima before clamping.
|
||||
features = [
|
||||
max(0.0, min(1.0, presence_score / 15.0)), # dim 0: presence score (raw 0-15)
|
||||
max(0.0, min(1.0, motion_energy / 10.0)), # dim 1: motion level (raw 0-10)
|
||||
max(0.0, min(1.0, br_bpm / 30.0)) if br_bpm > 0 else 0.0, # dim 2: breathing rate
|
||||
max(0.0, min(1.0, hr_bpm / 120.0)) if hr_bpm > 0 else 0.0, # dim 3: heart rate
|
||||
0.5, # dim 4: phase variance (future)
|
||||
float(n_persons) / 4.0 if n_persons <= 4 else 1.0, # dim 5: person count
|
||||
1.0 if fall else 0.0, # dim 6: fall detected
|
||||
max(0.0, min(1.0, (rssi + 100) / 100.0)), # dim 7: RSSI normalized
|
||||
]
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"seq": timestamp_ms,
|
||||
"timestamp_us": int(time.time() * 1_000_000),
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
def parse_raw_csi_packet(data: bytes) -> dict | None:
|
||||
"""Parse an ADR-018 raw CSI frame and extract basic features."""
|
||||
if len(data) < 8:
|
||||
return None
|
||||
magic = struct.unpack_from("<I", data)[0]
|
||||
if magic != MAGIC_CSI_RAW:
|
||||
return None
|
||||
# Extract node_id (byte 4) and RSSI (byte 5, signed)
|
||||
node_id = data[4] if len(data) > 4 else 0
|
||||
rssi = struct.unpack_from("b", data, 5)[0] if len(data) > 5 else -70
|
||||
# Minimal feature vector from raw CSI -- mostly placeholder
|
||||
features = [0.5, 0.5, 0.0, 0.0, 0.5, 0.5, 0.0, max(0.0, min(1.0, (rssi + 100) / 100.0))]
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"seq": 0,
|
||||
"timestamp_us": int(time.time() * 1_000_000),
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
def _validate_features(parsed: dict | None) -> dict | None:
|
||||
"""Reject packets with NaN, inf, or out-of-range feature values."""
|
||||
if parsed is None:
|
||||
return None
|
||||
features = parsed.get("features")
|
||||
if features is None:
|
||||
return None
|
||||
for i, f in enumerate(features):
|
||||
if math.isnan(f) or math.isinf(f):
|
||||
log.warning("Dropping packet: feature[%d] = %s (NaN/inf)", i, f)
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def parse_packet(data: bytes) -> dict | None:
|
||||
"""Try all packet formats."""
|
||||
if len(data) < 4:
|
||||
return None
|
||||
magic = struct.unpack_from("<I", data)[0]
|
||||
if magic == MAGIC_FEATURES:
|
||||
return _validate_features(parse_feature_packet(data))
|
||||
elif magic == MAGIC_VITALS:
|
||||
return _validate_features(parse_vitals_packet(data))
|
||||
elif magic == MAGIC_CSI_RAW:
|
||||
return _validate_features(parse_raw_csi_packet(data))
|
||||
return None
|
||||
|
||||
|
||||
def _make_vector_id(node_id: int, timestamp_us: int, seq_counter: int) -> int:
|
||||
"""Generate a unique vector ID from node_id + timestamp + sequence counter.
|
||||
|
||||
Uses a hash to produce a non-negative 32-bit integer, avoiding the
|
||||
content-addressed deduplication that occurs when all vectors use ID 0.
|
||||
"""
|
||||
key = f"{node_id}:{timestamp_us}:{seq_counter}".encode()
|
||||
digest = hashlib.sha256(key).digest()
|
||||
# Take first 4 bytes as unsigned 32-bit int
|
||||
return struct.unpack("<I", digest[:4])[0]
|
||||
|
||||
|
||||
class SeedClient:
|
||||
"""HTTPS client for Cognitum Seed REST API."""
|
||||
|
||||
def __init__(self, base_url: str, token: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
# Skip TLS verification for self-signed cert
|
||||
self.ctx = ssl.create_default_context()
|
||||
self.ctx.check_hostname = False
|
||||
self.ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def _request(self, method: str, path: str, body: dict | None = None,
|
||||
timeout: int = 10, auth: bool = True) -> dict:
|
||||
"""Issue an HTTP request and return parsed JSON.
|
||||
|
||||
Raises urllib.error.URLError on connection failure,
|
||||
urllib.error.HTTPError on non-2xx status, and
|
||||
ValueError on non-JSON response body.
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if auth:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, context=self.ctx, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"Non-JSON response from {method} {path} "
|
||||
f"(status {resp.status}): {raw[:200]!r}"
|
||||
) from exc
|
||||
|
||||
def ingest(self, vectors: list[tuple[int, list[float]]]) -> dict:
|
||||
"""Ingest vectors into the RVF store."""
|
||||
return self._request("POST", "/api/v1/store/ingest", {"vectors": vectors})
|
||||
|
||||
def query(self, vector: list[float], k: int = 5) -> dict:
|
||||
"""Query kNN for a vector."""
|
||||
return self._request("POST", "/api/v1/store/query", {"vector": vector, "k": k})
|
||||
|
||||
def compact(self) -> dict:
|
||||
"""Trigger store compaction."""
|
||||
return self._request("POST", "/api/v1/store/compact")
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Get device status."""
|
||||
return self._request("GET", "/api/v1/status", auth=False, timeout=5)
|
||||
|
||||
def boundary(self) -> dict:
|
||||
"""Get boundary analysis (fragility score)."""
|
||||
return self._request("GET", "/api/v1/boundary", auth=False, timeout=5)
|
||||
|
||||
def coherence_profile(self) -> dict:
|
||||
"""Get coherence profile."""
|
||||
return self._request("GET", "/api/v1/coherence/profile", auth=False, timeout=5)
|
||||
|
||||
def graph_stats(self) -> dict:
|
||||
"""Get kNN graph stats."""
|
||||
return self._request("GET", "/api/v1/store/graph/stats", auth=False, timeout=5)
|
||||
|
||||
def read_pir(self, pin: int = 6) -> dict | None:
|
||||
"""Read PIR sensor GPIO. Returns None if not available (404)."""
|
||||
try:
|
||||
return self._request("GET", f"/api/v1/sensor/gpio/read?pin={pin}",
|
||||
auth=False, timeout=5)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
raise
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def verify_witness(self) -> dict:
|
||||
"""Verify witness chain integrity."""
|
||||
return self._request("POST", "/api/v1/witness/verify", timeout=10)
|
||||
|
||||
|
||||
def _flush_batch(seed: SeedClient, batch: list, stats: dict,
|
||||
validate: bool = False, validation_stats: dict | None = None,
|
||||
last_features: list[float] | None = None) -> None:
|
||||
"""Ingest a batch of vectors into the Seed, with optional retry and validation."""
|
||||
max_attempts = 2
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
result = seed.ingest(batch)
|
||||
accepted = result.get("count", 0)
|
||||
epoch = result.get("new_epoch", "?")
|
||||
stats["ingested"] += accepted
|
||||
stats["batches"] += 1
|
||||
log.info(
|
||||
"Ingested %d vectors (epoch=%s, witness=%s)",
|
||||
accepted,
|
||||
epoch,
|
||||
str(result.get("witness_head", "?"))[:16] + "...",
|
||||
)
|
||||
break # success
|
||||
except Exception as e:
|
||||
if attempt == 0:
|
||||
log.warning("Ingest failed (attempt 1/2), retrying in 2s: %s", e)
|
||||
time.sleep(2.0)
|
||||
else:
|
||||
stats["errors"] += 1
|
||||
log.error("Ingest failed after retry: %s", e)
|
||||
return # skip validation on failure
|
||||
|
||||
# Validation: query the most recent vector and check kNN result
|
||||
if validate and last_features is not None and validation_stats is not None:
|
||||
_run_validation(seed, last_features, validation_stats)
|
||||
|
||||
|
||||
def _run_validation(seed: SeedClient, features: list[float],
|
||||
validation_stats: dict) -> None:
|
||||
"""Query kNN for the most recent vector and compare with PIR sensor."""
|
||||
try:
|
||||
qr = seed.query(features, k=1)
|
||||
results = qr.get("results", [])
|
||||
if results:
|
||||
dist = results[0].get("distance", -1)
|
||||
validation_stats["queries"] += 1
|
||||
if dist <= 0.01:
|
||||
validation_stats["exact_matches"] += 1
|
||||
log.info("Validation: kNN distance=%.6f (exact match)", dist)
|
||||
else:
|
||||
log.info("Validation: kNN distance=%.6f (approximate)", dist)
|
||||
else:
|
||||
log.warning("Validation: kNN returned empty results")
|
||||
except Exception as e:
|
||||
log.warning("Validation query failed: %s", e)
|
||||
|
||||
# PIR ground truth comparison
|
||||
csi_presence = features[0] # dim 0 is presence score
|
||||
csi_present = csi_presence > 0.3 # threshold for "someone present"
|
||||
try:
|
||||
pir = seed.read_pir(pin=6)
|
||||
if pir is not None:
|
||||
pir_state = bool(pir.get("value", 0))
|
||||
validation_stats["pir_readings"] += 1
|
||||
if csi_present == pir_state:
|
||||
validation_stats["pir_agreements"] += 1
|
||||
rate = (validation_stats["pir_agreements"] / validation_stats["pir_readings"] * 100
|
||||
if validation_stats["pir_readings"] > 0 else 0)
|
||||
log.info(
|
||||
"PIR=%s CSI_presence=%.2f (%s) — agreement %.1f%% (%d/%d)",
|
||||
"HIGH" if pir_state else "LOW",
|
||||
csi_presence,
|
||||
"present" if csi_present else "absent",
|
||||
rate,
|
||||
validation_stats["pir_agreements"],
|
||||
validation_stats["pir_readings"],
|
||||
)
|
||||
except Exception:
|
||||
pass # PIR not available, already handled gracefully
|
||||
|
||||
|
||||
def run_bridge(args):
|
||||
"""Main bridge loop: UDP -> batch -> HTTPS ingest."""
|
||||
seed = SeedClient(args.seed_url, args.token)
|
||||
|
||||
# Verify connectivity
|
||||
try:
|
||||
status = seed.status()
|
||||
log.info(
|
||||
"Connected to Seed %s — %d vectors, epoch %d, dim %d",
|
||||
status["device_id"][:8],
|
||||
status["total_vectors"],
|
||||
status["epoch"],
|
||||
status["dimension"],
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Cannot connect to Seed at %s: %s", args.seed_url, e)
|
||||
sys.exit(1)
|
||||
|
||||
# Parse allowed source IPs for UDP filtering (anti-spoofing)
|
||||
allowed_sources: set[str] | None = None
|
||||
if args.allowed_sources:
|
||||
allowed_sources = set(ip.strip() for ip in args.allowed_sources.split(",") if ip.strip())
|
||||
log.info("UDP source filter: only accepting packets from %s", allowed_sources)
|
||||
|
||||
# Open UDP listener
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("0.0.0.0", args.udp_port))
|
||||
sock.settimeout(1.0) # 1s timeout for responsive time-based flushing
|
||||
log.info(
|
||||
"Listening on UDP port %d (batch size: %d, flush interval: %.0fs)",
|
||||
args.udp_port, args.batch_size, args.flush_interval,
|
||||
)
|
||||
|
||||
batch: list[tuple[int, list[float]]] = []
|
||||
stats = {"received": 0, "ingested": 0, "errors": 0, "batches": 0}
|
||||
validation_stats = {"queries": 0, "exact_matches": 0, "pir_readings": 0, "pir_agreements": 0}
|
||||
last_log = time.time()
|
||||
last_flush = time.time()
|
||||
seq_counter = 0
|
||||
last_features: list[float] | None = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data, addr = sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
# Time-based flush: flush if interval elapsed and batch is non-empty
|
||||
now = time.time()
|
||||
if batch and (now - last_flush) >= args.flush_interval:
|
||||
_flush_batch(seed, batch, stats, args.validate,
|
||||
validation_stats, last_features)
|
||||
batch = []
|
||||
last_flush = now
|
||||
# Periodic status log
|
||||
if now - last_log > 30:
|
||||
log.info(
|
||||
"Stats: received=%d ingested=%d batches=%d errors=%d",
|
||||
stats["received"], stats["ingested"], stats["batches"], stats["errors"],
|
||||
)
|
||||
if args.validate and validation_stats["pir_readings"] > 0:
|
||||
rate = validation_stats["pir_agreements"] / validation_stats["pir_readings"] * 100
|
||||
log.info(
|
||||
"Validation: kNN queries=%d exact=%d | PIR agreement=%.1f%% (%d/%d)",
|
||||
validation_stats["queries"],
|
||||
validation_stats["exact_matches"],
|
||||
rate,
|
||||
validation_stats["pir_agreements"],
|
||||
validation_stats["pir_readings"],
|
||||
)
|
||||
last_log = now
|
||||
continue
|
||||
|
||||
# Source IP filtering (defense against UDP spoofing)
|
||||
if allowed_sources and addr[0] not in allowed_sources:
|
||||
log.debug("Dropping packet from unauthorized source %s", addr[0])
|
||||
continue
|
||||
|
||||
parsed = parse_packet(data)
|
||||
if parsed is None:
|
||||
continue
|
||||
|
||||
stats["received"] += 1
|
||||
seq_counter += 1
|
||||
|
||||
# Generate unique vector ID from hash(node_id + timestamp + seq)
|
||||
vec_id = _make_vector_id(parsed["node_id"], parsed["timestamp_us"], seq_counter)
|
||||
last_features = parsed["features"]
|
||||
batch.append((vec_id, parsed["features"]))
|
||||
|
||||
if args.verbose:
|
||||
log.debug(
|
||||
"node=%d seq=%d id=%08x features=[%s]",
|
||||
parsed["node_id"],
|
||||
parsed["seq"],
|
||||
vec_id,
|
||||
", ".join(f"{f:.3f}" for f in parsed["features"]),
|
||||
)
|
||||
|
||||
# Size-based flush
|
||||
if len(batch) >= args.batch_size:
|
||||
_flush_batch(seed, batch, stats, args.validate,
|
||||
validation_stats, last_features)
|
||||
batch = []
|
||||
last_flush = time.time()
|
||||
|
||||
# Also check time-based flush for slow packet rates
|
||||
if batch and (time.time() - last_flush) >= args.flush_interval:
|
||||
_flush_batch(seed, batch, stats, args.validate,
|
||||
validation_stats, last_features)
|
||||
batch = []
|
||||
last_flush = time.time()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log.info("Shutting down...")
|
||||
if batch:
|
||||
_flush_batch(seed, batch, stats, args.validate,
|
||||
validation_stats, last_features)
|
||||
finally:
|
||||
sock.close()
|
||||
log.info(
|
||||
"Final stats: received=%d ingested=%d batches=%d errors=%d",
|
||||
stats["received"], stats["ingested"], stats["batches"], stats["errors"],
|
||||
)
|
||||
if args.validate:
|
||||
log.info(
|
||||
"Validation: kNN queries=%d exact_matches=%d | PIR readings=%d agreements=%d",
|
||||
validation_stats["queries"],
|
||||
validation_stats["exact_matches"],
|
||||
validation_stats["pir_readings"],
|
||||
validation_stats["pir_agreements"],
|
||||
)
|
||||
# Verify witness chain on exit
|
||||
try:
|
||||
result = seed.verify_witness()
|
||||
log.info(
|
||||
"Witness chain: %s (length=%d)",
|
||||
"VALID" if result.get("valid") else "INVALID",
|
||||
result.get("chain_length", 0),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_stats(args):
|
||||
"""Query Seed and print comprehensive stats."""
|
||||
seed = SeedClient(args.seed_url, args.token)
|
||||
|
||||
# Status
|
||||
print("=== Seed Status ===")
|
||||
try:
|
||||
s = seed.status()
|
||||
print(f" Device ID: {s.get('device_id', '?')}")
|
||||
print(f" Total vectors: {s.get('total_vectors', '?')}")
|
||||
print(f" Epoch: {s.get('epoch', '?')}")
|
||||
print(f" Dimension: {s.get('dimension', '?')}")
|
||||
print(f" Uptime: {s.get('uptime_secs', '?')}s")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
# Witness chain
|
||||
print("\n=== Witness Chain ===")
|
||||
try:
|
||||
w = seed.verify_witness()
|
||||
print(f" Valid: {w.get('valid', '?')}")
|
||||
print(f" Chain length: {w.get('chain_length', '?')}")
|
||||
print(f" Head: {str(w.get('head', '?'))[:32]}...")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
# Boundary analysis
|
||||
print("\n=== Boundary Analysis ===")
|
||||
try:
|
||||
b = seed.boundary()
|
||||
print(f" Fragility score: {b.get('fragility_score', '?')}")
|
||||
print(f" Boundary count: {b.get('boundary_count', '?')}")
|
||||
for k, v in b.items():
|
||||
if k not in ("fragility_score", "boundary_count"):
|
||||
print(f" {k}: {v}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
# Coherence profile
|
||||
print("\n=== Coherence Profile ===")
|
||||
try:
|
||||
c = seed.coherence_profile()
|
||||
for k, v in c.items():
|
||||
print(f" {k}: {v}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
# kNN graph stats
|
||||
print("\n=== kNN Graph Stats ===")
|
||||
try:
|
||||
g = seed.graph_stats()
|
||||
for k, v in g.items():
|
||||
print(f" {k}: {v}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
|
||||
def run_compact(args):
|
||||
"""Trigger store compaction on the Seed."""
|
||||
seed = SeedClient(args.seed_url, args.token)
|
||||
print("Triggering store compaction...")
|
||||
try:
|
||||
result = seed.compact()
|
||||
print(f"Compaction result: {json.dumps(result, indent=2)}")
|
||||
except Exception as e:
|
||||
print(f"Compaction failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ADR-069: ESP32 CSI -> Cognitum Seed RVF Bridge"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed-url",
|
||||
default="https://169.254.42.1:8443",
|
||||
help="Cognitum Seed HTTPS URL (default: https://169.254.42.1:8443)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default=os.environ.get("SEED_TOKEN"),
|
||||
help="Bearer token from Seed pairing (or set SEED_TOKEN env var)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--udp-port",
|
||||
type=int,
|
||||
default=5006,
|
||||
help="UDP port to listen on (default: 5006)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Vectors per ingest batch (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--flush-interval",
|
||||
type=float,
|
||||
default=DEFAULT_FLUSH_INTERVAL,
|
||||
help="Max seconds between flushes (default: %.0f)" % DEFAULT_FLUSH_INTERVAL,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Log every received packet",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--validate",
|
||||
action="store_true",
|
||||
help="After each batch, query kNN and compare with PIR sensor",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stats",
|
||||
action="store_true",
|
||||
help="Print Seed stats (vectors, boundary, coherence, graph) and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compact",
|
||||
action="store_true",
|
||||
help="Trigger store compaction and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allowed-sources",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Comma-separated list of allowed source IPs for UDP packets "
|
||||
"(e.g. '192.168.1.105,192.168.1.106'). Packets from other IPs are dropped.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.token:
|
||||
parser.error("--token is required (or set SEED_TOKEN environment variable)")
|
||||
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
if args.stats:
|
||||
run_stats(args)
|
||||
elif args.compact:
|
||||
run_compact(args)
|
||||
else:
|
||||
run_bridge(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user