Compare commits

...

18 Commits

Author SHA1 Message Date
MarkT 05135a24a0
Merge f1c4e7e8f0 into f5d0e1e69e 2026-06-03 00:40:13 -06:00
rUv f5d0e1e69e
fix(#894): actionable diagnostic when --model gets a non-RVF file (#919)
Users who downloaded ruvnet/wifi-densepose-pretrained and passed
model.safetensors / model-q4.bin / model.rvf.jsonl to --model hit a bare
"Progressive loader init failed: invalid magic at offset 0: expected
0x52564653, got 0x77455735" and were stuck — the server then silently fell
back to signal heuristics (which over-count, feeding "is it fake" reports).

The HF files are a different *format* and encoder architecture than the RVF
binary container the progressive loader expects, so they can't load directly.
Now the load-failure path detects the common cases (safetensors header,
JSONL manifest, quantized .bin blob) and emits a plain explanation naming the
format, what --model actually expects (RVF `RVFS` container from
wifi-densepose-train), and that it's continuing with heuristics — with a
pointer to #894.

Pure, testable `diagnose_model_load_error()` + 4 unit tests (run under the
default `--no-default-features` CI). Full crate unit suite: 429 + 114 passed,
0 failed.
2026-06-02 20:05:30 +02:00
rUv b12662a54d
fix(mqtt): per-node HA devices use each node's own presence/motion (#872) (#918)
The MQTT bridge fanned out one Home-Assistant device per node (#898) but
applied the *room-level aggregate* classification to every node — so in a
multi-node setup a node in an empty corner inherited another node's
"present", and `motion_level: "absent"` was mis-mapped to full motion
(the aggregate match fell through `Some(_) => 1.0`).

Each node in the sensing broadcast's `nodes` array already carries its own
`classification` (`motion_level`/`presence`/`confidence`, see
PerNodeFeatureInfo) and RSSI. Now each per-node snapshot reads that node's
own classification, deferring to the room aggregate only for fields a node
omits. Vitals (breathing/heart rate) and person count stay room-level.

Extracted the JSON→VitalsSnapshot mapping into a pure, testable function
(`vitals_snapshots_from_sensing_json`) and added 4 unit tests covering
per-node divergence, partial-field fallback, the no-nodes aggregate path,
and the absent→zero-motion fix.

Supersedes #899, which targeted the right bug but read non-existent fields
(`node["motion_level"]` / `node["status"]` instead of the nested
`node["classification"]` + `stale`).

Verified: builds with `--features mqtt`; new tests pass; full crate unit
suite 432 + 114 passed, 0 failed.
2026-06-02 19:26:01 +02:00
rUv 573b00fd98
perf(ci): drop dead uvicorn start from perf job (#917)
Since #915 the perf job gates only on test_frame_budget.py, which drives
the CSIProcessor pipeline in-process and makes no HTTP calls. The
"Start application" step (uvicorn + `sleep 10`) was therefore dead weight:
it existed only for the now-excluded api_throughput/inference_speed tests,
wasted ~10-15 s per main-push run, and dumped ~50 misleading
"router requires hardware setup" ERROR lines into every CI log for a
server no test touched. MOCK_POSE_DATA is server-only, unused here.

Removed the step and the vestigial env. The gated test is unchanged and
passes (verified locally, 3/3).
2026-06-02 19:01:08 +02:00
rUv 91b0e625bd
docs(#882): complete the "100% presence" retraction across all docs (#916)
The v1 "100% presence accuracy" headline was already retracted in the
README / user-guide intro / proof-of-capabilities — but 6 secondary
spots still flatly claimed "100% accuracy, never false alarms", which
made proof-of-capabilities.md's "replaced everywhere" assertion untrue.

Completed the retraction in-place with the honest label-free metric
(82.3% held-out temporal-triplet; v1 was a single-class recording where
a constant "yes" scores ~99.98%):

- docs/readme-details.md — 2 benchmark tables + the pre-trained-model row
- docs/user-guide.md — capability table, model-file comment, applications list
- CHANGELOG.md — annotated the historical entry in-place (kept as public
  record per built-in-public ethos, not rewritten)

Verified: no remaining flat "100% presence/accuracy" claim lacks a
retraction marker; proof-of-capabilities.md "replaced everywhere" is now
accurate.
2026-06-02 18:50:39 +02:00
rUv 88b835dd89
fix(ci): perf job gates on the real frame-budget guard, not TDD stubs (#915)
After #914 fixed collection, the perf job actually ran the suite and
exposed that test_api_throughput.py / test_inference_speed.py are TDD
red-phase stubs (every test suffixed `_should_fail_initially`) that time
a *mock that sleeps* — not a real perf signal. They carry machine-
dependent wall-clock asserts (actual_rps >= 40, batch_time < individual_time)
that are inherently flaky on shared CI runners, plus a cross-class
fixture-scope bug (`fixture 'standard_model' not found`). Result: 3 failed,
10 errored — by design, not a regression.

Forcing those green would manufacture a false signal. Instead, gate only
on test_frame_budget.py, which times the *real* CSIProcessor pipeline
against the ADR 50 ms per-frame budget (single-frame, p95/100-frames,
+Doppler) — a genuine regression guard. Verified locally: 3 passed.

The stub files remain in-repo for local TDD; they re-enter CI when their
features are implemented and the mock-timing asserts are made deterministic.
2026-06-02 18:31:55 +02:00
rUv f8f08076eb
fix(ci): perf tests — use `python -m pytest` so `src` import resolves (#914)
The Performance Tests job collected 26 items then aborted with
`ModuleNotFoundError: No module named 'src'` on test_frame_budget.py,
which does `from src.core.csi_processor import CSIProcessor`. The bare
`pytest` console script does not put the cwd (archive/v1) on sys.path;
`python -m pytest` does. pytest aborts the whole session on a collection
error, so this one import masked the entire (otherwise mock-based,
self-contained) perf suite.

Verified locally: bare-script path reproduces the exact error; `-m`
resolves it and test_frame_budget.py passes 3/3. The other two files
(test_api_throughput.py mock server, test_inference_speed.py MockPoseModel
+psutil) are fully self-contained — no test hits the running server.

Closes the last red job in the v1-API CI chain (#910/#911/#913).
2026-06-02 18:12:00 +02:00
rUv 55f6a74e1e
Merge pull request #913 from ruvnet/fix/ci-v1-api-perms-locust
ci(v1-api): fix gh-pages 403 + run real pytest perf suite
2026-06-02 17:36:43 +02:00
ruv b5a91c5635 ci(v1-api): install pytest, drop root --cov addopts for perf suite, ascii comment 2026-06-02 17:29:04 +02:00
ruv 308d2fc89d ci(v1-api): fix gh-pages 403 + run real perf suite — green main CI
Two more latent v1-API CI bugs surfaced once #910/#911 let the jobs reach
their later steps:

- API Documentation: openapi generation now succeeds (psutil fix), but the
  gh-pages deploy failed with HTTP 403 — the job had no `permissions` block
  and GITHUB_TOKEN is read-only by default. Add `permissions: contents:
  write`, and make the deploy `continue-on-error` (the openapi generation is
  the real validation; Pages may be disabled).
- Performance Tests: ran `locust -f tests/performance/locustfile.py`, but
  there is no locustfile — the suite is pytest (test_api_throughput.py,
  test_frame_budget.py, test_inference_speed.py). Run pytest instead, with
  working-directory: archive/v1 and MOCK_POSE_DATA=true.

ci.yml validated as well-formed YAML.
2026-06-02 17:26:39 +02:00
rUv 5038e3c8e1
Merge pull request #911 from ruvnet/fix/ci-v1-api-mock-mode
ci(v1-api): MOCK_POSE_DATA + declare psutil — green Performance Tests & API Docs
2026-06-02 06:20:21 -04:00
ruv e239af3636 fix(deps): declare psutil in requirements.txt — green API Documentation CI
The API Documentation job (and any env without locust) failed with
`ModuleNotFoundError: No module named 'psutil'` when importing the app:
psutil is imported by src/api/routers/health.py, services/metrics.py,
commands/status.py, and tasks/monitoring.py, but was never declared as a
dependency — it only happened to be present where locust (Performance
Tests) pulled it in transitively. Declare it explicitly (psutil>=5.9.0).

Verified locally: `from src.api.main import app; app.openapi()` (the exact
docs-job operation) now succeeds.
2026-06-02 12:11:55 +02:00
ruv 4856afbd0c ci(v1-api): run Performance Tests + API Docs with MOCK_POSE_DATA=true
After the DensePoseHead startup fix (#910), the v1 API starts, but the
Performance Tests load-hit the pose endpoints which error "requires real
CSI data" (no hardware in CI, mock_pose_data defaults False), and the
API-docs job imports the app the same way. Set MOCK_POSE_DATA=true on both
jobs so they exercise the mock path. Verified: the env var maps to
settings.mock_pose_data=True (pydantic, no env_prefix).

(Note: Performance Tests is continue-on-error so this is cleanup, not a
run-blocker; the run-level red on main has been transient Docker Hub pull
timeouts on Tests/docker-build, which are infra flakes that pass on re-run.)
2026-06-02 12:04:58 +02:00
rUv 4d205a05c4
Merge pull request #910 from ruvnet/fix/v1-pose-service-densepose-config
fix(v1-api): pass required config to DensePoseHead — green main CI
2026-06-02 05:50:25 -04:00
ruv bc42ae7903 fix(v1-api): pass required config to DensePoseHead — green main CI
The "Continuous Integration" workflow (Performance Tests + API
Documentation jobs) has failed on every main commit since the API start
path was exercised: pose_service._initialize_models() called
`DensePoseHead()` with no args, but DensePoseHead.__init__ requires a
config dict → "TypeError: DensePoseHead.__init__() missing 1 required
positional argument: 'config'" → uvicorn "Application startup failed".

Pass a config: input_channels=256 (matches the modality translator's
output), num_body_parts=24 (DensePose standard), num_uv_coordinates=2.
Both call sites (with/without pose_model_path) fixed.

Verified locally: DensePoseHead(config) + ModalityTranslationNetwork(config)
both construct + eval, clearing the startup TypeError.
2026-06-02 11:42:52 +02:00
rUv b7b8c1109b
Merge pull request #908 from ruvnet/fix/893-release-bins-refresh
release(firmware): refresh release_bins with the #893 CSI fix → v0.6.7
2026-06-02 05:35:34 -04:00
ruv 786e834dae release(firmware): refresh release_bins with the #893 CSI fix → v0.6.7
The pre-built binaries in release_bins/ were v0.6.6 (May 21) and shipped
the MGMT-only promiscuous filter, so display-less boards flashed from them
got yield=0pps (#893/#866/#897 — the root cause of the "can't reproduce /
it's fake" reports). Rebuilt every flashable variant from main (which has
the #893 display-gated DATA-frame fix) and refreshed the binaries:

- top-level ESP32-S3 8MB (sdkconfig.defaults) — esp32-csi-node.bin +
  bootloader (partition-table/ota_data unchanged — code-only fix)
- esp32-csi-node-4mb.bin (ESP32-S3 4MB, sdkconfig.defaults.4mb)
- c6-adr110/ (ESP32-C6, sdkconfig.defaults.esp32c6) — the exact firmware
  hardware-verified on COM6 (CSI yield 0→27 pps, presence/motion alive,
  no #396 crash)
- s3-adr110/ (same production S3 8MB config)

Left untouched: s3-fair-adr110/ (a non-production size-comparison build,
features stripped — not a board anyone flashes for sensing).

version.txt → 0.6.7; SHA256SUMS regenerated for the changed variant dirs.
Display boards keep MGMT-only (preserves the #396 crash protection);
display-less boards now capture DATA frames and stream CSI.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-02 11:18:03 +02:00
markt-heximal f1c4e7e8f0 Fix ADR-045 display never refreshing (LVGL tick) + add ST7789 LCD HAL
- Add an esp_timer-driven lv_tick_inc() in the display task. Without it
  LVGL's tick never advances, its 30ms refresh timer never fires, and the
  panel only ever shows the boot frame (CONFIG_LV_TICK_CUSTOM is unset and
  lv_conf.h is ignored by the Kconfig-driven managed lvgl component).
  Fixes #889.
- Add display_hal_st7789.c: ST7789V2 SPI LCD + CST816 touch + LEDC backlight
  HAL (Waveshare ESP32-S3-Touch-LCD-1.69), selected via a new DISPLAY_PANEL
  Kconfig choice; CMake compiles exactly one panel HAL.
- Add display_ui_st7789.c: compact 240x280 UI (the 4-view AMOLED UI is laid
  out for 368x448 and overflows a 1.69in panel).
- display_hal_refresh() backlight re-assert for transient brownout recovery.
- sdkconfig.defaults.st7789 build overlay (panel select + fonts + bar).
2026-05-31 21:43:46 -04:00
26 changed files with 973 additions and 83 deletions

View File

@ -265,23 +265,45 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install locust
pip install pytest # the perf suite is pytest, not locust
- name: Start application
working-directory: archive/v1
run: |
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
sleep 10
# No "Start application" step: the gated test (test_frame_budget.py) drives
# the CSIProcessor pipeline in-process and makes no HTTP calls, so the old
# uvicorn server + `sleep 10` were dead weight — they only existed for the
# now-excluded api_throughput/inference_speed tests, and on every run dumped
# ~50 misleading "router requires hardware setup" ERROR lines for a server
# no test touched. MOCK_POSE_DATA is server-only and unused here.
- name: Run performance tests
working-directory: archive/v1
run: |
locust -f tests/performance/locustfile.py --headless --users 50 --spawn-rate 5 --run-time 60s --host http://localhost:8000
# Gate only on the genuine, deterministic perf guard:
# test_frame_budget.py times the *real* CSIProcessor pipeline against
# the ADR 50 ms per-frame budget (single-frame, p95 over 100 frames,
# +Doppler) — a true regression signal.
#
# test_api_throughput.py / test_inference_speed.py are excluded: every
# test there is a TDD red-phase stub (suffix `_should_fail_initially`)
# that times a *mock that sleeps* — meaningless as a perf signal, with
# machine-dependent wall-clock asserts (e.g. `actual_rps >= 40`,
# `batch_time < individual_time`) that are inherently flaky on shared
# CI runners, plus a cross-class fixture-scope bug. Forcing them green
# would be manufacturing a false signal; they stay in-repo for local
# TDD but do not gate CI until the underlying features are implemented.
#
# `python -m pytest` (not the bare `pytest` script) puts the cwd
# (archive/v1) on sys.path so `from src.core...` resolves — the bare
# script omits cwd and raises ModuleNotFoundError: No module named 'src'.
# -o addopts="" drops the root pyproject's --cov/--cov-fail-under=100.
python -m pytest tests/performance/test_frame_budget.py \
-o addopts="" -v --junitxml=perf-junit.xml
- name: Upload performance results
if: always()
uses: actions/upload-artifact@v4
with:
name: performance-results
path: locust_report.html
path: archive/v1/perf-junit.xml
# Docker Build and Test
# NOTE: the canonical Docker build for the sensing-server is now
@ -367,6 +389,8 @@ jobs:
runs-on: ubuntu-latest
needs: [docker-build]
if: github.ref == 'refs/heads/main'
permissions:
contents: write # gh-pages deploy needs write (GITHUB_TOKEN is read-only by default -> 403)
steps:
- name: Checkout code
uses: actions/checkout@v4
@ -384,6 +408,8 @@ jobs:
- name: Generate OpenAPI spec
working-directory: archive/v1
env:
MOCK_POSE_DATA: "true" # no CSI hardware in CI
run: |
python -c "
from src.api.main import app
@ -394,6 +420,7 @@ jobs:
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
continue-on-error: true # openapi generation above is the real validation; deploy is best-effort (Pages may be disabled)
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs

View File

@ -430,7 +430,7 @@ Model release (no new firmware binary). Firmware remains at v0.6.0-esp32.
- Security fix merged via PR #310.
### Performance
- Presence detection: 100% accuracy on 60,630 overnight samples.
- Presence detection: 100% accuracy on 60,630 overnight samples. *(Retracted — that recording was single-class (one sleeping person, 6,062/6,063 frames "present"), so a constant "yes" scores ~99.98%. Superseded by the honest 82.3% held-out temporal-triplet metric; see [#882](https://github.com/ruvnet/RuView/issues/882). Kept here as the in-place public record.)*
- Inference: 0.008 ms per sample, 164K embeddings/sec.
- Contrastive self-supervised training: 51.6% improvement over baseline.

View File

@ -107,16 +107,25 @@ class PoseService:
async def _initialize_models(self):
"""Initialize neural network models."""
try:
# Initialize DensePose model
# Initialize DensePose model. DensePoseHead requires a config
# dict — input_channels matches the modality translator's output
# (256), with the standard DensePose 24 body parts and 2 (U,V)
# coordinates. (Previously called with no args → TypeError at
# startup, which broke the API service.)
densepose_config = {
'input_channels': 256,
'num_body_parts': 24,
'num_uv_coordinates': 2,
}
if self.settings.pose_model_path:
self.densepose_model = DensePoseHead()
self.densepose_model = DensePoseHead(densepose_config)
# Load model weights if path is provided
# model_state = torch.load(self.settings.pose_model_path)
# self.densepose_model.load_state_dict(model_state)
self.logger.info("DensePose model loaded")
else:
self.logger.warning("No pose model path provided, using default model")
self.densepose_model = DensePoseHead()
self.densepose_model = DensePoseHead(densepose_config)
# Initialize modality translation
config = {

View File

@ -122,7 +122,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm # benchmark
| What we measured | Result | Why it matters |
|-----------------|--------|---------------|
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric on the last 20% by time (v1's "100% presence" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| **Inference speed** | **0.008 ms** per embedding | 125,000x faster than real-time |
| **Throughput** | **164,183 embeddings/sec** | One Mac Mini handles 1,600+ ESP32 nodes |
| **Contrastive learning** | **51.6% improvement** | Strong pattern learning from real overnight data |
@ -233,7 +233,7 @@ python firmware/esp32-csi-node/provision.py --port COM9 --hop-channels "1,6,11"
| **kNN similarity search** | "Find the 10 most similar states to right now" — anomaly detection, fingerprinting | Cognitum Seed |
| **Witness chain** | SHA-256 tamper-evident audit trail for every measurement (1,747 entries validated) | Cognitum Seed |
| **Camera-free pose training** | 17 COCO keypoints from 10 sensor signals — PIR, RSSI triangulation, subcarrier asymmetry, vibration, BME280 | 2x ESP32 + Seed |
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 100% presence accuracy, 0 skeleton violations | Download from release |
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 82.3% held-out temporal-triplet accuracy (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) | Download from release |
| **Sub-ms inference** | 0.012 ms latency, 171,472 embeddings/sec on M4 Pro | Any machine with Node.js |
| **SONA adaptation** | Adapts to new rooms in <1ms without retraining | ruvllm runtime |
| **LoRA room adapters** | Per-node fine-tuning with 2,048 parameters per adapter | Automatic |
@ -262,7 +262,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
| What we measured | Result | Why it matters |
|-----------------|--------|---------------|
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| **Person counting** | **24/24 correct** (MinCut) | Fixed the #1 user-reported issue |
| **Inference speed** | **0.012 ms** per embedding | 83,000x faster than real-time |
| **Throughput** | **171,472 embeddings/sec** | One Mac Mini handles 1,700+ ESP32 nodes |

View File

@ -1119,7 +1119,7 @@ What it ships (and what it does not):
| Capability | Status |
|------------|--------|
| Presence detection (occupied / empty) | ✅ Trained head — 100% accuracy on validation |
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
@ -1824,7 +1824,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
# model.safetensors — 48 KB contrastive encoder
# model-q4.bin — 8 KB quantized (recommended)
# model-q2.bin — 4 KB ultra-compact (ESP32 edge)
# presence-head.json — presence detection head (100% accuracy)
# presence-head.json — presence detection head (v2 encoder: 82.3% held-out triplet acc)
# node-1.json — LoRA adapter for room 1
# node-2.json — LoRA adapter for room 2
```
@ -1833,7 +1833,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
The pre-trained encoder converts 8-dim CSI feature vectors into 128-dim embeddings. These embeddings power all 17 sensing applications:
- **Presence detection**100% accuracy, never misses, never false alarms
- **Presence detection**v2 encoder: 82.3% held-out temporal-triplet accuracy (v1's "100%" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882))
- **Environment fingerprinting** — kNN search finds "states like this one"
- **Anomaly detection** — embeddings that don't match known clusters = anomaly
- **Activity classification** — different activities cluster in embedding space

View File

@ -52,9 +52,17 @@ if(CONFIG_CSI_MOCK_ENABLED)
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")
endif()
# ADR-045: AMOLED display support (compile-time optional)
# ADR-045: on-device display support (compile-time optional).
# Exactly one panel HAL is compiled, selected by the DISPLAY_PANEL choice.
if(CONFIG_DISPLAY_ENABLE)
list(APPEND SRCS "display_hal.c" "display_ui.c" "display_task.c")
list(APPEND SRCS "display_task.c")
if(CONFIG_DISPLAY_PANEL_ST7789)
# 240x280 ST7789: compact UI + ST7789 HAL.
list(APPEND SRCS "display_hal_st7789.c" "display_ui_st7789.c")
else()
# 368x448 SH8601 AMOLED: 4-view UI + QSPI HAL.
list(APPEND SRCS "display_hal.c" "display_ui.c")
endif()
list(APPEND REQUIRES esp_lcd esp_lcd_touch lvgl)
endif()

View File

@ -170,15 +170,104 @@ menu "Adaptive Controller (ADR-081)"
endmenu
menu "AMOLED Display (ADR-045)"
menu "Display (ADR-045)"
config DISPLAY_ENABLE
bool "Enable AMOLED display support"
bool "Enable on-device display support"
default y
help
Enable RM67162 QSPI AMOLED display and LVGL UI.
Auto-detects at boot; gracefully skips if no display hardware.
Requires SPIRAM for frame buffers.
Enable the LVGL UI on an attached panel. Auto-detects at boot;
gracefully skips if no display hardware. Requires SPIRAM for
frame buffers. Choose the panel type below — the CSI pipeline
runs unchanged regardless of panel.
choice DISPLAY_PANEL
prompt "Display panel"
depends on DISPLAY_ENABLE
default DISPLAY_PANEL_SH8601
help
Which physical panel is attached. Exactly one HAL is compiled.
config DISPLAY_PANEL_SH8601
bool "SH8601 QSPI AMOLED (Waveshare ESP32-S3-Touch-AMOLED-1.8, 368x448)"
config DISPLAY_PANEL_ST7789
bool "ST7789V2 SPI LCD (Waveshare ESP32-S3-Touch-LCD-1.69, 240x280)"
endchoice
# ---- ST7789 SPI LCD pins / geometry (Waveshare 1.69 defaults) ----
config DISPLAY_ST7789_SCLK
int "ST7789 SPI SCLK GPIO"
default 6
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_MOSI
int "ST7789 SPI MOSI GPIO"
default 7
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_CS
int "ST7789 SPI CS GPIO"
default 5
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_DC
int "ST7789 SPI DC GPIO"
default 4
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_RST
int "ST7789 RST GPIO"
default 8
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_BL
int "ST7789 backlight PWM GPIO"
default 15
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_H_RES
int "ST7789 horizontal resolution"
default 240
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_V_RES
int "ST7789 vertical resolution"
default 280
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_GAP_X
int "ST7789 column offset (gap X)"
default 0
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_GAP_Y
int "ST7789 row offset (gap Y)"
default 20
depends on DISPLAY_PANEL_ST7789
help
The 240x280 visible window sits at row 20 of the ST7789's
240x320 controller RAM. Adjust if the image is shifted.
config DISPLAY_ST7789_TOUCH_SDA
int "CST816 touch I2C SDA GPIO"
default 11
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_TOUCH_SCL
int "CST816 touch I2C SCL GPIO"
default 10
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_TOUCH_RST
int "CST816 touch RST GPIO"
default 13
depends on DISPLAY_PANEL_ST7789
config DISPLAY_ST7789_TOUCH_INT
int "CST816 touch INT GPIO"
default 14
depends on DISPLAY_PANEL_ST7789
config DISPLAY_FPS_LIMIT
int "Display refresh rate limit (FPS)"

View File

@ -379,4 +379,13 @@ void display_hal_set_brightness(uint8_t percent)
panel_write_cmd(0x51, &val, 1);
}
void display_hal_refresh(void)
{
/* Re-assert display-on (0x29) + brightness so a transient brownout that
* dimmed the panel self-recovers without a full re-init. */
if (!s_io_handle) return;
panel_write_cmd(0x29, NULL, 0);
display_hal_set_brightness(CONFIG_DISPLAY_BRIGHTNESS);
}
#endif /* CONFIG_DISPLAY_ENABLE */

View File

@ -64,6 +64,16 @@ bool display_hal_touch_read(uint16_t *x, uint16_t *y);
*/
void display_hal_set_brightness(uint8_t percent);
/**
* Self-heal: re-assert backlight + display-on state.
*
* Called periodically by the display task so a transient power sag
* (e.g. shared-USB brownout dimming the backlight) auto-recovers
* instead of leaving the panel dark while the MCU keeps running.
* Cheap and flicker-free does NOT re-init the panel.
*/
void display_hal_refresh(void);
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,309 @@
/**
* @file display_hal_st7789.c
* @brief ADR-045: ST7789V2 SPI LCD + CST816 touch HAL.
*
* Hardware abstraction for the Waveshare ESP32-S3-Touch-LCD-1.69
* (240x280 IPS, ST7789V2 over 4-wire SPI, CST816 capacitive touch).
*
* Implements the same display_hal.h contract as the SH8601 QSPI AMOLED HAL,
* but on ESP-IDF's built-in esp_lcd ST7789 panel driver. Selected at build
* time via CONFIG_DISPLAY_PANEL_ST7789 (see Kconfig.projbuild). Exactly one
* display_hal_*.c is compiled into the image (CMakeLists picks by panel).
*
* Pin assignments (Waveshare ESP32-S3-Touch-LCD-1.69, Kconfig-overridable):
* LCD SPI: SCLK=6, MOSI=7, CS=5, DC=4, RST=8, BL=15
* Touch: I2C SDA=11, SCL=10, RST=13, INT=14 (CST816 @ 0x15)
*
* Runs concurrently with the full CSI pipeline the panel sits on SPI2_HOST
* and a private I2C bus, neither of which the radio/UDP data plane touches.
*/
#include "display_hal.h"
#include "sdkconfig.h"
#if CONFIG_DISPLAY_ENABLE && CONFIG_DISPLAY_PANEL_ST7789
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_panel_vendor.h"
#include "driver/spi_master.h"
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "driver/i2c.h"
#include "esp_heap_caps.h"
static const char *TAG = "disp_hal_st7789";
/* ---- LCD SPI pins (Kconfig-overridable) ---- */
#define LCD_PIN_SCLK CONFIG_DISPLAY_ST7789_SCLK
#define LCD_PIN_MOSI CONFIG_DISPLAY_ST7789_MOSI
#define LCD_PIN_CS CONFIG_DISPLAY_ST7789_CS
#define LCD_PIN_DC CONFIG_DISPLAY_ST7789_DC
#define LCD_PIN_RST CONFIG_DISPLAY_ST7789_RST
#define LCD_PIN_BL CONFIG_DISPLAY_ST7789_BL
#define LCD_H_RES CONFIG_DISPLAY_ST7789_H_RES
#define LCD_V_RES CONFIG_DISPLAY_ST7789_V_RES
#define LCD_GAP_X CONFIG_DISPLAY_ST7789_GAP_X
#define LCD_GAP_Y CONFIG_DISPLAY_ST7789_GAP_Y
#define LCD_HOST SPI2_HOST
#define LCD_PCLK_HZ (40 * 1000 * 1000)
/* ---- Backlight PWM ---- */
#define BL_LEDC_TIMER LEDC_TIMER_0
#define BL_LEDC_MODE LEDC_LOW_SPEED_MODE
#define BL_LEDC_CHANNEL LEDC_CHANNEL_0
#define BL_LEDC_DUTY_RES LEDC_TIMER_8_BIT
#define BL_LEDC_FREQ_HZ 5000
/* ---- CST816 touch (I2C) ---- */
#define TOUCH_I2C_NUM I2C_NUM_0
#define TOUCH_I2C_FREQ_HZ 400000
#define TOUCH_PIN_SDA CONFIG_DISPLAY_ST7789_TOUCH_SDA
#define TOUCH_PIN_SCL CONFIG_DISPLAY_ST7789_TOUCH_SCL
#define TOUCH_PIN_RST CONFIG_DISPLAY_ST7789_TOUCH_RST
#define TOUCH_PIN_INT CONFIG_DISPLAY_ST7789_TOUCH_INT
#define CST816_ADDR 0x15
#define CST816_REG_CHIPID 0xA7
/* ---- State ---- */
static esp_lcd_panel_io_handle_t s_io_handle = NULL;
static esp_lcd_panel_handle_t s_panel = NULL;
static bool s_bl_initialized = false;
static bool s_touch_initialized = false;
/* ===================== Backlight ===================== */
static void init_backlight(void)
{
ledc_timer_config_t timer = {
.speed_mode = BL_LEDC_MODE,
.timer_num = BL_LEDC_TIMER,
.duty_resolution = BL_LEDC_DUTY_RES,
.freq_hz = BL_LEDC_FREQ_HZ,
.clk_cfg = LEDC_AUTO_CLK,
};
if (ledc_timer_config(&timer) != ESP_OK) {
ESP_LOGW(TAG, "LEDC timer config failed — backlight will be GPIO-on");
gpio_set_direction(LCD_PIN_BL, GPIO_MODE_OUTPUT);
gpio_set_level(LCD_PIN_BL, 1);
return;
}
ledc_channel_config_t ch = {
.gpio_num = LCD_PIN_BL,
.speed_mode = BL_LEDC_MODE,
.channel = BL_LEDC_CHANNEL,
.timer_sel = BL_LEDC_TIMER,
.duty = 0,
.hpoint = 0,
};
ledc_channel_config(&ch);
s_bl_initialized = true;
}
/* ===================== Panel ===================== */
static esp_err_t draw_test_pattern(void)
{
/* Clear to background once (low power — no full-white frame, which spiked
* current and worsened the startup brownout). LVGL draws over this. */
uint16_t *line = heap_caps_malloc(LCD_H_RES * sizeof(uint16_t), MALLOC_CAP_DMA);
if (!line) return ESP_ERR_NO_MEM;
for (int x = 0; x < LCD_H_RES; x++) line[x] = 0x0841; /* near-black */
for (int y = 0; y < LCD_V_RES; y++)
esp_lcd_panel_draw_bitmap(s_panel, 0, y, LCD_H_RES, y + 1, line);
free(line);
return ESP_OK;
}
esp_err_t display_hal_init_panel(void)
{
ESP_LOGI(TAG, "Initializing Waveshare 1.69\" LCD (ST7789V2 %dx%d)...",
LCD_H_RES, LCD_V_RES);
spi_bus_config_t bus_cfg = {
.sclk_io_num = LCD_PIN_SCLK,
.mosi_io_num = LCD_PIN_MOSI,
.miso_io_num = -1,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = LCD_H_RES * 80 * sizeof(uint16_t),
};
esp_err_t ret = spi_bus_initialize(LCD_HOST, &bus_cfg, SPI_DMA_CH_AUTO);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "SPI bus init failed: %s", esp_err_to_name(ret));
return ESP_ERR_NOT_FOUND;
}
esp_lcd_panel_io_spi_config_t io_config = {
.dc_gpio_num = LCD_PIN_DC,
.cs_gpio_num = LCD_PIN_CS,
.pclk_hz = LCD_PCLK_HZ,
.lcd_cmd_bits = 8,
.lcd_param_bits = 8,
.spi_mode = 0,
.trans_queue_depth = 10,
};
ret = esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST, &io_config, &s_io_handle);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Panel IO init failed: %s", esp_err_to_name(ret));
spi_bus_free(LCD_HOST);
return ESP_ERR_NOT_FOUND;
}
esp_lcd_panel_dev_config_t panel_config = {
.reset_gpio_num = LCD_PIN_RST,
.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB,
.bits_per_pixel = 16,
};
ret = esp_lcd_new_panel_st7789(s_io_handle, &panel_config, &s_panel);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "ST7789 panel create failed: %s", esp_err_to_name(ret));
esp_lcd_panel_io_del(s_io_handle);
spi_bus_free(LCD_HOST);
s_io_handle = NULL;
return ESP_ERR_NOT_FOUND;
}
esp_lcd_panel_reset(s_panel);
esp_lcd_panel_init(s_panel);
/* IPS ST7789 panels show inverted colour without this. */
esp_lcd_panel_invert_color(s_panel, true);
/* 240x280 visible window sits at row LCD_GAP_Y of the 240x320 controller RAM. */
esp_lcd_panel_set_gap(s_panel, LCD_GAP_X, LCD_GAP_Y);
esp_lcd_panel_disp_on_off(s_panel, true);
init_backlight();
display_hal_set_brightness(CONFIG_DISPLAY_BRIGHTNESS);
draw_test_pattern();
ESP_LOGI(TAG, "ST7789 panel init OK (%dx%d, gap %d,%d)",
LCD_H_RES, LCD_V_RES, LCD_GAP_X, LCD_GAP_Y);
return ESP_OK;
}
void display_hal_draw(int x_start, int y_start, int x_end, int y_end,
const void *color_data)
{
if (!s_panel) return;
/* esp_lcd takes an exclusive end coord, which is exactly what
* display_task's flush callback already passes (area->x2 + 1). */
esp_lcd_panel_draw_bitmap(s_panel, x_start, y_start, x_end, y_end, color_data);
}
/* ===================== Touch (CST816) ===================== */
static esp_err_t touch_i2c_init(void)
{
i2c_config_t cfg = {
.mode = I2C_MODE_MASTER,
.sda_io_num = TOUCH_PIN_SDA,
.scl_io_num = TOUCH_PIN_SCL,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = TOUCH_I2C_FREQ_HZ,
};
esp_err_t ret = i2c_param_config(TOUCH_I2C_NUM, &cfg);
if (ret != ESP_OK) return ret;
return i2c_driver_install(TOUCH_I2C_NUM, I2C_MODE_MASTER, 0, 0, 0);
}
static esp_err_t touch_read_reg(uint8_t reg, uint8_t *data, size_t len)
{
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (CST816_ADDR << 1) | I2C_MASTER_WRITE, true);
i2c_master_write_byte(cmd, reg, true);
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (CST816_ADDR << 1) | I2C_MASTER_READ, true);
i2c_master_read(cmd, data, len, I2C_MASTER_LAST_NACK);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(TOUCH_I2C_NUM, cmd, pdMS_TO_TICKS(100));
i2c_cmd_link_delete(cmd);
return ret;
}
esp_err_t display_hal_init_touch(void)
{
ESP_LOGI(TAG, "Probing CST816 touch controller...");
/* Hardware reset pulse (CST816 needs RST toggled to boot). */
gpio_set_direction(TOUCH_PIN_RST, GPIO_MODE_OUTPUT);
gpio_set_level(TOUCH_PIN_RST, 0);
vTaskDelay(pdMS_TO_TICKS(10));
gpio_set_level(TOUCH_PIN_RST, 1);
vTaskDelay(pdMS_TO_TICKS(60));
gpio_config_t int_cfg = {
.pin_bit_mask = (1ULL << TOUCH_PIN_INT),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&int_cfg);
esp_err_t ret = touch_i2c_init();
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Touch I2C init failed: %s", esp_err_to_name(ret));
return ESP_ERR_NOT_FOUND;
}
uint8_t chip_id = 0;
ret = touch_read_reg(CST816_REG_CHIPID, &chip_id, 1);
if (ret != ESP_OK || chip_id == 0x00 || chip_id == 0xFF) {
ESP_LOGW(TAG, "CST816 not found (ret=%s, id=0x%02X)", esp_err_to_name(ret), chip_id);
return ESP_ERR_NOT_FOUND;
}
s_touch_initialized = true;
ESP_LOGI(TAG, "CST816 touch init OK (chip_id=0x%02X)", chip_id);
return ESP_OK;
}
bool display_hal_touch_read(uint16_t *x, uint16_t *y)
{
if (!s_touch_initialized) return false;
/* Read gesture(0x01), finger_num(0x02), X hi/lo(0x03/04), Y hi/lo(0x05/06). */
uint8_t buf[6] = {0};
if (touch_read_reg(0x01, buf, sizeof(buf)) != ESP_OK) return false;
uint8_t fingers = buf[1] & 0x0F;
if (fingers == 0) return false;
*x = ((uint16_t)(buf[2] & 0x0F) << 8) | buf[3];
*y = ((uint16_t)(buf[4] & 0x0F) << 8) | buf[5];
return true;
}
/* ===================== Brightness ===================== */
void display_hal_set_brightness(uint8_t percent)
{
if (percent > 100) percent = 100;
if (!s_bl_initialized) {
/* LEDC unavailable — drive backlight GPIO directly. */
gpio_set_level(LCD_PIN_BL, percent > 0 ? 1 : 0);
return;
}
uint32_t duty = (uint32_t)percent * 255 / 100; /* 8-bit resolution */
ledc_set_duty(BL_LEDC_MODE, BL_LEDC_CHANNEL, duty);
ledc_update_duty(BL_LEDC_MODE, BL_LEDC_CHANNEL);
}
void display_hal_refresh(void)
{
/* Backlight-only self-heal: re-assert the LEDC duty (no SPI). The earlier
* version also poked esp_lcd_panel_disp_on_off over SPI every 2s, which
* could contend with the in-flight LVGL flush and hang the display task.
* On a stable supply the panel never powers off, so backlight is enough. */
display_hal_set_brightness(CONFIG_DISPLAY_BRIGHTNESS);
}
#endif /* CONFIG_DISPLAY_ENABLE && CONFIG_DISPLAY_PANEL_ST7789 */

View File

@ -24,13 +24,20 @@ bool display_is_active(void) { return s_display_active; }
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "lvgl.h"
#include "display_hal.h"
#include "display_ui.h"
/* Panel geometry: ST7789 (240x280) vs SH8601 AMOLED (368x448). */
#if CONFIG_DISPLAY_PANEL_ST7789
#define DISP_H_RES CONFIG_DISPLAY_ST7789_H_RES
#define DISP_V_RES CONFIG_DISPLAY_ST7789_V_RES
#else
#define DISP_H_RES 368
#define DISP_V_RES 448
#endif
static const char *TAG = "disp_task";
@ -67,6 +74,16 @@ static void lvgl_touch_cb(lv_indev_drv_t *drv, lv_indev_data_t *data)
}
}
/* ---- LVGL tick source ----
* Kconfig has CONFIG_LV_TICK_CUSTOM unset and nothing calls lv_tick_inc(),
* so LVGL's tick never advances and its refresh timer never fires the panel
* draws once and never repaints. This esp_timer drives the tick so LVGL
* actually refreshes (works headless; esp_timer runs with no USB host). */
static void lvgl_tick_cb(void *arg)
{
lv_tick_inc(2);
}
/* ---- Display task ---- */
static void display_task(void *arg)
{
@ -78,9 +95,15 @@ static void display_task(void *arg)
display_ui_create(lv_scr_act());
TickType_t last_wake = xTaskGetTickCount();
TickType_t last_heal = last_wake;
while (1) {
display_ui_update();
lv_timer_handler();
/* Backlight-only self-heal every ~2s (LEDC, no SPI). */
if ((xTaskGetTickCount() - last_heal) >= pdMS_TO_TICKS(2000)) {
last_heal = xTaskGetTickCount();
display_hal_refresh();
}
vTaskDelayUntil(&last_wake, frame_period);
}
}
@ -117,6 +140,19 @@ esp_err_t display_task_start(void)
/* Initialize LVGL */
lv_init();
/* Start the LVGL tick (2 ms) — WITHOUT this the display never refreshes. */
const esp_timer_create_args_t tick_args = {
.callback = &lvgl_tick_cb,
.name = "lvgl_tick",
};
esp_timer_handle_t tick_timer = NULL;
if (esp_timer_create(&tick_args, &tick_timer) == ESP_OK &&
esp_timer_start_periodic(tick_timer, 2000) == ESP_OK) {
ESP_LOGI(TAG, "LVGL tick timer started (2 ms)");
} else {
ESP_LOGE(TAG, "LVGL tick timer failed — display will not refresh");
}
/* Double-buffered draw buffers — prefer PSRAM, fall back to internal DMA */
size_t buf_lines = use_psram ? DISP_BUF_LINES : 10; /* Smaller buffers without PSRAM */
size_t buf_size = DISP_H_RES * buf_lines * sizeof(lv_color_t);

View File

@ -0,0 +1,134 @@
/**
* @file display_ui_st7789.c
* @brief ADR-045: compact LVGL UI for the 240x280 ST7789 panel.
*
* The 4-view AMOLED UI (display_ui.c) is laid out for 368x448 and overflows a
* 1.69" LCD. This variant is a single legible screen sized for 240x280: node
* identity, a big ACTIVITY bar driven by the CSI motion/presence metrics (so
* movement is visible across a room), and a live CSI packet-rate footer.
* Selected at build time via CONFIG_DISPLAY_PANEL_ST7789 (CMakeLists compiles
* this instead of display_ui.c). Same display_ui.h contract.
*/
#include "display_ui.h"
#include "csi_collector.h" /* node id + live CSI packet rate */
#include "sdkconfig.h"
#if CONFIG_DISPLAY_ENABLE && CONFIG_DISPLAY_PANEL_ST7789
#include <stdio.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "edge_processing.h"
static const char *TAG = "disp_ui_st7789";
#define COLOR_BG lv_color_make(0x0A, 0x0A, 0x0F)
#define COLOR_CYAN lv_color_make(0x00, 0xD4, 0xFF)
#define COLOR_TEXT lv_color_make(0xCC, 0xCC, 0xDD)
#define COLOR_DIM lv_color_make(0x66, 0x66, 0x77)
#define COLOR_GREEN lv_color_make(0x00, 0xFF, 0x80)
#define COLOR_TRACK lv_color_make(0x1C, 0x1C, 0x26)
/* Activity = max(motion_energy, presence_score); ~0 idle, climbs past 6 on
* movement near the link. Scale x10 to a 0-100 bar (clamped). */
#define ACT_SCALE 10.0f
static lv_obj_t *s_node = NULL;
static lv_obj_t *s_act_val = NULL; /* big activity number */
static lv_obj_t *s_bar = NULL; /* activity bar */
static lv_obj_t *s_foot = NULL; /* CSI rate + RSSI */
static lv_obj_t *s_foot2 = NULL; /* uptime + heap */
static lv_obj_t *s_hb = NULL; /* heartbeat dot */
static lv_obj_t *label(lv_obj_t *p, const lv_font_t *font, lv_color_t color,
lv_align_t align, int x, int y, const char *text)
{
lv_obj_t *l = lv_label_create(p);
lv_label_set_text(l, text);
lv_obj_set_style_text_font(l, font, 0);
lv_obj_set_style_text_color(l, color, 0);
lv_obj_align(l, align, x, y);
return l;
}
void display_ui_create(lv_obj_t *parent)
{
lv_obj_set_style_bg_color(parent, COLOR_BG, 0);
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0);
label(parent, &lv_font_montserrat_20, COLOR_DIM, LV_ALIGN_TOP_MID, 0, 6, "RuView CSI");
s_node = label(parent, &lv_font_montserrat_36, COLOR_CYAN, LV_ALIGN_TOP_MID, 0, 30, "NODE -");
s_hb = label(parent, &lv_font_montserrat_20, COLOR_CYAN, LV_ALIGN_TOP_RIGHT, -10, 8, "*");
label(parent, &lv_font_montserrat_20, COLOR_TEXT, LV_ALIGN_TOP_MID, 0, 92, "ACTIVITY");
s_act_val = label(parent, &lv_font_montserrat_36, COLOR_GREEN, LV_ALIGN_TOP_MID, 0, 116, "0");
s_bar = lv_bar_create(parent);
lv_obj_set_size(s_bar, 200, 24);
lv_obj_align(s_bar, LV_ALIGN_TOP_MID, 0, 172);
lv_bar_set_range(s_bar, 0, 100);
lv_bar_set_value(s_bar, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(s_bar, COLOR_TRACK, LV_PART_MAIN);
lv_obj_set_style_bg_color(s_bar, COLOR_GREEN, LV_PART_INDICATOR);
lv_obj_set_style_radius(s_bar, 4, LV_PART_MAIN);
s_foot = label(parent, &lv_font_montserrat_14, COLOR_TEXT, LV_ALIGN_BOTTOM_MID, 0, -26, "CSI --/s RSSI --");
s_foot2 = label(parent, &lv_font_montserrat_14, COLOR_DIM, LV_ALIGN_BOTTOM_MID, 0, -6, "up 0h00m heap -- KB");
ESP_LOGI(TAG, "Compact ST7789 UI created (240x280, activity bar)");
}
void display_ui_update(void)
{
char buf[48];
/* Heartbeat — blink ~2 Hz so the UI is visibly alive regardless of data. */
static uint32_t s_hb_last = 0;
static bool s_hb_on = false;
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000);
if (now_ms - s_hb_last >= 500) {
s_hb_last = now_ms;
s_hb_on = !s_hb_on;
lv_label_set_text(s_hb, s_hb_on ? "*" : " ");
}
snprintf(buf, sizeof(buf), "NODE %u", (unsigned)csi_collector_get_node_id());
lv_label_set_text(s_node, buf);
uint16_t pps = csi_collector_get_pkt_yield_per_sec();
edge_vitals_pkt_t v;
int rssi = 0;
float activity = 0.0f;
if (edge_get_vitals(&v)) {
rssi = v.rssi;
activity = v.motion_energy > v.presence_score ? v.motion_energy : v.presence_score;
}
int act100 = (int)(activity * ACT_SCALE);
if (act100 > 100) act100 = 100;
if (act100 < 0) act100 = 0;
lv_bar_set_value(s_bar, act100, LV_ANIM_OFF);
snprintf(buf, sizeof(buf), "%d", act100);
lv_label_set_text(s_act_val, buf);
lv_obj_set_style_text_color(s_act_val, act100 > 10 ? COLOR_GREEN : COLOR_DIM, 0);
snprintf(buf, sizeof(buf), "CSI %u/s RSSI %d", (unsigned)pps, rssi);
lv_label_set_text(s_foot, buf);
uint32_t up = (uint32_t)(esp_timer_get_time() / 1000000);
snprintf(buf, sizeof(buf), "up %luh%02lum heap %luK",
(unsigned long)(up / 3600), (unsigned long)((up % 3600) / 60),
(unsigned long)(esp_get_free_heap_size() / 1024));
lv_label_set_text(s_foot2, buf);
/* NOTE: no per-loop ESP_LOG here. On the USB-Serial-JTAG console, logging
* with no host attached (e.g. running off a wall charger) blocks once the
* TX buffer fills which would hang the display task. Keep this loop
* log-free so the panel keeps refreshing headless. */
}
#endif /* CONFIG_DISPLAY_ENABLE && CONFIG_DISPLAY_PANEL_ST7789 */

View File

@ -1,4 +1,4 @@
889715e9d698ad78f9978ad8b93b6af24a726b0494247201c8f0d920d9fc80ca *firmware/esp32-csi-node/release_bins/c6-adr110/bootloader.bin
d8539e47c6f10a3344679118619e3fe01cfd66eb560ea8883268ca7c9a12efa4 *firmware/esp32-csi-node/release_bins/c6-adr110/esp32-csi-node.bin
b0fb1f217a39c80bc95b5eb8208a0b8572ae64efa0f6d580b76caff4affe0f4d *firmware/esp32-csi-node/release_bins/c6-adr110/bootloader.bin
4764c5b20a353895f70122816adc98f861ec20e9a8ea9b344dc0648b6341073c *firmware/esp32-csi-node/release_bins/c6-adr110/esp32-csi-node.bin
7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f *firmware/esp32-csi-node/release_bins/c6-adr110/ota_data_initial.bin
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 *firmware/esp32-csi-node/release_bins/c6-adr110/partition-table.bin

View File

@ -1,3 +1,3 @@
3c4905dd202ccabf4230cbabcc9320f250a60b1a7254eff7424780201bcb2072 *firmware/esp32-csi-node/release_bins/s3-adr110/bootloader.bin
7a8bf9582c9031fed32f1ada44f5c41dd99bd07fadff8e5c86e07aa0f343e847 *firmware/esp32-csi-node/release_bins/s3-adr110/esp32-csi-node.bin
b973d7eda65affb746adcfa63ceb18f779f206d240b76f01b8c9ae7485455660 *firmware/esp32-csi-node/release_bins/s3-adr110/bootloader.bin
e21ef94aba779d534dc048c1b9da731c81e5dbe09d0645cfd70a05ad3642d3e9 *firmware/esp32-csi-node/release_bins/s3-adr110/esp32-csi-node.bin
67222c257c0477501fd4002275638dc4262b34eb68235b8289fb1337054d322b *firmware/esp32-csi-node/release_bins/s3-adr110/partition-table.bin

View File

@ -1,3 +1,4 @@
0.6.6
git-sha: cbcb389cb (pre-commit)
built: 2026-05-21
0.6.7
git-sha: 8703ade9b
built: 2026-06-02
note: RuView#893 — display-less boards capture DATA frames (CSI yield 0pps fix); hardware-verified on ESP32-C6 (0->27 pps)

View File

@ -0,0 +1,15 @@
# ST7789 display build overlay — Waveshare ESP32-S3-Touch-LCD-1.69.
# Merge AFTER sdkconfig.defaults (later file wins):
# SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.st7789"
# Keeps the proven CSI-node config; only swaps the display panel HAL.
CONFIG_DISPLAY_ENABLE=y
CONFIG_DISPLAY_PANEL_ST7789=y
# LVGL fonts for the compact 240x280 UI (LVGL is Kconfig-configured, not lv_conf.h).
CONFIG_LV_FONT_MONTSERRAT_20=y
CONFIG_LV_FONT_MONTSERRAT_36=y
CONFIG_LV_USE_BAR=y
# Dimmer backlight = less current draw on the marginal single-USB power path
# (helps avoid the startup brownout boot-loop on the LCD board).
CONFIG_DISPLAY_BRIGHTNESS=45

View File

@ -36,3 +36,4 @@ scikit-learn>=1.2.0
# Monitoring dependencies
prometheus-client>=0.16.0
psutil>=5.9.0 # system metrics — imported by health.py / metrics.py / status.py / monitoring.py

View File

@ -5476,6 +5476,149 @@ async fn broadcast_tick_task(state: SharedState, tick_ms: u64) {
}
}
/// Map one sensing-broadcast JSON document into the `VitalsSnapshot`(s) to
/// publish over MQTT (issues #872/#898).
///
/// Multi-node sources carry a `nodes` array where **each node has its own
/// `classification`** (`motion_level`, `presence`, `confidence`) and RSSI — so
/// each node must surface its *own* presence/motion, not the room-level
/// aggregate. Previously the bridge applied the aggregate `classification` to
/// every per-node Home-Assistant device, so a node in an empty corner inherited
/// another node's "present" (and `motion_level: "absent"` was mis-mapped to full
/// motion). Vitals (breathing / heart rate) and the person count are room-level
/// and shared across the per-node devices. Falls back to a single aggregate
/// snapshot when there is no per-node data (e.g. wifi / simulate sources).
#[cfg(feature = "mqtt")]
fn vitals_snapshots_from_sensing_json(
v: &serde_json::Value,
base_id: &str,
) -> Vec<wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot> {
use wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot;
// motion_level string -> motion scalar. "absent"/"none"/"still"/"idle"/""
// are non-moving; anything else (walking, …) is motion. `fallback` is used
// when the field is absent so a partial per-node payload defers to the
// room aggregate rather than silently reading 0.
fn motion_of(level: Option<&str>, fallback: f64) -> f64 {
match level {
Some("none") | Some("still") | Some("idle") | Some("absent") | Some("") => 0.0,
Some(_) => 1.0,
None => fallback,
}
}
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
let vit = &v["vital_signs"];
let breathing = vit["breathing_rate_bpm"].as_f64();
let hr = vit["heart_rate_bpm"].as_f64();
let n_persons = v["persons"]
.as_array()
.map(|a| a.len() as u32)
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
.unwrap_or(0);
// Room-level aggregate: the no-nodes fallback, and the per-node default for
// any field a node omits.
let acls = &v["classification"];
let agg_presence = acls["presence"].as_bool().unwrap_or(false);
let agg_motion = motion_of(acls["motion_level"].as_str(), 0.0);
let agg_conf = acls["confidence"].as_f64().unwrap_or(0.0);
let mk = |node_id: String, presence: bool, motion: f64, conf: f64, rssi: Option<f64>| {
VitalsSnapshot {
node_id,
timestamp_ms: ts,
presence,
motion,
presence_score: if presence { conf.max(0.0) } else { 0.0 },
breathing_rate_bpm: breathing,
heartrate_bpm: hr,
n_persons,
rssi_dbm: rssi,
vital_confidence: conf,
..Default::default()
}
};
match v["nodes"].as_array() {
Some(arr) if !arr.is_empty() => arr
.iter()
.map(|node| {
let n = node["node_id"].as_u64().unwrap_or(0);
// Each node carries its OWN classification — use it, deferring to
// the room aggregate only for fields the node omits.
let ncls = &node["classification"];
let presence = ncls["presence"].as_bool().unwrap_or(agg_presence);
let motion = motion_of(ncls["motion_level"].as_str(), agg_motion);
let conf = ncls["confidence"].as_f64().unwrap_or(agg_conf);
mk(
format!("{base_id}-node{n}"),
presence,
motion,
conf,
node["rssi_dbm"].as_f64(),
)
})
.collect(),
_ => vec![mk(
base_id.to_string(),
agg_presence,
agg_motion,
agg_conf,
v["nodes"][0]["rssi_dbm"].as_f64(),
)],
}
}
/// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894).
///
/// The published HuggingFace `ruvnet/wifi-densepose-pretrained` files
/// (`model.safetensors`, `model-q{2,4,8}.bin`, `model.rvf.jsonl`) are a
/// different *format* — and a different encoder architecture — than the RVF
/// binary container the `--model` progressive loader expects (`RVFS` magic
/// `0x52564653`). Feeding one to `--model` produced a bare
/// "invalid magic at offset 0 …" that left users stuck. Detect the common
/// cases and explain plainly what's loadable instead.
fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) -> String {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
// safetensors: 8-byte LE header length, then a JSON object starting with '{'.
let looks_safetensors = ext == "safetensors" || (data.len() > 9 && data[8] == b'{');
// JSONL manifest: starts with '{' (or the well-known suffix).
let looks_jsonl =
ext == "jsonl" || name.ends_with(".rvf.jsonl") || data.first() == Some(&b'{');
// Quantized weight blob shipped on HF (model-q2/q4/q8.bin).
let looks_quant_bin = ext == "bin" || name.contains("-q");
let kind = if looks_safetensors {
"a safetensors weight file"
} else if looks_jsonl {
"a JSONL manifest, not the binary container"
} else if looks_quant_bin {
"a quantized weight blob (e.g. HuggingFace model-q4.bin)"
} else {
"not an RVF binary container"
};
format!(
"model `{}` could not be loaded: it is {kind}. The --model flag expects an \
RVF binary container (`RVFS` magic 0x52564653) produced by the \
wifi-densepose-train pipeline. The HuggingFace ruvnet/wifi-densepose-pretrained \
files are a different format and encoder architecture, so they do not load \
here directly (issue #894). Continuing with signal heuristics. (loader: {err})",
path.display()
)
}
// ── Main ─────────────────────────────────────────────────────────────────────
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
@ -6113,7 +6256,9 @@ async fn main() {
model_loaded = true;
progressive_loader = Some(loader);
}
Err(e) => error!("Progressive loader init failed: {e}"),
Err(e) => {
error!("{}", diagnose_model_load_error(mp, &data, &e.to_string()))
}
},
Err(e) => error!("Failed to read model file: {e}"),
}
@ -6200,56 +6345,13 @@ async fn main() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) else {
continue;
};
let cls = &v["classification"];
let vit = &v["vital_signs"];
let presence = cls["presence"].as_bool().unwrap_or(false);
let n_persons = v["persons"]
.as_array()
.map(|a| a.len() as u32)
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
.unwrap_or(0);
let motion = match cls["motion_level"].as_str() {
Some("none") | Some("still") | Some("idle") | Some("") => 0.0,
Some(_) => 1.0,
None => 0.0,
};
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
let conf = cls["confidence"].as_f64().unwrap_or(0.0);
let presence_score = if presence { conf.max(0.0) } else { 0.0 };
let breathing = vit["breathing_rate_bpm"].as_f64();
let hr = vit["heart_rate_bpm"].as_f64();
// #898: emit one snapshot per physical node so each
// surfaces as its own Home-Assistant device (with
// its own RSSI + availability). Falls back to a
// single aggregate snapshot when there is no
// per-node data (e.g. wifi / simulate sources).
let mk = |nid: String, rssi: Option<f64>| mqtt::state::VitalsSnapshot {
node_id: nid,
timestamp_ms: ts,
presence,
motion,
presence_score,
breathing_rate_bpm: breathing,
heartrate_bpm: hr,
n_persons,
rssi_dbm: rssi,
vital_confidence: conf,
..Default::default()
};
match v["nodes"].as_array() {
Some(arr) if !arr.is_empty() => {
for node in arr {
let n = node["node_id"].as_u64().unwrap_or(0);
let nid = format!("{node_id}-node{n}");
let _ = vtx.send(mk(nid, node["rssi_dbm"].as_f64()));
}
}
_ => {
let _ = vtx.send(mk(
node_id.clone(),
v["nodes"][0]["rssi_dbm"].as_f64(),
));
}
// #898/#872: emit one snapshot per physical node so
// each surfaces as its own Home-Assistant device with
// its *own* presence/motion/RSSI (see
// vitals_snapshots_from_sensing_json). Falls back to a
// single aggregate snapshot for per-node-less sources.
for snap in vitals_snapshots_from_sensing_json(&v, &node_id) {
let _ = vtx.send(snap);
}
}
});
@ -7068,3 +7170,143 @@ mod rolling_p95_tests {
assert_eq!(p.len(), 1);
}
}
#[cfg(all(test, feature = "mqtt"))]
mod mqtt_bridge_tests {
use super::vitals_snapshots_from_sensing_json;
use serde_json::json;
/// Regression for the per-node presence bug (#872/#898): each node must
/// surface its OWN classification, not the room-level aggregate. Node 1 is
/// present+moving; node 2 is absent — node 2 must NOT inherit node 1's
/// "present".
#[test]
fn per_node_presence_uses_each_nodes_own_classification() {
let v = json!({
"timestamp": 1.0,
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.9 },
"vital_signs": { "breathing_rate_bpm": 14.0, "heart_rate_bpm": 60.0 },
"persons": [{}, {}],
"nodes": [
{ "node_id": 1, "rssi_dbm": -40.0,
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.8 } },
{ "node_id": 2, "rssi_dbm": -70.0,
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.1 } }
]
});
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
assert_eq!(snaps.len(), 2, "one snapshot per node");
let n1 = snaps.iter().find(|s| s.node_id == "ruview-node1").unwrap();
let n2 = snaps.iter().find(|s| s.node_id == "ruview-node2").unwrap();
assert!(n1.presence && n1.motion > 0.0, "node1 present + moving");
assert!(
!n2.presence && n2.motion == 0.0,
"node2 must be absent — not inherit the room aggregate"
);
// Per-node RSSI preserved.
assert_eq!(n1.rssi_dbm, Some(-40.0));
assert_eq!(n2.rssi_dbm, Some(-70.0));
// Vitals + person count are room-level, shared across node devices.
assert_eq!(n1.n_persons, 2);
assert_eq!(n2.n_persons, 2);
assert_eq!(n1.breathing_rate_bpm, Some(14.0));
assert_eq!(n2.heartrate_bpm, Some(60.0));
// presence_score is gated on presence.
assert!(n1.presence_score > 0.0);
assert_eq!(n2.presence_score, 0.0);
}
/// A node that omits a classification field defers to the room aggregate
/// rather than silently reading false/0.
#[test]
fn per_node_missing_fields_fall_back_to_aggregate() {
let v = json!({
"timestamp": 1.0,
"classification": { "presence": true, "motion_level": "still", "confidence": 0.7 },
"vital_signs": {},
"nodes": [ { "node_id": 3, "rssi_dbm": -55.0 } ] // no per-node classification
});
let snaps = vitals_snapshots_from_sensing_json(&v, "n");
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].node_id, "n-node3");
assert!(snaps[0].presence, "defers to aggregate presence");
assert_eq!(snaps[0].motion, 0.0, "aggregate 'still' => no motion");
}
/// No `nodes` array (wifi / simulate sources): single aggregate snapshot
/// keyed by the base id.
#[test]
fn falls_back_to_single_aggregate_when_no_nodes() {
let v = json!({
"timestamp": 2.0,
"classification": { "presence": true, "motion_level": "idle", "confidence": 0.6 },
"vital_signs": { "breathing_rate_bpm": 12.0 },
"persons": [{}]
});
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].node_id, "ruview");
assert!(snaps[0].presence);
assert_eq!(snaps[0].motion, 0.0, "idle => no motion");
assert_eq!(snaps[0].n_persons, 1);
}
/// `motion_level: "absent"` must map to zero motion (the old aggregate
/// match fell through to `Some(_) => 1.0`, treating absent as full motion).
#[test]
fn absent_motion_level_is_zero_motion() {
let v = json!({
"timestamp": 0.0,
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.0 },
"vital_signs": {}
});
let snaps = vitals_snapshots_from_sensing_json(&v, "x");
assert_eq!(snaps[0].motion, 0.0);
assert!(!snaps[0].presence);
}
}
#[cfg(test)]
mod model_load_diagnostic_tests {
use super::diagnose_model_load_error;
use std::path::Path;
#[test]
fn safetensors_is_named_and_points_at_894() {
// 8-byte LE header length then '{' — the safetensors signature.
let data = [0x10, 0, 0, 0, 0, 0, 0, 0, b'{', b'"'];
let msg = diagnose_model_load_error(
Path::new("models/wifi-densepose-pretrained/model.safetensors"),
&data,
"invalid magic at offset 0",
);
assert!(msg.contains("safetensors"), "{msg}");
assert!(msg.contains("#894"), "{msg}");
assert!(msg.contains("signal heuristics"), "{msg}");
}
#[test]
fn quantized_bin_is_identified() {
let data = [0x35, 0x57, 0x45, 0x77]; // the 0x77455735 the loader reports
let msg = diagnose_model_load_error(Path::new("model-q4.bin"), &data, "bad magic");
assert!(msg.contains("quantized weight blob"), "{msg}");
assert!(msg.contains("RVFS") || msg.contains("0x52564653"), "{msg}");
}
#[test]
fn jsonl_manifest_is_identified() {
let data = *b"{\"seg\":0}";
let msg = diagnose_model_load_error(Path::new("model.rvf.jsonl"), &data, "x");
assert!(msg.contains("JSONL manifest"), "{msg}");
}
#[test]
fn unknown_format_still_gives_guidance() {
let data = [0u8, 1, 2, 3];
let msg = diagnose_model_load_error(Path::new("weird.dat"), &data, "x");
assert!(msg.contains("RVF binary container"), "{msg}");
assert!(msg.contains("wifi-densepose-train"), "{msg}");
}
}