Compare commits
14 Commits
5d094e8eb1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 871689efdd | |||
| d85d22661b | |||
| f5b0983044 | |||
| 37c9f7d67d | |||
| 58ba021de1 | |||
| 41850b5892 | |||
| 4396c745a8 | |||
| 3aadc09e13 | |||
| 7535feb445 | |||
| a46af03f90 | |||
| 7a1c7ef621 | |||
| 515e2d1504 | |||
| 67c376e92c | |||
| 9f32e448ab |
@@ -0,0 +1,36 @@
|
|||||||
|
# Keep the image small and reproducible. The 11 GB test corpora, the local
|
||||||
|
# venv, and the vendored Flipper firmware are NOT needed to serve the app.
|
||||||
|
|
||||||
|
# Heavy / irrelevant
|
||||||
|
data/rf_test_datasets/
|
||||||
|
data/test_db/
|
||||||
|
data/test_known_devices/
|
||||||
|
data/test_rtl433_real/
|
||||||
|
signatures/
|
||||||
|
venv/
|
||||||
|
logs/
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
test_files/
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
# Benched Phase 3B CNN binaries (not on the serving path)
|
||||||
|
models/category_cnn.pt
|
||||||
|
models/category_cnn.onnx
|
||||||
|
|
||||||
|
# VCS / caches / editor
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Docs & planning (not needed at runtime)
|
||||||
|
*.md
|
||||||
|
CONTEXT.md
|
||||||
|
FABLE.md
|
||||||
|
PLAN_TO_PROD.md
|
||||||
@@ -106,3 +106,10 @@ site/
|
|||||||
# External signature databases (git repositories)
|
# External signature databases (git repositories)
|
||||||
signatures/flipperzero-firmware/
|
signatures/flipperzero-firmware/
|
||||||
signatures/rtl_433/
|
signatures/rtl_433/
|
||||||
|
|
||||||
|
# UI verification screenshots (scripts/verify_ui.mjs)
|
||||||
|
verify-shots/
|
||||||
|
|
||||||
|
# Trained model binaries (regenerated by training scripts, not versioned)
|
||||||
|
models/*.onnx
|
||||||
|
models/*.pt
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# GigLez — production image for the live "simple mode" API.
|
||||||
|
# Serves: FastAPI upload/map UI + statistical device-category identification.
|
||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
# Faster, quieter, unbuffered logs in containers.
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dependencies first for layer caching — only re-runs when reqs change.
|
||||||
|
COPY requirements-prod.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements-prod.txt
|
||||||
|
|
||||||
|
# Application code + assets the live path needs.
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY static/ ./static/
|
||||||
|
COPY templates/ ./templates/
|
||||||
|
COPY config/ ./config/
|
||||||
|
COPY models/category_classifier.joblib ./models/category_classifier.joblib
|
||||||
|
|
||||||
|
# Seed data: the static rtl_433 protocol table (read-only) and an initial
|
||||||
|
# capture store. When /app/data is mounted as a *named volume*, Docker seeds
|
||||||
|
# the empty volume from these baked files on first run, then persists writes
|
||||||
|
# to captures_simple.json across restarts.
|
||||||
|
COPY data/rtl_433_protocols.json data/captures_simple.json data/weather_sensors_found.txt ./data/
|
||||||
|
|
||||||
|
# Run as a non-root user.
|
||||||
|
RUN useradd --create-home --uid 10001 giglez \
|
||||||
|
&& chown -R giglez:giglez /app
|
||||||
|
USER giglez
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Container-native healthcheck hits the app's /health endpoint.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)"
|
||||||
|
|
||||||
|
# Serve with uvicorn (module app object), not the __main__ dev banner.
|
||||||
|
CMD ["uvicorn", "src.api.main_simple:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
# GigLez — Deployable-Artifact Verification
|
||||||
|
|
||||||
|
**Purpose:** prove that the *thing you actually ship* — the Docker container running
|
||||||
|
`src/api/main_simple.py` — serves the **full user journey**, end to end, the way a real
|
||||||
|
user experiences it. Unit tests and in-process checks passing is **not** the same as the
|
||||||
|
shipped app working. This doc is self-contained: you can clear context, come back, and run
|
||||||
|
it top to bottom.
|
||||||
|
|
||||||
|
Last grounded against the code on **2026-07-20** (branch `master`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. What "the deployable artifact" actually is
|
||||||
|
|
||||||
|
| Layer | What it is | Where |
|
||||||
|
|-------|-----------|-------|
|
||||||
|
| Container | `python:3.10-slim`, non-root, torch-free (417 MB) | `Dockerfile` |
|
||||||
|
| Process | `uvicorn src.api.main_simple:app` on `0.0.0.0:8000` | `docker-compose.yml` |
|
||||||
|
| Served UI | One SPA page + 6 JS modules | `templates/index.html`, `static/js/{detail-modal,map,upload,search,stats,main}.js` |
|
||||||
|
| API | FastAPI JSON endpoints (see §3) | `main_simple.py` |
|
||||||
|
| Data store | JSON file `{captures:[...], upload_counter, last_updated}` on a **named volume** `giglez_data:/app/data` | `data/captures_simple.json` |
|
||||||
|
| ML model | `models/category_classifier.joblib` (sklearn 1.6.1 / numpy 2.2.6 — pins must match) | baked into image |
|
||||||
|
|
||||||
|
**Runtime external dependencies (important):** the UI pulls Leaflet, Leaflet.markercluster,
|
||||||
|
Leaflet.heat, and Chart.js from **unpkg/jsdelivr CDNs**, and map tiles from the **OSM tile
|
||||||
|
server**. The artifact is **not self-contained for offline use** — an air-gapped host will
|
||||||
|
render a broken map. If offline deploy is ever a requirement, vendor these assets locally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The full user journey (the 9 things to prove)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. BUILD docker compose up --build → container reports healthy
|
||||||
|
2. LOAD GET / returns the SPA; all 6 JS + CSS assets load; no console errors
|
||||||
|
3. MAP Leaflet map renders; existing captures show as markers/clusters; stat counters populate
|
||||||
|
4. UPLOAD user picks a .sub, sets GPS (or "use my location"), submits → progress → result card with a device match
|
||||||
|
5. IDENTIFY the uploaded capture gets a device_name + device_category + confidence (parse→match→ML ran)
|
||||||
|
6. PLOT the new capture appears on the map at its GPS
|
||||||
|
7. SEARCH query by protocol / frequency / geo returns the capture
|
||||||
|
8. STATS counters + distributions reflect the new data
|
||||||
|
9. PERSIST restart the container → data survives (named volume did its job)
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification is layered so a failure tells you *where* it broke:
|
||||||
|
|
||||||
|
- **§2 Layer 0** — artifact builds & boots
|
||||||
|
- **§3 Layer 1** — API contract (deterministic curl/jq) → proves the backend serves the journey
|
||||||
|
- **§4 Layer 2** — browser/UI (Playwright) → proves the *actual UI a user touches* works
|
||||||
|
- **§5 Layer 3** — persistence across restart
|
||||||
|
- **§6 Layer 4** — regression guardrails (things fixed recently that must stay fixed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Layer 0 — Build & boot the real artifact
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/coding/security/giglez
|
||||||
|
|
||||||
|
# Build + run exactly what ships
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# Wait for healthy (compose healthcheck hits /health). Give it ~15s.
|
||||||
|
docker compose ps # STATUS should show "healthy"
|
||||||
|
docker inspect --format '{{.State.Health.Status}}' $(docker compose ps -q giglez)
|
||||||
|
```
|
||||||
|
|
||||||
|
**PASS:** container status = `healthy`.
|
||||||
|
**If it fails:** `docker compose logs giglez` — look for import errors (usually a pin
|
||||||
|
mismatch: the joblib model needs sklearn 1.6.1 / numpy 2.2.6; `requirements-prod.txt`, not
|
||||||
|
the stale root `requirements.txt`).
|
||||||
|
|
||||||
|
Set a base URL for the rest of the doc:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Caveat when running inside Claude Code's shell:** the `rtk` hook intercepts *piped*
|
||||||
|
> `curl` and returns a fake compressed body like `{"ok":true,"top":[]}`. Always write the
|
||||||
|
> response to a file first (`curl -s "$BASE/..." -o /tmp/r.json`) then inspect with
|
||||||
|
> `jq . /tmp/r.json`. In a normal human terminal this caveat does not apply.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layer 1 — API contract checks (deterministic)
|
||||||
|
|
||||||
|
These prove the backend serves stages 2–8 without a browser. Copy-paste each block; the
|
||||||
|
expected shape is shown.
|
||||||
|
|
||||||
|
### 3.1 Health + API banner + SPA HTML
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$BASE/health" -o /tmp/h.json; jq . /tmp/h.json
|
||||||
|
# expect: {"status":"healthy","database":"not_connected","mode":"simple"}
|
||||||
|
|
||||||
|
curl -s "$BASE/api" -o /tmp/api.json; jq .status /tmp/api.json
|
||||||
|
# expect: "operational"
|
||||||
|
|
||||||
|
curl -s "$BASE/" -o /tmp/index.html
|
||||||
|
grep -c 'id="map"' /tmp/index.html # expect: 1 (SPA shell served)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Static assets the page actually loads (stage 2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in detail-modal map upload search stats main; do
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/static/js/$f.js")
|
||||||
|
echo "$f.js -> $code" # every one must be 200
|
||||||
|
done
|
||||||
|
curl -s -o /dev/null -w 'main.css -> %{http_code}\n' "$BASE/static/css/main.css"
|
||||||
|
```
|
||||||
|
|
||||||
|
**PASS:** all `200`. A `404` here = broken page for real users.
|
||||||
|
|
||||||
|
### 3.3 Baseline data snapshot (stages 3 & 8)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$BASE/api/v1/stats/summary" -o /tmp/s0.json
|
||||||
|
jq '{total_captures, unique_devices, data_sources}' /tmp/s0.json
|
||||||
|
BASELINE=$(jq .total_captures /tmp/s0.json); echo "baseline captures = $BASELINE"
|
||||||
|
|
||||||
|
curl -s "$BASE/api/v1/query/captures?limit=1000" -o /tmp/q0.json
|
||||||
|
jq '.total' /tmp/q0.json # should equal $BASELINE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Upload → identify → persist (stages 4, 5, 9-write)
|
||||||
|
|
||||||
|
A minimal, self-contained RAW `.sub` (no dependency on the big dataset, which is *excluded*
|
||||||
|
from the image):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /tmp/sample.sub <<'SUB'
|
||||||
|
Filetype: Flipper SubGhz RAW File
|
||||||
|
Version: 1
|
||||||
|
Frequency: 433920000
|
||||||
|
Preset: FuriHalSubGhzPresetOok650Async
|
||||||
|
Protocol: RAW
|
||||||
|
RAW_Data: 350 -350 350 -700 700 -350 350 -350 700 -700 350 -350 350 -700 700 -350 350 -700 350 -350 700 -350 350 -700 350 -350 350 -700 700 -700 350 -350
|
||||||
|
SUB
|
||||||
|
|
||||||
|
MANIFEST='{"captures":[{"filename":"sample.sub","latitude":47.6062,"longitude":-122.3321,"timestamp":"2026-07-20T12:00:00Z"}]}'
|
||||||
|
|
||||||
|
curl -s -X POST "$BASE/api/v1/captures/upload" \
|
||||||
|
-F "files=@/tmp/sample.sub" \
|
||||||
|
-F "manifest=$MANIFEST" -o /tmp/up.json
|
||||||
|
|
||||||
|
jq '{success, total_successful, name:.successful[0].device_name,
|
||||||
|
category:.successful[0].device_category,
|
||||||
|
conf:.successful[0].match_confidence,
|
||||||
|
id:.successful[0].id}' /tmp/up.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**PASS:** `success=true`, `total_successful=1`, and `device_name` / `device_category` /
|
||||||
|
`id` are non-null (parse → match → ML category all ran). Note the returned `id`.
|
||||||
|
|
||||||
|
### 3.5 New capture is queryable & plottable (stages 6 & 8)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$BASE/api/v1/stats/summary" -o /tmp/s1.json
|
||||||
|
echo "after = $(jq .total_captures /tmp/s1.json) (expect baseline+1 = $((BASELINE+1)))"
|
||||||
|
|
||||||
|
# The capture carries GPS so the map can plot it:
|
||||||
|
NEWID=$(jq '.successful[0].id' /tmp/up.json)
|
||||||
|
curl -s "$BASE/api/v1/captures/$NEWID" -o /tmp/det.json
|
||||||
|
jq '.capture | {id, latitude, longitude, device_category}' /tmp/det.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**PASS:** count = baseline+1; the capture has numeric `latitude`/`longitude`.
|
||||||
|
|
||||||
|
### 3.6 Export (bonus)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$BASE/api/v1/export" -o /tmp/export.ndjson
|
||||||
|
head -1 /tmp/export.ndjson | jq . # each line is one capture record
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layer 2 — Browser / UI checks (the real user journey)
|
||||||
|
|
||||||
|
The API can be perfect while the UI is broken (bad JS, CDN blocked, map never inits). This
|
||||||
|
layer drives a **real headless browser** through the DOM. Node Playwright 1.58.2 is present.
|
||||||
|
|
||||||
|
Run the bundled script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/coding/security/giglez
|
||||||
|
BASE=http://localhost:8000 node scripts/verify_ui.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
It performs, against real element IDs from `index.html`:
|
||||||
|
|
||||||
|
1. `goto /`, collect **console errors** and **failed network requests**.
|
||||||
|
2. Assert the Leaflet map container `#map` exists and Leaflet initialised (`.leaflet-container`).
|
||||||
|
3. Read the `#total-captures` / `#unique-devices` counters.
|
||||||
|
4. Switch to the Upload section (`a[href="#upload"]`), set `#file-input` to `/tmp/sample.sub`,
|
||||||
|
fill `#default-lat` / `#default-lon`, click the **Upload** button (`onclick="uploadFiles()"`).
|
||||||
|
5. Wait for `#upload-results` to become visible and assert it shows a device match.
|
||||||
|
6. Re-query `/api/v1/query/captures` and assert the total incremented.
|
||||||
|
7. Save screenshots to `./verify-shots/` at each step.
|
||||||
|
|
||||||
|
**PASS criteria (the script prints a final PASS/FAIL and exits non-zero on failure):**
|
||||||
|
- 0 console errors, 0 failed asset requests
|
||||||
|
- `.leaflet-container` present (map actually rendered)
|
||||||
|
- upload results panel shows a device name + category
|
||||||
|
- capture total incremented by 1
|
||||||
|
|
||||||
|
If you'd rather check by eye: open `http://localhost:8000` in a browser, open DevTools
|
||||||
|
Console (should be clean), confirm the map tiles draw, then walk Upload → Search yourself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Layer 3 — Persistence across restart (stage 9)
|
||||||
|
|
||||||
|
The whole point of the named volume: a redeploy/restart must not lose user data.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BEFORE=$(curl -s "$BASE/api/v1/stats/summary" | jq .total_captures 2>/dev/null); echo "before=$BEFORE"
|
||||||
|
docker compose restart giglez
|
||||||
|
sleep 12
|
||||||
|
AFTER=$(curl -s "$BASE/api/v1/stats/summary" -o /tmp/sa.json && jq .total_captures /tmp/sa.json)
|
||||||
|
echo "after=$AFTER" # must equal before
|
||||||
|
```
|
||||||
|
|
||||||
|
**PASS:** `after == before`. If it resets, the volume mount or `save_captures()` path is
|
||||||
|
wrong and every restart wipes contributions.
|
||||||
|
|
||||||
|
> Harder test (proves the volume, not just the file): `docker compose down` (keeps named
|
||||||
|
> volumes) then `docker compose up -d` and re-check the count. Use `down -v` only when you
|
||||||
|
> intend to wipe the store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Layer 4 — Regression guardrails (recently fixed — must stay fixed)
|
||||||
|
|
||||||
|
### 6.1 BinRAW captures produce a category (commit `4396c74`)
|
||||||
|
BinRAW `.sub` used to yield zero identification. Upload one and confirm a category comes back:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /tmp/binraw.sub <<'SUB'
|
||||||
|
Filetype: Flipper SubGhz RAW File
|
||||||
|
Version: 1
|
||||||
|
Frequency: 433920000
|
||||||
|
Preset: FuriHalSubGhzPreset2FSKDev476Async
|
||||||
|
Protocol: BinRAW
|
||||||
|
Bit: 48
|
||||||
|
TE: 400
|
||||||
|
Bit_RAW: 48
|
||||||
|
Data_RAW: A9 3C 5F 12 87 D4
|
||||||
|
SUB
|
||||||
|
curl -s -X POST "$BASE/api/v1/captures/upload" \
|
||||||
|
-F "files=@/tmp/binraw.sub" \
|
||||||
|
-F 'manifest={"captures":[{"filename":"binraw.sub","latitude":47.61,"longitude":-122.33,"timestamp":"2026-07-20T12:05:00Z"}]}' \
|
||||||
|
-o /tmp/b.json
|
||||||
|
jq '.successful[0] | {device_category, match_confidence}' /tmp/b.json
|
||||||
|
```
|
||||||
|
**PASS:** `device_category` is non-null.
|
||||||
|
|
||||||
|
### 6.2 Per-device category shows the device's OWN catalog category (commit `41850b5`)
|
||||||
|
A named device must not be mislabeled with the ML overall call (e.g. "Acurite … → Fan
|
||||||
|
Controller"). Inspect the match list from §3.4's upload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jq '.successful[0] | {headline_category: .device_category,
|
||||||
|
per_device: [.matched_devices[] | {device_name, category}]}' /tmp/up.json
|
||||||
|
```
|
||||||
|
**PASS:** each `matched_devices[].category` is that device's real catalog category; the
|
||||||
|
top-level `.device_category` may differ (it's the ML overall call) — that's expected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Pass/fail scorecard
|
||||||
|
|
||||||
|
| # | Stage | Check | Pass condition |
|
||||||
|
|---|-------|-------|----------------|
|
||||||
|
| 0 | Build | §2 | container `healthy` |
|
||||||
|
| 2 | Load | §3.1–3.2 | SPA HTML + all 6 JS + CSS = 200 |
|
||||||
|
| 3 | Map data | §3.3 | stats + query return baseline |
|
||||||
|
| 4/5 | Upload+ID | §3.4 | success, device_name+category+id non-null |
|
||||||
|
| 6/8 | Plot+Stats | §3.5 | count = baseline+1, GPS present |
|
||||||
|
| 2–8 | **UI** | §4 | Playwright PASS, 0 console errors, map rendered, upload result shown |
|
||||||
|
| 9 | Persist | §5 | count unchanged after restart |
|
||||||
|
| R1 | BinRAW | §6.1 | category non-null |
|
||||||
|
| R2 | Category | §6.2 | per-device catalog category correct |
|
||||||
|
|
||||||
|
All green ⇒ the deployable artifact serves the full user journey and is safe to put in
|
||||||
|
front of a real user (beta). Remaining true prod gaps are **operational**, not functional:
|
||||||
|
public host, TLS/reverse proxy, and (optional) vendoring the CDN assets for offline use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Teardown
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down # keep the named volume (data persists)
|
||||||
|
# docker compose down -v # ONLY if you want to wipe the capture store
|
||||||
|
rm -f /tmp/sample.sub /tmp/binraw.sub /tmp/*.json /tmp/index.html /tmp/export.ndjson
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do not** commit any captures created during verification — the JSON store is baked into
|
||||||
|
the image as seed data. If you uploaded against a local (non-Docker) `main_simple`, strip
|
||||||
|
your test records from `data/captures_simple.json` before committing (filter by the test
|
||||||
|
GPS you used).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# GigLez — single-service deployment of the live simple-mode API.
|
||||||
|
# docker compose up --build → http://localhost:8000
|
||||||
|
services:
|
||||||
|
giglez:
|
||||||
|
build: .
|
||||||
|
image: giglez:latest
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
# Persist the JSON capture store across restarts. Seeded from the
|
||||||
|
# baked-in data/ on first run (see Dockerfile), then app writes survive.
|
||||||
|
- giglez_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 15s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
giglez_data:
|
||||||
Binary file not shown.
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"best_model": "gradient_boost",
|
||||||
|
"n_samples": 803,
|
||||||
|
"n_train": 583,
|
||||||
|
"n_test": 220,
|
||||||
|
"features": [
|
||||||
|
"freq_mhz",
|
||||||
|
"short_pulse_us",
|
||||||
|
"long_pulse_us",
|
||||||
|
"pulse_ratio",
|
||||||
|
"short_gap_us",
|
||||||
|
"long_gap_us",
|
||||||
|
"gap_ratio",
|
||||||
|
"duty_cycle",
|
||||||
|
"pulse_count",
|
||||||
|
"pulse_mean_abs",
|
||||||
|
"pulse_std_abs",
|
||||||
|
"pulse_min_abs",
|
||||||
|
"pulse_max_abs",
|
||||||
|
"preamble_type"
|
||||||
|
],
|
||||||
|
"class_balance": {
|
||||||
|
"Doorbell": 39,
|
||||||
|
"Garage Door Opener": 565,
|
||||||
|
"Weather Sensor": 12,
|
||||||
|
"Fan Controller": 91,
|
||||||
|
"Security Sensor": 2,
|
||||||
|
"Remote Control": 94
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"random_forest": {
|
||||||
|
"accuracy": 0.8272727272727273,
|
||||||
|
"balanced_accuracy": 0.5789748226457088,
|
||||||
|
"macro_f1": 0.5360433604336042
|
||||||
|
},
|
||||||
|
"gradient_boost": {
|
||||||
|
"accuracy": 0.8681818181818182,
|
||||||
|
"balanced_accuracy": 0.6253728690437551,
|
||||||
|
"macro_f1": 0.5340975664713835
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"heuristic_baseline_same_test": {
|
||||||
|
"top1": 0.3,
|
||||||
|
"routed": 0.5590909090909091,
|
||||||
|
"n_test": 220
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"n_samples": 803,
|
||||||
|
"n_train": 489,
|
||||||
|
"n_val": 94,
|
||||||
|
"n_test": 220,
|
||||||
|
"classes": [
|
||||||
|
"Doorbell",
|
||||||
|
"Fan Controller",
|
||||||
|
"Garage Door Opener",
|
||||||
|
"Remote Control",
|
||||||
|
"Security Sensor",
|
||||||
|
"Weather Sensor"
|
||||||
|
],
|
||||||
|
"pulse_len": 512,
|
||||||
|
"epochs": 60,
|
||||||
|
"class_balance": {
|
||||||
|
"Doorbell": 39,
|
||||||
|
"Garage Door Opener": 565,
|
||||||
|
"Weather Sensor": 12,
|
||||||
|
"Fan Controller": 91,
|
||||||
|
"Security Sensor": 2,
|
||||||
|
"Remote Control": 94
|
||||||
|
},
|
||||||
|
"cnn": {
|
||||||
|
"accuracy": 0.7954545454545454,
|
||||||
|
"balanced_accuracy": 0.33796618290289177,
|
||||||
|
"macro_f1": 0.3552945963506287
|
||||||
|
},
|
||||||
|
"heuristic_same_test": {
|
||||||
|
"top1": 0.3
|
||||||
|
},
|
||||||
|
"statistical_same_test": {
|
||||||
|
"top1": 0.8681818181818182,
|
||||||
|
"balanced_accuracy": 0.6253728690437551
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# GigLez production runtime — the LIVE simple-mode path only
|
||||||
|
# (src/api/main_simple.py → SignatureMatcher → pattern_decoder → statistical ML).
|
||||||
|
#
|
||||||
|
# Versions are pinned to what actually trained/serves the model in this env.
|
||||||
|
# In particular scikit-learn/numpy MUST match the versions the
|
||||||
|
# models/category_classifier.joblib bundle was built with (1.6.1 / 2.2.x),
|
||||||
|
# or joblib.load() will warn/break. Do NOT downgrade to the old
|
||||||
|
# requirements.txt pins — those drive the dormant SQLAlchemy/PostGIS path.
|
||||||
|
#
|
||||||
|
# NOTE: torch/onnx are intentionally absent — the Phase 3B CNN is benched
|
||||||
|
# (loses to the statistical model), so it is not part of the serving path.
|
||||||
|
|
||||||
|
# Web stack
|
||||||
|
fastapi==0.121.1
|
||||||
|
starlette==0.46.0
|
||||||
|
uvicorn[standard]==0.31.1
|
||||||
|
jinja2==3.1.6
|
||||||
|
python-multipart==0.0.22
|
||||||
|
pydantic==2.12.4
|
||||||
|
|
||||||
|
# Numerics + statistical ML (RAW category classifier)
|
||||||
|
numpy==2.2.6
|
||||||
|
scipy==1.15.3
|
||||||
|
scikit-learn==1.6.1
|
||||||
|
joblib==1.5.3
|
||||||
|
|
||||||
|
# Support
|
||||||
|
loguru==0.7.2
|
||||||
|
pyyaml==6.0.2
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Real-World Device-Type Validation
|
||||||
|
==================================
|
||||||
|
|
||||||
|
Unlike ``benchmark_phase0.py`` (which *fabricates* .sub files from the protocol
|
||||||
|
DB's own timing parameters and therefore only measures an upper bound), this
|
||||||
|
harness runs the **real identification pipeline** against **real community
|
||||||
|
Flipper Zero captures** — the UberGuidoZ Sub-GHz corpus — where the folder name
|
||||||
|
is the ground-truth device type.
|
||||||
|
|
||||||
|
It measures what actually matters for "what kind of device is this?":
|
||||||
|
|
||||||
|
1. Category routing — does the router put the capture in the right device
|
||||||
|
family? Reported two ways:
|
||||||
|
top1 : router's single best category == ground truth
|
||||||
|
routed : ground truth ∈ router's allowed_categories (the searched set)
|
||||||
|
2. Coverage — % of files the pipeline can even act on (RAW-parseable,
|
||||||
|
timing-extractable, ≥1 device match).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python scripts/benchmark_realworld.py # default sample
|
||||||
|
python scripts/benchmark_realworld.py --per-category 80 --out /tmp/rw.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.parser.sub_parser import parse_sub_file
|
||||||
|
from src.matcher.pattern_decoder import get_pattern_decoder
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ground truth: UberGuidoZ folder name -> GigLez router category ──────────
|
||||||
|
# Only folders with an unambiguous mapping onto a category the router can emit
|
||||||
|
# are included. Ambiguous grab-bags (Misc, Jamming, Settings, Pocsag, ...) are
|
||||||
|
# intentionally excluded so the denominator stays honest.
|
||||||
|
FOLDER_TO_CATEGORY = {
|
||||||
|
"Doorbells": "Doorbell",
|
||||||
|
"Garages": "Garage Door Opener",
|
||||||
|
"Gates": "Garage Door Opener",
|
||||||
|
"Weather_stations": "Weather Sensor",
|
||||||
|
"Ceiling_Fans": "Fan Controller",
|
||||||
|
"Fans": "Fan Controller",
|
||||||
|
"Motion_Sensors": "Security Sensor",
|
||||||
|
"Smoke_Alarm": "Security Sensor",
|
||||||
|
"Vehicles": "Remote Control",
|
||||||
|
"Smart_Home_Remotes": "Remote Control",
|
||||||
|
"Remote_Outlet_Switches": "Remote Control",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_DATASET = (
|
||||||
|
Path(__file__).parent.parent
|
||||||
|
/ "data/rf_test_datasets/UberGuidoZ_Flipper/Sub-GHz"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_files(dataset_root: Path, per_category: int, seed: int):
|
||||||
|
"""Return list of (path, ground_truth_category, folder) sampled per folder."""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
out = []
|
||||||
|
for folder, category in FOLDER_TO_CATEGORY.items():
|
||||||
|
folder_path = dataset_root / folder
|
||||||
|
if not folder_path.is_dir():
|
||||||
|
continue
|
||||||
|
subs = sorted(folder_path.rglob("*.sub"))
|
||||||
|
rng.shuffle(subs)
|
||||||
|
for p in subs[:per_category]:
|
||||||
|
out.append((p, category, folder))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(files, decoder):
|
||||||
|
"""Run the routing + decode pipeline over the sampled files."""
|
||||||
|
ta = decoder.timing_analyzer
|
||||||
|
pd = decoder.preamble_detector
|
||||||
|
router = decoder.category_router
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for path, gt_category, folder in files:
|
||||||
|
rec = {
|
||||||
|
"file": str(path),
|
||||||
|
"folder": folder,
|
||||||
|
"ground_truth": gt_category,
|
||||||
|
"status": None, # ok | no_raw | no_timing | parse_error
|
||||||
|
"file_format": None,
|
||||||
|
"protocol": None, # Flipper Protocol: field (present on KEY files)
|
||||||
|
"predicted_top1": None,
|
||||||
|
"allowed_categories": [],
|
||||||
|
"routed_hit": False,
|
||||||
|
"top1_hit": False,
|
||||||
|
"n_device_matches": 0,
|
||||||
|
"top_device": None,
|
||||||
|
"top_confidence": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
meta = parse_sub_file(str(path))
|
||||||
|
rec["file_format"] = getattr(meta, "file_format", None)
|
||||||
|
rec["protocol"] = getattr(meta, "protocol", None)
|
||||||
|
|
||||||
|
if not getattr(meta, "has_raw_data", False):
|
||||||
|
# Decoded KEY file: no pulses to time, but a Protocol name is
|
||||||
|
# itself identifying — route by name.
|
||||||
|
if not getattr(meta, "protocol", None):
|
||||||
|
rec["status"] = "no_raw" # nothing to go on
|
||||||
|
results.append(rec)
|
||||||
|
continue
|
||||||
|
pred = router.route_by_protocol(meta.protocol, meta.frequency)
|
||||||
|
rec["status"] = "ok_key"
|
||||||
|
rec["predicted_top1"] = pred.primary_category
|
||||||
|
rec["allowed_categories"] = list(pred.allowed_categories or [])
|
||||||
|
rec["top1_hit"] = (pred.primary_category == gt_category)
|
||||||
|
rec["routed_hit"] = (
|
||||||
|
gt_category in rec["allowed_categories"] or pred.use_full_db
|
||||||
|
)
|
||||||
|
matches = decoder.decode(meta)
|
||||||
|
rec["n_device_matches"] = len(matches)
|
||||||
|
if matches:
|
||||||
|
rec["top_device"] = matches[0].name
|
||||||
|
rec["top_confidence"] = round(matches[0].confidence, 3)
|
||||||
|
results.append(rec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
pulses = meta.raw_data
|
||||||
|
timing = ta.extract_timing(pulses)
|
||||||
|
short, long = timing.short_pulse_us, timing.long_pulse_us
|
||||||
|
if short == 0 or long == 0:
|
||||||
|
rec["status"] = "no_timing"
|
||||||
|
results.append(rec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
detected = pd.detect(pulses, short, long)
|
||||||
|
ptype = detected.type if detected else "none"
|
||||||
|
|
||||||
|
pred = router.predict(
|
||||||
|
frequency=meta.frequency,
|
||||||
|
short_pulse_us=short,
|
||||||
|
long_pulse_us=long,
|
||||||
|
pulse_count=len(pulses),
|
||||||
|
preamble_type=ptype,
|
||||||
|
)
|
||||||
|
rec["status"] = "ok"
|
||||||
|
rec["predicted_top1"] = pred.primary_category
|
||||||
|
rec["allowed_categories"] = list(pred.allowed_categories or [])
|
||||||
|
rec["top1_hit"] = (pred.primary_category == gt_category)
|
||||||
|
rec["routed_hit"] = (
|
||||||
|
gt_category in rec["allowed_categories"]
|
||||||
|
or (pred.use_full_db) # full-DB fallback searches everything
|
||||||
|
)
|
||||||
|
|
||||||
|
matches = decoder.decode(meta)
|
||||||
|
rec["n_device_matches"] = len(matches)
|
||||||
|
if matches:
|
||||||
|
rec["top_device"] = matches[0].name
|
||||||
|
rec["top_confidence"] = round(matches[0].confidence, 3)
|
||||||
|
|
||||||
|
except Exception as e: # noqa: BLE001 — want to bucket, not crash
|
||||||
|
rec["status"] = "parse_error"
|
||||||
|
rec["error"] = str(e)[:200]
|
||||||
|
|
||||||
|
results.append(rec)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def report(results):
|
||||||
|
total = len(results)
|
||||||
|
status_counts = Counter(r["status"] for r in results)
|
||||||
|
fmt_counts = Counter(r["file_format"] for r in results)
|
||||||
|
|
||||||
|
routable = [r for r in results if r["status"] in ("ok", "ok_key")]
|
||||||
|
n_routable = len(routable)
|
||||||
|
n_raw = sum(1 for r in routable if r["status"] == "ok")
|
||||||
|
n_key = sum(1 for r in routable if r["status"] == "ok_key")
|
||||||
|
|
||||||
|
top1_hits = sum(r["top1_hit"] for r in routable)
|
||||||
|
routed_hits = sum(r["routed_hit"] for r in routable)
|
||||||
|
with_device = sum(1 for r in routable if r["n_device_matches"] > 0)
|
||||||
|
|
||||||
|
print("=" * 74)
|
||||||
|
print("REAL-WORLD DEVICE-TYPE VALIDATION (UberGuidoZ Sub-GHz corpus)")
|
||||||
|
print("=" * 74)
|
||||||
|
print(f"Files sampled : {total}")
|
||||||
|
print(f" status breakdown : {dict(status_counts)}")
|
||||||
|
print(f" file formats : {dict(fmt_counts)}")
|
||||||
|
print(f"Routable (RAW + KEY) : {n_routable} "
|
||||||
|
f"({n_routable/total:.0%} of sampled) "
|
||||||
|
f"[RAW timing={n_raw}, KEY protocol={n_key}]")
|
||||||
|
print()
|
||||||
|
if n_routable:
|
||||||
|
print("── Category accuracy (over routable files) ──")
|
||||||
|
print(f" top-1 (best == truth) : {top1_hits}/{n_routable} "
|
||||||
|
f"= {top1_hits/n_routable:.1%}")
|
||||||
|
print(f" routed (truth ∈ allowed set): {routed_hits}/{n_routable} "
|
||||||
|
f"= {routed_hits/n_routable:.1%}")
|
||||||
|
print(f" device match coverage : {with_device}/{n_routable} "
|
||||||
|
f"= {with_device/n_routable:.1%}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# End-to-end (routable AND top-1 correct) over ALL sampled files — the
|
||||||
|
# number a user actually experiences on an arbitrary upload.
|
||||||
|
print("── End-to-end over ALL sampled (incl. unparseable) ──")
|
||||||
|
print(f" top-1 : {top1_hits}/{total} = {top1_hits/total:.1%}")
|
||||||
|
print(f" routed : {routed_hits}/{total} = {routed_hits/total:.1%}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Per-category breakdown
|
||||||
|
print("── Per-category (routable only) ──")
|
||||||
|
by_cat = defaultdict(list)
|
||||||
|
for r in routable:
|
||||||
|
by_cat[r["ground_truth"]].append(r)
|
||||||
|
print(f" {'category':22} {'n':>4} {'top1':>7} {'routed':>7}")
|
||||||
|
for cat in sorted(by_cat):
|
||||||
|
rs = by_cat[cat]
|
||||||
|
n = len(rs)
|
||||||
|
t1 = sum(x["top1_hit"] for x in rs) / n
|
||||||
|
rt = sum(x["routed_hit"] for x in rs) / n
|
||||||
|
print(f" {cat:22} {n:>4} {t1:>6.0%} {rt:>6.0%}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Confusion: where did top-1 send the misses?
|
||||||
|
print("── Top-1 confusion (ground_truth -> predicted, misses only) ──")
|
||||||
|
conf = Counter()
|
||||||
|
for r in routable:
|
||||||
|
if not r["top1_hit"]:
|
||||||
|
conf[(r["ground_truth"], r["predicted_top1"])] += 1
|
||||||
|
for (gt, pred), c in conf.most_common(15):
|
||||||
|
print(f" {gt:22} -> {str(pred):22} x{c}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
|
||||||
|
ap.add_argument("--per-category", type=int, default=50,
|
||||||
|
help="max files sampled per folder (0 = all)")
|
||||||
|
ap.add_argument("--seed", type=int, default=42)
|
||||||
|
ap.add_argument("--out", type=Path, default=None,
|
||||||
|
help="write per-file JSON results here")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.dataset.is_dir():
|
||||||
|
print(f"Dataset not found: {args.dataset}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
per_cat = args.per_category or 10**9
|
||||||
|
files = collect_files(args.dataset, per_cat, args.seed)
|
||||||
|
if not files:
|
||||||
|
print("No .sub files found under mapped folders.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Loading decoder + protocol DB ...")
|
||||||
|
decoder = get_pattern_decoder()
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
results = evaluate(files, decoder)
|
||||||
|
dt = time.time() - t0
|
||||||
|
print(f"Evaluated {len(files)} files in {dt:.1f}s "
|
||||||
|
f"({dt/len(files)*1000:.0f} ms/file)\n")
|
||||||
|
|
||||||
|
report(results)
|
||||||
|
|
||||||
|
if args.out:
|
||||||
|
args.out.write_text(json.dumps(results, indent=2))
|
||||||
|
print(f"\nPer-file results -> {args.out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Honest experiment: does rtl_433 IQ data help the category classifier?
|
||||||
|
=====================================================================
|
||||||
|
|
||||||
|
The statistical category classifier is starved on minority classes
|
||||||
|
(train counts ~ Doorbell 39, Weather 12, Security 2). Local Flipper .sub
|
||||||
|
corpora are byte-identical mirrors, so they add nothing. The rtl_433 project
|
||||||
|
ships genuinely-independent labelled OOK captures (``.cu8`` IQ), heavy on
|
||||||
|
exactly the starved classes (weather + PIR/door/smoke security).
|
||||||
|
|
||||||
|
This script measures — honestly — whether adding those demodulated captures to
|
||||||
|
TRAINING helps, without fooling ourselves:
|
||||||
|
|
||||||
|
* Flipper captures are split group-disjoint into train/test (the real
|
||||||
|
product distribution; prod input is Flipper .sub RAW).
|
||||||
|
* rtl_433 captures are demodulated (``rtl433_iq_demod.demod_file``), split
|
||||||
|
group-disjoint by device folder, and used to AUGMENT training only.
|
||||||
|
* We compare, on the SAME held-out FLIPPER test set:
|
||||||
|
baseline = train on Flipper-only
|
||||||
|
augmented = train on Flipper + rtl_433
|
||||||
|
Regression check: do the well-populated Flipper classes
|
||||||
|
(Garage/Remote/Fan/Doorbell) get WORSE? If augmentation tanks them, reject.
|
||||||
|
* New-capability check: on a held-out rtl_433 test set, can the augmented
|
||||||
|
model recognise Weather/Security at all (classes Flipper can't measure —
|
||||||
|
too few held-out samples)?
|
||||||
|
|
||||||
|
Nothing here touches the serving path; it only decides whether to retrain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import warnings
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
from scripts.rtl433_iq_demod import demod_file
|
||||||
|
from scripts.train_category_classifier import (
|
||||||
|
collect as collect_flipper, DEFAULT_DATASET, FEATURE_NAMES, extract_features,
|
||||||
|
)
|
||||||
|
from src.matcher.timing_analyzer import get_timing_analyzer
|
||||||
|
from src.matcher.preamble_detector import get_preamble_detector
|
||||||
|
|
||||||
|
RTL433_ROOT = (Path(__file__).parent.parent
|
||||||
|
/ "data/rf_test_datasets/rtl_433_tests/tests")
|
||||||
|
|
||||||
|
# Conservative folder -> category map. Only clearly in-taxonomy devices; the
|
||||||
|
# demod quality-gate additionally drops any FSK/degenerate captures. TPMS,
|
||||||
|
# energy/utility meters, thermostats and unknowns are intentionally omitted.
|
||||||
|
RTL433_FOLDER_TO_CATEGORY = {
|
||||||
|
# Weather Sensor (the biggest starved-class win)
|
||||||
|
**{f: "Weather Sensor" for f in [
|
||||||
|
"acurite", "alectov1", "alecto_ws_1200", "ambient_weather", "auriol",
|
||||||
|
"bresser_3ch", "bresser_5in1", "bresser_6in1", "bt_rain", "calibeur",
|
||||||
|
"companion_wtr001", "conrad_pool_thermometer", "cotech", "cresta_ws688",
|
||||||
|
"ecowitt", "EcoWitt-WH40", "esperanza_ews", "Eurochron-EFTH800",
|
||||||
|
"eurochron-th", "fineoffset", "froggit_wh1080_Pass14c", "ft004b",
|
||||||
|
"hideki", "holman_ws5029", "imagintronix_wh5", "infactory_PV-8796-675",
|
||||||
|
"InFactroy-Temp-Humidity-Sensor-T05K-THC", "inkbird", "inovalley-kw9015b",
|
||||||
|
"lacrosse", "lacrosse_ltv", "lacrosse_ws7000", "maverick_et-73",
|
||||||
|
"maverick_et733", "mebus_te204nl", "misol", "missil_ml0757", "nexus",
|
||||||
|
"Opus-XT300", "oregon_scientific", "prologue", "proove", "rubicson",
|
||||||
|
"s3318p", "sharp_spc344", "sharp_spc775", "solight_te44", "springfield",
|
||||||
|
"TFA_30.3196_TempHumid", "tfa_30_3211_02", "TFA-Drop-30.3233.01",
|
||||||
|
"TFA_Marbella", "TFA-Pool-thermometer-30.3160", "TFA-Twin-Plus-30.3049",
|
||||||
|
"thermopro-tp11", "thermopro-tp12", "thermopro-tx2", "TS-FT002",
|
||||||
|
"ttx201", "tx22-it", "wssensor", "wt0124", "XC-0324", "xc0348",
|
||||||
|
"Globaltronics", "PoolWT0122", "rh787t",
|
||||||
|
]},
|
||||||
|
# Security Sensor (motion / PIR / smoke / door / alarm)
|
||||||
|
**{f: "Security Sensor" for f in [
|
||||||
|
"generic_door_sensor", "generic_motion", "honeywell", "honeywell_5816",
|
||||||
|
"honeywell_5890PI", "honeywell_activlink", "dsc", "simplisafe",
|
||||||
|
"skylink_motion", "smoke_gs558", "cavius", "KIDDE_RF-SM-ACDC",
|
||||||
|
"visonic_powercode", "interlogix", "x10_sec", "EV1527-PIR-SGOOWAY",
|
||||||
|
"Kerui-D026", "chuango",
|
||||||
|
]},
|
||||||
|
# Doorbell
|
||||||
|
**{f: "Doorbell" for f in [
|
||||||
|
"Byron-BY101", "Byron-BY34", "Byron_db304", "door_bell", "quhwa",
|
||||||
|
]},
|
||||||
|
# Remote Control (remotes, outlet switches, key fobs)
|
||||||
|
**{f: "Remote Control" for f in [
|
||||||
|
"akhan_remote", "dish_remote_6.3", "directv", "EV1527-Universal-Remote",
|
||||||
|
"fordremote", "generic-4ch-black-remote-315mhz",
|
||||||
|
"generic-4ch-black-remote-433mhz", "generic_remote",
|
||||||
|
"ge-coloreffects-remote", "honda_remote", "HT680_remote",
|
||||||
|
"hyundai_remote", "intertechno", "nexa_LMST-606", "newkaku", "PT2262",
|
||||||
|
"rayrun_rm03", "waveman", "x10", "philips", "Philips-AJ7010",
|
||||||
|
"status-rc202", "kangtai", "kedsum", "etekcity", "elro", "blyss",
|
||||||
|
"brennstuhl_rcs_2044", "telldus", "smarthome", "silvercrest",
|
||||||
|
"uni-com-66125", "valeo",
|
||||||
|
]},
|
||||||
|
# Garage Door Opener / gate
|
||||||
|
**{f: "Garage Door Opener" for f in [
|
||||||
|
"cardin", "LiftMaster_4330E", "secplus", "keeloq", "Microchip-HCS200",
|
||||||
|
]},
|
||||||
|
# Fan Controller
|
||||||
|
**{f: "Fan Controller" for f in [
|
||||||
|
"lucci-air-ceiling-fan-remote", "SQMLtdFan",
|
||||||
|
]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_rtl433(seed: int, per_folder: int = 40):
|
||||||
|
"""Demodulate rtl_433 captures into (X, y, groups) using the folder map."""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
ta, pd = get_timing_analyzer(), get_preamble_detector()
|
||||||
|
X, y, groups = [], [], []
|
||||||
|
skipped = Counter()
|
||||||
|
for folder, category in RTL433_FOLDER_TO_CATEGORY.items():
|
||||||
|
fpath = RTL433_ROOT / folder
|
||||||
|
if not fpath.is_dir():
|
||||||
|
skipped["missing_folder"] += 1
|
||||||
|
continue
|
||||||
|
cu8s = sorted(fpath.rglob("*.cu8"))
|
||||||
|
rng.shuffle(cu8s)
|
||||||
|
taken = 0
|
||||||
|
for c in cu8s:
|
||||||
|
if taken >= per_folder:
|
||||||
|
break
|
||||||
|
pulses, freq = demod_file(c)
|
||||||
|
if pulses is None:
|
||||||
|
skipped["rejected_demod"] += 1
|
||||||
|
continue
|
||||||
|
feats = extract_features(pulses, freq, ta, pd)
|
||||||
|
if feats is None:
|
||||||
|
skipped["no_timing"] += 1
|
||||||
|
continue
|
||||||
|
X.append(feats)
|
||||||
|
y.append(category)
|
||||||
|
groups.append(f"rtl433:{folder}") # group per device folder
|
||||||
|
taken += 1
|
||||||
|
return np.array(X), np.array(y), np.array(groups), skipped
|
||||||
|
|
||||||
|
|
||||||
|
def per_class_recall(y_true, y_pred, labels):
|
||||||
|
out = {}
|
||||||
|
for lab in labels:
|
||||||
|
idx = y_true == lab
|
||||||
|
n = int(idx.sum())
|
||||||
|
out[lab] = (int((y_pred[idx] == lab).sum()), n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
from sklearn.model_selection import GroupShuffleSplit
|
||||||
|
from sklearn.ensemble import GradientBoostingClassifier
|
||||||
|
from sklearn.metrics import balanced_accuracy_score, accuracy_score
|
||||||
|
|
||||||
|
seed = 42
|
||||||
|
print("Collecting Flipper RAW ...")
|
||||||
|
Xf, yf, gf, ff, _ = collect_flipper(DEFAULT_DATASET, 400, seed)
|
||||||
|
print(f" Flipper: {len(Xf)} samples {dict(Counter(yf))}")
|
||||||
|
|
||||||
|
print("Demodulating rtl_433 IQ ...")
|
||||||
|
Xr, yr, gr, sk = collect_rtl433(seed)
|
||||||
|
print(f" rtl_433: {len(Xr)} samples {dict(Counter(yr))} (skipped {dict(sk)})")
|
||||||
|
|
||||||
|
labels = sorted(set(yf) | set(yr))
|
||||||
|
|
||||||
|
# Group-disjoint Flipper train/test (the real product distribution).
|
||||||
|
gss = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=seed)
|
||||||
|
ftr, fte = next(gss.split(Xf, yf, gf))
|
||||||
|
Xf_tr, Xf_te = Xf[ftr], Xf[fte]
|
||||||
|
yf_tr, yf_te = yf[ftr], yf[fte]
|
||||||
|
|
||||||
|
# Group-disjoint rtl_433 train/test.
|
||||||
|
rtr, rte = next(GroupShuffleSplit(1, test_size=0.30, random_state=seed)
|
||||||
|
.split(Xr, yr, gr))
|
||||||
|
Xr_tr, Xr_te = Xr[rtr], Xr[rte]
|
||||||
|
yr_tr, yr_te = yr[rtr], yr[rte]
|
||||||
|
|
||||||
|
def fit(X, y):
|
||||||
|
return GradientBoostingClassifier(random_state=seed).fit(X, y)
|
||||||
|
|
||||||
|
base = fit(Xf_tr, yf_tr)
|
||||||
|
aug = fit(np.vstack([Xf_tr, Xr_tr]), np.concatenate([yf_tr, yr_tr]))
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("A) REGRESSION CHECK — held-out FLIPPER test (does augmentation hurt?)")
|
||||||
|
print("=" * 70)
|
||||||
|
for name, model in [("baseline (Flipper-only)", base),
|
||||||
|
("augmented (Flipper+rtl433)", aug)]:
|
||||||
|
p = model.predict(Xf_te)
|
||||||
|
print(f"\n{name}: bal={balanced_accuracy_score(yf_te, p):.3f} "
|
||||||
|
f"top1={accuracy_score(yf_te, p):.3f}")
|
||||||
|
for lab, (ok, n) in per_class_recall(yf_te, p, labels).items():
|
||||||
|
if n:
|
||||||
|
print(f" {lab:20} {ok:3d}/{n:<3d}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("B) NEW-CAPABILITY — held-out rtl_433 test (classes Flipper can't measure)")
|
||||||
|
print("=" * 70)
|
||||||
|
for name, model in [("baseline (Flipper-only)", base),
|
||||||
|
("augmented (Flipper+rtl433)", aug)]:
|
||||||
|
p = model.predict(Xr_te)
|
||||||
|
print(f"\n{name}: bal={balanced_accuracy_score(yr_te, p):.3f} "
|
||||||
|
f"top1={accuracy_score(yr_te, p):.3f}")
|
||||||
|
for lab, (ok, n) in per_class_recall(yr_te, p, labels).items():
|
||||||
|
if n:
|
||||||
|
print(f" {lab:20} {ok:3d}/{n:<3d}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
rtl_433 .cu8 IQ -> Flipper-style signed pulse train (µs)
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
Turns the rtl_433 project's ``.cu8`` test captures (interleaved I/Q uint8) into
|
||||||
|
the same signed-duration pulse array our RAW feature extractor already consumes
|
||||||
|
(``+high`` / ``-low`` in microseconds), so genuinely-independent, non-Flipper
|
||||||
|
captures can augment the statistical category classifier's training set.
|
||||||
|
|
||||||
|
Why amplitude (OOK) demod: the target Sub-GHz device classes we care about
|
||||||
|
(weather sensors, doorbells, PIR/door/smoke security sensors, simple remotes)
|
||||||
|
are overwhelmingly OOK/ASK — the carrier is keyed on/off, so the signal
|
||||||
|
*envelope* (magnitude of the complex sample) is the message. FSK captures keep
|
||||||
|
constant amplitude, so this demod yields no usable transitions on them; that is
|
||||||
|
a feature, not a bug — ``demod_file`` returns ``None`` and such captures
|
||||||
|
self-filter out of the training set via ``looks_like_ook``.
|
||||||
|
|
||||||
|
The sample rate and centre frequency are read from the rtl_433 filename
|
||||||
|
convention ``*_<freq>M_<rate>k.cu8`` (e.g. ``g003_433.92M_250k.cu8``), falling
|
||||||
|
back to 250 kHz / 433.92 MHz.
|
||||||
|
|
||||||
|
This is a training-data-prep tool (offline), not part of the serving path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
DEFAULT_RATE = 250_000
|
||||||
|
DEFAULT_FREQ = 433_920_000
|
||||||
|
|
||||||
|
# Quality gate — reject captures that did not demodulate into a plausible
|
||||||
|
# OOK pulse train (FSK, pure noise, or empty).
|
||||||
|
MIN_TRANSITIONS = 16 # need real structure, not one long burst
|
||||||
|
MIN_SHORT_US = 40 # ignore sub-symbol glitches
|
||||||
|
MAX_KEEP_PULSES = 1024 # cap runaway repeats (Flipper captures are bounded)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rate_freq(name: str) -> Tuple[int, int]:
|
||||||
|
"""Extract (sample_rate_hz, frequency_hz) from an rtl_433 filename."""
|
||||||
|
rate_m = re.search(r"_(\d+)k", name)
|
||||||
|
rate = int(rate_m.group(1)) * 1000 if rate_m else DEFAULT_RATE
|
||||||
|
freq_m = re.search(r"_(\d+(?:\.\d+)?)M", name)
|
||||||
|
freq = int(float(freq_m.group(1)) * 1_000_000) if freq_m else DEFAULT_FREQ
|
||||||
|
return rate, freq
|
||||||
|
|
||||||
|
|
||||||
|
def _magnitude(path: Path) -> Optional[np.ndarray]:
|
||||||
|
raw = np.fromfile(path, dtype=np.uint8)
|
||||||
|
if raw.size < 4:
|
||||||
|
return None
|
||||||
|
raw = raw.astype(np.float32) - 127.5 # centre uint8 IQ
|
||||||
|
i, q = raw[0::2], raw[1::2]
|
||||||
|
n = min(i.size, q.size)
|
||||||
|
if n == 0:
|
||||||
|
return None
|
||||||
|
return np.sqrt(i[:n] * i[:n] + q[:n] * q[:n])
|
||||||
|
|
||||||
|
|
||||||
|
def _runs_to_pulses(hyst: np.ndarray, us_per_sample: float) -> List[int]:
|
||||||
|
"""Run-length encode a boolean high/low mask into signed µs durations."""
|
||||||
|
if hyst.size == 0:
|
||||||
|
return []
|
||||||
|
# Indices where the level changes.
|
||||||
|
change = np.flatnonzero(np.diff(hyst.view(np.int8))) + 1
|
||||||
|
bounds = np.concatenate(([0], change, [hyst.size]))
|
||||||
|
pulses: List[int] = []
|
||||||
|
for a, b in zip(bounds[:-1], bounds[1:]):
|
||||||
|
dur = (b - a) * us_per_sample
|
||||||
|
if dur < MIN_SHORT_US:
|
||||||
|
continue
|
||||||
|
sign = 1 if hyst[a] else -1
|
||||||
|
pulses.append(int(round(dur)) * sign)
|
||||||
|
return pulses
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_gaps(pulses: List[int]) -> List[int]:
|
||||||
|
"""Drop leading/trailing low (gap) runs so the train starts on a pulse."""
|
||||||
|
start = 0
|
||||||
|
while start < len(pulses) and pulses[start] < 0:
|
||||||
|
start += 1
|
||||||
|
end = len(pulses)
|
||||||
|
while end > start and pulses[end - 1] < 0:
|
||||||
|
end -= 1
|
||||||
|
return pulses[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def demod_file(path: Path) -> Tuple[Optional[List[int]], int]:
|
||||||
|
"""Demodulate one .cu8 file to (pulse_train | None, frequency_hz)."""
|
||||||
|
path = Path(path)
|
||||||
|
rate, freq = parse_rate_freq(path.name)
|
||||||
|
us_per_sample = 1_000_000.0 / rate
|
||||||
|
mag = _magnitude(path)
|
||||||
|
if mag is None or mag.size == 0:
|
||||||
|
return None, freq
|
||||||
|
|
||||||
|
# Threshold between the noise floor and the signal peak. OOK captures have
|
||||||
|
# a large high/low separation, so a fixed fraction of the range is robust;
|
||||||
|
# hysteresis is unnecessary given the >15 dB SNR of these test files.
|
||||||
|
lo = float(np.percentile(mag, 50))
|
||||||
|
hi = float(np.percentile(mag, 99))
|
||||||
|
if hi - lo < 1.0: # no amplitude modulation (FSK / dead air)
|
||||||
|
return None, freq
|
||||||
|
thr = lo + 0.3 * (hi - lo)
|
||||||
|
hyst = mag > thr
|
||||||
|
|
||||||
|
pulses = _trim_gaps(_runs_to_pulses(hyst, us_per_sample))
|
||||||
|
if len(pulses) > MAX_KEEP_PULSES:
|
||||||
|
pulses = pulses[:MAX_KEEP_PULSES]
|
||||||
|
if not looks_like_ook(pulses):
|
||||||
|
return None, freq
|
||||||
|
return pulses, freq
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_ook(pulses: List[int]) -> bool:
|
||||||
|
"""True when the train has enough OOK structure to be a real signal."""
|
||||||
|
if pulses is None or len(pulses) < MIN_TRANSITIONS:
|
||||||
|
return False
|
||||||
|
highs = [p for p in pulses if p > 0]
|
||||||
|
if len(highs) < MIN_TRANSITIONS // 2:
|
||||||
|
return False
|
||||||
|
# A single constant width with no variation is not a keyed message.
|
||||||
|
return len(set(highs)) > 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser(description="Demodulate an rtl_433 .cu8 to a pulse train")
|
||||||
|
ap.add_argument("file", type=Path)
|
||||||
|
args = ap.parse_args()
|
||||||
|
pulses, freq = demod_file(args.file)
|
||||||
|
if pulses is None:
|
||||||
|
print(f"{args.file.name}: no OOK pulse train (FSK/degenerate)")
|
||||||
|
else:
|
||||||
|
print(f"{args.file.name}: freq={freq} pulses={len(pulses)}")
|
||||||
|
print("first 24:", pulses[:24])
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Device-Category Classifier — Phase 3A (statistical ML)
|
||||||
|
======================================================
|
||||||
|
|
||||||
|
PLAN_TO_PROD Phase 3A: "Statistical Features (Lowest effort, highest ROI)".
|
||||||
|
Trains a supervised classifier that predicts a device *category* from the
|
||||||
|
timing/statistical features of a RAW Sub-GHz capture — the gap the heuristic
|
||||||
|
category router struggles with, because shared line-encoders (Princeton,
|
||||||
|
EV1527, Holtek, ...) reuse identical timing across device types.
|
||||||
|
|
||||||
|
Why RAW-only:
|
||||||
|
KEY .sub files already carry a decoded ``Protocol:`` name and are handled
|
||||||
|
well by ``CategoryRouter.route_by_protocol``. ML's job is the RAW signals
|
||||||
|
with no protocol match, so we train and evaluate on RAW captures only.
|
||||||
|
|
||||||
|
Honesty guardrails:
|
||||||
|
* GROUP-AWARE split — the same device sub-folder (e.g. one physical remote
|
||||||
|
captured many times) never appears in both train and test, so we don't
|
||||||
|
score inflated accuracy off near-duplicate captures.
|
||||||
|
* The heuristic ``CategoryRouter`` is scored on the *exact same* held-out
|
||||||
|
files, so the ML number is directly comparable, not cherry-picked.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python scripts/train_category_classifier.py
|
||||||
|
python scripts/train_category_classifier.py --per-category 400 --out models/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.parser.sub_parser import parse_sub_file
|
||||||
|
from src.matcher.timing_analyzer import get_timing_analyzer
|
||||||
|
from src.matcher.preamble_detector import get_preamble_detector
|
||||||
|
from src.matcher.category_router import get_category_router
|
||||||
|
from scripts.benchmark_realworld import FOLDER_TO_CATEGORY, DEFAULT_DATASET
|
||||||
|
|
||||||
|
# Feature order is frozen — inference must build vectors the same way.
|
||||||
|
FEATURE_NAMES = [
|
||||||
|
"freq_mhz", "short_pulse_us", "long_pulse_us", "pulse_ratio",
|
||||||
|
"short_gap_us", "long_gap_us", "gap_ratio", "duty_cycle",
|
||||||
|
"pulse_count", "pulse_mean_abs", "pulse_std_abs", "pulse_min_abs",
|
||||||
|
"pulse_max_abs", "preamble_type",
|
||||||
|
]
|
||||||
|
|
||||||
|
_PREAMBLE_ID = {"none": 0, "long_burst": 1, "alternating": 2,
|
||||||
|
"sync_word": 3, "custom": 4}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_features(pulses, frequency, ta, pd):
|
||||||
|
"""Timing + statistical feature vector for one RAW capture (or None)."""
|
||||||
|
if not pulses:
|
||||||
|
return None
|
||||||
|
timing = ta.extract_timing(pulses)
|
||||||
|
short, long = timing.short_pulse_us, timing.long_pulse_us
|
||||||
|
if short == 0 or long == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
detected = pd.detect(pulses, short, long)
|
||||||
|
ptype = detected.type if detected else "none"
|
||||||
|
|
||||||
|
abs_p = np.abs(np.asarray(pulses, dtype=float))
|
||||||
|
gap_ratio = (timing.long_gap_us / timing.short_gap_us
|
||||||
|
if timing.short_gap_us > 0 else 0.0)
|
||||||
|
|
||||||
|
return [
|
||||||
|
frequency / 1_000_000,
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
timing.pulse_ratio,
|
||||||
|
timing.short_gap_us,
|
||||||
|
timing.long_gap_us,
|
||||||
|
gap_ratio,
|
||||||
|
timing.duty_cycle,
|
||||||
|
len(pulses),
|
||||||
|
float(abs_p.mean()),
|
||||||
|
float(abs_p.std()),
|
||||||
|
float(abs_p.min()),
|
||||||
|
float(abs_p.max()),
|
||||||
|
_PREAMBLE_ID.get(ptype, 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def collect(dataset_root: Path, per_category: int, seed: int):
|
||||||
|
"""Return (X, y, groups, freqs) over RAW files in the mapped folders.
|
||||||
|
|
||||||
|
groups = device sub-folder path (keeps repeat captures of one device
|
||||||
|
together across the train/test split).
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
rng = random.Random(seed)
|
||||||
|
ta, pd = get_timing_analyzer(), get_preamble_detector()
|
||||||
|
|
||||||
|
X, y, groups, freqs = [], [], [], []
|
||||||
|
skipped = Counter()
|
||||||
|
for folder, category in FOLDER_TO_CATEGORY.items():
|
||||||
|
folder_path = dataset_root / folder
|
||||||
|
if not folder_path.is_dir():
|
||||||
|
continue
|
||||||
|
subs = sorted(folder_path.rglob("*.sub"))
|
||||||
|
rng.shuffle(subs)
|
||||||
|
taken = 0
|
||||||
|
for p in subs:
|
||||||
|
if taken >= per_category:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
meta = parse_sub_file(str(p))
|
||||||
|
except Exception:
|
||||||
|
skipped["parse_error"] += 1
|
||||||
|
continue
|
||||||
|
if not getattr(meta, "has_raw_data", False):
|
||||||
|
skipped["no_raw"] += 1
|
||||||
|
continue
|
||||||
|
feats = extract_features(meta.raw_data, meta.frequency, ta, pd)
|
||||||
|
if feats is None:
|
||||||
|
skipped["no_timing"] += 1
|
||||||
|
continue
|
||||||
|
X.append(feats)
|
||||||
|
y.append(category)
|
||||||
|
groups.append(str(p.parent))
|
||||||
|
freqs.append(meta.frequency)
|
||||||
|
taken += 1
|
||||||
|
return np.array(X), np.array(y), np.array(groups), np.array(freqs), skipped
|
||||||
|
|
||||||
|
|
||||||
|
def heuristic_top1(X_row, freq, router):
|
||||||
|
"""Heuristic router's top-1 category for one feature row (same features)."""
|
||||||
|
# X columns: 0 freq_mhz,1 short,2 long,...,8 pulse_count,...,13 preamble
|
||||||
|
inv = {v: k for k, v in _PREAMBLE_ID.items()}
|
||||||
|
pred = router.predict(
|
||||||
|
frequency=int(freq),
|
||||||
|
short_pulse_us=int(X_row[1]),
|
||||||
|
long_pulse_us=int(X_row[2]),
|
||||||
|
pulse_count=int(X_row[8]),
|
||||||
|
preamble_type=inv.get(int(X_row[13]), "none"),
|
||||||
|
)
|
||||||
|
return pred.primary_category, list(pred.allowed_categories or []), pred.use_full_db
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
|
||||||
|
ap.add_argument("--per-category", type=int, default=400,
|
||||||
|
help="max RAW files sampled per folder")
|
||||||
|
ap.add_argument("--seed", type=int, default=42)
|
||||||
|
ap.add_argument("--test-size", type=float, default=0.25)
|
||||||
|
ap.add_argument("--out", type=Path, default=Path("models"))
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.dataset.is_dir():
|
||||||
|
print(f"Dataset not found: {args.dataset}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
|
||||||
|
from sklearn.model_selection import GroupShuffleSplit
|
||||||
|
from sklearn.metrics import (accuracy_score, balanced_accuracy_score,
|
||||||
|
f1_score, confusion_matrix, classification_report)
|
||||||
|
import joblib
|
||||||
|
|
||||||
|
print("Collecting + extracting features ...")
|
||||||
|
t0 = time.time()
|
||||||
|
X, y, groups, freqs, skipped = collect(args.dataset, args.per_category, args.seed)
|
||||||
|
print(f" {len(X)} RAW samples in {time.time()-t0:.1f}s (skipped: {dict(skipped)})")
|
||||||
|
print(f" class balance: {dict(Counter(y))}")
|
||||||
|
print(f" distinct device groups: {len(set(groups))}")
|
||||||
|
|
||||||
|
# Group-aware split (no device leakage between train/test)
|
||||||
|
gss = GroupShuffleSplit(n_splits=1, test_size=args.test_size,
|
||||||
|
random_state=args.seed)
|
||||||
|
train_idx, test_idx = next(gss.split(X, y, groups))
|
||||||
|
Xtr, Xte = X[train_idx], X[test_idx]
|
||||||
|
ytr, yte = y[train_idx], y[test_idx]
|
||||||
|
fte = freqs[test_idx]
|
||||||
|
print(f" train={len(Xtr)} test={len(Xte)} "
|
||||||
|
f"(group-disjoint, {len(set(groups[test_idx]))} test groups)")
|
||||||
|
|
||||||
|
models = {
|
||||||
|
"random_forest": RandomForestClassifier(
|
||||||
|
n_estimators=300, class_weight="balanced",
|
||||||
|
random_state=args.seed, n_jobs=-1),
|
||||||
|
"gradient_boost": GradientBoostingClassifier(random_state=args.seed),
|
||||||
|
}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
best_name, best_bal = None, -1.0
|
||||||
|
for name, clf in models.items():
|
||||||
|
clf.fit(Xtr, ytr)
|
||||||
|
pred = clf.predict(Xte)
|
||||||
|
acc = accuracy_score(yte, pred)
|
||||||
|
bal = balanced_accuracy_score(yte, pred)
|
||||||
|
f1 = f1_score(yte, pred, average="macro")
|
||||||
|
results[name] = (acc, bal, f1)
|
||||||
|
print(f"\n── {name} ──")
|
||||||
|
print(f" accuracy : {acc:.1%}")
|
||||||
|
print(f" balanced accuracy : {bal:.1%}")
|
||||||
|
print(f" macro F1 : {f1:.1%}")
|
||||||
|
if bal > best_bal:
|
||||||
|
best_name, best_bal, best_clf, best_pred = name, bal, clf, pred
|
||||||
|
|
||||||
|
# ── Heuristic baseline on the SAME test files ──────────────────────────
|
||||||
|
h_top1, h_routed = 0, 0
|
||||||
|
labels = sorted(set(y))
|
||||||
|
for row, freq, truth in zip(Xte, fte, yte):
|
||||||
|
cat, allowed, full = heuristic_top1(row, freq, router=get_category_router())
|
||||||
|
if cat == truth:
|
||||||
|
h_top1 += 1
|
||||||
|
if truth in allowed or full:
|
||||||
|
h_routed += 1
|
||||||
|
n = len(Xte)
|
||||||
|
|
||||||
|
print("\n" + "=" * 66)
|
||||||
|
print(f"BEST MODEL: {best_name} (balanced acc {best_bal:.1%})")
|
||||||
|
print("=" * 66)
|
||||||
|
print(f"{'':26}{'top-1':>8}{'routed':>9}")
|
||||||
|
print(f"{'heuristic router':26}{h_top1/n:>8.1%}{h_routed/n:>9.1%}")
|
||||||
|
print(f"{'ML classifier (top-1)':26}"
|
||||||
|
f"{accuracy_score(yte, best_pred):>8.1%}{'—':>9}")
|
||||||
|
print("\nPer-category (best model):")
|
||||||
|
print(classification_report(yte, best_pred, zero_division=0))
|
||||||
|
print("Confusion (rows=truth, cols=pred): labels=", labels)
|
||||||
|
print(confusion_matrix(yte, best_pred, labels=labels))
|
||||||
|
print("\nFeature importances (best model, if available):")
|
||||||
|
if hasattr(best_clf, "feature_importances_"):
|
||||||
|
for nm, imp in sorted(zip(FEATURE_NAMES, best_clf.feature_importances_),
|
||||||
|
key=lambda t: -t[1]):
|
||||||
|
print(f" {nm:16} {imp:.3f}")
|
||||||
|
|
||||||
|
# ── Persist ────────────────────────────────────────────────────────────
|
||||||
|
args.out.mkdir(parents=True, exist_ok=True)
|
||||||
|
model_path = args.out / "category_classifier.joblib"
|
||||||
|
joblib.dump({"model": best_clf, "features": FEATURE_NAMES,
|
||||||
|
"classes": list(best_clf.classes_)}, model_path)
|
||||||
|
meta = {
|
||||||
|
"best_model": best_name,
|
||||||
|
"n_samples": int(len(X)),
|
||||||
|
"n_train": int(len(Xtr)),
|
||||||
|
"n_test": int(len(Xte)),
|
||||||
|
"features": FEATURE_NAMES,
|
||||||
|
"class_balance": {k: int(v) for k, v in Counter(y).items()},
|
||||||
|
"metrics": {k: {"accuracy": v[0], "balanced_accuracy": v[1],
|
||||||
|
"macro_f1": v[2]} for k, v in results.items()},
|
||||||
|
"heuristic_baseline_same_test": {
|
||||||
|
"top1": h_top1 / n, "routed": h_routed / n, "n_test": n},
|
||||||
|
}
|
||||||
|
(args.out / "category_classifier_metrics.json").write_text(
|
||||||
|
json.dumps(meta, indent=2))
|
||||||
|
print(f"\nSaved model -> {model_path}")
|
||||||
|
print(f"Saved metrics-> {args.out / 'category_classifier_metrics.json'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Device-Category 1D-CNN — Phase 3B (PLAN_TO_PROD)
|
||||||
|
================================================
|
||||||
|
|
||||||
|
Trains a 1D convolutional net that predicts a device *category* directly from
|
||||||
|
the normalized RAW pulse array of a Sub-GHz capture — the "shape of the signal"
|
||||||
|
rather than the hand-picked timing statistics the Phase 3A model uses.
|
||||||
|
|
||||||
|
Architecture (per PLAN_TO_PROD §Phase B):
|
||||||
|
Conv1D(64,3) → BN → ReLU
|
||||||
|
Conv1D(128,3) → BN → ReLU
|
||||||
|
Conv1D(256,3) → BN → ReLU
|
||||||
|
GlobalAvgPool → Dense(256) → Dropout(0.3) → Dense(N) → Softmax
|
||||||
|
Input: pulse array scaled to [-1,1], padded/truncated to 512 (pulse_encoder).
|
||||||
|
|
||||||
|
Honesty guardrails (identical discipline to the Phase 3A trainer):
|
||||||
|
* GROUP-AWARE split — a device sub-folder never spans train/test, so we
|
||||||
|
don't score inflated accuracy off near-duplicate repeat captures.
|
||||||
|
* Epoch selection uses a validation slice carved from TRAIN only; the
|
||||||
|
group-disjoint TEST set is scored exactly once at the end.
|
||||||
|
* The heuristic router AND the Phase 3A statistical model are scored on the
|
||||||
|
*same* held-out files → a fair three-way comparison, not cherry-picked.
|
||||||
|
* Class-weighted loss + balanced accuracy reported, because the RAW corpus
|
||||||
|
is ~70% Garage/Gate and raw accuracy alone is misleading.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python scripts/train_cnn_classifier.py
|
||||||
|
python scripts/train_cnn_classifier.py --per-category 400 --epochs 60
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.parser.sub_parser import parse_sub_file
|
||||||
|
from src.matcher.timing_analyzer import get_timing_analyzer
|
||||||
|
from src.matcher.preamble_detector import get_preamble_detector
|
||||||
|
from src.matcher.category_router import get_category_router
|
||||||
|
from src.matcher.pulse_encoder import encode_pulses, PULSE_LEN
|
||||||
|
from scripts.benchmark_realworld import FOLDER_TO_CATEGORY, DEFAULT_DATASET
|
||||||
|
from scripts.train_category_classifier import (
|
||||||
|
extract_features, heuristic_top1, FEATURE_NAMES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect(dataset_root: Path, per_category: int, seed: int):
|
||||||
|
"""Return aligned CNN arrays + stat features + labels/groups/freqs.
|
||||||
|
|
||||||
|
Every accepted RAW file yields BOTH the CNN encoding and the Phase-3A
|
||||||
|
14-feature vector, indexed identically, so the same group split scores all
|
||||||
|
three models on the same files.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
rng = random.Random(seed)
|
||||||
|
ta, pd = get_timing_analyzer(), get_preamble_detector()
|
||||||
|
|
||||||
|
Xcnn, Xstat, y, groups, freqs = [], [], [], [], []
|
||||||
|
skipped = Counter()
|
||||||
|
for folder, category in FOLDER_TO_CATEGORY.items():
|
||||||
|
folder_path = dataset_root / folder
|
||||||
|
if not folder_path.is_dir():
|
||||||
|
continue
|
||||||
|
subs = sorted(folder_path.rglob("*.sub"))
|
||||||
|
rng.shuffle(subs)
|
||||||
|
taken = 0
|
||||||
|
for p in subs:
|
||||||
|
if taken >= per_category:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
meta = parse_sub_file(str(p))
|
||||||
|
except Exception:
|
||||||
|
skipped["parse_error"] += 1
|
||||||
|
continue
|
||||||
|
if not getattr(meta, "has_raw_data", False):
|
||||||
|
skipped["no_raw"] += 1
|
||||||
|
continue
|
||||||
|
enc = encode_pulses(meta.raw_data)
|
||||||
|
if enc is None:
|
||||||
|
skipped["bad_pulses"] += 1
|
||||||
|
continue
|
||||||
|
feats = extract_features(meta.raw_data, meta.frequency, ta, pd)
|
||||||
|
if feats is None:
|
||||||
|
skipped["no_timing"] += 1
|
||||||
|
continue
|
||||||
|
Xcnn.append(enc)
|
||||||
|
Xstat.append(feats)
|
||||||
|
y.append(category)
|
||||||
|
groups.append(str(p.parent))
|
||||||
|
freqs.append(meta.frequency)
|
||||||
|
taken += 1
|
||||||
|
return (np.asarray(Xcnn, dtype=np.float32),
|
||||||
|
np.asarray(Xstat, dtype=np.float32),
|
||||||
|
np.array(y), np.array(groups), np.array(freqs), skipped)
|
||||||
|
|
||||||
|
|
||||||
|
def build_model(n_classes: int):
|
||||||
|
import torch.nn as nn
|
||||||
|
|
||||||
|
class PulseCNN(nn.Module):
|
||||||
|
def __init__(self, n_out):
|
||||||
|
super().__init__()
|
||||||
|
self.features = nn.Sequential(
|
||||||
|
nn.Conv1d(1, 64, 3, padding=1), nn.BatchNorm1d(64), nn.ReLU(),
|
||||||
|
nn.MaxPool1d(2),
|
||||||
|
nn.Conv1d(64, 128, 3, padding=1), nn.BatchNorm1d(128), nn.ReLU(),
|
||||||
|
nn.MaxPool1d(2),
|
||||||
|
nn.Conv1d(128, 256, 3, padding=1), nn.BatchNorm1d(256), nn.ReLU(),
|
||||||
|
nn.AdaptiveAvgPool1d(1), # GlobalAvgPool
|
||||||
|
)
|
||||||
|
self.head = nn.Sequential(
|
||||||
|
nn.Flatten(),
|
||||||
|
nn.Linear(256, 256), nn.ReLU(), nn.Dropout(0.3),
|
||||||
|
nn.Linear(256, n_out),
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
return self.head(self.features(x))
|
||||||
|
|
||||||
|
return PulseCNN(n_classes)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
|
||||||
|
ap.add_argument("--per-category", type=int, default=400)
|
||||||
|
ap.add_argument("--seed", type=int, default=42)
|
||||||
|
ap.add_argument("--test-size", type=float, default=0.25)
|
||||||
|
ap.add_argument("--val-size", type=float, default=0.15)
|
||||||
|
ap.add_argument("--epochs", type=int, default=60)
|
||||||
|
ap.add_argument("--batch-size", type=int, default=64)
|
||||||
|
ap.add_argument("--lr", type=float, default=1e-3)
|
||||||
|
ap.add_argument("--out", type=Path, default=Path("models"))
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.dataset.is_dir():
|
||||||
|
print(f"Dataset not found: {args.dataset}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from sklearn.model_selection import GroupShuffleSplit
|
||||||
|
from sklearn.metrics import (accuracy_score, balanced_accuracy_score,
|
||||||
|
f1_score, confusion_matrix, classification_report)
|
||||||
|
|
||||||
|
torch.manual_seed(args.seed)
|
||||||
|
np.random.seed(args.seed)
|
||||||
|
|
||||||
|
print("Collecting + encoding RAW pulses ...")
|
||||||
|
t0 = time.time()
|
||||||
|
Xcnn, Xstat, y, groups, freqs, skipped = collect(
|
||||||
|
args.dataset, args.per_category, args.seed)
|
||||||
|
print(f" {len(Xcnn)} RAW samples in {time.time()-t0:.1f}s (skipped: {dict(skipped)})")
|
||||||
|
print(f" class balance: {dict(Counter(y))}")
|
||||||
|
print(f" distinct device groups: {len(set(groups))}")
|
||||||
|
|
||||||
|
classes = sorted(set(y))
|
||||||
|
cls_to_i = {c: i for i, c in enumerate(classes)}
|
||||||
|
yi = np.array([cls_to_i[c] for c in y])
|
||||||
|
|
||||||
|
# Group-aware test split (no device leakage)
|
||||||
|
gss = GroupShuffleSplit(n_splits=1, test_size=args.test_size,
|
||||||
|
random_state=args.seed)
|
||||||
|
trainval_idx, test_idx = next(gss.split(Xcnn, yi, groups))
|
||||||
|
# Validation carved from train (for epoch selection only; test untouched)
|
||||||
|
gss2 = GroupShuffleSplit(n_splits=1, test_size=args.val_size,
|
||||||
|
random_state=args.seed)
|
||||||
|
tr_rel, val_rel = next(gss2.split(Xcnn[trainval_idx], yi[trainval_idx],
|
||||||
|
groups[trainval_idx]))
|
||||||
|
train_idx = trainval_idx[tr_rel]
|
||||||
|
val_idx = trainval_idx[val_rel]
|
||||||
|
print(f" train={len(train_idx)} val={len(val_idx)} test={len(test_idx)} "
|
||||||
|
f"(test groups={len(set(groups[test_idx]))}, group-disjoint)")
|
||||||
|
|
||||||
|
def to_tensor(idx):
|
||||||
|
x = torch.from_numpy(Xcnn[idx]).unsqueeze(1) # (N,1,L)
|
||||||
|
t = torch.from_numpy(yi[idx]).long()
|
||||||
|
return x, t
|
||||||
|
|
||||||
|
Xtr, ytr = to_tensor(train_idx)
|
||||||
|
Xval, yval = to_tensor(val_idx)
|
||||||
|
Xte, yte = to_tensor(test_idx)
|
||||||
|
|
||||||
|
# Class weights (inverse freq) for imbalance
|
||||||
|
counts = np.bincount(yi[train_idx], minlength=len(classes)).astype(float)
|
||||||
|
counts[counts == 0] = 1.0
|
||||||
|
weights = torch.tensor((counts.sum() / counts), dtype=torch.float32)
|
||||||
|
weights = weights / weights.mean()
|
||||||
|
|
||||||
|
model = build_model(len(classes))
|
||||||
|
opt = torch.optim.Adam(model.parameters(), lr=args.lr, weight_decay=1e-4)
|
||||||
|
lossf = nn.CrossEntropyLoss(weight=weights)
|
||||||
|
|
||||||
|
n = len(train_idx)
|
||||||
|
best_val, best_state = -1.0, None
|
||||||
|
for epoch in range(args.epochs):
|
||||||
|
model.train()
|
||||||
|
perm = torch.randperm(n)
|
||||||
|
for i in range(0, n, args.batch_size):
|
||||||
|
bi = perm[i:i + args.batch_size]
|
||||||
|
opt.zero_grad()
|
||||||
|
out = model(Xtr[bi])
|
||||||
|
loss = lossf(out, ytr[bi])
|
||||||
|
loss.backward()
|
||||||
|
opt.step()
|
||||||
|
# Validation balanced accuracy
|
||||||
|
model.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
vpred = model(Xval).argmax(1).numpy()
|
||||||
|
vbal = balanced_accuracy_score(yval.numpy(), vpred)
|
||||||
|
if vbal > best_val:
|
||||||
|
best_val = vbal
|
||||||
|
best_state = {k: v.clone() for k, v in model.state_dict().items()}
|
||||||
|
if (epoch + 1) % 10 == 0:
|
||||||
|
print(f" epoch {epoch+1:3d} val_bal_acc={vbal:.1%} best={best_val:.1%}")
|
||||||
|
|
||||||
|
model.load_state_dict(best_state)
|
||||||
|
model.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
te_logits = model(Xte)
|
||||||
|
te_pred = te_logits.argmax(1).numpy()
|
||||||
|
yte_np = yte.numpy()
|
||||||
|
acc = accuracy_score(yte_np, te_pred)
|
||||||
|
bal = balanced_accuracy_score(yte_np, te_pred)
|
||||||
|
f1 = f1_score(yte_np, te_pred, average="macro")
|
||||||
|
|
||||||
|
# ── Baselines on the SAME test files ───────────────────────────────────
|
||||||
|
router = get_category_router()
|
||||||
|
h_top1 = 0
|
||||||
|
for row, freq, truth in zip(Xstat[test_idx], freqs[test_idx], y[test_idx]):
|
||||||
|
cat, _allowed, _full = heuristic_top1(row, freq, router)
|
||||||
|
if cat == truth:
|
||||||
|
h_top1 += 1
|
||||||
|
h_acc = h_top1 / len(test_idx)
|
||||||
|
|
||||||
|
stat_acc = stat_bal = None
|
||||||
|
try:
|
||||||
|
import joblib
|
||||||
|
bundle = joblib.load(args.out / "category_classifier.joblib")
|
||||||
|
stat_model, stat_classes = bundle["model"], [str(c) for c in bundle["classes"]]
|
||||||
|
stat_pred = stat_model.predict(Xstat[test_idx])
|
||||||
|
stat_acc = accuracy_score(y[test_idx], stat_pred)
|
||||||
|
stat_bal = balanced_accuracy_score(y[test_idx], stat_pred)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" (statistical baseline unavailable: {e})")
|
||||||
|
|
||||||
|
print("\n" + "=" * 66)
|
||||||
|
print("THREE-WAY COMPARISON (same group-disjoint test set)")
|
||||||
|
print("=" * 66)
|
||||||
|
print(f"{'model':26}{'top-1':>10}{'balanced':>11}")
|
||||||
|
print(f"{'heuristic router':26}{h_acc:>10.1%}{'—':>11}")
|
||||||
|
if stat_acc is not None:
|
||||||
|
print(f"{'statistical (RF/GB)':26}{stat_acc:>10.1%}{stat_bal:>11.1%}")
|
||||||
|
print(f"{'1D CNN (this run)':26}{acc:>10.1%}{bal:>11.1%}")
|
||||||
|
print(f"\nCNN macro F1: {f1:.1%}")
|
||||||
|
print("\nPer-category (CNN):")
|
||||||
|
print(classification_report(yte_np, te_pred,
|
||||||
|
labels=list(range(len(classes))),
|
||||||
|
target_names=classes, zero_division=0))
|
||||||
|
print("Confusion (rows=truth, cols=pred): labels=", classes)
|
||||||
|
print(confusion_matrix(yte_np, te_pred, labels=list(range(len(classes)))))
|
||||||
|
|
||||||
|
# ── Persist: torch state + ONNX + metrics ──────────────────────────────
|
||||||
|
args.out.mkdir(parents=True, exist_ok=True)
|
||||||
|
torch.save({"state_dict": best_state, "classes": classes,
|
||||||
|
"pulse_len": PULSE_LEN}, args.out / "category_cnn.pt")
|
||||||
|
dummy = torch.zeros(1, 1, PULSE_LEN)
|
||||||
|
onnx_path = args.out / "category_cnn.onnx"
|
||||||
|
try:
|
||||||
|
torch.onnx.export(
|
||||||
|
model, dummy, str(onnx_path),
|
||||||
|
input_names=["pulses"], output_names=["logits"],
|
||||||
|
dynamic_axes={"pulses": {0: "batch"}, "logits": {0: "batch"}},
|
||||||
|
opset_version=13,
|
||||||
|
)
|
||||||
|
onnx_ok = True
|
||||||
|
except Exception as e:
|
||||||
|
onnx_ok = False
|
||||||
|
print(f" (ONNX export failed, torch .pt still saved: {e})")
|
||||||
|
meta = {
|
||||||
|
"n_samples": int(len(Xcnn)), "n_train": int(len(train_idx)),
|
||||||
|
"n_val": int(len(val_idx)), "n_test": int(len(test_idx)),
|
||||||
|
"classes": classes, "pulse_len": PULSE_LEN, "epochs": args.epochs,
|
||||||
|
"class_balance": {k: int(v) for k, v in Counter(y).items()},
|
||||||
|
"cnn": {"accuracy": acc, "balanced_accuracy": bal, "macro_f1": f1},
|
||||||
|
"heuristic_same_test": {"top1": h_acc},
|
||||||
|
"statistical_same_test": (
|
||||||
|
{"top1": stat_acc, "balanced_accuracy": stat_bal}
|
||||||
|
if stat_acc is not None else None),
|
||||||
|
}
|
||||||
|
(args.out / "category_cnn_metrics.json").write_text(json.dumps(meta, indent=2))
|
||||||
|
print(f"\nSaved torch -> {args.out / 'category_cnn.pt'}")
|
||||||
|
if onnx_ok:
|
||||||
|
print(f"Saved ONNX -> {onnx_path}")
|
||||||
|
print(f"Saved metrics-> {args.out / 'category_cnn_metrics.json'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* GigLez UI verification — drives a real headless browser through the full
|
||||||
|
* user journey against the deployed SPA, asserting the things the API layer
|
||||||
|
* cannot: the page loads clean, the Leaflet map renders, and an upload done
|
||||||
|
* *through the actual DOM* returns a device match and increments the store.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* BASE=http://localhost:8000 node scripts/verify_ui.mjs
|
||||||
|
*
|
||||||
|
* Requires Node Playwright (present: 1.58.x). Exits non-zero on any failure.
|
||||||
|
* Screenshots are written to ./verify-shots/.
|
||||||
|
*/
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || 'http://localhost:8000';
|
||||||
|
const SHOTS = 'verify-shots';
|
||||||
|
mkdirSync(SHOTS, { recursive: true });
|
||||||
|
|
||||||
|
const fails = [];
|
||||||
|
const ok = (cond, msg) => { console.log(`${cond ? 'PASS' : 'FAIL'} ${msg}`); if (!cond) fails.push(msg); };
|
||||||
|
|
||||||
|
// A minimal self-contained RAW .sub (no dependency on the big dataset).
|
||||||
|
const SAMPLE_SUB = [
|
||||||
|
'Filetype: Flipper SubGhz RAW File',
|
||||||
|
'Version: 1',
|
||||||
|
'Frequency: 433920000',
|
||||||
|
'Preset: FuriHalSubGhzPresetOok650Async',
|
||||||
|
'Protocol: RAW',
|
||||||
|
'RAW_Data: 350 -350 350 -700 700 -350 350 -350 700 -700 350 -350 350 -700 700 -350 350 -700 350 -350 700 -350 350 -700 350 -350 350 -700 700 -700 350 -350',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const consoleErrors = [];
|
||||||
|
const failedRequests = [];
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||||||
|
page.on('requestfailed', (r) => failedRequests.push(`${r.url()} (${r.failure()?.errorText})`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ---- Stage 2: load ----
|
||||||
|
const resp = await page.goto(BASE, { waitUntil: 'networkidle', timeout: 30000 });
|
||||||
|
ok(resp && resp.ok(), `GET / returned ${resp && resp.status()}`);
|
||||||
|
await page.screenshot({ path: `${SHOTS}/1-loaded.png`, fullPage: true });
|
||||||
|
|
||||||
|
// ---- Stage 3: map rendered ----
|
||||||
|
await page.waitForSelector('#map', { timeout: 10000 });
|
||||||
|
const leafletUp = await page.locator('#map .leaflet-container').count();
|
||||||
|
ok(leafletUp > 0, 'Leaflet map initialised (.leaflet-container present)');
|
||||||
|
|
||||||
|
const baselineTxt = await page.locator('#total-captures').innerText().catch(() => '0');
|
||||||
|
const baseline = parseInt(baselineTxt, 10) || 0;
|
||||||
|
console.log(` baseline #total-captures = ${baseline}`);
|
||||||
|
|
||||||
|
// ---- Stage 4: switch to Upload, fill the real form ----
|
||||||
|
await page.click('a[href="#upload"]').catch(() => {});
|
||||||
|
await page.waitForSelector('#upload-section.section.active, #upload-section', { timeout: 5000 });
|
||||||
|
|
||||||
|
await page.setInputFiles('#file-input', {
|
||||||
|
name: 'sample.sub',
|
||||||
|
mimeType: 'application/octet-stream',
|
||||||
|
buffer: Buffer.from(SAMPLE_SUB),
|
||||||
|
});
|
||||||
|
await page.fill('#default-lat', '47.6062');
|
||||||
|
await page.fill('#default-lon', '-122.3321');
|
||||||
|
await page.screenshot({ path: `${SHOTS}/2-upload-ready.png`, fullPage: true });
|
||||||
|
|
||||||
|
// The upload button is a global onclick="uploadFiles()".
|
||||||
|
await page.click('button[onclick="uploadFiles()"]');
|
||||||
|
|
||||||
|
// ---- Stage 5: results panel shows a match ----
|
||||||
|
await page.waitForSelector('#upload-results', { state: 'visible', timeout: 20000 });
|
||||||
|
const resultsTxt = (await page.locator('#upload-results').innerText()).trim();
|
||||||
|
ok(resultsTxt.length > 0, 'upload results panel is populated');
|
||||||
|
ok(/success|processed|device|match|categ/i.test(resultsTxt), 'results mention a processed/device match');
|
||||||
|
await page.screenshot({ path: `${SHOTS}/3-results.png`, fullPage: true });
|
||||||
|
|
||||||
|
// ---- Stage 6/8: store incremented (checked via the API the UI uses) ----
|
||||||
|
const total = await page.evaluate(async () => {
|
||||||
|
const r = await fetch('/api/v1/query/captures?limit=1000');
|
||||||
|
return (await r.json()).total;
|
||||||
|
});
|
||||||
|
ok(total >= baseline + 1, `capture total incremented (${baseline} -> ${total})`);
|
||||||
|
|
||||||
|
// ---- clean-page assertions ----
|
||||||
|
ok(consoleErrors.length === 0, `no console errors (saw ${consoleErrors.length})`);
|
||||||
|
if (consoleErrors.length) consoleErrors.slice(0, 5).forEach((e) => console.log(' console:', e));
|
||||||
|
// CDN/tile requests may legitimately fail if offline — report but treat local assets strictly.
|
||||||
|
const localFailed = failedRequests.filter((u) => u.includes('/static/') || u.includes(BASE));
|
||||||
|
ok(localFailed.length === 0, `no failed LOCAL asset requests (saw ${localFailed.length})`);
|
||||||
|
if (failedRequests.length) failedRequests.slice(0, 8).forEach((u) => console.log(' reqfail:', u));
|
||||||
|
} catch (err) {
|
||||||
|
ok(false, `unhandled error: ${err.message}`);
|
||||||
|
await page.screenshot({ path: `${SHOTS}/error.png`, fullPage: true }).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(`${SHOTS}/summary.json`, JSON.stringify({ fails, consoleErrors, failedRequests }, null, 2));
|
||||||
|
console.log('\n' + (fails.length ? `FAILED (${fails.length}): ${fails.join(' | ')}` : 'ALL CHECKS PASSED'));
|
||||||
|
process.exit(fails.length ? 1 : 0);
|
||||||
@@ -557,7 +557,11 @@ def _build_sub_capture(content: bytes, filename: str, entry: dict,
|
|||||||
{
|
{
|
||||||
"device_name": m.device_name,
|
"device_name": m.device_name,
|
||||||
"manufacturer": m.manufacturer,
|
"manufacturer": m.manufacturer,
|
||||||
"category": m.match_details.get("predicted_category"),
|
# Per-device category is that device's own catalog category
|
||||||
|
# (e.g. an Acurite sensor stays "Weather Sensor"). The overall
|
||||||
|
# ML call lives in the top-level `device_category` field.
|
||||||
|
"category": m.match_details.get("device_category")
|
||||||
|
or m.match_details.get("predicted_category"),
|
||||||
"confidence": m.confidence,
|
"confidence": m.confidence,
|
||||||
"method": m.match_method,
|
"method": m.match_method,
|
||||||
"details": m.match_details,
|
"details": m.match_details,
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Statistical Category Classifier — inference wrapper (PLAN_TO_PROD Phase 3A)
|
||||||
|
==========================================================================
|
||||||
|
|
||||||
|
Loads the supervised device-category model trained by
|
||||||
|
``scripts/train_category_classifier.py`` and predicts a device *category*
|
||||||
|
(with class probabilities) from the timing/statistical features of a RAW
|
||||||
|
Sub-GHz capture.
|
||||||
|
|
||||||
|
This is the "statistical" leg of the designed ensemble. It is used as a
|
||||||
|
category *prior* over the heuristic RAW matches (see
|
||||||
|
``pattern_decoder._apply_ml_category_boost``) — it re-ranks toward the
|
||||||
|
category the model believes the signal belongs to, but never invents a match.
|
||||||
|
|
||||||
|
Graceful degradation: if the model file is missing or joblib/sklearn are
|
||||||
|
unavailable, ``available`` is False and the decoder falls back to the pure
|
||||||
|
heuristic path unchanged.
|
||||||
|
|
||||||
|
Feature parity: the vector is built in the frozen order carried in the model
|
||||||
|
bundle (``features``), matching ``train_category_classifier.extract_features``
|
||||||
|
exactly. If the two ever diverge, retrain rather than hand-edit one side.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from src.matcher.timing_analyzer import get_timing_analyzer
|
||||||
|
from src.matcher.preamble_detector import get_preamble_detector
|
||||||
|
|
||||||
|
DEFAULT_MODEL_PATH = (
|
||||||
|
Path(__file__).parent.parent.parent / "models" / "category_classifier.joblib"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Must match scripts/train_category_classifier._PREAMBLE_ID
|
||||||
|
_PREAMBLE_ID = {"none": 0, "long_burst": 1, "alternating": 2,
|
||||||
|
"sync_word": 3, "custom": 4}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MLCategoryPrediction:
|
||||||
|
"""Result of a statistical category prediction."""
|
||||||
|
available: bool = False
|
||||||
|
top_category: Optional[str] = None
|
||||||
|
top_prob: float = 0.0
|
||||||
|
probabilities: Dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MLCategoryClassifier:
|
||||||
|
"""Lazy-loading inference wrapper around the trained sklearn model."""
|
||||||
|
|
||||||
|
def __init__(self, model_path: Path = DEFAULT_MODEL_PATH):
|
||||||
|
self.model_path = Path(model_path)
|
||||||
|
self._loaded = False
|
||||||
|
self._model = None
|
||||||
|
self._features: List[str] = []
|
||||||
|
self._classes: List[str] = []
|
||||||
|
self.timing_analyzer = get_timing_analyzer()
|
||||||
|
self.preamble_detector = get_preamble_detector()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
self._ensure_loaded()
|
||||||
|
return self._model is not None
|
||||||
|
|
||||||
|
def _ensure_loaded(self):
|
||||||
|
if self._loaded:
|
||||||
|
return
|
||||||
|
self._loaded = True
|
||||||
|
try:
|
||||||
|
import joblib
|
||||||
|
bundle = joblib.load(self.model_path)
|
||||||
|
self._model = bundle["model"]
|
||||||
|
self._features = list(bundle["features"])
|
||||||
|
self._classes = [str(c) for c in bundle["classes"]]
|
||||||
|
except Exception:
|
||||||
|
# Missing model, joblib, or sklearn — degrade to heuristic-only.
|
||||||
|
self._model = None
|
||||||
|
|
||||||
|
def _feature_vector(self, pulses, frequency) -> Optional[List[float]]:
|
||||||
|
"""Build the frozen-order feature vector for one RAW capture.
|
||||||
|
|
||||||
|
Mirrors ``train_category_classifier.extract_features`` — keep in sync.
|
||||||
|
"""
|
||||||
|
if not pulses:
|
||||||
|
return None
|
||||||
|
timing = self.timing_analyzer.extract_timing(pulses)
|
||||||
|
short, long = timing.short_pulse_us, timing.long_pulse_us
|
||||||
|
if short == 0 or long == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
detected = self.preamble_detector.detect(pulses, short, long)
|
||||||
|
ptype = detected.type if detected else "none"
|
||||||
|
|
||||||
|
abs_p = np.abs(np.asarray(pulses, dtype=float))
|
||||||
|
gap_ratio = (timing.long_gap_us / timing.short_gap_us
|
||||||
|
if timing.short_gap_us > 0 else 0.0)
|
||||||
|
|
||||||
|
named = {
|
||||||
|
"freq_mhz": (frequency or 0) / 1_000_000,
|
||||||
|
"short_pulse_us": short,
|
||||||
|
"long_pulse_us": long,
|
||||||
|
"pulse_ratio": timing.pulse_ratio,
|
||||||
|
"short_gap_us": timing.short_gap_us,
|
||||||
|
"long_gap_us": timing.long_gap_us,
|
||||||
|
"gap_ratio": gap_ratio,
|
||||||
|
"duty_cycle": timing.duty_cycle,
|
||||||
|
"pulse_count": len(pulses),
|
||||||
|
"pulse_mean_abs": float(abs_p.mean()),
|
||||||
|
"pulse_std_abs": float(abs_p.std()),
|
||||||
|
"pulse_min_abs": float(abs_p.min()),
|
||||||
|
"pulse_max_abs": float(abs_p.max()),
|
||||||
|
"preamble_type": _PREAMBLE_ID.get(ptype, 0),
|
||||||
|
}
|
||||||
|
return [named[name] for name in self._features]
|
||||||
|
|
||||||
|
def predict(self, pulses, frequency) -> MLCategoryPrediction:
|
||||||
|
"""Predict a device category from RAW pulses. Never raises."""
|
||||||
|
self._ensure_loaded()
|
||||||
|
if self._model is None:
|
||||||
|
return MLCategoryPrediction(available=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
vec = self._feature_vector(pulses, frequency)
|
||||||
|
if vec is None:
|
||||||
|
return MLCategoryPrediction(available=False)
|
||||||
|
X = np.asarray([vec], dtype=float)
|
||||||
|
probs = self._model.predict_proba(X)[0]
|
||||||
|
prob_map = {self._classes[i]: float(probs[i])
|
||||||
|
for i in range(len(self._classes))}
|
||||||
|
top_i = int(np.argmax(probs))
|
||||||
|
return MLCategoryPrediction(
|
||||||
|
available=True,
|
||||||
|
top_category=self._classes[top_i],
|
||||||
|
top_prob=float(probs[top_i]),
|
||||||
|
probabilities=prob_map,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return MLCategoryPrediction(available=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_classifier: Optional[MLCategoryClassifier] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_classifier() -> MLCategoryClassifier:
|
||||||
|
"""Get singleton statistical category classifier."""
|
||||||
|
global _classifier
|
||||||
|
if _classifier is None:
|
||||||
|
_classifier = MLCategoryClassifier()
|
||||||
|
return _classifier
|
||||||
@@ -36,6 +36,36 @@ class DeviceCategory:
|
|||||||
UNKNOWN = "Unknown"
|
UNKNOWN = "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Decoded-protocol-name routing (KEY .sub files) ─────────────────────────
|
||||||
|
# A KEY file already carries a Flipper `Protocol:` name. For device-specific
|
||||||
|
# brands that name alone pins the category. Checked in order; first hit wins.
|
||||||
|
# (keywords, category, confidence)
|
||||||
|
_PROTOCOL_BRAND_RULES = [
|
||||||
|
(("honeywell", "2gig", "magellan", "scher", "khan", "mastercode"),
|
||||||
|
DeviceCategory.SECURITY_SENSOR, 0.80),
|
||||||
|
(("came", "nice", "faac", "hormann", "somfy", "keeloq", "gate", "megacode",
|
||||||
|
"linear", "chamberlain", "liftmaster", "marantec", "doorhan", "an-motors",
|
||||||
|
"an_motors", "aprimatic", "beninca", "bft", "security+", "secplus", "genie",
|
||||||
|
"clemsa", "ansonic", "sommer", "novoferm", "dooya", "alutech", "elmes",
|
||||||
|
"nero", "hcs101", "starline", "star_line"),
|
||||||
|
DeviceCategory.GARAGE_DOOR, 0.80),
|
||||||
|
(("nexus", "lacrosse", "acurite", "oregon", "ambient", "infactory", "auriol",
|
||||||
|
"bresser", "thermo", "gt-wt", "gt_wt", "wt450", "tx8300", "tx_8300",
|
||||||
|
"wendox", "kedsum"),
|
||||||
|
DeviceCategory.WEATHER_SENSOR, 0.75),
|
||||||
|
(("tpms", "schrader", "pmv"),
|
||||||
|
DeviceCategory.TPMS, 0.80),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Shared line-encoder silicon reused across remotes/doorbells/fans/gates. The
|
||||||
|
# name does NOT determine device type, so we route to the whole family rather
|
||||||
|
# than dishonestly narrowing to one category.
|
||||||
|
_GENERIC_ENCODERS = (
|
||||||
|
"princeton", "ev1527", "pt2262", "pt2264", "holtek", "ht12", "ht6",
|
||||||
|
"smc5326", "intertechno", "rcswitch", "rc-switch", "x10", "power_smart",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CategoryPrediction:
|
class CategoryPrediction:
|
||||||
"""Result of category routing"""
|
"""Result of category routing"""
|
||||||
@@ -136,6 +166,72 @@ class CategoryRouter:
|
|||||||
use_full_db=True,
|
use_full_db=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def route_by_protocol(
|
||||||
|
self, protocol: Optional[str], frequency: int
|
||||||
|
) -> CategoryPrediction:
|
||||||
|
"""
|
||||||
|
Route a *decoded* KEY-file to a category from its Protocol name.
|
||||||
|
|
||||||
|
KEY .sub files have no RAW pulses to time, but they DO carry a Flipper
|
||||||
|
`Protocol:` name. Device-specific brands (CAME, Nice, KeeLoq, Security+,
|
||||||
|
Honeywell, ...) pin one category. Generic shared encoders (Princeton,
|
||||||
|
EV1527, Holtek, Intertechno, ...) are the same silicon in remotes,
|
||||||
|
doorbells, fans and gates — so we route to the whole family (a broad
|
||||||
|
allowed set) rather than guessing a single type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
protocol: Decoded Flipper protocol name (e.g. "CAME", "Princeton")
|
||||||
|
frequency: Carrier frequency in Hz
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CategoryPrediction
|
||||||
|
"""
|
||||||
|
name = (protocol or "").strip().lower()
|
||||||
|
freq_mhz = (frequency or 0) / 1_000_000
|
||||||
|
|
||||||
|
if not name or name in ("raw", "binraw", "unknown"):
|
||||||
|
return CategoryPrediction(
|
||||||
|
primary_category=DeviceCategory.UNKNOWN,
|
||||||
|
confidence=0.0,
|
||||||
|
allowed_categories=[],
|
||||||
|
reasoning="no decoded protocol name",
|
||||||
|
use_full_db=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device-specific brand → single confident category
|
||||||
|
for keywords, category, conf in _PROTOCOL_BRAND_RULES:
|
||||||
|
if any(k in name for k in keywords):
|
||||||
|
return CategoryPrediction(
|
||||||
|
primary_category=category,
|
||||||
|
confidence=conf,
|
||||||
|
allowed_categories=[category],
|
||||||
|
reasoning=f"decoded protocol '{protocol}' → {category}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generic shared encoder → broad family (honest 'routed' signal)
|
||||||
|
if any(k in name for k in _GENERIC_ENCODERS):
|
||||||
|
allowed = [DeviceCategory.REMOTE_CONTROL, DeviceCategory.DOORBELL,
|
||||||
|
DeviceCategory.FAN_CONTROLLER, DeviceCategory.GARAGE_DOOR]
|
||||||
|
# 315/390 bands skew toward garage/gate; 433 is the free-for-all.
|
||||||
|
primary = (DeviceCategory.GARAGE_DOOR if 300 <= freq_mhz <= 392
|
||||||
|
else DeviceCategory.REMOTE_CONTROL)
|
||||||
|
return CategoryPrediction(
|
||||||
|
primary_category=primary,
|
||||||
|
confidence=0.45,
|
||||||
|
allowed_categories=allowed,
|
||||||
|
reasoning=(f"generic encoder '{protocol}' @ {freq_mhz:.1f} MHz "
|
||||||
|
"→ shared-silicon family (remote/doorbell/fan/gate)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unrecognised name — don't guess, search everything.
|
||||||
|
return CategoryPrediction(
|
||||||
|
primary_category=DeviceCategory.UNKNOWN,
|
||||||
|
confidence=0.0,
|
||||||
|
allowed_categories=[],
|
||||||
|
reasoning=f"unrecognised protocol '{protocol}'",
|
||||||
|
use_full_db=True,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Band-specific helpers ──────────────────────────────────────────────
|
# ── Band-specific helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
def _route_300mhz_band(
|
def _route_300mhz_band(
|
||||||
|
|||||||
@@ -117,16 +117,17 @@ class DeviceIdentifier:
|
|||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
result = IdentificationResult(signal_metadata=signal_data)
|
result = IdentificationResult(signal_metadata=signal_data)
|
||||||
|
|
||||||
if not signal_data.has_raw_data:
|
if not signal_data.has_raw_data and not signal_data.protocol:
|
||||||
# No raw data - cannot identify
|
# No raw data AND no decoded protocol name - cannot identify
|
||||||
result.method = "none"
|
result.method = "none"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Step 1: Pattern-based decoding (heuristic)
|
# Step 1: Pattern-based decoding (heuristic). For KEY files this routes
|
||||||
|
# by the decoded protocol name; for RAW files it times the pulses.
|
||||||
heuristic_matches = self.pattern_decoder.decode(signal_data)
|
heuristic_matches = self.pattern_decoder.decode(signal_data)
|
||||||
|
|
||||||
# Step 2: Statistical classification (if enabled and sufficient data)
|
# Step 2: Statistical classification (needs RAW pulses; skip on KEY)
|
||||||
if use_statistical and heuristic_matches:
|
if use_statistical and heuristic_matches and signal_data.has_raw_data:
|
||||||
statistical_matches = self._apply_statistical_scoring(
|
statistical_matches = self._apply_statistical_scoring(
|
||||||
signal_data,
|
signal_data,
|
||||||
heuristic_matches
|
heuristic_matches
|
||||||
|
|||||||
@@ -87,7 +87,14 @@ class SignatureMatcher:
|
|||||||
manufacturer=device_match.manufacturer or "Unknown",
|
manufacturer=device_match.manufacturer or "Unknown",
|
||||||
confidence=device_match.confidence,
|
confidence=device_match.confidence,
|
||||||
match_method=device_match.match_method,
|
match_method=device_match.match_method,
|
||||||
match_details=device_match.details
|
# Preserve each device's own catalog category so the API can
|
||||||
|
# surface it per-device (e.g. an Acurite sensor stays
|
||||||
|
# "Weather Sensor"); the ML overall call remains in the
|
||||||
|
# match_details' predicted_category.
|
||||||
|
match_details={
|
||||||
|
**device_match.details,
|
||||||
|
"device_category": device_match.category,
|
||||||
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
return match_results
|
return match_results
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from src.matcher.timing_analyzer import get_timing_analyzer
|
|||||||
from src.matcher.preamble_detector import get_preamble_detector
|
from src.matcher.preamble_detector import get_preamble_detector
|
||||||
from src.matcher.frequency_fingerprint import get_frequency_fingerprinter
|
from src.matcher.frequency_fingerprint import get_frequency_fingerprinter
|
||||||
from src.matcher.category_router import get_category_router
|
from src.matcher.category_router import get_category_router
|
||||||
|
from src.matcher.category_classifier import get_category_classifier
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -91,6 +92,7 @@ class PatternDecoder:
|
|||||||
self.preamble_detector = get_preamble_detector()
|
self.preamble_detector = get_preamble_detector()
|
||||||
self.frequency_fingerprinter = get_frequency_fingerprinter()
|
self.frequency_fingerprinter = get_frequency_fingerprinter()
|
||||||
self.category_router = get_category_router()
|
self.category_router = get_category_router()
|
||||||
|
self.ml_classifier = get_category_classifier()
|
||||||
|
|
||||||
def decode(self, metadata: SignalMetadata) -> List[DeviceMatch]:
|
def decode(self, metadata: SignalMetadata) -> List[DeviceMatch]:
|
||||||
"""
|
"""
|
||||||
@@ -103,6 +105,10 @@ class PatternDecoder:
|
|||||||
List of DeviceMatch sorted by confidence (highest first)
|
List of DeviceMatch sorted by confidence (highest first)
|
||||||
"""
|
"""
|
||||||
if not metadata.has_raw_data:
|
if not metadata.has_raw_data:
|
||||||
|
# KEY files carry no pulses to time, but a decoded Protocol name is
|
||||||
|
# itself identifying — route it instead of returning nothing.
|
||||||
|
if metadata.protocol:
|
||||||
|
return self._decode_from_key(metadata)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
pulses = metadata.raw_data
|
pulses = metadata.raw_data
|
||||||
@@ -123,7 +129,103 @@ class PatternDecoder:
|
|||||||
# Already integrated into timing_matches above
|
# Already integrated into timing_matches above
|
||||||
|
|
||||||
# Deduplicate and rank by confidence
|
# Deduplicate and rank by confidence
|
||||||
return self._rank_matches(matches)
|
matches = self._rank_matches(matches)
|
||||||
|
|
||||||
|
# Strategy 4 (Phase 3A): statistical category classifier. The trained
|
||||||
|
# model predicts a device *category* far better than the heuristic
|
||||||
|
# router on RAW captures, so when confident it owns the user-facing
|
||||||
|
# category, re-ranks candidates toward that family, and — when no
|
||||||
|
# protocol matched at all — still supplies a category-only result so
|
||||||
|
# the upload isn't left unidentified. No-op if the model is absent.
|
||||||
|
matches = self._apply_ml_category(matches, pulses, frequency)
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
def _decode_from_key(self, metadata: SignalMetadata) -> List[DeviceMatch]:
|
||||||
|
"""
|
||||||
|
Identify a decoded (KEY) .sub file from its Protocol name.
|
||||||
|
|
||||||
|
KEY files have no RAW pulses but carry a decoded `Protocol:` name,
|
||||||
|
frequency and key. Two cases:
|
||||||
|
|
||||||
|
1. The named protocol is in our signature database. This is the
|
||||||
|
strongest possible identification — the capture device already
|
||||||
|
demodulated and *named* the protocol, and we recognise it — so we
|
||||||
|
surface the REAL catalog signature (true manufacturer + category)
|
||||||
|
with high, frequency-aware confidence. This is an exact "database
|
||||||
|
match", not a timing guess, and it keeps the surfaced category
|
||||||
|
consistent with the catalog (e.g. Princeton -> Garage Door Opener)
|
||||||
|
instead of the router's coarser guess.
|
||||||
|
|
||||||
|
2. The name is unknown to us. Fall back to category routing so the
|
||||||
|
upload is still identified by family rather than silently dropped.
|
||||||
|
"""
|
||||||
|
known = self._lookup_known_protocol(metadata.protocol, metadata.frequency)
|
||||||
|
if known is not None:
|
||||||
|
freq_ok = bool(metadata.frequency) and known.matches_frequency(metadata.frequency)
|
||||||
|
# Exact, already-decoded match against a known protocol. Confidence
|
||||||
|
# is high but never a literal 1.0 (the key payload itself is not
|
||||||
|
# cryptographically verified); a consistent frequency lifts it.
|
||||||
|
confidence = 0.95 if freq_ok else 0.90
|
||||||
|
details = {
|
||||||
|
"predicted_category": known.category,
|
||||||
|
"source": "decoded_key_exact",
|
||||||
|
"bit_length": metadata.bit_length,
|
||||||
|
"frequency_match": freq_ok,
|
||||||
|
"manufacturer": known.manufacturer,
|
||||||
|
}
|
||||||
|
return [DeviceMatch(
|
||||||
|
protocol=known,
|
||||||
|
confidence=confidence,
|
||||||
|
match_method="decoded_key_exact",
|
||||||
|
details=details,
|
||||||
|
)]
|
||||||
|
|
||||||
|
pred = self.category_router.route_by_protocol(
|
||||||
|
metadata.protocol, metadata.frequency
|
||||||
|
)
|
||||||
|
signature = ProtocolSignature(
|
||||||
|
name=metadata.protocol,
|
||||||
|
category=pred.primary_category,
|
||||||
|
frequency=metadata.frequency or 433920000,
|
||||||
|
)
|
||||||
|
details = {
|
||||||
|
"predicted_category": pred.primary_category,
|
||||||
|
"allowed_categories": pred.allowed_categories,
|
||||||
|
"routing_reason": pred.reasoning,
|
||||||
|
"source": "decoded_key",
|
||||||
|
"bit_length": metadata.bit_length,
|
||||||
|
}
|
||||||
|
return [DeviceMatch(
|
||||||
|
protocol=signature,
|
||||||
|
confidence=pred.confidence,
|
||||||
|
match_method="decoded_key",
|
||||||
|
details=details,
|
||||||
|
)]
|
||||||
|
|
||||||
|
def _lookup_known_protocol(
|
||||||
|
self, name: Optional[str], frequency: Optional[int]
|
||||||
|
) -> Optional[ProtocolSignature]:
|
||||||
|
"""Exact (case-insensitive) protocol-name lookup in the signature DB.
|
||||||
|
|
||||||
|
When several catalog entries share a name, prefer one whose frequency
|
||||||
|
band matches the capture; otherwise return the first. Returns None if
|
||||||
|
the name is unknown.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
target = name.strip().lower()
|
||||||
|
candidates = [
|
||||||
|
p for p in self.protocol_db.get_all()
|
||||||
|
if p.name.strip().lower() == target
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
if frequency:
|
||||||
|
for cand in candidates:
|
||||||
|
if cand.matches_frequency(frequency):
|
||||||
|
return cand
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
def _identify_pulse_widths(self, pulses: List[int]) -> Tuple[int, int, int, int]:
|
def _identify_pulse_widths(self, pulses: List[int]) -> Tuple[int, int, int, int]:
|
||||||
"""
|
"""
|
||||||
@@ -369,6 +471,91 @@ class PatternDecoder:
|
|||||||
|
|
||||||
return matches
|
return matches
|
||||||
|
|
||||||
|
def _apply_ml_category(
|
||||||
|
self,
|
||||||
|
matches: List[DeviceMatch],
|
||||||
|
pulses: List[int],
|
||||||
|
frequency: int,
|
||||||
|
) -> List[DeviceMatch]:
|
||||||
|
"""
|
||||||
|
Apply the statistical category classifier to the RAW decode result.
|
||||||
|
|
||||||
|
The classifier predicts a device *category* far more accurately than
|
||||||
|
the heuristic router on RAW captures (Phase 3A: ~62.5% balanced vs
|
||||||
|
~30%), so when it is confident it:
|
||||||
|
|
||||||
|
1. Becomes the source of the user-facing ``predicted_category`` on every
|
||||||
|
match (the field the live upload path surfaces).
|
||||||
|
2. Re-ranks candidate protocols toward the believed family (``BOOST``,
|
||||||
|
0.25, mirrors the designed ensemble's statistical weight).
|
||||||
|
3. When *no* protocol matched, emits a single category-only match so
|
||||||
|
the capture still receives a device category instead of nothing —
|
||||||
|
the whole point of Phase 3A category-level ID for unknown devices.
|
||||||
|
|
||||||
|
Degrades to a pure no-op when the model is unavailable or too unsure
|
||||||
|
(``top_prob`` below ``MIN_PROB``), preserving the heuristic path exactly.
|
||||||
|
"""
|
||||||
|
pred = self.ml_classifier.predict(pulses, frequency)
|
||||||
|
if not pred.available or not pred.top_category:
|
||||||
|
return matches
|
||||||
|
|
||||||
|
BOOST = 0.25 # statistical leg weight (ensemble design)
|
||||||
|
MIN_PROB = 0.40 # ignore low-confidence predictions
|
||||||
|
|
||||||
|
if pred.top_prob < MIN_PROB:
|
||||||
|
return matches
|
||||||
|
|
||||||
|
# No protocol matched: supply a category-only result (like KEY files).
|
||||||
|
if not matches:
|
||||||
|
signature = ProtocolSignature(
|
||||||
|
name=f"Unknown ({pred.top_category})",
|
||||||
|
category=pred.top_category,
|
||||||
|
frequency=frequency or 433920000,
|
||||||
|
)
|
||||||
|
# Category-level ID without protocol confirmation — cap the reported
|
||||||
|
# confidence so it never masquerades as a precise device match.
|
||||||
|
CATEGORY_ONLY_CEIL = 0.75
|
||||||
|
return [DeviceMatch(
|
||||||
|
protocol=signature,
|
||||||
|
confidence=round(min(pred.top_prob, CATEGORY_ONLY_CEIL), 3),
|
||||||
|
match_method="ml_category",
|
||||||
|
details={
|
||||||
|
"predicted_category": pred.top_category,
|
||||||
|
"category_source": "ml_statistical",
|
||||||
|
"ml_category_prob": f"{pred.top_prob:.2%}",
|
||||||
|
"source": "ml_category_only",
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
|
||||||
|
adjusted = []
|
||||||
|
for m in matches:
|
||||||
|
agree_prob = pred.probabilities.get(m.protocol.category, 0.0)
|
||||||
|
# +top_prob if this protocol is in the predicted category; else a
|
||||||
|
# soft penalty scaled by how much less mass the model gave it.
|
||||||
|
if m.protocol.category == pred.top_category:
|
||||||
|
factor = 1.0 + BOOST * pred.top_prob
|
||||||
|
else:
|
||||||
|
factor = 1.0 - BOOST * (pred.top_prob - agree_prob)
|
||||||
|
new_conf = max(0.0, min(1.0, m.confidence * factor))
|
||||||
|
heuristic_category = m.details.get("predicted_category")
|
||||||
|
adjusted.append(DeviceMatch(
|
||||||
|
protocol=m.protocol,
|
||||||
|
confidence=new_conf,
|
||||||
|
match_method=m.match_method,
|
||||||
|
details={
|
||||||
|
**m.details,
|
||||||
|
# ML is the better RAW category predictor -> it owns the
|
||||||
|
# user-facing category; keep the heuristic one for audit.
|
||||||
|
"predicted_category": pred.top_category,
|
||||||
|
"heuristic_category": heuristic_category,
|
||||||
|
"category_source": "ml_statistical",
|
||||||
|
"ml_category_prob": f"{pred.top_prob:.2%}",
|
||||||
|
"ml_boost_applied": f"{factor:.3f}",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
return sorted(adjusted, key=lambda m: m.confidence, reverse=True)
|
||||||
|
|
||||||
def _apply_spread_penalty(self, matches: List[DeviceMatch]) -> List[DeviceMatch]:
|
def _apply_spread_penalty(self, matches: List[DeviceMatch]) -> List[DeviceMatch]:
|
||||||
"""
|
"""
|
||||||
Reduce confidence when top matches are too close together.
|
Reduce confidence when top matches are too close together.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Shared RAW-pulse encoder for the 1D-CNN (Phase 3B).
|
||||||
|
|
||||||
|
A single source of truth for turning a Flipper RAW pulse array into the fixed
|
||||||
|
normalized tensor the CNN consumes — imported by BOTH the trainer
|
||||||
|
(``scripts/train_cnn_classifier.py``) and the inference wrapper
|
||||||
|
(``src/matcher/cnn_classifier.py``) so the two can never drift.
|
||||||
|
|
||||||
|
Encoding:
|
||||||
|
* take the first ``length`` signed durations (µs; +high / -low),
|
||||||
|
* scale by the sequence's *median absolute* duration — a robust, TE-like
|
||||||
|
unit so a short pulse is ~±1 and a long pulse ~±2-3 (the ratios that carry
|
||||||
|
the protocol identity), while absolute gain / capture level is normalised
|
||||||
|
out. Using the median instead of the max is deliberate: a single huge
|
||||||
|
inter-packet gap (~10 000 µs) would otherwise squash every informative
|
||||||
|
short pulse toward zero and blind the CNN.
|
||||||
|
* clip to ±``CLIP`` and rescale to [-1, 1] so outsized gaps saturate the
|
||||||
|
ceiling instead of dominating the dynamic range,
|
||||||
|
* right-pad with zeros to ``length``.
|
||||||
|
|
||||||
|
Returns a float32 array of shape ``(length,)`` or ``None`` when the pulse train
|
||||||
|
is empty or degenerate (all-zero).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
PULSE_LEN = 512
|
||||||
|
CLIP = 8.0 # in median-pulse units; gaps beyond this saturate to ±1
|
||||||
|
|
||||||
|
|
||||||
|
def encode_pulses(pulses: List[int], length: int = PULSE_LEN) -> Optional[np.ndarray]:
|
||||||
|
if pulses is None or len(pulses) == 0:
|
||||||
|
return None
|
||||||
|
arr = np.asarray(pulses[:length], dtype=np.float32)
|
||||||
|
nz = np.abs(arr[arr != 0])
|
||||||
|
if nz.size == 0:
|
||||||
|
return None
|
||||||
|
scale = float(np.median(nz))
|
||||||
|
if scale <= 0.0:
|
||||||
|
scale = float(np.max(np.abs(arr)))
|
||||||
|
if scale <= 0.0:
|
||||||
|
return None
|
||||||
|
arr = np.clip(arr / scale, -CLIP, CLIP) / CLIP
|
||||||
|
if arr.size < length:
|
||||||
|
arr = np.pad(arr, (0, length - arr.size))
|
||||||
|
return arr.astype(np.float32)
|
||||||
@@ -182,6 +182,20 @@ class SubFileParser:
|
|||||||
data_hex = self.fields['Data_RAW'].replace(' ', '')
|
data_hex = self.fields['Data_RAW'].replace(' ', '')
|
||||||
metadata.bin_raw_data = bytes.fromhex(data_hex)
|
metadata.bin_raw_data = bytes.fromhex(data_hex)
|
||||||
|
|
||||||
|
# Reconstruct a signed pulse train (µs) from the demodulated bit
|
||||||
|
# stream so BinRAW captures flow through the same RAW identification
|
||||||
|
# path as RAW_Data files. Data_RAW is the signal sampled at TE-µs
|
||||||
|
# steps: a run of N identical bits is one pulse of N*TE µs (+high on
|
||||||
|
# 1s, -low on 0s). This populates raw_data, which makes the
|
||||||
|
# has_raw_data property true.
|
||||||
|
n_bits = metadata.bin_raw_bit or metadata.bit_length
|
||||||
|
if metadata.bin_raw_data and metadata.bin_raw_te and n_bits:
|
||||||
|
pulses = self._binraw_to_pulses(
|
||||||
|
metadata.bin_raw_data, n_bits, metadata.bin_raw_te)
|
||||||
|
if pulses:
|
||||||
|
metadata.raw_data = pulses
|
||||||
|
metadata.pulse_count = len(pulses)
|
||||||
|
|
||||||
# Calculate duration
|
# Calculate duration
|
||||||
if metadata.bit_length and metadata.bin_raw_te:
|
if metadata.bit_length and metadata.bin_raw_te:
|
||||||
metadata.duration_ms = (metadata.bit_length * metadata.bin_raw_te) / 1000.0
|
metadata.duration_ms = (metadata.bit_length * metadata.bin_raw_te) / 1000.0
|
||||||
@@ -190,6 +204,32 @@ class SubFileParser:
|
|||||||
self.errors.append(f"Error parsing BinRAW fields: {e}")
|
self.errors.append(f"Error parsing BinRAW fields: {e}")
|
||||||
logger.warning(f"Parse error: {e}")
|
logger.warning(f"Parse error: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _binraw_to_pulses(data: bytes, n_bits: int, te: int) -> List[int]:
|
||||||
|
"""Expand a BinRAW bit stream into a signed timing array (µs).
|
||||||
|
|
||||||
|
Bits are read MSB-first, truncated to ``n_bits``, then run-length
|
||||||
|
encoded: each run of ``k`` equal bits becomes ``k*te`` µs, signed
|
||||||
|
positive for 1s (pulse) and negative for 0s (gap).
|
||||||
|
"""
|
||||||
|
bits = []
|
||||||
|
for byte in data:
|
||||||
|
for i in range(7, -1, -1):
|
||||||
|
bits.append((byte >> i) & 1)
|
||||||
|
bits = bits[:n_bits]
|
||||||
|
if not bits:
|
||||||
|
return []
|
||||||
|
pulses: List[int] = []
|
||||||
|
current, run = bits[0], 1
|
||||||
|
for b in bits[1:]:
|
||||||
|
if b == current:
|
||||||
|
run += 1
|
||||||
|
else:
|
||||||
|
pulses.append(run * te * (1 if current else -1))
|
||||||
|
current, run = b, 1
|
||||||
|
pulses.append(run * te * (1 if current else -1))
|
||||||
|
return pulses
|
||||||
|
|
||||||
|
|
||||||
def parse_sub_file(file_path: str) -> SignalMetadata:
|
def parse_sub_file(file_path: str) -> SignalMetadata:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Offline unit tests for the parser's device-identification path.
|
||||||
|
|
||||||
|
Covers two things the offline (no-server) pipeline is responsible for:
|
||||||
|
|
||||||
|
1. BinRAW -> RAW identification route (commit 4396c74): a BinRAW capture has
|
||||||
|
no RAW_Data array, so the parser must reconstruct a signed pulse train from
|
||||||
|
the demodulated Data_RAW bit stream. That reconstruction is what lets a
|
||||||
|
BinRAW file flow through the SAME identification path as a RAW file
|
||||||
|
(``has_raw_data`` becomes true), yielding device matches.
|
||||||
|
|
||||||
|
2. Per-match catalog-category surfacing (commit 41850b5): every device match
|
||||||
|
the engine returns must carry its OWN catalog category in
|
||||||
|
``match_details['device_category']`` (e.g. an Acurite sensor stays a
|
||||||
|
"Weather Sensor"), not just the single ML overall call.
|
||||||
|
|
||||||
|
Everything here is author-time / offline: bare ``pytest`` runs it with no
|
||||||
|
Docker, no bound server, and no network. ``.sub`` fixtures are written to the
|
||||||
|
per-test ``tmp_path`` because ``*.sub`` is git-ignored repo-wide, so embedding
|
||||||
|
the content keeps the suite green on a fresh clone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.parser.sub_parser import SubFileParser
|
||||||
|
from src.matcher.engine import SignatureMatcher
|
||||||
|
from src.matcher.pattern_decoder import PatternDecoder
|
||||||
|
from src.matcher.protocol_database import get_protocol_database
|
||||||
|
|
||||||
|
|
||||||
|
# --- minimal, self-contained fixtures (written to tmp_path at runtime) -------
|
||||||
|
|
||||||
|
# BinRAW: alternating bit stream (0xAA == 0b10101010) x 6 bytes = 48 bits.
|
||||||
|
# Alternating bits mean every run has length 1, so the reconstructed pulse
|
||||||
|
# train is exactly 48 pulses, each of magnitude TE (495 us), sign-alternating.
|
||||||
|
BINRAW_SUB = "\n".join([
|
||||||
|
"Filetype: Flipper SubGhz Key File",
|
||||||
|
"Version: 1",
|
||||||
|
"Frequency: 868350000",
|
||||||
|
"Preset: FuriHalSubGhzPresetOok270Async",
|
||||||
|
"Protocol: BinRAW",
|
||||||
|
"Bit: 48",
|
||||||
|
"TE: 495",
|
||||||
|
"Bit_RAW: 48",
|
||||||
|
"Data_RAW: AA AA AA AA AA AA",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
# RAW: a small but real timing array (315 MHz OOK).
|
||||||
|
RAW_SUB = "\n".join([
|
||||||
|
"Filetype: Flipper SubGhz RAW File",
|
||||||
|
"Version: 1",
|
||||||
|
"Frequency: 315000000",
|
||||||
|
"Preset: FuriHalSubGhzPresetOok650Async",
|
||||||
|
"Protocol: RAW",
|
||||||
|
"RAW_Data: 2980 -240 520 -980 520 -980 1000 -500 480 -1020 520 -980 1000 -500",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path, name, content):
|
||||||
|
p = tmp_path / name
|
||||||
|
p.write_text(content)
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_categories():
|
||||||
|
"""Authoritative {protocol_name: catalog_category} from the protocol DB."""
|
||||||
|
return {proto.name: proto.category for proto in get_protocol_database().get_all()}
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_categories():
|
||||||
|
"""The set of every catalog category the protocol DB knows about."""
|
||||||
|
return {proto.category for proto in get_protocol_database().get_all()}
|
||||||
|
|
||||||
|
|
||||||
|
# --- BinRAW parsing ----------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBinRawParsing:
|
||||||
|
def test_binraw_fields_extracted(self, tmp_path):
|
||||||
|
meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
|
||||||
|
assert meta.file_format == "BinRAW"
|
||||||
|
assert meta.frequency == 868350000
|
||||||
|
assert meta.modulation == "OOK"
|
||||||
|
assert meta.bin_raw_te == 495
|
||||||
|
assert meta.bin_raw_bit == 48
|
||||||
|
assert meta.bin_raw_data == bytes.fromhex("AAAAAAAAAAAA")
|
||||||
|
|
||||||
|
def test_binraw_reconstructs_raw_pulse_train(self, tmp_path):
|
||||||
|
"""The BinRAW->RAW route: Data_RAW must expand into raw_data pulses."""
|
||||||
|
meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
|
||||||
|
assert meta.has_raw_data is True
|
||||||
|
# 48 alternating bits -> 48 unit-length runs -> 48 pulses.
|
||||||
|
assert meta.pulse_count == 48
|
||||||
|
assert len(meta.raw_data) == 48
|
||||||
|
# Every pulse is exactly one TE, alternating sign.
|
||||||
|
assert all(abs(t) == 495 for t in meta.raw_data)
|
||||||
|
assert meta.raw_data[0] > 0 and meta.raw_data[1] < 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBinRawToPulses:
|
||||||
|
"""The run-length expansion is deterministic; test it directly."""
|
||||||
|
|
||||||
|
def test_run_length_encoding(self):
|
||||||
|
# 0xF0 == 11110000 -> run of 4 ones then 4 zeros.
|
||||||
|
pulses = SubFileParser._binraw_to_pulses(bytes([0xF0]), 8, 100)
|
||||||
|
assert pulses == [400, -400]
|
||||||
|
|
||||||
|
def test_alternating_bits(self):
|
||||||
|
# 0xAA == 10101010 -> 8 unit runs, alternating sign starting high.
|
||||||
|
pulses = SubFileParser._binraw_to_pulses(bytes([0xAA]), 8, 100)
|
||||||
|
assert pulses == [100, -100, 100, -100, 100, -100, 100, -100]
|
||||||
|
|
||||||
|
def test_truncates_to_n_bits(self):
|
||||||
|
# Only the first 4 bits (1111) of 0xF0 are used.
|
||||||
|
pulses = SubFileParser._binraw_to_pulses(bytes([0xF0]), 4, 50)
|
||||||
|
assert pulses == [200]
|
||||||
|
|
||||||
|
|
||||||
|
# --- RAW parsing -------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRawParsing:
|
||||||
|
def test_raw_fields_extracted(self, tmp_path):
|
||||||
|
meta = SubFileParser().parse(_write(tmp_path, "r.sub", RAW_SUB))
|
||||||
|
assert meta.file_format == "RAW"
|
||||||
|
assert meta.frequency == 315000000
|
||||||
|
assert meta.modulation == "OOK"
|
||||||
|
assert meta.has_raw_data is True
|
||||||
|
assert meta.pulse_count == 14
|
||||||
|
assert meta.raw_data[0] == 2980
|
||||||
|
assert meta.avg_pulse_width > 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- identification: BinRAW flows through RAW, categories surface -------------
|
||||||
|
|
||||||
|
class TestBinRawIdentificationRoute:
|
||||||
|
def test_binraw_yields_matches_via_raw_path(self, tmp_path):
|
||||||
|
"""A BinRAW capture identifies at all only because of the RAW route."""
|
||||||
|
meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
|
||||||
|
matches = SignatureMatcher().match(meta, max_results=5)
|
||||||
|
assert len(matches) > 0
|
||||||
|
for m in matches:
|
||||||
|
assert m.device_name # non-empty device name
|
||||||
|
cat = m.match_details.get("device_category")
|
||||||
|
assert isinstance(cat, str) and cat, \
|
||||||
|
"every match must surface a per-device catalog category"
|
||||||
|
|
||||||
|
def test_binraw_category_is_valid_catalog_category(self, tmp_path):
|
||||||
|
"""Every surfaced device_category is a real category from the catalog."""
|
||||||
|
meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
|
||||||
|
valid = _valid_categories()
|
||||||
|
matches = SignatureMatcher().match(meta, max_results=5)
|
||||||
|
assert len(matches) > 0
|
||||||
|
for m in matches:
|
||||||
|
assert m.match_details.get("device_category") in valid
|
||||||
|
|
||||||
|
def test_binraw_decoder_category_is_protocol_catalog_category(self, tmp_path):
|
||||||
|
"""At the source, DeviceMatch.category (surfaced by the engine) == protocol.category."""
|
||||||
|
meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
|
||||||
|
decoded = PatternDecoder().decode(meta)
|
||||||
|
assert len(decoded) > 0
|
||||||
|
for dm in decoded:
|
||||||
|
assert dm.category == dm.protocol.category
|
||||||
|
|
||||||
|
|
||||||
|
class TestRawIdentificationCategory:
|
||||||
|
def test_raw_match_surfaces_catalog_category(self, tmp_path):
|
||||||
|
meta = SubFileParser().parse(_write(tmp_path, "r.sub", RAW_SUB))
|
||||||
|
catalog = _catalog_categories()
|
||||||
|
matches = SignatureMatcher().match(meta, max_results=5)
|
||||||
|
assert len(matches) > 0
|
||||||
|
checked = 0
|
||||||
|
for m in matches:
|
||||||
|
cat = m.match_details.get("device_category")
|
||||||
|
assert isinstance(cat, str) and cat
|
||||||
|
if m.device_name in catalog:
|
||||||
|
assert cat == catalog[m.device_name]
|
||||||
|
checked += 1
|
||||||
|
assert checked > 0
|
||||||
|
|
||||||
|
def test_decoder_category_is_protocol_catalog_category(self, tmp_path):
|
||||||
|
"""DeviceMatch.category (the source of the surfaced value) == protocol.category."""
|
||||||
|
meta = SubFileParser().parse(_write(tmp_path, "r.sub", RAW_SUB))
|
||||||
|
decoded = PatternDecoder().decode(meta)
|
||||||
|
assert len(decoded) > 0
|
||||||
|
for dm in decoded:
|
||||||
|
assert dm.category == dm.protocol.category
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Offline unit tests for decoded (KEY) .sub identification against the signature DB.
|
||||||
|
|
||||||
|
A Flipper KEY file carries no RAW pulses but *names* a decoded protocol (e.g.
|
||||||
|
``Protocol: Princeton``). When that name is in our signature database this is the
|
||||||
|
strongest possible identification -- the capture device already demodulated and
|
||||||
|
named the protocol and we recognise it -- so it must:
|
||||||
|
|
||||||
|
- resolve to the REAL catalog signature (true manufacturer + category), not the
|
||||||
|
category router's coarser guess (Princeton is a Garage Door Opener in the
|
||||||
|
catalog, which the router previously mislabelled "Remote Control"), and
|
||||||
|
- score with high, frequency-aware confidence (exact database match), not the
|
||||||
|
~0.45 fuzzy-guess confidence it used to get.
|
||||||
|
|
||||||
|
Unknown protocol names must still fall back to category routing so an upload is
|
||||||
|
identified by family rather than silently dropped ("accept any capture ->
|
||||||
|
identify the device").
|
||||||
|
|
||||||
|
Offline / author-time: bare ``pytest`` runs this with no docker, no server, no
|
||||||
|
network. KEY .sub content is embedded and written to ``tmp_path``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.parser.sub_parser import SubFileParser
|
||||||
|
from src.matcher.engine import SignatureMatcher
|
||||||
|
from src.matcher.pattern_decoder import PatternDecoder
|
||||||
|
from src.matcher.protocol_database import get_protocol_database
|
||||||
|
|
||||||
|
|
||||||
|
def _key_sub(protocol: str, frequency: int, bit: int = 24, key: str = "00 00 00 00 00 95 D5 D4") -> str:
|
||||||
|
return "\n".join([
|
||||||
|
"Filetype: Flipper SubGhz Key File",
|
||||||
|
"Version: 1",
|
||||||
|
f"Frequency: {frequency}",
|
||||||
|
"Preset: FuriHalSubGhzPresetOok270Async",
|
||||||
|
f"Protocol: {protocol}",
|
||||||
|
f"Bit: {bit}",
|
||||||
|
f"Key: {key}",
|
||||||
|
"TE: 400",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path, content, name="k.sub"):
|
||||||
|
p = tmp_path / name
|
||||||
|
p.write_text(content)
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _identify(tmp_path, content):
|
||||||
|
meta = SubFileParser().parse(_write(tmp_path, content))
|
||||||
|
return meta, SignatureMatcher().match(meta, max_results=5)
|
||||||
|
|
||||||
|
|
||||||
|
# A known catalog protocol and a known one that carries a manufacturer, both
|
||||||
|
# taken from the DB itself so the tests stay correct if the catalog changes.
|
||||||
|
_DB = get_protocol_database()
|
||||||
|
_KNOWN = next(p for p in _DB.get_all())
|
||||||
|
_KNOWN_WITH_MFR = next((p for p in _DB.get_all() if p.manufacturer), None)
|
||||||
|
_UNKNOWN_NAME = "ZZ_NOT_A_REAL_PROTOCOL_9999"
|
||||||
|
|
||||||
|
|
||||||
|
class TestKnownProtocolExactMatch:
|
||||||
|
def test_named_protocol_resolves_to_catalog_signature(self, tmp_path):
|
||||||
|
meta, matches = _identify(tmp_path, _key_sub(_KNOWN.name, _KNOWN.frequency))
|
||||||
|
assert meta.file_format == "KEY"
|
||||||
|
assert meta.is_decoded is True
|
||||||
|
assert len(matches) >= 1
|
||||||
|
top = matches[0]
|
||||||
|
assert top.device_name == _KNOWN.name
|
||||||
|
assert top.match_method == "decoded_key_exact"
|
||||||
|
# Category comes from the catalog, not the router's guess.
|
||||||
|
assert top.match_details.get("device_category") == _KNOWN.category
|
||||||
|
|
||||||
|
def test_exact_match_scores_high_when_frequency_consistent(self, tmp_path):
|
||||||
|
_, matches = _identify(tmp_path, _key_sub(_KNOWN.name, _KNOWN.frequency))
|
||||||
|
assert matches[0].confidence == pytest.approx(0.95)
|
||||||
|
assert matches[0].match_details.get("frequency_match") is True
|
||||||
|
|
||||||
|
def test_exact_match_still_high_but_lower_on_frequency_mismatch(self, tmp_path):
|
||||||
|
# Same known name, but a frequency far outside its band.
|
||||||
|
off_band = _KNOWN.frequency + 50_000_000
|
||||||
|
_, matches = _identify(tmp_path, _key_sub(_KNOWN.name, off_band))
|
||||||
|
top = matches[0]
|
||||||
|
assert top.match_method == "decoded_key_exact"
|
||||||
|
assert top.confidence == pytest.approx(0.90)
|
||||||
|
assert top.match_details.get("frequency_match") is False
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_KNOWN_WITH_MFR is None, reason="no catalog protocol carries a manufacturer")
|
||||||
|
def test_real_manufacturer_is_surfaced(self, tmp_path):
|
||||||
|
_, matches = _identify(
|
||||||
|
tmp_path, _key_sub(_KNOWN_WITH_MFR.name, _KNOWN_WITH_MFR.frequency, bit=66)
|
||||||
|
)
|
||||||
|
assert matches[0].manufacturer == _KNOWN_WITH_MFR.manufacturer
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnknownProtocolFallback:
|
||||||
|
def test_unknown_name_falls_back_to_router(self, tmp_path):
|
||||||
|
_, matches = _identify(tmp_path, _key_sub(_UNKNOWN_NAME, 433920000))
|
||||||
|
assert len(matches) >= 1, "unknown decoded protocol must still be identified by family"
|
||||||
|
top = matches[0]
|
||||||
|
assert top.match_method == "decoded_key"
|
||||||
|
# A routed guess must not masquerade as an exact database match.
|
||||||
|
assert top.confidence < 0.90
|
||||||
|
|
||||||
|
def test_unknown_name_still_names_the_protocol(self, tmp_path):
|
||||||
|
_, matches = _identify(tmp_path, _key_sub(_UNKNOWN_NAME, 433920000))
|
||||||
|
assert matches[0].device_name == _UNKNOWN_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class TestLookupHelper:
|
||||||
|
def test_known_name_case_insensitive(self):
|
||||||
|
dec = PatternDecoder()
|
||||||
|
sig = dec._lookup_known_protocol(_KNOWN.name.upper(), _KNOWN.frequency)
|
||||||
|
assert sig is not None
|
||||||
|
assert sig.name == _KNOWN.name
|
||||||
|
|
||||||
|
def test_unknown_name_returns_none(self):
|
||||||
|
assert PatternDecoder()._lookup_known_protocol(_UNKNOWN_NAME, 433920000) is None
|
||||||
|
|
||||||
|
def test_empty_name_returns_none(self):
|
||||||
|
assert PatternDecoder()._lookup_known_protocol(None, 433920000) is None
|
||||||
|
|
||||||
|
def test_prefers_frequency_matching_candidate(self):
|
||||||
|
"""If duplicate-named entries exist, the frequency-consistent one wins."""
|
||||||
|
dec = PatternDecoder()
|
||||||
|
sig = dec._lookup_known_protocol(_KNOWN.name, _KNOWN.frequency)
|
||||||
|
assert sig is not None
|
||||||
|
assert sig.matches_frequency(_KNOWN.frequency)
|
||||||
Reference in New Issue
Block a user