Compare commits
22 Commits
feature/se
...
268b595340
| Author | SHA1 | Date | |
|---|---|---|---|
| 268b595340 | |||
| a795cfa0ad | |||
| d943b3df5a | |||
| 2d95069bd1 | |||
| 867e90b1f6 | |||
| 5c9f5df0ce | |||
| f08f70a8fb | |||
| 7b546d0ed7 | |||
| 8f8ad0b1b0 | |||
| 57129ba078 | |||
| af3067d481 | |||
| cfa0d2563f | |||
| 84d9ba349b | |||
| 9f293b4639 | |||
| 95724bf3ff | |||
| 9eb1e19651 | |||
| 95f91d3656 | |||
| 9232766e60 | |||
| a37207b6ff | |||
| 3b471992f2 | |||
| 24aaae6ff2 | |||
| 62931e26ff |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
.worktrees/
|
||||
.agent/
|
||||
firmware/.pio/
|
||||
.claude/
|
||||
graphify-out/
|
||||
firmware/.pio/
|
||||
*.log
|
||||
|
||||
254
README.md
254
README.md
@@ -1,6 +1,8 @@
|
||||
# DoorCounter
|
||||
|
||||
Retail door traffic counter using M5Stack TimerCamera-F (ESP32 + OV3660). Counts entries/exits via overhead camera CV, passively scans BLE foot traffic, and reports hourly to `logs.research.bike`.
|
||||
Retail door traffic counter using M5Stack TimerCamera-F (ESP32 + OV3660). Counts walker traversals via overhead camera CV, passively scans BLE foot traffic, and reports hourly to `logs.research.bike`.
|
||||
|
||||
> **Known limitation — directional accuracy.** This firmware reports counts as `{entries, exits}` for API compatibility, but **per-walk direction labelling is not reliable at the current mount (7' overhead, straight down).** In bench testing, event detection was 100% (8/8 walks detected) while per-walk direction matched the physical walk only ~50% of the time — the centroid trajectories produced by entries and exits were nearly indistinguishable. **The number to trust is gross traffic: `entries + exits` ≈ total walkers through the doorway.** The directional split is an unreliable best-effort heuristic. See [Directional counting](#directional-counting) for why.
|
||||
|
||||
## Hardware
|
||||
|
||||
@@ -21,7 +23,8 @@ pio run -t upload --upload-port /dev/ttyUSB0
|
||||
|
||||
| Module | Behavior |
|
||||
|--------|----------|
|
||||
| CV pipeline | 5 fps, 96×96 grayscale, blob tracking, line-crossing count |
|
||||
| CV pipeline | 5 fps, 96×96 grayscale, event-based walker detector (foreground-count state machine; centroid-trajectory direction heuristic) with post-fire refractory period |
|
||||
| Detection LED | Single blink on entry, double blink on exit (preserves upload/no-WiFi status LED) |
|
||||
| BLE scanner | Continuous passive scan; deinits during hourly upload to free heap |
|
||||
| Reporter | Hourly HMAC-signed POST; 60s boot report for fast connectivity check |
|
||||
| Provisioning | Captive portal AP on first boot for WiFi setup |
|
||||
@@ -32,6 +35,71 @@ pio run -t upload --upload-port /dev/ttyUSB0
|
||||
- **First report**: 60 seconds after NTP sync (connectivity check)
|
||||
- **Subsequent reports**: every 3600 seconds
|
||||
|
||||
### Counting model — event-based walker detector
|
||||
|
||||
The CV pipeline is a **single event state machine** (no per-blob tracking
|
||||
for counting). Per-frame foreground pixel count gates event start and end;
|
||||
centroid trajectory within the active event decides direction.
|
||||
|
||||
**Event lifecycle:**
|
||||
1. **Idle → Active**: `fg_count ≥ CV_EVENT_ENTER_THRESH` (250 px) fires event start.
|
||||
Background updates freeze while the event is active so the walker does
|
||||
not get absorbed into the baseline.
|
||||
2. **Active accumulation**: every frame updates `first_c` (once), `min_c`,
|
||||
`max_c`, `last_c`, `min_y_seen`, `max_y_seen`, and the frame count.
|
||||
3. **Active → End** (either):
|
||||
- **Quiet exit**: `fg_count < CV_EVENT_EXIT_THRESH` (150 px) for
|
||||
`CV_EVENT_QUIET_FRAMES` (3) consecutive frames — walker has left.
|
||||
- **Timeout**: `event_frame_count > CV_EVENT_MAX_FRAMES` (25 frames ≈ 5s).
|
||||
4. On end, the event is finalized: gated by minimum duration, vertical
|
||||
extent (must span a large fraction of the frame), and minimum centroid
|
||||
trajectory magnitude. Background snaps to the current frame.
|
||||
5. A **refractory period** (`CV_EVENT_REFRACTORY_FRAMES` = 10 ≈ 2s) after
|
||||
a fire blocks a new event from starting — absorbs residual lingering
|
||||
motion that would otherwise double-count.
|
||||
|
||||
**Direction heuristic** (applied only if the event passes all gates):
|
||||
- `up_score = first_c − min_c` (how far centroid excursed upward)
|
||||
- `down_score = max_c − first_c` (how far it excursed downward)
|
||||
- Quiet-exit events: `is_entry = (up_score ≥ down_score)`
|
||||
- Timeout events: `is_entry = (last_c < first_c)` — net displacement is
|
||||
more reliable than excursion when the walker is still in frame at timeout.
|
||||
|
||||
Per-mount convention: centroid moving **up through the frame** (y decreasing)
|
||||
= **entry** into the store.
|
||||
|
||||
### Directional counting — known limitation
|
||||
|
||||
**Per-walk direction labelling is unreliable at the current mount.** In
|
||||
bench testing (8 alternating entry/exit walks at 4s intervals, 7' overhead
|
||||
mount pointing straight down):
|
||||
|
||||
- **Event detection**: 8/8 (100%) — every walk produced exactly one event.
|
||||
- **Aggregate split**: 4 entries + 4 exits — matches the 4+4 ground truth.
|
||||
- **Per-walk direction**: 4/8 (50%) — essentially a coin flip.
|
||||
|
||||
At this mount, entries and exits produce nearly identical centroid
|
||||
trajectories: both begin near mid-frame (walker is already large when
|
||||
`fg_count` crosses 250), both reach a peak excursion toward the top, and
|
||||
both end near mid-frame (walker's tail is still visible when `fg_count`
|
||||
drops below 150). No heuristic over the recorded centroid statistics
|
||||
separates them with better than ~50% accuracy on alternating walks.
|
||||
|
||||
**What we ship, and what the server should trust:**
|
||||
- **Gross traffic (`entries + exits`) is accurate.** This is the number
|
||||
downstream analytics should use as "people through the door this hour."
|
||||
- **Directional split is reported but unreliable.** Treat individual
|
||||
`entries` and `exits` values as a best-effort labelling. Do not infer
|
||||
net flow or dwell from them.
|
||||
|
||||
To actually recover per-walk direction would require either a physical
|
||||
change (raise or tilt the camera so walkers enter/leave through the frame
|
||||
edges) or a richer signal than centroid statistics (e.g. time-resolved
|
||||
optical flow, or a second sensor). That work is out of scope for v1.
|
||||
|
||||
See `firmware/lib/cv/cv.h` for tuning constants and `cv.cpp` for the
|
||||
finalize logic.
|
||||
|
||||
## Operator Setup
|
||||
|
||||
### 1. Flash firmware
|
||||
@@ -55,6 +123,12 @@ python tools/flash_device.py \
|
||||
|
||||
WiFi credentials are optional — if omitted, device starts captive portal on boot.
|
||||
|
||||
> **Re-provision after firmware uploads.** Flashing firmware via
|
||||
> `pio run -t upload` may clear the NVS partition on this board. If the device
|
||||
> boots into a ~1 Hz LED blink (the "not provisioned" fatal state) after a
|
||||
> firmware update, re-run `flash_device.py` with the same credentials. See
|
||||
> [Troubleshooting](#troubleshooting).
|
||||
|
||||
### 3. OTA updates
|
||||
|
||||
```bash
|
||||
@@ -70,7 +144,7 @@ python tools/ota_push.py \
|
||||
3. Connect phone to `DoorCounter-Setup` WiFi
|
||||
4. Browser opens automatically → enter store WiFi password → done
|
||||
|
||||
**LED indicators**: Red = no WiFi · Blue = counting · Yellow = uploading
|
||||
**LED indicators**: Red = no WiFi · Blue = counting · Yellow = uploading · Brief flash (×1) on entry · Brief flash (×2) on exit
|
||||
|
||||
## API
|
||||
|
||||
@@ -84,72 +158,15 @@ Endpoint: `http://logs.research.bike`
|
||||
|
||||
All requests are HMAC-SHA256 signed. See [design spec](docs/superpowers/specs/2026-04-13-door-counter-design.md) for full API shapes and auth scheme.
|
||||
|
||||
## Runtime Configuration
|
||||
|
||||
The backend can push CV tuning parameters to individual devices in the response to `POST /api/v1/heartbeat`. No HTTP server runs on the device — updates ride the existing outbound, HMAC-authenticated channel.
|
||||
|
||||
### Configurable fields
|
||||
|
||||
| Field | Type / Range | Meaning |
|
||||
|-------|--------------|---------|
|
||||
| `cfg_version` | uint32, non-zero | Monotonic version; device ignores updates with version ≤ stored. |
|
||||
| `diff_thresh` | 5–120 | Per-pixel motion threshold; higher = less sensitive. |
|
||||
| `min_blob_px` | 16–4096 | Minimum connected foreground pixels to count as a blob; higher = fewer false positives from small motion. |
|
||||
| `max_move` | 2.0–50.0 | Max inter-frame track displacement, in pixels on the 96×96 frame. |
|
||||
| `max_missed` | 1–60 | Frames a track can be missed before dropped. |
|
||||
| `line_offset` | 0–100 | Virtual counting line, as percent of frame height. |
|
||||
|
||||
### Push flow
|
||||
|
||||
The heartbeat response MAY include a `config` object. All fields except `cfg_version` are optional; missing fields retain the device's current value.
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"cfg_version": 7,
|
||||
"diff_thresh": 25,
|
||||
"min_blob_px": 200,
|
||||
"max_move": 12.0,
|
||||
"max_missed": 8,
|
||||
"line_offset": 55
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation and apply rules
|
||||
|
||||
- Missing response body, non-200, malformed JSON, or missing `config` object → silent no-op.
|
||||
- Missing `cfg_version` → rejected, logged `[CFG] missing cfg_version`.
|
||||
- `cfg_version` ≤ stored → rejected as stale, logged.
|
||||
- Any present field with wrong JSON type → whole update rejected, logged `[CFG] rejected malformed config`.
|
||||
- Any field out of range → whole update rejected, logged `[CFG] rejected invalid config`.
|
||||
- Valid update → applied atomically under mutex, persisted to NVS, logged `[CFG] applied v=N`.
|
||||
|
||||
### Persistence
|
||||
|
||||
Tuning is stored in the `doorcounter` NVS namespace and survives reboot. On boot, the device loads the persisted values; if none present, compiled defaults apply.
|
||||
|
||||
### Trust model
|
||||
|
||||
The reporting channel is plain HTTP today. The HMAC scheme signs only outbound **requests** (method + path + timestamp + sha256(body)) — it does not authenticate response bodies. A network attacker with access to the customer LAN can rewrite a heartbeat response and push any config that passes the device's range validator (`diff_thresh` 5–120, `min_blob_px` 16–4096, `max_move` 2.0–50.0, `max_missed` 1–60, `line_offset` 0–100). The validator is the last line of defense: malicious-but-in-range pushes can still degrade counting (e.g., `min_blob_px = 16` makes the detector noisy).
|
||||
|
||||
Per-device targeting (keyed by `device_id`) still works correctly and is unaffected by the integrity gap — each device only applies updates addressed to itself.
|
||||
|
||||
Operators should treat customer LANs as untrusted and rely on monitoring heartbeat cadence and count anomalies to detect tampering. No inbound HTTP surface is exposed on customer LANs by design — the device only makes outbound requests.
|
||||
|
||||
## Roadmap
|
||||
|
||||
**Gated local config portal.** Holding the BOOT button for ~3 seconds would raise a WiFiManager-style captive portal on the local network for ~5 minutes, exposing a tuning page for field techs operating without backend connectivity. Deferred because (a) the server-push mechanism above covers routine tuning, (b) an always-on HTTP server on customer LANs is an undesirable attack surface, and (c) the gated-by-physical-access model needs additional auth design to be safe.
|
||||
|
||||
**Authenticated config push.** Move reporting to HTTPS, or include a signed envelope on pushed config (e.g., `config_sig = HMAC(secret, cfg_version || canonical_json(config))` verified on device) so pushed tuning is tamper-evident over plain HTTP.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
DoorCounter/
|
||||
├── firmware/
|
||||
│ ├── platformio.ini
|
||||
│ ├── lib/hmac/ — HMAC-SHA256 signing library
|
||||
│ ├── lib/
|
||||
│ │ ├── cv/ — CV pipeline (event state machine, centroid-trajectory direction)
|
||||
│ │ └── hmac/ — HMAC-SHA256 signing library
|
||||
│ └── src/
|
||||
│ ├── main.cpp — FreeRTOS tasks, boot sequence
|
||||
│ ├── config.* — NVS read/write
|
||||
@@ -159,8 +176,113 @@ DoorCounter/
|
||||
│ └── reporter.* — hourly batch POST + local buffer
|
||||
├── tools/
|
||||
│ ├── flash_device.py — NVS provisioning script
|
||||
│ └── ota_push.py — OTA push script
|
||||
├── docs/superpowers/specs/
|
||||
│ └── 2026-04-13-door-counter-design.md
|
||||
│ ├── ota_push.py — OTA push script
|
||||
│ └── serial_monitor.py — reset + read serial with timestamps (diagnostic)
|
||||
├── docs/
|
||||
│ ├── server-prompt-crossing-cooldown.md — server-side coordination notes
|
||||
│ └── superpowers/specs/2026-04-13-door-counter-design.md
|
||||
└── server/ — API server (separate deployment)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Remedy |
|
||||
|---------|--------------|--------|
|
||||
| ~1 Hz LED blink after boot, no serial beyond `esp_core_dump_flash: No core dump partition found!` | NVS missing `device_id` / `location_id` / `hmac_secret`. Commonly triggered by a firmware upload wiping NVS. | Re-run `flash_device.py` with the device's known credentials. |
|
||||
| Device stays on `DoorCounter-Setup` AP instead of joining customer WiFi | SSID/password in NVS wrong, or network out of range. | Connect phone to `DoorCounter-Setup` → captive portal → re-enter WiFi. Or reflash NVS with correct `--wifi-ssid` / `--wifi-password`. |
|
||||
| No entries/exits counted for a known-walking doorway | WiFi captive portal still up (camera task starts only after connect); or camera blocked/unfocused. | Check LED: solid on = booting/uploading, off = counting. Run `serial_monitor.py` to see `[CV] entry/exit` log lines. |
|
||||
|
||||
Capture a boot log with timestamps:
|
||||
|
||||
```bash
|
||||
python tools/serial_monitor.py --port /dev/ttyUSB0 --reset --timestamp --seconds 30
|
||||
```
|
||||
|
||||
## Deploying firmware 1.1 (network resilience)
|
||||
|
||||
### Before you flash
|
||||
|
||||
Firmware 1.1 adds five new fields to the `POST /api/v1/heartbeat` payload
|
||||
(`reset_reason`, `heap_free`, `heap_min_free`, `last_disconnect_code`,
|
||||
`recent_events`). **The real server must accept these optional fields before
|
||||
you deploy firmware 1.1**, or strict-schema validation will 4xx every
|
||||
heartbeat; after 6 consecutive misses (~6h) the heartbeat-miss watchdog
|
||||
will reboot the device, producing a reboot loop.
|
||||
|
||||
Reference migration and handler code for the real server are in this repo:
|
||||
|
||||
- `server/heartbeat_diagnostics_stub.py` — Pydantic model extensions,
|
||||
`store_heartbeat_diagnostics()` helper, and `EVENT_TAG_DECODER` /
|
||||
`REBOOT_REASON_DECODER` reference tables.
|
||||
- `server/migrations/005_heartbeat_diagnostics.sql` — adds five nullable
|
||||
columns to the `heartbeats` table (adjust table name to match the real
|
||||
server's schema).
|
||||
|
||||
Copy the stub additions into the production server repo, run the
|
||||
migration, and confirm a v1.1.0-shape heartbeat returns 200 before you
|
||||
flash any device.
|
||||
|
||||
### Flash command
|
||||
|
||||
```bash
|
||||
cd firmware && pio run -e timercam -t upload
|
||||
```
|
||||
|
||||
### Expected first boot
|
||||
|
||||
On the serial log (115200 baud), the device prints the boot banner, then
|
||||
initializes `event_log`, then records the reset reason via `EVT_BOOT`.
|
||||
The first heartbeat fires roughly 60-70s after power-on (15s WiFi
|
||||
busy-wait + NTP sync + 60s `BOOT_REPORT_DELAY_S`). Monitor with
|
||||
`pio device monitor` or:
|
||||
|
||||
```bash
|
||||
python tools/serial_monitor.py --port /dev/ttyUSB0 --reset --timestamp --seconds 90
|
||||
```
|
||||
|
||||
### What's new in 1.1
|
||||
|
||||
- Event-driven WiFi reconnect with 1s→60s exponential backoff (`net_guard` module); disconnect reasons logged.
|
||||
- HTTP timeouts (5s connect / 10s response) + 3-try retry on every POST.
|
||||
- ESP-IDF Task Watchdog (30s) on camera, reporter, and loop tasks; panic → reboot → reason surfaces in the next heartbeat.
|
||||
- Software heartbeat-miss watchdog: 6 consecutive missed heartbeats (~6 h) triggers a clean reboot.
|
||||
- Persistent NVS event-log ring buffer (32 entries) surfaced in the heartbeat's `recent_events` field.
|
||||
- New heartbeat fields: `reset_reason`, `heap_free`, `heap_min_free`, `last_disconnect_code`, `recent_events`.
|
||||
|
||||
### 24-hour field checks
|
||||
|
||||
After deploying a device, run through this checklist against the server's
|
||||
heartbeat records at the 24-hour mark:
|
||||
|
||||
- **Heartbeat count ≥ 22** — ≥ 92% uptime across 24 h at the hourly cadence.
|
||||
- **No sustained `t=6` (EVT_HEARTBEAT_MISS) entries in `recent_events`** — transient singletons are expected; repeated misses indicate a sticky network problem worth investigating.
|
||||
- **`heap_min_free` stable day over day** — a downward drift indicates a leak. Alert threshold: min-free drops by more than 20% vs baseline.
|
||||
- **`last_disconnect_code` matches known AP behavior** — reason 8 (assoc lost) and reason 15 (4-way handshake timeout) are common on busy APs; recurring reason 200+ indicates a firmware bug.
|
||||
- **`reset_reason` has no unexpected values** — see table below.
|
||||
|
||||
| `reset_reason` | Meaning | Expected? |
|
||||
|----------------|---------|-----------|
|
||||
| 1 | Power-on | Normal immediately after a deployment. |
|
||||
| 4 | Software reset (our `ESP.restart()`) | Correlate with `EVT_REBOOT` in `recent_events`. |
|
||||
| 6 | Task watchdog | Investigate — a task hung for 30s. |
|
||||
| 7 | Brownout | Investigate power supply / USB cable. |
|
||||
| 8 | SDIO reset | Unusual — investigate. |
|
||||
|
||||
### Decoding recent_events
|
||||
|
||||
The `recent_events` array is a ring buffer of `{t, d0, d1, ts}` entries.
|
||||
Tag definitions live in `firmware/lib/event_log/event_log.h`:
|
||||
|
||||
| `t` | Event | `d0` | `d1` |
|
||||
|-----|-------|------|------|
|
||||
| 1 | `EVT_BOOT` | `esp_reset_reason()` | — |
|
||||
| 2 | `EVT_WIFI_UP` | RSSI | — |
|
||||
| 3 | `EVT_WIFI_DOWN` | disconnect reason code; `0xFF` = silent-death fallback | — |
|
||||
| 4 | `EVT_HTTP_OK` | fnv1a-16 path hash | elapsed ms (capped at 65535) |
|
||||
| 5 | `EVT_HTTP_FAIL` | path hash | HTTP status or negative errno cast to `uint16` |
|
||||
| 6 | `EVT_HEARTBEAT_MISS` | consecutive miss count | — |
|
||||
| 7 | `EVT_NTP_SYNC` | reserved | — |
|
||||
| 8 | `EVT_REBOOT` | `RebootReason`: 1=HEARTBEAT_MISS, 2=FACTORY_RESET, 3=OTA, 4=WIFI_REPROV | — |
|
||||
|
||||
Server-side decoder tables (`EVENT_TAG_DECODER`, `REBOOT_REASON_DECODER`)
|
||||
live in `server/heartbeat_diagnostics_stub.py`.
|
||||
|
||||
78
docs/server-prompt-crossing-cooldown.md
Normal file
78
docs/server-prompt-crossing-cooldown.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Server-Side Prompt — Crossing Cooldown Awareness
|
||||
|
||||
> Give this to your server-side agent after the firmware commit that introduces
|
||||
> `CV_CROSSING_COOLDOWN_FRAMES` in `firmware/lib/cv/cv.h` has been flashed to devices.
|
||||
|
||||
## Context
|
||||
|
||||
The DoorCounter firmware now enforces a **per-direction crossing cooldown** in
|
||||
its CV pipeline. After a counted entry, subsequent entries within 5 frames
|
||||
(~1 second at 5 fps) are silently dropped on-device. Exits follow the same
|
||||
rule independently. This is a device-side fix for the known track-churn bug
|
||||
(single person producing 5+ counts per visit when their blob briefly drops
|
||||
below the minimum-blob-pixel threshold).
|
||||
|
||||
Constants live in `firmware/lib/cv/cv.h`:
|
||||
|
||||
```c++
|
||||
static const uint32_t CV_CROSSING_COOLDOWN_FRAMES = 5; // per-direction
|
||||
```
|
||||
|
||||
Cooldown is **fixed at compile time**. It is **not** currently a server-
|
||||
pushable tuning parameter.
|
||||
|
||||
## What the server should do
|
||||
|
||||
1. **Expect lower per-hour entry/exit counts** from devices running this
|
||||
firmware compared to their historical baseline. This reflects suppression
|
||||
of false positives, not a device regression. Do not alert on the drop.
|
||||
|
||||
2. **If you have a per-device tuning-config push mechanism** (the one planned
|
||||
in `.agent/plan.md` — server-push CV config via heartbeat response), add
|
||||
`cooldown_frames` to the sendable tuning set with:
|
||||
- Default: `5`
|
||||
- Valid range: `0..60` (0 disables cooldown; 60 ≈ 12s at 5 fps)
|
||||
- Semantics: per-direction, applied to both entries and exits
|
||||
- Persist alongside other CV tuning keys in NVS under a new key `cv_cool`.
|
||||
- Bump the `cfg_version` scheme accordingly.
|
||||
|
||||
**Do not ship this server-side change yet** — the firmware change in this
|
||||
commit keeps the cooldown as a compile-time constant. A future firmware
|
||||
commit on `feature/server-push-config` will expose it as a runtime
|
||||
tunable and bump `cfg_version`. Coordinate the rollout: firmware first,
|
||||
then server.
|
||||
|
||||
3. **Dashboard**: if you render device CV parameters in a per-device settings
|
||||
view, add a read-only row "Crossing cooldown (frames): 5" sourced from
|
||||
the firmware's compiled default. Mark it editable only once the firmware
|
||||
exposes it as a tunable.
|
||||
|
||||
4. **Telemetry (optional, low priority)**: consider adding a field
|
||||
`suppressed_crossings_delta` to the heartbeat or camera-events payload
|
||||
so operators can see how often cooldown is kicking in. This would require
|
||||
a firmware change; flag it as future work only if churn continues.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do not attempt to push `cooldown_frames` via the existing config channel
|
||||
today — the firmware will ignore unknown fields, which is fine, but
|
||||
shipping server changes that assume the firmware-side plumbing exists
|
||||
will break the integration contract.
|
||||
- Do not "correct" the lower counts via server-side multiplication. The
|
||||
cooldown is the correct behavior; old counts were inflated by the churn
|
||||
bug.
|
||||
|
||||
## Verification checklist
|
||||
|
||||
- [ ] Historical counts chart annotated with "firmware v{N} deployed"
|
||||
marker on the rollout date.
|
||||
- [ ] Per-device tuning view renders cooldown row (read-only for now).
|
||||
- [ ] No alert fires on the per-device count drop post-rollout.
|
||||
|
||||
## Reference
|
||||
|
||||
- Firmware change: `firmware/lib/cv/cv.h` (`CV_CROSSING_COOLDOWN_FRAMES`),
|
||||
`firmware/lib/cv/cv.cpp` (suppression logic in `cv_process`).
|
||||
- Design spec: `docs/superpowers/specs/2026-04-13-door-counter-design.md`
|
||||
§ 3.1 "Counting logic".
|
||||
- Unit test: `firmware/test/test_cv/test_cv.cpp::test_cooldown_suppresses_rapid_re_entry`.
|
||||
@@ -12,8 +12,8 @@
|
||||
```
|
||||
[TimerCamera-F Device]
|
||||
├── Provisioning module — captive portal AP on first boot
|
||||
├── Config store — NVS: device_id, location_id, HMAC secret, WiFi creds, CV tuning (server-pushed)
|
||||
├── Camera + CV module — captures frames, runs line-crossing counter
|
||||
├── Config store — NVS: device_id, location_id, HMAC secret, WiFi creds, line_offset
|
||||
├── Camera + CV module — captures frames, runs event-based walker detector
|
||||
├── BLE scanner — continuous passive scan (WiFi coexistence mode)
|
||||
├── Report buffer — accumulates counts in RAM, flushes hourly
|
||||
└── HTTP client — HMAC-signed POSTs to logs.research.bike
|
||||
@@ -65,12 +65,7 @@ Writes directly to NVS over serial. WiFi credentials are optional — if omitted
|
||||
| `hmac_secret` | Operator | Yes |
|
||||
| `wifi_ssid` | User/operator | Yes |
|
||||
| `wifi_pass` | User/operator | Yes |
|
||||
|
||||
CV tuning (`cv_diff`, `cv_blob`, `cv_move`, `cv_miss`, `cv_line`, `cv_ver`) lives in the same namespace but is set at runtime by the backend via heartbeat-response push — see §2.1. On first boot with no pushed config, compiled defaults apply.
|
||||
|
||||
### 2.1 Runtime tuning (server push)
|
||||
|
||||
The backend may include a `config` object in the `POST /api/v1/heartbeat` response to update per-device CV parameters. The device validates, persists to NVS, and applies atomically under mutex. Stale (`cfg_version ≤ stored`), malformed, or out-of-range updates are rejected. See `README.md` → "Runtime Configuration" for the full wire contract, field ranges, and trust-model caveat (plain HTTP; HMAC signs requests only).
|
||||
| `line_offset` | Default 50% | No |
|
||||
|
||||
### Factory reset
|
||||
|
||||
@@ -94,15 +89,38 @@ Capture → Grayscale → Downscale 96×96 → Frame diff → Threshold → Blob
|
||||
| Downscale | Bilinear to 96×96 (~11× compute reduction) |
|
||||
| Frame diff | Absolute difference against rolling background (updated every ~2s when no motion) |
|
||||
| Threshold | Pixels > 30 intensity delta = foreground |
|
||||
| Blob detect | Connected components; blobs < 8×8 px discarded as noise |
|
||||
| Centroid track | Nearest-centroid matching frame-to-frame (max 15px), tracks persist up to 10 missed frames |
|
||||
| Line crossing | Virtual horizontal line at configurable vertical position (default: 50% of frame height) |
|
||||
| Event state machine | Single global state machine (not per-blob). Per-frame `fg_count` (total foreground pixels) gates event start and end. |
|
||||
| Event start | `fg_count ≥ CV_EVENT_ENTER_THRESH` (250 px) → event becomes active. Background updates freeze for the event's duration so the walker does not blend into the baseline. |
|
||||
| Event accumulation | Each frame records `first_c` (centroid_y at start), running `min_c` / `max_c` / `last_c`, vertical extents (`min_y_seen`, `max_y_seen`), and frame count. |
|
||||
| Event end | Either **quiet exit** (`fg_count < CV_EVENT_EXIT_THRESH` (150 px) for `CV_EVENT_QUIET_FRAMES` (3) consecutive frames) or **timeout** (`event_frame_count > CV_EVENT_MAX_FRAMES` (25)). On end, background snaps to the current frame. |
|
||||
| Fire gates | Duration ≥ `CV_EVENT_MIN_FRAMES` (5), `min_y_seen ≤ CV_EVENT_EXTENT_TOP` (25) AND `max_y_seen ≥ CV_EVENT_EXTENT_BOT` (50) — event must span a large fraction of the frame — AND `max(up_score, down_score) ≥ CV_EVENT_MIN_TRAJ` (5) |
|
||||
| Refractory | `CV_EVENT_REFRACTORY_FRAMES` (10 ≈ 2s) after a fire, the machine refuses to start a new event — absorbs lingering motion of the just-counted walker. |
|
||||
|
||||
**Counting logic:**
|
||||
- Centroid crosses line top→bottom = **entry**
|
||||
- Centroid crosses line bottom→top = **exit**
|
||||
**Direction heuristic (applied after fire gates pass):**
|
||||
- `up_score = first_c − min_c` (peak upward centroid excursion)
|
||||
- `down_score = max_c − first_c` (peak downward centroid excursion)
|
||||
- **Quiet-exit fires**: `is_entry = (up_score ≥ down_score)`
|
||||
- **Timeout fires**: `is_entry = (last_c < first_c)` — walker is still in frame at timeout, so net displacement is a better signal than excursion.
|
||||
|
||||
Counts accumulate as `{entries, exits}` in RAM and reset each hour on report.
|
||||
Per-mount convention: centroid moving **up through the frame** (y decreasing) = **entry** into the store.
|
||||
|
||||
**Counting surface**: `{entries, exits}` accumulate in RAM and reset each hour on report.
|
||||
|
||||
**Directional accuracy is best-effort, not guaranteed.** In bench testing at the intended 7' overhead straight-down mount:
|
||||
|
||||
| Metric | Result |
|
||||
|--------|--------|
|
||||
| Event detection | 8/8 walks (100%) |
|
||||
| Aggregate entry/exit split | 4+4 vs ground-truth 4+4 (matches) |
|
||||
| Per-walk direction labelling | 4/8 (50%) — no better than chance |
|
||||
|
||||
At this mount, entries and exits produce nearly identical centroid trajectories: the walker is already large when `fg_count` crosses 250 (so `first_c` is always near mid-frame), their tail is still visible when `fg_count` drops below 150 (so `last_c` is always near mid-frame), and the excursion in between peaks upward for both directions. No statistic computable from (`first_c`, `min_c`, `max_c`, `last_c`, duration) separates them reliably.
|
||||
|
||||
**Contract with downstream consumers (API and analytics):**
|
||||
- **`entries + exits` is the trustworthy number** — it is the count of walkers through the doorway in the hour. Use this as "foot traffic."
|
||||
- **Individual `entries` and `exits` are reported for API shape compatibility, but should not be relied on for net flow, dwell, or any per-direction analysis.**
|
||||
|
||||
Recovering true direction requires either a physical change (tilt or raise the camera so walkers pass fully through the frame edges) or a richer signal (time-resolved centroid trajectory, optical flow, secondary sensor). Both are out of scope for v1.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
// File-local defaults. Runtime values live in CVState::tuning and can be
|
||||
// overridden via config_load_tuning() on boot or server push at runtime.
|
||||
static constexpr uint8_t CV_DEFAULT_DIFF_THRESH = 30;
|
||||
static constexpr int CV_DEFAULT_MIN_BLOB_PX = 64;
|
||||
static constexpr float CV_DEFAULT_MAX_MOVE = 15.0f;
|
||||
static constexpr int CV_DEFAULT_MAX_MISSED = 10;
|
||||
static constexpr uint8_t CV_DEFAULT_LINE_OFFSET = 50;
|
||||
static void event_reset(CVState& s) {
|
||||
s.event_active = false;
|
||||
s.event_start_frame = 0;
|
||||
s.event_frame_count = 0;
|
||||
s.event_peak_n = 0;
|
||||
s.event_first_c = -1.0f;
|
||||
s.event_last_c = -1.0f;
|
||||
s.event_min_c = (float)CV_H;
|
||||
s.event_max_c = -1.0f;
|
||||
s.event_min_y_seen = CV_H;
|
||||
s.event_max_y_seen = -1;
|
||||
s.event_quiet_count = 0;
|
||||
}
|
||||
|
||||
void cv_init(CVState& state) {
|
||||
// Initialize members directly — avoid CVState{} temporary which puts 9KB on stack
|
||||
memset(state.background, 0, sizeof(state.background));
|
||||
state.bg_valid = false;
|
||||
state.last_motion_frame = 0;
|
||||
@@ -23,12 +28,8 @@ void cv_init(CVState& state) {
|
||||
state.tracks.clear();
|
||||
state.entries = 0;
|
||||
state.exits = 0;
|
||||
state.tuning.diff_thresh = CV_DEFAULT_DIFF_THRESH;
|
||||
state.tuning.min_blob_px = CV_DEFAULT_MIN_BLOB_PX;
|
||||
state.tuning.max_move = CV_DEFAULT_MAX_MOVE;
|
||||
state.tuning.max_missed = CV_DEFAULT_MAX_MISSED;
|
||||
state.tuning.line_offset = CV_DEFAULT_LINE_OFFSET;
|
||||
state.tuning.cfg_version = 0;
|
||||
state.last_fire_frame = 0;
|
||||
event_reset(state);
|
||||
}
|
||||
|
||||
void cv_reset_counts(CVState& state) {
|
||||
@@ -36,23 +37,9 @@ void cv_reset_counts(CVState& state) {
|
||||
state.exits = 0;
|
||||
}
|
||||
|
||||
bool cv_tuning_validate(const CVTuning& t) {
|
||||
if (t.cfg_version == 0) return false;
|
||||
if (t.diff_thresh < 5 || t.diff_thresh > 120) return false;
|
||||
if (t.min_blob_px < 16 || t.min_blob_px > 4096) return false;
|
||||
if (t.max_move < 2.0f || t.max_move > 50.0f) return false;
|
||||
if (t.max_missed < 1 || t.max_missed > 60) return false;
|
||||
if (t.line_offset > 100) return false; // uint8, min 0
|
||||
return true;
|
||||
}
|
||||
|
||||
struct Point { int x, y; };
|
||||
|
||||
// Note: queue may grow to CV_PIXELS entries (~72KB) on large blobs.
|
||||
// Requires PSRAM (enabled via -DBOARD_HAS_PSRAM in platformio.ini).
|
||||
// BFS flood fill. Marks visited pixels (sets fg to 0). Returns {-1,-1} if blob < min_blob_px.
|
||||
static std::pair<float,float> extract_blob(uint8_t* fg, int start_x, int start_y,
|
||||
int min_blob_px) {
|
||||
static std::pair<float,float> extract_blob(uint8_t* fg, int start_x, int start_y) {
|
||||
std::vector<Point> queue;
|
||||
queue.reserve(512);
|
||||
queue.push_back({start_x, start_y});
|
||||
@@ -77,20 +64,19 @@ static std::pair<float,float> extract_blob(uint8_t* fg, int start_x, int start_y
|
||||
}
|
||||
}
|
||||
|
||||
if (count < min_blob_px) return {-1.0f, -1.0f};
|
||||
if (count < CV_MIN_BLOB_PX) return {-1.0f, -1.0f};
|
||||
return {sum_x / count, sum_y / count};
|
||||
}
|
||||
|
||||
static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg,
|
||||
int min_blob_px) {
|
||||
static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg) {
|
||||
std::vector<std::pair<float,float>> result;
|
||||
static uint8_t fg_copy[CV_PIXELS]; // static to avoid 9KB stack allocation
|
||||
static uint8_t fg_copy[CV_PIXELS];
|
||||
memcpy(fg_copy, fg, CV_PIXELS);
|
||||
|
||||
for (int y = 0; y < CV_H; y++) {
|
||||
for (int x = 0; x < CV_W; x++) {
|
||||
if (!fg_copy[y * CV_W + x]) continue;
|
||||
auto c = extract_blob(fg_copy, x, y, min_blob_px);
|
||||
auto c = extract_blob(fg_copy, x, y);
|
||||
if (c.first >= 0) result.push_back(c);
|
||||
}
|
||||
}
|
||||
@@ -98,16 +84,70 @@ static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg,
|
||||
}
|
||||
|
||||
static void frame_diff(const uint8_t* frame, const uint8_t* bg,
|
||||
uint8_t* fg, int pixels, uint8_t diff_thresh) {
|
||||
uint8_t* fg, int pixels) {
|
||||
for (int i = 0; i < pixels; i++) {
|
||||
int diff = (int)frame[i] - (int)bg[i];
|
||||
if (diff < 0) diff = -diff;
|
||||
fg[i] = (diff > diff_thresh) ? 1 : 0;
|
||||
fg[i] = (diff > CV_DIFF_THRESH) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
CVResult cv_process(CVState& state, const uint8_t* frame) {
|
||||
CVResult result = {0, 0};
|
||||
// Decide whether the just-ended event should fire and in which direction.
|
||||
// Up-through-frame (centroid excursion from high y toward low y) maps to
|
||||
// ENTRY per mount convention.
|
||||
static void finalize_event(CVState& s, CVResult& result) {
|
||||
if (s.event_frame_count < CV_EVENT_MIN_FRAMES) return;
|
||||
// Note: no MAX_FRAMES rejection here. An event that runs the full duration
|
||||
// may still be a valid walker whose fg_count stayed above EXIT_THRESH due
|
||||
// to a stale bg or an AEC-driven lighting shift. Extent + MIN_TRAJ gates
|
||||
// below already reject stationary-person / wobble events.
|
||||
if (s.event_min_y_seen > CV_EVENT_EXTENT_TOP) return;
|
||||
if (s.event_max_y_seen < CV_EVENT_EXTENT_BOT) return;
|
||||
|
||||
// Direction from centroid excursion relative to event start.
|
||||
// up_score: how far centroid excursed upward (smaller y) from first_c.
|
||||
// down_score: how far it excursed downward (larger y) from first_c.
|
||||
float up_score = s.event_first_c - s.event_min_c;
|
||||
float down_score = s.event_max_c - s.event_first_c;
|
||||
|
||||
float winning = (up_score >= down_score) ? up_score : down_score;
|
||||
if (winning < CV_EVENT_MIN_TRAJ) return;
|
||||
|
||||
// Timeout-aware direction. Quiet-exit events (fg fell below EXIT_THRESH)
|
||||
// have walker fully out of frame → min/max excursion bracket the true
|
||||
// traversal and up/down scores are reliable. Timeout events (event hit
|
||||
// MAX_FRAMES while still elevated) captured both an approach and a
|
||||
// departure within the window, so excursion measures the walker's
|
||||
// *range in frame* rather than direction — an entry walker who paused
|
||||
// near the top, then drifted back toward the middle before timeout
|
||||
// gets (wrongly) called an entry by up-score even though net motion is
|
||||
// mixed. For those, the net first→last centroid displacement is a
|
||||
// better direction signal (it's where the walker ended up, not just
|
||||
// where they peaked).
|
||||
bool timed_out = (s.event_frame_count > CV_EVENT_MAX_FRAMES);
|
||||
bool is_entry;
|
||||
if (timed_out) {
|
||||
is_entry = (s.event_last_c < s.event_first_c);
|
||||
} else {
|
||||
is_entry = (up_score >= down_score);
|
||||
}
|
||||
if (is_entry) {
|
||||
s.entries++;
|
||||
result.entries_delta++;
|
||||
} else {
|
||||
s.exits++;
|
||||
result.exits_delta++;
|
||||
}
|
||||
s.last_fire_frame = s.frame_index;
|
||||
result.fire_first_c = s.event_first_c;
|
||||
result.fire_min_c = s.event_min_c;
|
||||
result.fire_max_c = s.event_max_c;
|
||||
result.fire_last_c = s.event_last_c;
|
||||
result.fire_duration = s.event_frame_count;
|
||||
}
|
||||
|
||||
CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t /*line_pct*/) {
|
||||
CVResult result = {0, 0, 0, -1, -1, -1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0};
|
||||
state.frame_index++;
|
||||
|
||||
if (!state.bg_valid) {
|
||||
@@ -116,90 +156,147 @@ CVResult cv_process(CVState& state, const uint8_t* frame) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const uint8_t diff_thresh = state.tuning.diff_thresh;
|
||||
const int min_blob_px = state.tuning.min_blob_px;
|
||||
const float max_move = state.tuning.max_move;
|
||||
const int max_missed = state.tuning.max_missed;
|
||||
static uint8_t fg[CV_PIXELS];
|
||||
frame_diff(frame, state.background, fg, CV_PIXELS);
|
||||
|
||||
static uint8_t fg[CV_PIXELS]; // static: avoids 9KB on task stack
|
||||
frame_diff(frame, state.background, fg, CV_PIXELS, diff_thresh);
|
||||
// Running-average background blend: bg = (31*bg + frame)/32. Adapts to
|
||||
// slow scene drift during idle periods. Frozen during an active event so
|
||||
// the walker's signature is never absorbed — otherwise bg retains a
|
||||
// "ghost" of the walker for ~30 frames after they leave, keeping fg_count
|
||||
// elevated and preventing subsequent walkers from producing a clean
|
||||
// trajectory.
|
||||
if (!state.event_active) {
|
||||
for (int i = 0; i < CV_PIXELS; i++) {
|
||||
state.background[i] = (uint8_t)(((uint16_t)state.background[i] * 31 + frame[i]) >> 5);
|
||||
}
|
||||
}
|
||||
|
||||
int fg_count = 0;
|
||||
for (int i = 0; i < CV_PIXELS; i++) fg_count += fg[i];
|
||||
|
||||
bool motion = fg_count > min_blob_px;
|
||||
if (!motion) {
|
||||
if (state.frame_index - state.last_motion_frame > 10) {
|
||||
memcpy(state.background, frame, CV_PIXELS);
|
||||
int min_y = CV_H, max_y = -1;
|
||||
long sum_y = 0;
|
||||
for (int y = 0; y < CV_H; y++) {
|
||||
const uint8_t* row = &fg[y * CV_W];
|
||||
int row_count = 0;
|
||||
for (int x = 0; x < CV_W; x++) row_count += row[x];
|
||||
if (row_count > 0) {
|
||||
if (y < min_y) min_y = y;
|
||||
if (y > max_y) max_y = y;
|
||||
sum_y += (long)row_count * y;
|
||||
fg_count += row_count;
|
||||
}
|
||||
for (auto& t : state.tracks) t.missed++;
|
||||
state.tracks.erase(
|
||||
std::remove_if(state.tracks.begin(), state.tracks.end(),
|
||||
[max_missed](const CVTrack& t){ return t.missed > max_missed; }),
|
||||
state.tracks.end());
|
||||
}
|
||||
result.fg_count = fg_count;
|
||||
result.fg_min_y = (fg_count > 0) ? min_y : -1;
|
||||
result.fg_max_y = (fg_count > 0) ? max_y : -1;
|
||||
result.fg_centroid_y = (fg_count > 0) ? ((float)sum_y / fg_count) : -1.0f;
|
||||
|
||||
// Hard self-heal: if more than half the frame is fg, bg is catastrophically
|
||||
// wrong. Snap and skip the event machine this frame.
|
||||
if (fg_count > CV_PIXELS / 2) {
|
||||
memcpy(state.background, frame, CV_PIXELS);
|
||||
state.last_motion_frame = state.frame_index;
|
||||
if (state.event_active) event_reset(state);
|
||||
return result;
|
||||
}
|
||||
|
||||
state.last_motion_frame = state.frame_index;
|
||||
|
||||
auto centroids = find_centroids(fg, min_blob_px);
|
||||
|
||||
std::vector<bool> centroid_matched(centroids.size(), false);
|
||||
|
||||
for (auto& track : state.tracks) {
|
||||
float best_dist = max_move * max_move;
|
||||
int best_idx = -1;
|
||||
|
||||
for (int i = 0; i < (int)centroids.size(); i++) {
|
||||
if (centroid_matched[i]) continue;
|
||||
float dx = centroids[i].first - track.x;
|
||||
float dy = centroids[i].second - track.y;
|
||||
float d2 = dx*dx + dy*dy;
|
||||
if (d2 < best_dist) { best_dist = d2; best_idx = i; }
|
||||
}
|
||||
|
||||
if (best_idx >= 0) {
|
||||
centroid_matched[best_idx] = true;
|
||||
track.x = centroids[best_idx].first;
|
||||
track.y = centroids[best_idx].second;
|
||||
track.missed = 0;
|
||||
} else {
|
||||
track.missed++;
|
||||
}
|
||||
}
|
||||
|
||||
state.tracks.erase(
|
||||
std::remove_if(state.tracks.begin(), state.tracks.end(),
|
||||
[max_missed](const CVTrack& t){ return t.missed > max_missed; }),
|
||||
state.tracks.end());
|
||||
|
||||
float line_y = (state.tuning.line_offset / 100.0f) * CV_H;
|
||||
for (int i = 0; i < (int)centroids.size(); i++) {
|
||||
if (centroid_matched[i]) continue;
|
||||
CVTrack t;
|
||||
t.id = state.next_id++;
|
||||
t.x = centroids[i].first;
|
||||
t.y = centroids[i].second;
|
||||
t.above_line = (t.y < line_y);
|
||||
t.missed = 0;
|
||||
state.tracks.push_back(t);
|
||||
}
|
||||
// Line crossing check
|
||||
for (auto& track : state.tracks) {
|
||||
if (track.missed > 0) continue; // only check tracks matched this frame
|
||||
bool now_above = (track.y < line_y);
|
||||
if (now_above != track.above_line) {
|
||||
if (!now_above) {
|
||||
// was above, now below → entry
|
||||
state.entries++;
|
||||
result.entries_delta++;
|
||||
// Diagnostic track management (no effect on counting).
|
||||
bool motion = fg_count > CV_MIN_BLOB_PX;
|
||||
if (motion) {
|
||||
state.last_motion_frame = state.frame_index;
|
||||
auto centroids = find_centroids(fg);
|
||||
std::vector<bool> centroid_matched(centroids.size(), false);
|
||||
for (auto& track : state.tracks) {
|
||||
float best_dist = CV_MAX_MOVE * CV_MAX_MOVE;
|
||||
int best_idx = -1;
|
||||
for (int i = 0; i < (int)centroids.size(); i++) {
|
||||
if (centroid_matched[i]) continue;
|
||||
float dx = centroids[i].first - track.x;
|
||||
float dy = centroids[i].second - track.y;
|
||||
float d2 = dx*dx + dy*dy;
|
||||
if (d2 < best_dist) { best_dist = d2; best_idx = i; }
|
||||
}
|
||||
if (best_idx >= 0) {
|
||||
centroid_matched[best_idx] = true;
|
||||
track.x = centroids[best_idx].first;
|
||||
track.y = centroids[best_idx].second;
|
||||
track.missed = 0;
|
||||
} else {
|
||||
// was below, now above → exit
|
||||
state.exits++;
|
||||
result.exits_delta++;
|
||||
track.missed++;
|
||||
}
|
||||
}
|
||||
state.tracks.erase(
|
||||
std::remove_if(state.tracks.begin(), state.tracks.end(),
|
||||
[](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }),
|
||||
state.tracks.end());
|
||||
for (int i = 0; i < (int)centroids.size(); i++) {
|
||||
if (centroid_matched[i]) continue;
|
||||
CVTrack t;
|
||||
t.id = state.next_id++;
|
||||
t.x = centroids[i].first;
|
||||
t.y = centroids[i].second;
|
||||
t.spawn_y = t.y;
|
||||
t.missed = 0;
|
||||
state.tracks.push_back(t);
|
||||
}
|
||||
} else {
|
||||
for (auto& t : state.tracks) t.missed++;
|
||||
state.tracks.erase(
|
||||
std::remove_if(state.tracks.begin(), state.tracks.end(),
|
||||
[](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }),
|
||||
state.tracks.end());
|
||||
}
|
||||
|
||||
// Event state machine. Refractory period after a fire blocks new events
|
||||
// for CV_EVENT_REFRACTORY_FRAMES frames — absorbs lingering-walker motion
|
||||
// that would otherwise re-trigger a second count.
|
||||
bool in_refractory = state.last_fire_frame != 0 &&
|
||||
(state.frame_index - state.last_fire_frame) < CV_EVENT_REFRACTORY_FRAMES;
|
||||
|
||||
if (!state.event_active) {
|
||||
if (!in_refractory && fg_count >= CV_EVENT_ENTER_THRESH) {
|
||||
state.event_active = true;
|
||||
state.event_start_frame = state.frame_index;
|
||||
state.event_frame_count = 1;
|
||||
state.event_peak_n = fg_count;
|
||||
state.event_first_c = result.fg_centroid_y;
|
||||
state.event_last_c = result.fg_centroid_y;
|
||||
state.event_min_c = result.fg_centroid_y;
|
||||
state.event_max_c = result.fg_centroid_y;
|
||||
state.event_min_y_seen = min_y;
|
||||
state.event_max_y_seen = max_y;
|
||||
state.event_quiet_count = 0;
|
||||
}
|
||||
} else {
|
||||
state.event_frame_count++;
|
||||
if (fg_count > state.event_peak_n) state.event_peak_n = fg_count;
|
||||
if (fg_count > 0) {
|
||||
state.event_last_c = result.fg_centroid_y;
|
||||
if (result.fg_centroid_y < state.event_min_c) state.event_min_c = result.fg_centroid_y;
|
||||
if (result.fg_centroid_y > state.event_max_c) state.event_max_c = result.fg_centroid_y;
|
||||
if (min_y < state.event_min_y_seen) state.event_min_y_seen = min_y;
|
||||
if (max_y > state.event_max_y_seen) state.event_max_y_seen = max_y;
|
||||
}
|
||||
if (fg_count < CV_EVENT_EXIT_THRESH) {
|
||||
state.event_quiet_count++;
|
||||
if (state.event_quiet_count >= CV_EVENT_QUIET_FRAMES) {
|
||||
finalize_event(state, result);
|
||||
event_reset(state);
|
||||
memcpy(state.background, frame, CV_PIXELS);
|
||||
}
|
||||
} else {
|
||||
state.event_quiet_count = 0;
|
||||
if (state.event_frame_count > CV_EVENT_MAX_FRAMES) {
|
||||
// Timeout end: fg still elevated. Snap bg anyway — in practice
|
||||
// a stuck-high event means bg is stale (walker has merged
|
||||
// with stale bg, or AEC shifted). Leaving bg stale permanently
|
||||
// poisons subsequent events. If a walker truly is mid-frame
|
||||
// they'll get absorbed into bg, but that's a rare corner
|
||||
// beaten by the common case of stale bg chaining events.
|
||||
finalize_event(state, result);
|
||||
event_reset(state);
|
||||
memcpy(state.background, frame, CV_PIXELS);
|
||||
}
|
||||
}
|
||||
track.above_line = now_above;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -7,22 +7,71 @@ static const int CV_W = 96;
|
||||
static const int CV_H = 96;
|
||||
static const int CV_PIXELS = CV_W * CV_H;
|
||||
|
||||
static const uint8_t CV_DIFF_THRESH = 30;
|
||||
static const int CV_MIN_BLOB_PX = 64;
|
||||
static const float CV_MAX_MOVE = 15.0f;
|
||||
static const int CV_MAX_MISSED = 10;
|
||||
|
||||
// Event-based walker detector. Per-frame zone-flip approaches were direction-
|
||||
// blind at realistic mounts: a walker traversing top-to-bottom and a walker
|
||||
// traversing bottom-to-top produced identical zone-dominance sequences
|
||||
// (geometric artifact of asymmetric zones + body spanning the line). The
|
||||
// event approach buffers a whole walker event, then decides direction from
|
||||
// the centroid trajectory: sign(first_centroid_y - peak_centroid_y) > 0 means
|
||||
// the centroid moved upward through the frame during the event.
|
||||
//
|
||||
// Per-mount convention: UP through frame == ENTRY into store. Flip the camera
|
||||
// mount or invert the mapping in cv_process if the physical install differs.
|
||||
|
||||
// fg_count thresholds that gate event start/end. Tuned against a real
|
||||
// 8-walk isolated test (see .agent/walk_isolated_8walks.log). Lower than
|
||||
// initial guesses because the 7' overhead mount produces smaller centroid
|
||||
// excursions than we originally modelled.
|
||||
static const int CV_EVENT_ENTER_THRESH = 250;
|
||||
static const int CV_EVENT_EXIT_THRESH = 150;
|
||||
|
||||
// Number of consecutive sub-EXIT frames required to end an event.
|
||||
static const int CV_EVENT_QUIET_FRAMES = 3;
|
||||
|
||||
// Min/max event duration in frames. Below min = too brief to be a walker
|
||||
// (noise burst). Above max = stationary object or stuck detection.
|
||||
static const int CV_EVENT_MIN_FRAMES = 5;
|
||||
// MAX bounds the event duration. Too low (15) cut events off while walker
|
||||
// was still physically in frame — every fire hit dur=MAX+1 and bg snapped
|
||||
// with a walker-ghost baked in, corrupting the next walk. Too high (40)
|
||||
// merged multiple walkers. 25 frames (5s) lets a single walker reach the
|
||||
// quiet-exit path (fg drops below EXIT_THRESH) before timeout, so bg snaps
|
||||
// on a clean empty frame.
|
||||
static const int CV_EVENT_MAX_FRAMES = 25;
|
||||
|
||||
// Required vertical extent: during the event, fg must have reached near the
|
||||
// top of the frame (min_y <= TOP) AND near the bottom (max_y >= BOT). At a
|
||||
// 7' overhead mount real walkers span fg y≈0..70, not 0..95 — the original
|
||||
// 10/85 gates rejected most real walks. Relaxed to catch them while still
|
||||
// filtering small local motion that doesn't span the doorway.
|
||||
static const int CV_EVENT_EXTENT_TOP = 25;
|
||||
static const int CV_EVENT_EXTENT_BOT = 50;
|
||||
|
||||
// Minimum centroid excursion (max of up_score/down_score) for a valid
|
||||
// trajectory. At overhead mount walker centroid traverses ~15-40 pixels;
|
||||
// 15 was too aggressive and dropped clean walks. 5 still filters wobble.
|
||||
static const float CV_EVENT_MIN_TRAJ = 5.0f;
|
||||
|
||||
// Refractory period after a fire. Shorter than originally chosen — at 5 fps
|
||||
// a second walker can arrive within 2s of the first, especially at busy
|
||||
// doorways. 10 frames = 2s of back-pressure, tuned to match the gap between
|
||||
// consecutive isolated walks in the test log.
|
||||
static const uint32_t CV_EVENT_REFRACTORY_FRAMES = 10;
|
||||
|
||||
// Diagnostic only: tracks are kept for spawn logging. Counting does NOT
|
||||
// depend on tracks.
|
||||
struct CVTrack {
|
||||
int id;
|
||||
float x, y;
|
||||
bool above_line;
|
||||
float spawn_y;
|
||||
int missed;
|
||||
};
|
||||
|
||||
struct CVTuning {
|
||||
uint8_t diff_thresh; // per-pixel motion threshold
|
||||
int min_blob_px; // min foreground pixels for a blob
|
||||
float max_move; // max inter-frame track jump (px)
|
||||
int max_missed; // frames before drop
|
||||
uint8_t line_offset; // 0-100, percent of frame height for virtual line
|
||||
uint32_t cfg_version; // monotonic; server increments on push
|
||||
};
|
||||
|
||||
struct CVState {
|
||||
uint8_t background[CV_PIXELS];
|
||||
bool bg_valid;
|
||||
@@ -32,18 +81,38 @@ struct CVState {
|
||||
std::vector<CVTrack> tracks;
|
||||
int entries;
|
||||
int exits;
|
||||
CVTuning tuning;
|
||||
|
||||
// Event state machine.
|
||||
bool event_active;
|
||||
uint32_t event_start_frame;
|
||||
int event_frame_count;
|
||||
int event_peak_n;
|
||||
float event_first_c;
|
||||
float event_last_c;
|
||||
float event_min_c; // min centroid_y observed during event
|
||||
float event_max_c; // max centroid_y observed during event
|
||||
int event_min_y_seen;
|
||||
int event_max_y_seen;
|
||||
int event_quiet_count;
|
||||
uint32_t last_fire_frame; // 0 = never; frame of last counted fire
|
||||
};
|
||||
|
||||
struct CVResult {
|
||||
int entries_delta;
|
||||
int exits_delta;
|
||||
// Per-frame foreground diagnostics (populated every call).
|
||||
int fg_count;
|
||||
int fg_min_y;
|
||||
int fg_max_y;
|
||||
float fg_centroid_y;
|
||||
// Populated only on a fire frame; zeroed otherwise.
|
||||
float fire_first_c;
|
||||
float fire_min_c;
|
||||
float fire_max_c;
|
||||
float fire_last_c;
|
||||
int fire_duration;
|
||||
};
|
||||
|
||||
void cv_init(CVState& state);
|
||||
CVResult cv_process(CVState& state, const uint8_t* frame);
|
||||
CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct);
|
||||
void cv_reset_counts(CVState& state);
|
||||
|
||||
// Pure validator: returns true iff all tunable fields are in range and
|
||||
// cfg_version is non-zero. No Arduino deps — safe for native tests.
|
||||
bool cv_tuning_validate(const CVTuning& t);
|
||||
|
||||
156
firmware/lib/event_log/event_log.cpp
Normal file
156
firmware/lib/event_log/event_log.cpp
Normal file
@@ -0,0 +1,156 @@
|
||||
// firmware/lib/event_log/event_log.cpp
|
||||
#include "event_log.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#ifdef ARDUINO
|
||||
#include <Arduino.h>
|
||||
#include <Preferences.h>
|
||||
#include <time.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
static Preferences s_prefs;
|
||||
static const char* NVS_NS = "evlog";
|
||||
static bool s_ok = false;
|
||||
static SemaphoreHandle_t s_mutex = nullptr;
|
||||
static uint32_t g_head = 0; // next write slot (0..31), RAM-only
|
||||
static uint32_t g_cnt = 0; // total writes since boot scan, RAM-only
|
||||
static constexpr time_t NTP_SYNC_THRESHOLD = 1700000000; // 2023-11-14
|
||||
#else
|
||||
// Native build: in-memory stub
|
||||
#include <cstdint>
|
||||
static uint8_t g_slots[32 * 32];
|
||||
static uint32_t g_head = 0;
|
||||
static uint32_t g_cnt = 0;
|
||||
extern "C" void event_log_test_reset() {
|
||||
memset(g_slots, 0, sizeof(g_slots));
|
||||
g_head = 0;
|
||||
g_cnt = 0;
|
||||
}
|
||||
extern "C" void event_log_test_simulate_reboot() {
|
||||
// Simulate device reboot: clear in-RAM state, keep persistent slots.
|
||||
g_head = 0;
|
||||
g_cnt = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
static const size_t SLOTS = 32;
|
||||
static const size_t SLOT_SIZE = sizeof(EventLogEntry);
|
||||
|
||||
uint16_t event_log_path_hash(const char* path) {
|
||||
// fnv1a-16 (fold 32-bit fnv1a down to 16 bits)
|
||||
uint32_t h = 0x811c9dc5u;
|
||||
while (*path) { h ^= (uint8_t)*path++; h *= 0x01000193u; }
|
||||
return (uint16_t)((h >> 16) ^ (h & 0xFFFF));
|
||||
}
|
||||
|
||||
static void slot_write(size_t idx, const EventLogEntry& e) {
|
||||
#ifdef ARDUINO
|
||||
char key[8]; snprintf(key, sizeof(key), "s%u", (unsigned)idx);
|
||||
s_prefs.putBytes(key, &e, SLOT_SIZE);
|
||||
#else
|
||||
memcpy(&g_slots[idx * SLOT_SIZE], &e, SLOT_SIZE);
|
||||
#endif
|
||||
}
|
||||
|
||||
static bool slot_read(size_t idx, EventLogEntry& e) {
|
||||
#ifdef ARDUINO
|
||||
char key[8]; snprintf(key, sizeof(key), "s%u", (unsigned)idx);
|
||||
size_t n = s_prefs.getBytes(key, &e, SLOT_SIZE);
|
||||
return n == SLOT_SIZE;
|
||||
#else
|
||||
memcpy(&e, &g_slots[idx * SLOT_SIZE], SLOT_SIZE);
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
void event_log_init() {
|
||||
#ifdef ARDUINO
|
||||
if (s_mutex == nullptr) {
|
||||
s_mutex = xSemaphoreCreateMutex();
|
||||
}
|
||||
s_ok = s_prefs.begin(NVS_NS, /*readOnly=*/false);
|
||||
if (!s_ok) {
|
||||
Serial.println("[evlog] NVS begin failed");
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// Scan all 32 slots; locate the one with the largest seq.
|
||||
// Empty log: every slot tag == 0 (not a valid EventLogTag, which starts at 1).
|
||||
uint32_t max_seq = 0;
|
||||
int max_idx = -1;
|
||||
bool any_valid = false;
|
||||
for (size_t i = 0; i < SLOTS; i++) {
|
||||
EventLogEntry e = {};
|
||||
if (!slot_read(i, e)) continue;
|
||||
if (e.tag == 0) continue;
|
||||
any_valid = true;
|
||||
if (max_idx < 0 || e.seq >= max_seq) {
|
||||
max_seq = e.seq;
|
||||
max_idx = (int)i;
|
||||
}
|
||||
}
|
||||
if (any_valid) {
|
||||
g_head = (uint32_t)((max_idx + 1) % SLOTS);
|
||||
g_cnt = max_seq + 1;
|
||||
} else {
|
||||
g_head = 0;
|
||||
g_cnt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void event_log_write(EventLogTag tag, uint16_t data0, uint16_t data1) {
|
||||
#ifdef ARDUINO
|
||||
if (!s_ok) return;
|
||||
// Bounded wait: skip on contention rather than stall the calling task.
|
||||
// This matters because event_log_write runs from the WiFi event task
|
||||
// (priority 23); blocking it on a 10-100ms NVS write can overflow the
|
||||
// event queue. Diagnostic loss is preferable to dropped WiFi events.
|
||||
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) != pdTRUE) return;
|
||||
EventLogEntry e = {};
|
||||
time_t now = time(nullptr);
|
||||
e.ts_unix = (now > NTP_SYNC_THRESHOLD) ? (uint32_t)now : 0;
|
||||
e.uptime_s = (uint32_t)(millis() / 1000);
|
||||
e.tag = (uint8_t)tag;
|
||||
e.data0 = data0;
|
||||
e.data1 = data1;
|
||||
e.seq = g_cnt;
|
||||
slot_write(g_head % SLOTS, e);
|
||||
g_head = (g_head + 1) % SLOTS;
|
||||
g_cnt = g_cnt + 1;
|
||||
if (s_mutex) xSemaphoreGive(s_mutex);
|
||||
#else
|
||||
EventLogEntry e = {};
|
||||
e.ts_unix = 0;
|
||||
e.uptime_s = 0;
|
||||
e.tag = (uint8_t)tag;
|
||||
e.data0 = data0;
|
||||
e.data1 = data1;
|
||||
e.seq = g_cnt;
|
||||
slot_write(g_head % SLOTS, e);
|
||||
g_head = (g_head + 1) % SLOTS;
|
||||
g_cnt = g_cnt + 1;
|
||||
#endif
|
||||
}
|
||||
|
||||
size_t event_log_read_recent(EventLogEntry* out, size_t max_entries) {
|
||||
#ifdef ARDUINO
|
||||
if (!s_ok) return 0;
|
||||
// Bounded wait to match event_log_write. Reads are slower (32 NVS gets),
|
||||
// but returning 0 entries under contention beats blocking the caller.
|
||||
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) != pdTRUE) return 0;
|
||||
#endif
|
||||
uint32_t head = g_head;
|
||||
uint32_t cnt = g_cnt;
|
||||
size_t available = (cnt < SLOTS) ? (size_t)cnt : SLOTS;
|
||||
size_t n = (max_entries < available) ? max_entries : available;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
// newest is at (head - 1), then (head - 2), ... modulo SLOTS
|
||||
size_t idx = (head + SLOTS - 1 - i) % SLOTS;
|
||||
slot_read(idx, out[i]);
|
||||
}
|
||||
#ifdef ARDUINO
|
||||
if (s_mutex) xSemaphoreGive(s_mutex);
|
||||
#endif
|
||||
return n;
|
||||
}
|
||||
48
firmware/lib/event_log/event_log.h
Normal file
48
firmware/lib/event_log/event_log.h
Normal file
@@ -0,0 +1,48 @@
|
||||
// firmware/lib/event_log/event_log.h
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
enum EventLogTag : uint8_t {
|
||||
EVT_BOOT = 1, // data0 = esp_reset_reason() value
|
||||
EVT_WIFI_UP = 2, // data0 = rssi (signed, cast)
|
||||
EVT_WIFI_DOWN = 3, // data0 = disconnect reason code
|
||||
EVT_HTTP_OK = 4, // data0 = path hash (fnv1a16), data1 = elapsed_ms
|
||||
EVT_HTTP_FAIL = 5, // data0 = path hash, data1 = (http_code or negative errno)
|
||||
EVT_HEARTBEAT_MISS = 6, // data0 = consecutive miss count
|
||||
EVT_NTP_SYNC = 7, // data0 = seconds since boot
|
||||
EVT_REBOOT = 8, // data0 = reason enum (defined below)
|
||||
};
|
||||
|
||||
enum RebootReason : uint8_t {
|
||||
REBOOT_HEARTBEAT_MISS = 1,
|
||||
REBOOT_FACTORY_RESET = 2,
|
||||
REBOOT_OTA = 3,
|
||||
REBOOT_WIFI_REPROV = 4,
|
||||
REBOOT_FATAL_CONFIG = 5,
|
||||
REBOOT_FATAL_CAMERA = 6,
|
||||
};
|
||||
|
||||
struct EventLogEntry {
|
||||
uint32_t ts_unix; // 0 if NTP not synced yet; fall back to millis/1000
|
||||
uint32_t uptime_s; // millis()/1000 at log time
|
||||
uint16_t data0;
|
||||
uint16_t data1;
|
||||
uint8_t tag; // EventLogTag
|
||||
uint32_t seq; // widened; survives multi-year event rates
|
||||
uint8_t _pad[15]; // pad to 32 bytes for fixed slot size
|
||||
} __attribute__((packed));
|
||||
static_assert(sizeof(EventLogEntry) == 32, "EventLogEntry must be 32 bytes");
|
||||
|
||||
// NVS-backed 32-slot ring buffer. Safe to call before NTP sync.
|
||||
// Call exactly once from application setup, before any task writes events.
|
||||
void event_log_init();
|
||||
|
||||
// Safe to call from any FreeRTOS task after event_log_init().
|
||||
// Bounded mutex wait (~50ms) — will silently skip on contention rather than
|
||||
// block the calling task. Acceptable for diagnostic logging.
|
||||
void event_log_write(EventLogTag tag, uint16_t data0 = 0, uint16_t data1 = 0);
|
||||
|
||||
// Same bounded-wait contract as event_log_write: returns 0 on mutex timeout.
|
||||
size_t event_log_read_recent(EventLogEntry* out, size_t max_entries);
|
||||
uint16_t event_log_path_hash(const char* path); // fnv1a16 — exposed for tests
|
||||
6
firmware/lib/net_guard/library.json
Normal file
6
firmware/lib/net_guard/library.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "net_guard",
|
||||
"build": {
|
||||
"flags": ["-I$PROJECT_SRC_DIR"]
|
||||
}
|
||||
}
|
||||
75
firmware/lib/net_guard/net_guard.cpp
Normal file
75
firmware/lib/net_guard/net_guard.cpp
Normal file
@@ -0,0 +1,75 @@
|
||||
// firmware/lib/net_guard/net_guard.cpp
|
||||
#include "net_guard.h"
|
||||
|
||||
uint32_t net_guard_next_backoff_ms(uint32_t attempt) {
|
||||
if (attempt >= 6) return 60000;
|
||||
return 1000u * (1u << attempt);
|
||||
}
|
||||
|
||||
#ifdef ARDUINO
|
||||
#include "config.h"
|
||||
#include <WiFi.h>
|
||||
#include "event_log.h"
|
||||
|
||||
// Shared with the WiFi event task. 32-bit aligned loads/stores are atomic on
|
||||
// Xtensa; volatile suffices. Tick re-evaluates every loop iteration, so stale
|
||||
// reads self-correct within ~200ms.
|
||||
static const DeviceConfig* s_cfg = nullptr;
|
||||
static volatile uint8_t s_last_disconnect = 0;
|
||||
static volatile bool s_up = false;
|
||||
static volatile uint32_t s_attempts = 0;
|
||||
static volatile uint32_t s_next_retry_ms = 0;
|
||||
|
||||
static void on_wifi_event(WiFiEvent_t event, WiFiEventInfo_t info) {
|
||||
switch (event) {
|
||||
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
|
||||
s_up = true;
|
||||
s_attempts = 0;
|
||||
s_next_retry_ms = 0;
|
||||
event_log_write(EVT_WIFI_UP, (uint16_t)(int16_t)WiFi.RSSI(), 0);
|
||||
break;
|
||||
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
|
||||
s_up = false;
|
||||
s_last_disconnect = (uint8_t)info.wifi_sta_disconnected.reason;
|
||||
event_log_write(EVT_WIFI_DOWN, s_last_disconnect, 0);
|
||||
s_next_retry_ms = millis() + net_guard_next_backoff_ms(s_attempts);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void net_guard_start(const DeviceConfig& cfg) {
|
||||
s_cfg = &cfg;
|
||||
// Seed s_up from the current WiFi state. setup()'s busy-wait on
|
||||
// WiFi.begin() can produce a STA_GOT_IP before onEvent() is registered;
|
||||
// without this seed, the first tick would force a spurious reconnect.
|
||||
if (WiFi.status() == WL_CONNECTED) s_up = true;
|
||||
WiFi.onEvent(on_wifi_event);
|
||||
WiFi.setAutoReconnect(false); // we drive reconnect ourselves
|
||||
}
|
||||
|
||||
bool net_guard_is_up() { return s_up; }
|
||||
|
||||
uint8_t net_guard_last_disconnect_reason() { return s_last_disconnect; }
|
||||
|
||||
extern "C" void net_guard_tick() {
|
||||
// Watchdog against silent WiFi death: if we think we're up but the radio
|
||||
// disagrees, force the DOWN state so reconnect scheduling kicks in.
|
||||
if (s_up && WiFi.status() != WL_CONNECTED) {
|
||||
s_up = false;
|
||||
s_last_disconnect = 0xFF; // 0xFF = "silent death, no event"
|
||||
event_log_write(EVT_WIFI_DOWN, s_last_disconnect, 0);
|
||||
s_next_retry_ms = millis() + net_guard_next_backoff_ms(s_attempts);
|
||||
}
|
||||
|
||||
if (s_up || s_cfg == nullptr) return;
|
||||
if (millis() < s_next_retry_ms) return;
|
||||
if (s_up) return; // re-check after the timing gate — closes GOT_IP-vs-tick race
|
||||
s_attempts++;
|
||||
// WiFi.begin() alone re-associates cleanly; a prior WiFi.disconnect() call
|
||||
// synchronously emits STA_DISCONNECTED on the event task, which would
|
||||
// double-log EVT_WIFI_DOWN (reason=ASSOC_LEAVE) on every retry.
|
||||
WiFi.begin(s_cfg->wifi_ssid.c_str(), s_cfg->wifi_pass.c_str());
|
||||
s_next_retry_ms = millis() + net_guard_next_backoff_ms(s_attempts);
|
||||
}
|
||||
#endif
|
||||
24
firmware/lib/net_guard/net_guard.h
Normal file
24
firmware/lib/net_guard/net_guard.h
Normal file
@@ -0,0 +1,24 @@
|
||||
// firmware/lib/net_guard/net_guard.h
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s, ...
|
||||
// attempt 0 -> 1000ms, clamped at 60000ms.
|
||||
uint32_t net_guard_next_backoff_ms(uint32_t attempt);
|
||||
|
||||
#ifdef ARDUINO
|
||||
struct DeviceConfig; // forward-decl; only net_guard_start needs the full type
|
||||
|
||||
// Registers WiFi.onEvent() handler and starts auto-reconnect loop.
|
||||
// Must be called once after WiFi.begin() succeeds.
|
||||
void net_guard_start(const DeviceConfig& cfg);
|
||||
|
||||
// True iff WiFi is currently associated with IP.
|
||||
bool net_guard_is_up();
|
||||
|
||||
// Last disconnect reason code from WIFI_EVENT_STA_DISCONNECTED (0 = none).
|
||||
uint8_t net_guard_last_disconnect_reason();
|
||||
|
||||
// Non-blocking tick called from loop(); kicks reconnect if due.
|
||||
extern "C" void net_guard_tick();
|
||||
#endif
|
||||
@@ -7,6 +7,7 @@ platform = espressif32@6.6.0
|
||||
board = m5stack-timer-cam
|
||||
framework = arduino
|
||||
board_build.partitions = partitions_4mb_ota.csv
|
||||
build_src_filter = +<*> -<main_capture.cpp>
|
||||
build_flags =
|
||||
-DBOARD_HAS_PSRAM
|
||||
-mfix-esp32-psram-cache-issue
|
||||
@@ -23,6 +24,25 @@ lib_deps =
|
||||
h2zero/NimBLE-Arduino@^1.4.2
|
||||
espressif/esp32-camera
|
||||
|
||||
; Frame-capture build. Strips WiFi/BLE/CV/reporter; streams raw 96x96 frames
|
||||
; over serial at 921600 baud for offline algorithm iteration.
|
||||
[env:timercam-capture]
|
||||
platform = espressif32@6.6.0
|
||||
board = m5stack-timer-cam
|
||||
framework = arduino
|
||||
board_build.partitions = partitions_4mb_ota.csv
|
||||
build_flags =
|
||||
-DBOARD_HAS_PSRAM
|
||||
-mfix-esp32-psram-cache-issue
|
||||
-DCORE_DEBUG_LEVEL=0
|
||||
-DCONFIG_SPIRAM_USE_MALLOC=1
|
||||
build_src_filter = -<*> +<main_capture.cpp> +<camera.cpp>
|
||||
monitor_speed = 460800
|
||||
upload_speed = 115200
|
||||
upload_flags = --no-stub
|
||||
lib_deps =
|
||||
espressif/esp32-camera
|
||||
|
||||
[env:native]
|
||||
platform = native
|
||||
test_framework = unity
|
||||
|
||||
@@ -13,6 +13,7 @@ bool config_load(DeviceConfig& cfg) {
|
||||
cfg.hmac_secret = prefs.getString("hmac_secret", "");
|
||||
cfg.wifi_ssid = prefs.getString("wifi_ssid", "");
|
||||
cfg.wifi_pass = prefs.getString("wifi_pass", "");
|
||||
cfg.line_offset = (uint8_t)prefs.getUInt("line_offset", 50);
|
||||
|
||||
prefs.end();
|
||||
|
||||
@@ -45,55 +46,3 @@ void config_clear_wifi() {
|
||||
prefs.remove("wifi_pass");
|
||||
prefs.end();
|
||||
}
|
||||
|
||||
bool config_load_tuning(CVTuning& tuning) {
|
||||
Preferences prefs;
|
||||
prefs.begin(NS, true); // read-only
|
||||
|
||||
uint32_t ver = prefs.getUInt("cv_ver", UINT32_MAX);
|
||||
if (ver == UINT32_MAX) {
|
||||
prefs.end();
|
||||
return false;
|
||||
}
|
||||
|
||||
// All six keys must be present; use sentinels to detect missing.
|
||||
uint32_t diff = prefs.getUInt("cv_diff", UINT32_MAX);
|
||||
uint32_t blob = prefs.getUInt("cv_blob", UINT32_MAX);
|
||||
uint32_t miss = prefs.getUInt("cv_miss", UINT32_MAX);
|
||||
uint32_t line = prefs.getUInt("cv_line", UINT32_MAX);
|
||||
bool has_move = prefs.isKey("cv_move");
|
||||
float move = prefs.getFloat("cv_move", 0.0f);
|
||||
|
||||
prefs.end();
|
||||
|
||||
if (diff == UINT32_MAX || blob == UINT32_MAX ||
|
||||
miss == UINT32_MAX || line == UINT32_MAX || !has_move) {
|
||||
return false;
|
||||
}
|
||||
|
||||
tuning.diff_thresh = (uint8_t)diff;
|
||||
tuning.min_blob_px = (int)blob;
|
||||
tuning.max_move = move;
|
||||
tuning.max_missed = (int)miss;
|
||||
tuning.line_offset = (uint8_t)line;
|
||||
tuning.cfg_version = ver;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool config_save_tuning(const CVTuning& tuning) {
|
||||
Preferences prefs;
|
||||
prefs.begin(NS, false);
|
||||
size_t r1 = prefs.putUInt("cv_diff", (uint32_t)tuning.diff_thresh);
|
||||
size_t r2 = prefs.putUInt("cv_blob", (uint32_t)tuning.min_blob_px);
|
||||
size_t r3 = prefs.putFloat("cv_move", tuning.max_move);
|
||||
size_t r4 = prefs.putUInt("cv_miss", (uint32_t)tuning.max_missed);
|
||||
size_t r5 = prefs.putUInt("cv_line", (uint32_t)tuning.line_offset);
|
||||
if (!(r1 > 0 && r2 > 0 && r3 > 0 && r4 > 0 && r5 > 0)) {
|
||||
prefs.end();
|
||||
return false;
|
||||
}
|
||||
// cv_ver is the atomic commit marker: only written after all tunables succeed.
|
||||
size_t r6 = prefs.putUInt("cv_ver", tuning.cfg_version);
|
||||
prefs.end();
|
||||
return r6 > 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// firmware/src/config.h
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include "cv.h"
|
||||
|
||||
struct DeviceConfig {
|
||||
String device_id; // e.g. "dc-0042"
|
||||
@@ -9,6 +8,7 @@ struct DeviceConfig {
|
||||
String hmac_secret; // 32-byte hex string
|
||||
String wifi_ssid;
|
||||
String wifi_pass;
|
||||
uint8_t line_offset; // 0-100, percent of frame height for virtual line
|
||||
};
|
||||
|
||||
// Load all config from NVS. Returns false if device_id/location_id/hmac_secret missing.
|
||||
@@ -22,10 +22,3 @@ bool config_has_wifi();
|
||||
|
||||
// Erase WiFi credentials only (factory reset — preserves device_id etc).
|
||||
void config_clear_wifi();
|
||||
|
||||
// Load CV tuning from NVS. Returns true only if all keys present (cfg_version sentinel).
|
||||
// If any key missing, tuning is NOT modified (caller keeps its defaults).
|
||||
bool config_load_tuning(CVTuning& tuning);
|
||||
|
||||
// Save CV tuning to NVS atomically. Returns true if all writes succeeded.
|
||||
bool config_save_tuning(const CVTuning& tuning);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// firmware/src/cv_apply.h
|
||||
#pragma once
|
||||
#include "cv.h"
|
||||
|
||||
// Take s_cv_mutex, copy the tuning into g_cv.tuning, release.
|
||||
// Non-blocking semantics: if mutex is unavailable after 500 ms, logs and
|
||||
// drops (caller may retry next cycle).
|
||||
void cv_apply_tuning(const CVTuning& incoming);
|
||||
|
||||
// Take s_cv_mutex, copy g_cv.tuning into out. For reporter use when
|
||||
// comparing candidate configs. If the mutex is unavailable, logs and
|
||||
// leaves `out` unchanged.
|
||||
void cv_get_tuning(CVTuning& out);
|
||||
@@ -6,9 +6,12 @@
|
||||
#include "provisioning.h"
|
||||
#include "camera.h"
|
||||
#include "cv.h"
|
||||
#include "cv_apply.h"
|
||||
#include "ble_scanner.h"
|
||||
#include "reporter.h"
|
||||
#include "event_log.h"
|
||||
#include "net_guard.h"
|
||||
#include <esp_system.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
// LED on GPIO2 (TimerCamera-F built-in LED) — verify against board schematic
|
||||
// Factory reset: hold GPIO37 (BOOT button) for 5 seconds
|
||||
@@ -25,71 +28,86 @@ static DeviceConfig g_cfg;
|
||||
static CVState g_cv;
|
||||
static SemaphoreHandle_t s_cv_mutex = nullptr;
|
||||
|
||||
// cv_apply.h definitions — live here because they need g_cv + s_cv_mutex.
|
||||
void cv_apply_tuning(const CVTuning& incoming) {
|
||||
if (!s_cv_mutex) return; // pre-init guard
|
||||
if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(500)) == pdTRUE) {
|
||||
g_cv.tuning = incoming;
|
||||
xSemaphoreGive(s_cv_mutex);
|
||||
} else {
|
||||
Serial.println("[CFG] apply skipped (mutex busy)");
|
||||
}
|
||||
}
|
||||
|
||||
void cv_get_tuning(CVTuning& out) {
|
||||
out = CVTuning{};
|
||||
if (!s_cv_mutex) return;
|
||||
if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(500)) == pdTRUE) {
|
||||
out = g_cv.tuning;
|
||||
xSemaphoreGive(s_cv_mutex);
|
||||
} else {
|
||||
Serial.println("[CFG] get_tuning skipped (mutex busy)");
|
||||
}
|
||||
}
|
||||
|
||||
// LED: simple on/off — blink patterns can be added later
|
||||
static void led_set(bool on) { digitalWrite(LED_PIN, on ? HIGH : LOW); }
|
||||
|
||||
// Non-blocking-ish detection blink. Saves and restores the current LED state
|
||||
// so it doesn't clobber upload/no-wifi indicators. Total duration: ~60ms per
|
||||
// pulse + 80ms gap between pulses.
|
||||
static void led_blink_pattern(int pulses) {
|
||||
bool prev = digitalRead(LED_PIN);
|
||||
for (int i = 0; i < pulses; i++) {
|
||||
led_set(true);
|
||||
vTaskDelay(pdMS_TO_TICKS(60));
|
||||
led_set(false);
|
||||
if (i < pulses - 1) vTaskDelay(pdMS_TO_TICKS(80));
|
||||
}
|
||||
led_set(prev);
|
||||
}
|
||||
|
||||
static void check_factory_reset() {
|
||||
if (digitalRead(BUTTON_PIN) != LOW) return;
|
||||
uint32_t held = millis();
|
||||
while (digitalRead(BUTTON_PIN) == LOW) {
|
||||
if (millis() - held >= FACTORY_RESET_HOLD_MS) {
|
||||
event_log_write(EVT_REBOOT, REBOOT_FACTORY_RESET, 0);
|
||||
config_clear_wifi();
|
||||
ESP.restart();
|
||||
}
|
||||
delay(50);
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Camera + CV task — runs on core 1 at 5 fps
|
||||
static void task_camera(void*) {
|
||||
static uint8_t frame[CV_PIXELS]; // static: avoids 9KB on task stack
|
||||
int last_logged_track_id = 0; // diagnostic: log each new track once
|
||||
esp_task_wdt_add(nullptr);
|
||||
while (true) {
|
||||
if (camera_capture_96(frame)) {
|
||||
if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
CVResult r = cv_process(g_cv, frame);
|
||||
if (r.entries_delta) Serial.printf("[CV] entry +%d (total %d)\n", r.entries_delta, g_cv.entries);
|
||||
if (r.exits_delta) Serial.printf("[CV] exit +%d (total %d)\n", r.exits_delta, g_cv.exits);
|
||||
CVResult r = cv_process(g_cv, frame, g_cfg.line_offset);
|
||||
for (const auto& t : g_cv.tracks) {
|
||||
if (t.id > last_logged_track_id) {
|
||||
last_logged_track_id = t.id;
|
||||
Serial.printf("[CV] spawn id=%d y=%.1f\n", t.id, t.spawn_y);
|
||||
}
|
||||
}
|
||||
if (r.fg_count > 0) {
|
||||
Serial.printf("[F] n=%d y=%d..%d c=%.1f\n",
|
||||
r.fg_count, r.fg_min_y, r.fg_max_y, r.fg_centroid_y);
|
||||
}
|
||||
if (r.entries_delta) Serial.printf("[CV] entry +%d (total %d) first=%.1f min=%.1f max=%.1f last=%.1f dur=%d\n",
|
||||
r.entries_delta, g_cv.entries,
|
||||
r.fire_first_c, r.fire_min_c, r.fire_max_c, r.fire_last_c, r.fire_duration);
|
||||
if (r.exits_delta) Serial.printf("[CV] exit +%d (total %d) first=%.1f min=%.1f max=%.1f last=%.1f dur=%d\n",
|
||||
r.exits_delta, g_cv.exits,
|
||||
r.fire_first_c, r.fire_min_c, r.fire_max_c, r.fire_last_c, r.fire_duration);
|
||||
xSemaphoreGive(s_cv_mutex);
|
||||
if (r.entries_delta) led_blink_pattern(1);
|
||||
if (r.exits_delta) led_blink_pattern(2);
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(CAM_INTERVAL_MS));
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Hourly reporter task — runs on core 0
|
||||
static void task_reporter(void*) {
|
||||
uint32_t last_report_ts = 0; // 0 = not initialized yet
|
||||
esp_task_wdt_add(nullptr);
|
||||
|
||||
while (true) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10000)); // check every 10s
|
||||
esp_task_wdt_reset();
|
||||
|
||||
uint32_t now = (uint32_t)(time(nullptr));
|
||||
if (now < 1700000000UL) continue; // NTP not synced
|
||||
|
||||
// First valid timestamp — schedule boot report 60s from now
|
||||
if (last_report_ts == 0) {
|
||||
event_log_write(EVT_NTP_SYNC, (uint16_t)(millis() / 1000), 0);
|
||||
last_report_ts = now - (REPORT_INTERVAL_S - BOOT_REPORT_DELAY_S);
|
||||
continue;
|
||||
}
|
||||
@@ -120,10 +138,24 @@ static void task_reporter(void*) {
|
||||
|
||||
reporter_submit_camera(g_cfg, cam_rec);
|
||||
reporter_submit_ble(g_cfg, ble_rec);
|
||||
reporter_heartbeat(g_cfg, millis() / 1000, WiFi.RSSI());
|
||||
bool hb_ok = reporter_heartbeat(g_cfg, millis() / 1000, WiFi.RSSI());
|
||||
|
||||
ble_scanner_reinit();
|
||||
led_set(false);
|
||||
|
||||
static uint8_t consecutive_misses = 0;
|
||||
if (hb_ok) {
|
||||
consecutive_misses = 0;
|
||||
} else {
|
||||
consecutive_misses++;
|
||||
event_log_write(EVT_HEARTBEAT_MISS, consecutive_misses, 0);
|
||||
Serial.printf("[WDG] heartbeat miss %u/6\n", consecutive_misses);
|
||||
if (consecutive_misses >= 6) {
|
||||
event_log_write(EVT_REBOOT, REBOOT_HEARTBEAT_MISS, 0);
|
||||
delay(200); // let NVS commit before reboot
|
||||
ESP.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,14 +165,26 @@ void setup() {
|
||||
pinMode(BUTTON_PIN, INPUT_PULLUP);
|
||||
led_set(true); // on = booting
|
||||
|
||||
event_log_init();
|
||||
event_log_write(EVT_BOOT, (uint16_t)esp_reset_reason(), 0);
|
||||
|
||||
if (!config_load(g_cfg)) {
|
||||
Serial.println("FATAL: device_id/location_id/hmac_secret not provisioned");
|
||||
while (true) { delay(500); led_set(!digitalRead(LED_PIN)); } // fast blink
|
||||
event_log_write(EVT_REBOOT, REBOOT_FATAL_CONFIG, 0);
|
||||
// Blink fast for 3s so a physically-present operator can see it,
|
||||
// then reboot so EVT_BOOT history on the next heartbeat surfaces
|
||||
// the failure — though in this case the device can't heartbeat
|
||||
// without config, so the real signal is the fast-blink-then-reboot
|
||||
// cycle visible on the LED.
|
||||
uint32_t t0 = millis();
|
||||
while (millis() - t0 < 3000) { led_set(!digitalRead(LED_PIN)); delay(100); }
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
// Connect to WiFi
|
||||
if (!config_has_wifi()) {
|
||||
provisioning_run();
|
||||
event_log_write(EVT_REBOOT, REBOOT_WIFI_REPROV, 0);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
@@ -154,24 +198,24 @@ void setup() {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
// Saved creds failed — re-provision
|
||||
provisioning_run();
|
||||
event_log_write(EVT_REBOOT, REBOOT_WIFI_REPROV, 0);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
net_guard_start(g_cfg);
|
||||
led_set(false); // off = connected
|
||||
|
||||
// NTP sync (UTC)
|
||||
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
|
||||
|
||||
cv_init(g_cv);
|
||||
if (config_load_tuning(g_cv.tuning)) {
|
||||
Serial.printf("[CFG] tuning loaded from NVS, cfg_version=%u\n", g_cv.tuning.cfg_version);
|
||||
} else {
|
||||
Serial.println("[CFG] no persisted tuning, using defaults");
|
||||
}
|
||||
|
||||
if (!camera_init()) {
|
||||
Serial.println("FATAL: camera init failed");
|
||||
while (true) delay(1000);
|
||||
event_log_write(EVT_REBOOT, REBOOT_FATAL_CAMERA, 0);
|
||||
uint32_t t0 = millis();
|
||||
while (millis() - t0 < 3000) { led_set(!digitalRead(LED_PIN)); delay(100); }
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
reporter_init();
|
||||
@@ -181,28 +225,37 @@ void setup() {
|
||||
// OTA update support
|
||||
ArduinoOTA.setHostname(g_cfg.device_id.c_str());
|
||||
ArduinoOTA.onStart([]() { ble_scanner_pause(); });
|
||||
ArduinoOTA.onEnd([]() { ble_scanner_resume(); ESP.restart(); });
|
||||
ArduinoOTA.onEnd([]() {
|
||||
ble_scanner_resume();
|
||||
event_log_write(EVT_REBOOT, REBOOT_OTA, 0);
|
||||
ESP.restart();
|
||||
});
|
||||
ArduinoOTA.onError([](ota_error_t e) { ble_scanner_resume(); });
|
||||
ArduinoOTA.begin();
|
||||
|
||||
s_cv_mutex = xSemaphoreCreateMutex();
|
||||
|
||||
// Task watchdog: 30s timeout, panic on trigger so we reboot and log
|
||||
// via esp_reset_reason() in EVT_BOOT on the next boot.
|
||||
esp_task_wdt_init(30, /*panic=*/true);
|
||||
esp_task_wdt_add(nullptr); // subscribe the Arduino loopTask
|
||||
|
||||
xTaskCreatePinnedToCore(task_camera, "cam", 8192, nullptr, 2, nullptr, 1);
|
||||
xTaskCreatePinnedToCore(task_reporter, "rep", 8192, nullptr, 1, nullptr, 0);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
esp_task_wdt_reset();
|
||||
ArduinoOTA.handle();
|
||||
check_factory_reset();
|
||||
net_guard_tick();
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
led_set(true); // on = no WiFi
|
||||
WiFi.reconnect();
|
||||
delay(5000);
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
led_set(false);
|
||||
reporter_flush(g_cfg);
|
||||
}
|
||||
static bool s_was_up = true;
|
||||
bool up = net_guard_is_up();
|
||||
if (up != s_was_up) {
|
||||
led_set(!up); // LED on when NOT up
|
||||
if (up) reporter_flush(g_cfg);
|
||||
s_was_up = up;
|
||||
}
|
||||
delay(1000);
|
||||
delay(200);
|
||||
}
|
||||
|
||||
64
firmware/src/main_capture.cpp
Normal file
64
firmware/src/main_capture.cpp
Normal file
@@ -0,0 +1,64 @@
|
||||
// firmware/src/main_capture.cpp
|
||||
//
|
||||
// Frame-dump firmware. Replaces main.cpp when building env:timercam-capture.
|
||||
// Streams raw 96x96 grayscale frames at 5 fps over serial (921600 baud) for
|
||||
// offline algorithm iteration.
|
||||
//
|
||||
// Wire format per frame (little-endian):
|
||||
// magic uint32 0xDC0FC0DE
|
||||
// frame_ix uint32 monotonic counter
|
||||
// millis uint32 ms since boot
|
||||
// pixels byte[9216] raw grayscale 96x96, row-major
|
||||
//
|
||||
// No WiFi, no BLE, no CV. Just camera → serial.
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "camera.h"
|
||||
#include "cv.h" // for CV_PIXELS
|
||||
|
||||
#define LED_PIN 2
|
||||
#define CAM_FPS 5
|
||||
#define CAM_INTERVAL_MS (1000 / CAM_FPS)
|
||||
|
||||
// Magic chosen from bytes that commonly survive; 'FRM1' ascii.
|
||||
// Avoid high bytes 0xA0-AF / 0xD0-DF — observed missing from the CH9102 stream.
|
||||
static const uint32_t FRAME_MAGIC = 0x314D5246; // 'FRM1' little-endian on wire
|
||||
|
||||
void setup() {
|
||||
Serial.begin(460800);
|
||||
pinMode(LED_PIN, OUTPUT);
|
||||
digitalWrite(LED_PIN, HIGH);
|
||||
|
||||
delay(500);
|
||||
Serial.println("# capture-mode: 460800 baud, 96x96 gray @ 5fps");
|
||||
Serial.flush();
|
||||
|
||||
if (!camera_init()) {
|
||||
Serial.println("# FATAL: camera init failed");
|
||||
while (true) {
|
||||
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
|
||||
delay(200);
|
||||
}
|
||||
}
|
||||
|
||||
digitalWrite(LED_PIN, LOW);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
static uint8_t frame[CV_PIXELS];
|
||||
static uint32_t frame_ix = 0;
|
||||
uint32_t t0 = millis();
|
||||
|
||||
if (camera_capture_96(frame)) {
|
||||
uint32_t ms = millis();
|
||||
Serial.write((uint8_t*)&FRAME_MAGIC, 4);
|
||||
Serial.write((uint8_t*)&frame_ix, 4);
|
||||
Serial.write((uint8_t*)&ms, 4);
|
||||
Serial.write(frame, CV_PIXELS);
|
||||
frame_ix++;
|
||||
digitalWrite(LED_PIN, frame_ix & 1);
|
||||
}
|
||||
|
||||
uint32_t elapsed = millis() - t0;
|
||||
if (elapsed < CAM_INTERVAL_MS) delay(CAM_INTERVAL_MS - elapsed);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
// firmware/src/reporter.cpp
|
||||
#include "reporter.h"
|
||||
#include "hmac.h"
|
||||
#include "cv_apply.h"
|
||||
#include "config.h"
|
||||
#include "event_log.h"
|
||||
#include "net_guard.h"
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <WiFi.h>
|
||||
#include <vector>
|
||||
#include <time.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <esp_task_wdt.h>
|
||||
#include <esp_system.h>
|
||||
#include <esp_heap_caps.h>
|
||||
|
||||
static std::vector<CameraHourlyRecord> s_cam_buf;
|
||||
static std::vector<BLEHourlyRecord> s_ble_buf;
|
||||
@@ -23,35 +26,48 @@ static uint32_t now_ts() {
|
||||
return (uint32_t)time(nullptr);
|
||||
}
|
||||
|
||||
// Returns HTTP status code on success, negative HTTPClient error code on
|
||||
// transport failure. Returns -1 if pre-flight checks (NTP, HMAC) fail.
|
||||
// If response_body is non-null and the request succeeded (2xx), captures
|
||||
// the response (truncated to 2048 chars).
|
||||
static int post_json(const DeviceConfig& cfg, const char* path,
|
||||
const String& body, String* response_body = nullptr) {
|
||||
static bool post_json_once(const DeviceConfig& cfg, const char* path, const String& body) {
|
||||
uint32_t ts = now_ts();
|
||||
// Reject if NTP hasn't synced yet (timestamp would be near epoch 0)
|
||||
if (ts < 1700000000UL) return -1; // pre-2023 → clock not valid
|
||||
if (ts < 1700000000UL) return false;
|
||||
String sig = hmac_sign(cfg.hmac_secret, "POST", path, ts, body);
|
||||
if (sig.isEmpty()) return -1; // HMAC failed
|
||||
if (sig.isEmpty()) return false;
|
||||
|
||||
HTTPClient http;
|
||||
String url = String(REPORTER_API_HOST) + path;
|
||||
http.begin(url);
|
||||
http.setConnectTimeout(5000); // DNS + TCP connect
|
||||
http.setTimeout(10000); // per-transaction response timeout
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
http.addHeader("X-Device-Id", cfg.device_id);
|
||||
http.addHeader("X-Timestamp", String(ts));
|
||||
http.addHeader("X-Signature", sig);
|
||||
|
||||
uint32_t t0 = millis();
|
||||
int code = http.POST(body);
|
||||
if (response_body && code >= 200 && code < 300) {
|
||||
String r = http.getString();
|
||||
if (r.length() > 2048) r.remove(2048);
|
||||
*response_body = r;
|
||||
}
|
||||
uint32_t elapsed = millis() - t0;
|
||||
http.end();
|
||||
Serial.printf("[HTTP] POST %s → %d\n", url.c_str(), code);
|
||||
return code;
|
||||
uint16_t phash = event_log_path_hash(path);
|
||||
Serial.printf("[HTTP] POST %s -> %d (%u ms)\n", url.c_str(), code, (unsigned)elapsed);
|
||||
if (code == 200) {
|
||||
event_log_write(EVT_HTTP_OK, phash, (uint16_t)((elapsed > 65535) ? 65535 : elapsed));
|
||||
return true;
|
||||
}
|
||||
event_log_write(EVT_HTTP_FAIL, phash, (uint16_t)code);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool post_json(const DeviceConfig& cfg, const char* path, const String& body) {
|
||||
// 3 attempts. Worst case per call: 3 × (5s connect + 10s response) + 0 + 2 + 5 = 52s.
|
||||
// TWDT is fed before the backoff delay and before each attempt so the 30s
|
||||
// timeout doesn't fire mid-sequence.
|
||||
static const uint16_t DELAYS_MS[] = { 0, 2000, 5000 };
|
||||
for (int i = 0; i < 3; i++) {
|
||||
esp_task_wdt_reset();
|
||||
if (DELAYS_MS[i]) vTaskDelay(pdMS_TO_TICKS(DELAYS_MS[i]));
|
||||
esp_task_wdt_reset();
|
||||
if (post_json_once(cfg, path, body)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static String build_camera_batch(const DeviceConfig& cfg,
|
||||
@@ -123,7 +139,7 @@ void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& r
|
||||
}
|
||||
|
||||
String body = build_camera_batch(cfg, batch);
|
||||
if (post_json(cfg, "/api/v1/camera/events/batch", body) != 200) {
|
||||
if (!post_json(cfg, "/api/v1/camera/events/batch", body)) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
s_cam_buf = batch; // re-buffer the whole capped batch
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
@@ -152,96 +168,43 @@ void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec) {
|
||||
}
|
||||
|
||||
String body = build_ble_batch(cfg, batch);
|
||||
if (post_json(cfg, "/api/v1/events/batch", body) != 200) {
|
||||
if (!post_json(cfg, "/api/v1/events/batch", body)) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
s_ble_buf = batch; // re-buffer the whole capped batch
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
}
|
||||
}
|
||||
|
||||
void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi) {
|
||||
bool reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi) {
|
||||
JsonDocument doc;
|
||||
doc["device_id"] = cfg.device_id;
|
||||
doc["firmware_version"] = "1.0.0";
|
||||
doc["firmware_version"] = "1.1.0";
|
||||
doc["free_storage_pct"] = 100;
|
||||
doc["wifi_rssi"] = wifi_rssi;
|
||||
doc["pending_records"] = (int)(s_cam_buf.size() + s_ble_buf.size());
|
||||
doc["uptime_seconds"] = uptime_s;
|
||||
|
||||
// Diagnostics (new in 1.1.0)
|
||||
doc["reset_reason"] = (int)esp_reset_reason();
|
||||
doc["heap_free"] = (int)esp_get_free_heap_size();
|
||||
doc["heap_min_free"] = (int)esp_get_minimum_free_heap_size();
|
||||
doc["last_disconnect_code"] = (int)net_guard_last_disconnect_reason();
|
||||
|
||||
// Last 8 event-log entries, newest first
|
||||
EventLogEntry recent[8];
|
||||
size_t n = event_log_read_recent(recent, 8);
|
||||
JsonArray evs = doc["recent_events"].to<JsonArray>();
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
JsonObject e = evs.add<JsonObject>();
|
||||
e["t"] = recent[i].tag;
|
||||
e["d0"] = recent[i].data0;
|
||||
e["d1"] = recent[i].data1;
|
||||
e["ts"] = recent[i].ts_unix;
|
||||
e["up"] = recent[i].uptime_s;
|
||||
}
|
||||
|
||||
String body; serializeJson(doc, body);
|
||||
|
||||
String resp;
|
||||
int code = post_json(cfg, "/api/v1/heartbeat", body, &resp);
|
||||
if (code != 200 || resp.isEmpty()) return;
|
||||
|
||||
JsonDocument rdoc;
|
||||
DeserializationError err = deserializeJson(rdoc, resp);
|
||||
if (err) {
|
||||
Serial.println("[CFG] bad JSON in heartbeat response");
|
||||
return;
|
||||
}
|
||||
JsonVariant cfgv = rdoc["config"];
|
||||
if (!cfgv.is<JsonObject>()) return; // no config pushed → silent no-op
|
||||
JsonObject obj = cfgv.as<JsonObject>();
|
||||
|
||||
if (!obj["cfg_version"].is<uint32_t>() && !obj["cfg_version"].is<int>()) {
|
||||
Serial.println("[CFG] missing cfg_version, skip");
|
||||
return;
|
||||
}
|
||||
uint32_t new_ver = obj["cfg_version"].as<uint32_t>();
|
||||
|
||||
CVTuning current;
|
||||
cv_get_tuning(current);
|
||||
if (new_ver <= current.cfg_version) {
|
||||
Serial.printf("[CFG] stale version %u (have %u), skip\n",
|
||||
(unsigned)new_ver, (unsigned)current.cfg_version);
|
||||
return;
|
||||
}
|
||||
|
||||
CVTuning candidate = current;
|
||||
candidate.cfg_version = new_ver;
|
||||
|
||||
// Present-but-wrong-type fields reject the whole update. Absent fields
|
||||
// (isNull == true) fall back to the current value. This prevents
|
||||
// cfg_version bumping while silently dropping malformed fields.
|
||||
bool bad_type = false;
|
||||
if (!obj["diff_thresh"].isNull()) {
|
||||
if (!obj["diff_thresh"].is<int>()) bad_type = true;
|
||||
else candidate.diff_thresh = (uint8_t)obj["diff_thresh"].as<int>();
|
||||
}
|
||||
if (!obj["min_blob_px"].isNull()) {
|
||||
if (!obj["min_blob_px"].is<int>()) bad_type = true;
|
||||
else candidate.min_blob_px = obj["min_blob_px"].as<int>();
|
||||
}
|
||||
if (!obj["max_move"].isNull()) {
|
||||
// max_move is float — accept int literal too (JSON 12 vs 12.0)
|
||||
if (!(obj["max_move"].is<float>() || obj["max_move"].is<double>()
|
||||
|| obj["max_move"].is<int>())) bad_type = true;
|
||||
else candidate.max_move = obj["max_move"].as<float>();
|
||||
}
|
||||
if (!obj["max_missed"].isNull()) {
|
||||
if (!obj["max_missed"].is<int>()) bad_type = true;
|
||||
else candidate.max_missed = obj["max_missed"].as<int>();
|
||||
}
|
||||
if (!obj["line_offset"].isNull()) {
|
||||
if (!obj["line_offset"].is<int>()) bad_type = true;
|
||||
else candidate.line_offset = (uint8_t)obj["line_offset"].as<int>();
|
||||
}
|
||||
if (bad_type) {
|
||||
Serial.printf("[CFG] rejected malformed config v=%u\n", (unsigned)new_ver);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cv_tuning_validate(candidate)) {
|
||||
Serial.printf("[CFG] rejected invalid config v=%u\n", (unsigned)new_ver);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config_save_tuning(candidate)) {
|
||||
Serial.printf("[CFG] rejected v=%u: NVS save failed\n", (unsigned)new_ver);
|
||||
return;
|
||||
}
|
||||
cv_apply_tuning(candidate);
|
||||
Serial.printf("[CFG] applied v=%u\n", (unsigned)new_ver);
|
||||
return post_json(cfg, "/api/v1/heartbeat", body);
|
||||
}
|
||||
|
||||
void reporter_flush(const DeviceConfig& cfg) {
|
||||
@@ -252,7 +215,7 @@ void reporter_flush(const DeviceConfig& cfg) {
|
||||
|
||||
if (!cam_snap.empty()) {
|
||||
String body = build_camera_batch(cfg, cam_snap);
|
||||
if (post_json(cfg, "/api/v1/camera/events/batch", body) == 200) {
|
||||
if (post_json(cfg, "/api/v1/camera/events/batch", body)) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
s_cam_buf.clear();
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
@@ -260,7 +223,7 @@ void reporter_flush(const DeviceConfig& cfg) {
|
||||
}
|
||||
if (!ble_snap.empty()) {
|
||||
String body = build_ble_batch(cfg, ble_snap);
|
||||
if (post_json(cfg, "/api/v1/events/batch", body) == 200) {
|
||||
if (post_json(cfg, "/api/v1/events/batch", body)) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
s_ble_buf.clear();
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
|
||||
@@ -17,5 +17,5 @@ static const char* REPORTER_API_HOST = "http://logs.research.bike";
|
||||
void reporter_init();
|
||||
void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& rec);
|
||||
void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec);
|
||||
void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi);
|
||||
bool reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi);
|
||||
void reporter_flush(const DeviceConfig& cfg);
|
||||
|
||||
@@ -7,261 +7,290 @@ static void fill_frame(uint8_t* f, uint8_t val) {
|
||||
memset(f, val, CV_PIXELS);
|
||||
}
|
||||
|
||||
// Draw a rectangular walker-blob spanning rows [y0, y1], columns [cx-hw, cx+hw].
|
||||
// Pixel value 200 over background 100 -> frame_diff threshold (30) is cleared.
|
||||
static void draw_walker(uint8_t* f, int y0, int y1, int cx, int hw) {
|
||||
fill_frame(f, 100);
|
||||
for (int y = y0; y <= y1; y++) {
|
||||
if (y < 0 || y >= CV_H) continue;
|
||||
for (int x = cx - hw; x <= cx + hw; x++) {
|
||||
if (x < 0 || x >= CV_W) continue;
|
||||
f[y * CV_W + x] = 200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void prime_bg(CVState& state) {
|
||||
uint8_t bg[CV_PIXELS];
|
||||
fill_frame(bg, 100);
|
||||
cv_process(state, bg, 50);
|
||||
}
|
||||
|
||||
// Let the event state machine see QUIET_FRAMES+1 empty frames so any active
|
||||
// event finalizes before the next test assertion.
|
||||
static void quiesce(CVState& state) {
|
||||
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
|
||||
for (int i = 0; i < CV_EVENT_QUIET_FRAMES + 1; i++) cv_process(state, bg, 50);
|
||||
}
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
void test_frame_diff_no_change_gives_no_fg() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
|
||||
uint8_t frame[CV_PIXELS];
|
||||
fill_frame(frame, 128);
|
||||
|
||||
CVResult r1 = cv_process(state, frame);
|
||||
void test_no_change_no_event() {
|
||||
CVState state; cv_init(state);
|
||||
uint8_t frame[CV_PIXELS]; fill_frame(frame, 128);
|
||||
CVResult r1 = cv_process(state, frame, 50);
|
||||
TEST_ASSERT_EQUAL_INT(0, r1.entries_delta);
|
||||
|
||||
CVResult r2 = cv_process(state, frame);
|
||||
CVResult r2 = cv_process(state, frame, 50);
|
||||
TEST_ASSERT_EQUAL_INT(0, r2.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(0, r2.exits_delta);
|
||||
}
|
||||
|
||||
void test_frame_diff_large_change_detected_no_crash() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
|
||||
uint8_t bg[CV_PIXELS], fg_frame[CV_PIXELS];
|
||||
fill_frame(bg, 100);
|
||||
fill_frame(fg_frame, 200);
|
||||
|
||||
cv_process(state, bg);
|
||||
CVResult r = cv_process(state, fg_frame);
|
||||
|
||||
// Tracking not yet implemented — just verify no crash and result is zero
|
||||
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
|
||||
}
|
||||
|
||||
void test_cv_init_clears_state() {
|
||||
CVState state;
|
||||
state.entries = 99; state.exits = 88;
|
||||
state.entries = 99; state.exits = 88; state.event_active = true;
|
||||
cv_init(state);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
TEST_ASSERT_FALSE(state.bg_valid);
|
||||
TEST_ASSERT_FALSE(state.event_active);
|
||||
}
|
||||
|
||||
void test_cv_reset_counts() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
state.entries = 5;
|
||||
state.exits = 3;
|
||||
CVState state; cv_init(state);
|
||||
state.entries = 5; state.exits = 3;
|
||||
cv_reset_counts(state);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
}
|
||||
|
||||
void test_tracking_spawns_track_for_new_blob() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
void test_walker_up_through_frame_is_entry() {
|
||||
// Simulate a walker traversing from bottom to top of frame.
|
||||
// Per-frame fg_count and centroid (11-wide column, height H -> n=11*H):
|
||||
// t0 y=60..95 n=396 c=77 <- event starts (n >= ENTER=300)
|
||||
// t1 y=30..95 n=726 c=62
|
||||
// t2 y=0..95 n=1056 c=47
|
||||
// t3 y=0..60 n=671 c=30
|
||||
// t4 y=0..25 n=286 c=12 (below EXIT=200, quiet=1)
|
||||
// t5 y=0..10 n=121 c=5 (below EXIT, quiet=2)
|
||||
// t6 empty quiet=3 -> finalize
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
uint8_t bg[CV_PIXELS];
|
||||
fill_frame(bg, 100);
|
||||
cv_process(state, bg); // init background
|
||||
|
||||
// Frame with a bright 30x30 blob in top-left quadrant
|
||||
uint8_t blob_frame[CV_PIXELS];
|
||||
fill_frame(blob_frame, 100);
|
||||
for (int y = 5; y < 35; y++)
|
||||
for (int x = 5; x < 35; x++)
|
||||
blob_frame[y * CV_W + x] = 200;
|
||||
|
||||
cv_process(state, blob_frame);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(1, (int)state.tracks.size());
|
||||
TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].x);
|
||||
TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].y);
|
||||
}
|
||||
|
||||
static void make_blob_frame(uint8_t* f, int cx, int cy) {
|
||||
fill_frame(f, 100);
|
||||
for (int y = cy - 12; y <= cy + 12; y++)
|
||||
for (int x = cx - 12; x <= cx + 12; x++)
|
||||
if (y >= 0 && y < CV_H && x >= 0 && x < CV_W)
|
||||
f[y * CV_W + x] = 200;
|
||||
}
|
||||
|
||||
void test_blob_crossing_line_top_to_bottom_is_entry() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
|
||||
// Line at 50% = y=48; step ≤14px per frame to stay within max_move (default 15)
|
||||
uint8_t bg[CV_PIXELS];
|
||||
fill_frame(bg, 100);
|
||||
cv_process(state, bg); // init background
|
||||
|
||||
// Walk blob from y=20 toward line; crossing occurs at y=48 (above→below)
|
||||
// Stop at crossing frame and assert its result
|
||||
int setup[] = {20, 34};
|
||||
for (int i = 0; i < 2; i++) {
|
||||
uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, setup[i]);
|
||||
cv_process(state, f);
|
||||
int rows[][2] = {{60,95},{30,95},{0,95},{0,60},{0,25},{0,10}};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, rows[i][0], rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 48);
|
||||
CVResult r = cv_process(state, fcross);
|
||||
quiesce(state);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(1, r.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
|
||||
TEST_ASSERT_EQUAL_INT(1, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
}
|
||||
|
||||
void test_walker_down_through_frame_is_exit() {
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
int rows[][2] = {{0,35},{0,65},{0,95},{35,95},{70,95},{85,95}};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, rows[i][0], rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(1, state.exits);
|
||||
}
|
||||
|
||||
void test_approach_retreat_without_full_extent_does_not_fire() {
|
||||
// Walker approaches from bottom, reaches y=30, retreats, never reaches top.
|
||||
// Extent gate requires min_y_seen <= 10; this event tops out at y=30 so
|
||||
// extent never clears and no fire occurs regardless of trajectory score.
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
int rows[][2] = {{60,95},{40,95},{30,95},{40,95},{60,95},{80,95}};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, rows[i][0], rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
}
|
||||
|
||||
void test_brief_burst_below_min_duration_does_not_fire() {
|
||||
// One frame of large fg, then gone. Event starts, immediately quiesces,
|
||||
// duration ends up below CV_EVENT_MIN_FRAMES.
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, 0, 95, 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
quiesce(state);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
}
|
||||
|
||||
void test_stationary_large_blob_does_not_fire() {
|
||||
// Static large blob in frame for many frames, then removed. Centroid
|
||||
// never moves -> MIN_TRAJ gate blocks fire.
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, 0, 95, 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
}
|
||||
|
||||
// Wait out the refractory period with bg-only frames so the next walker
|
||||
// event is accepted.
|
||||
static void wait_refractory(CVState& state) {
|
||||
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
|
||||
for (uint32_t i = 0; i < CV_EVENT_REFRACTORY_FRAMES + 2; i++) {
|
||||
cv_process(state, bg, 50);
|
||||
}
|
||||
}
|
||||
|
||||
void test_two_sequential_walkers_count_twice() {
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
int rows[][2] = {{60,95},{30,95},{0,95},{0,60},{0,25},{0,10}};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, rows[i][0], rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
wait_refractory(state);
|
||||
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, rows[i][0], rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(2, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
}
|
||||
|
||||
void test_full_reversal_counts_entry_then_exit() {
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
int up_rows[][2] = {{60,95},{30,95},{0,95},{0,60},{0,25},{0,10}};
|
||||
int down_rows[][2] = {{0,35},{0,65},{0,95},{35,95},{70,95},{85,95}};
|
||||
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, up_rows[i][0], up_rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
wait_refractory(state);
|
||||
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, down_rows[i][0], down_rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(1, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(1, state.exits);
|
||||
}
|
||||
|
||||
void test_refractory_suppresses_back_to_back_fire() {
|
||||
// After a fire, a second event attempted within CV_EVENT_REFRACTORY_FRAMES
|
||||
// is suppressed. Simulates walker lingering / ghost re-triggering.
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
int rows[][2] = {{60,95},{30,95},{0,95},{0,60},{0,25},{0,10}};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, rows[i][0], rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
TEST_ASSERT_EQUAL_INT(1, state.entries);
|
||||
|
||||
// Immediate second walker within refractory window — should NOT count.
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, rows[i][0], rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
TEST_ASSERT_EQUAL_INT(1, state.entries);
|
||||
}
|
||||
|
||||
void test_blob_crossing_line_bottom_to_top_is_exit() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
void test_event_counts_after_refractory_expires() {
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
|
||||
cv_process(state, bg);
|
||||
|
||||
// Walk blob from y=76 toward line; crossing occurs at y=34 (below→above)
|
||||
// Stop at crossing frame and assert its result
|
||||
int setup[] = {76, 62, 48};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, setup[i]);
|
||||
cv_process(state, f);
|
||||
int rows[][2] = {{60,95},{30,95},{0,95},{0,60},{0,25},{0,10}};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, rows[i][0], rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 34);
|
||||
CVResult r = cv_process(state, fcross);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(1, r.exits_delta);
|
||||
}
|
||||
|
||||
void test_cv_init_populates_tuning_defaults() {
|
||||
CVState state;
|
||||
// Pre-pollute to make sure cv_init overwrites
|
||||
state.tuning.diff_thresh = 0;
|
||||
state.tuning.min_blob_px = 0;
|
||||
state.tuning.max_move = 0.0f;
|
||||
state.tuning.max_missed = 0;
|
||||
state.tuning.line_offset = 0;
|
||||
state.tuning.cfg_version = 0xDEADBEEF;
|
||||
|
||||
cv_init(state);
|
||||
|
||||
// Values mirror CV_DEFAULT_* constants in cv.cpp (now file-local).
|
||||
TEST_ASSERT_EQUAL_UINT8(30, state.tuning.diff_thresh);
|
||||
TEST_ASSERT_EQUAL_INT(64, state.tuning.min_blob_px);
|
||||
TEST_ASSERT_EQUAL_FLOAT(15.0f, state.tuning.max_move);
|
||||
TEST_ASSERT_EQUAL_INT(10, state.tuning.max_missed);
|
||||
TEST_ASSERT_EQUAL_UINT8(50, state.tuning.line_offset);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, state.tuning.cfg_version);
|
||||
}
|
||||
|
||||
void test_cv_process_respects_runtime_min_blob() {
|
||||
// Proves cv_process reads min_blob_px from state.tuning at runtime
|
||||
// (not from a compile-time constant). With a very high threshold, the
|
||||
// same blob-producing frame that spawns a track in other tests must NOT
|
||||
// spawn one here.
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
state.tuning.min_blob_px = 10000; // larger than CV_PIXELS → no blob can qualify
|
||||
|
||||
uint8_t bg[CV_PIXELS];
|
||||
fill_frame(bg, 100);
|
||||
cv_process(state, bg); // init background
|
||||
|
||||
// Same 30x30 blob used by test_tracking_spawns_track_for_new_blob
|
||||
uint8_t blob_frame[CV_PIXELS];
|
||||
fill_frame(blob_frame, 100);
|
||||
for (int y = 5; y < 35; y++)
|
||||
for (int x = 5; x < 35; x++)
|
||||
blob_frame[y * CV_W + x] = 200;
|
||||
|
||||
CVResult r = cv_process(state, blob_frame);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, (int)state.tracks.size());
|
||||
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
|
||||
}
|
||||
|
||||
// Helper: init tuning to defaults (via cv_init) with cfg_version = 1
|
||||
static CVTuning make_default_tuning() {
|
||||
CVState s;
|
||||
cv_init(s);
|
||||
s.tuning.cfg_version = 1;
|
||||
return s.tuning;
|
||||
}
|
||||
|
||||
void test_cv_tuning_validate_accepts_defaults() {
|
||||
CVTuning t = make_default_tuning();
|
||||
TEST_ASSERT_TRUE(cv_tuning_validate(t));
|
||||
}
|
||||
|
||||
void test_cv_tuning_validate_rejects_zero_version() {
|
||||
CVTuning t = make_default_tuning();
|
||||
t.cfg_version = 0;
|
||||
TEST_ASSERT_FALSE(cv_tuning_validate(t));
|
||||
}
|
||||
|
||||
void test_cv_tuning_validate_rejects_each_boundary() {
|
||||
// diff_thresh: 5–120
|
||||
{ CVTuning t = make_default_tuning(); t.diff_thresh = 4; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.diff_thresh = 121; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
|
||||
// min_blob_px: 16–4096
|
||||
{ CVTuning t = make_default_tuning(); t.min_blob_px = 15; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.min_blob_px = 4097; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
|
||||
// max_move: 2.0–50.0
|
||||
{ CVTuning t = make_default_tuning(); t.max_move = 1.9f; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.max_move = 50.1f; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
|
||||
// max_missed: 1–60
|
||||
{ CVTuning t = make_default_tuning(); t.max_missed = 0; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.max_missed = 61; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
|
||||
// line_offset: 0–100 (uint8 so only upper bound is meaningful)
|
||||
{ CVTuning t = make_default_tuning(); t.line_offset = 101; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
|
||||
|
||||
// Sanity: inclusive mins/maxes still pass
|
||||
{ CVTuning t = make_default_tuning(); t.diff_thresh = 5; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.diff_thresh = 120; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.min_blob_px = 16; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.min_blob_px = 4096; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.max_move = 2.0f; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.max_move = 50.0f;TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.max_missed = 1; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.max_missed = 60; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.line_offset = 0; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
{ CVTuning t = make_default_tuning(); t.line_offset = 100; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
|
||||
}
|
||||
|
||||
void test_no_crossing_same_side_no_count() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
quiesce(state);
|
||||
TEST_ASSERT_EQUAL_INT(1, state.entries);
|
||||
|
||||
// Wait out the refractory period.
|
||||
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
|
||||
cv_process(state, bg);
|
||||
for (uint32_t i = 0; i < CV_EVENT_REFRACTORY_FRAMES + 2; i++) {
|
||||
cv_process(state, bg, 50);
|
||||
}
|
||||
|
||||
uint8_t f1[CV_PIXELS]; make_blob_frame(f1, 48, 20); // above line
|
||||
cv_process(state, f1);
|
||||
// Second walker — should now count.
|
||||
for (int i = 0; i < 6; i++) {
|
||||
uint8_t f[CV_PIXELS]; draw_walker(f, rows[i][0], rows[i][1], 48, 5);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
TEST_ASSERT_EQUAL_INT(2, state.entries);
|
||||
}
|
||||
|
||||
uint8_t f2[CV_PIXELS]; make_blob_frame(f2, 48, 30); // still above line, moved closer
|
||||
CVResult r = cv_process(state, f2);
|
||||
void test_noise_below_enter_thresh_does_not_start_event() {
|
||||
// Tiny 5x5 blob (25 px) never crosses ENTER=300, event never starts.
|
||||
CVState state; cv_init(state);
|
||||
prime_bg(state);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
|
||||
auto small = [](uint8_t* f, int cy) {
|
||||
fill_frame(f, 100);
|
||||
for (int y = cy-2; y <= cy+2; y++)
|
||||
for (int x = 46; x <= 50; x++)
|
||||
if (y>=0 && y<CV_H && x>=0 && x<CV_W) f[y*CV_W+x] = 200;
|
||||
};
|
||||
for (int cy = 10; cy <= 90; cy += 8) {
|
||||
uint8_t f[CV_PIXELS]; small(f, cy);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
quiesce(state);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_frame_diff_no_change_gives_no_fg);
|
||||
RUN_TEST(test_frame_diff_large_change_detected_no_crash);
|
||||
RUN_TEST(test_no_change_no_event);
|
||||
RUN_TEST(test_cv_init_clears_state);
|
||||
RUN_TEST(test_cv_reset_counts);
|
||||
RUN_TEST(test_tracking_spawns_track_for_new_blob);
|
||||
RUN_TEST(test_blob_crossing_line_top_to_bottom_is_entry);
|
||||
RUN_TEST(test_blob_crossing_line_bottom_to_top_is_exit);
|
||||
RUN_TEST(test_no_crossing_same_side_no_count);
|
||||
RUN_TEST(test_cv_init_populates_tuning_defaults);
|
||||
RUN_TEST(test_cv_process_respects_runtime_min_blob);
|
||||
RUN_TEST(test_cv_tuning_validate_accepts_defaults);
|
||||
RUN_TEST(test_cv_tuning_validate_rejects_zero_version);
|
||||
RUN_TEST(test_cv_tuning_validate_rejects_each_boundary);
|
||||
RUN_TEST(test_walker_up_through_frame_is_entry);
|
||||
RUN_TEST(test_walker_down_through_frame_is_exit);
|
||||
RUN_TEST(test_approach_retreat_without_full_extent_does_not_fire);
|
||||
RUN_TEST(test_brief_burst_below_min_duration_does_not_fire);
|
||||
RUN_TEST(test_stationary_large_blob_does_not_fire);
|
||||
RUN_TEST(test_two_sequential_walkers_count_twice);
|
||||
RUN_TEST(test_full_reversal_counts_entry_then_exit);
|
||||
RUN_TEST(test_refractory_suppresses_back_to_back_fire);
|
||||
RUN_TEST(test_event_counts_after_refractory_expires);
|
||||
RUN_TEST(test_noise_below_enter_thresh_does_not_start_event);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
141
firmware/test/test_event_log/test_event_log.cpp
Normal file
141
firmware/test/test_event_log/test_event_log.cpp
Normal file
@@ -0,0 +1,141 @@
|
||||
// firmware/test/test_native/test_event_log.cpp
|
||||
#include <unity.h>
|
||||
#include <string.h>
|
||||
#include "event_log.h"
|
||||
|
||||
// --- Native NVS stub (declared in event_log.cpp for native builds) ---
|
||||
extern "C" void event_log_test_reset();
|
||||
|
||||
void setUp() { event_log_test_reset(); }
|
||||
void tearDown() {}
|
||||
|
||||
void test_entry_is_32_bytes() {
|
||||
TEST_ASSERT_EQUAL(32, sizeof(EventLogEntry));
|
||||
}
|
||||
|
||||
void test_path_hash_is_stable_and_differs() {
|
||||
uint16_t a = event_log_path_hash("/api/v1/heartbeat");
|
||||
uint16_t b = event_log_path_hash("/api/v1/heartbeat");
|
||||
uint16_t c = event_log_path_hash("/api/v1/camera/events/batch");
|
||||
TEST_ASSERT_EQUAL(a, b);
|
||||
TEST_ASSERT_NOT_EQUAL(a, c);
|
||||
}
|
||||
|
||||
void test_write_then_read_recent_returns_newest_first() {
|
||||
event_log_init();
|
||||
event_log_write(EVT_BOOT, 1, 0);
|
||||
event_log_write(EVT_WIFI_UP, 2, 0);
|
||||
event_log_write(EVT_HTTP_FAIL, 3, 500);
|
||||
EventLogEntry buf[8];
|
||||
size_t n = event_log_read_recent(buf, 8);
|
||||
TEST_ASSERT_EQUAL(3, n);
|
||||
TEST_ASSERT_EQUAL(EVT_HTTP_FAIL, buf[0].tag);
|
||||
TEST_ASSERT_EQUAL(500, buf[0].data1);
|
||||
TEST_ASSERT_EQUAL(EVT_WIFI_UP, buf[1].tag);
|
||||
TEST_ASSERT_EQUAL(EVT_BOOT, buf[2].tag);
|
||||
}
|
||||
|
||||
void test_ring_buffer_wraps_after_32_entries() {
|
||||
event_log_init();
|
||||
for (int i = 0; i < 40; i++) event_log_write(EVT_HTTP_OK, (uint16_t)i, 0);
|
||||
EventLogEntry buf[32];
|
||||
size_t n = event_log_read_recent(buf, 32);
|
||||
TEST_ASSERT_EQUAL(32, n);
|
||||
// Newest first: data0 should be 39, 38, 37, ... down to 8
|
||||
TEST_ASSERT_EQUAL(39, buf[0].data0);
|
||||
TEST_ASSERT_EQUAL(8, buf[31].data0);
|
||||
}
|
||||
|
||||
void test_empty_log_read_returns_zero() {
|
||||
event_log_init();
|
||||
EventLogEntry buf[8];
|
||||
size_t n = event_log_read_recent(buf, 8);
|
||||
TEST_ASSERT_EQUAL(0, n);
|
||||
}
|
||||
|
||||
void test_read_recent_truncates_to_max_entries() {
|
||||
event_log_init();
|
||||
for (int i = 0; i < 10; i++) event_log_write(EVT_HTTP_OK, (uint16_t)i, 0);
|
||||
EventLogEntry buf[3];
|
||||
size_t n = event_log_read_recent(buf, 3);
|
||||
TEST_ASSERT_EQUAL(3, n);
|
||||
// Newest 3: data0 == 9, 8, 7
|
||||
TEST_ASSERT_EQUAL(9, buf[0].data0);
|
||||
TEST_ASSERT_EQUAL(8, buf[1].data0);
|
||||
TEST_ASSERT_EQUAL(7, buf[2].data0);
|
||||
}
|
||||
|
||||
void test_path_hash_distinguishes_real_api_paths() {
|
||||
uint16_t h1 = event_log_path_hash("/api/v1/heartbeat");
|
||||
uint16_t h2 = event_log_path_hash("/api/v1/camera/events/batch");
|
||||
uint16_t h3 = event_log_path_hash("/api/v1/events/batch");
|
||||
TEST_ASSERT_NOT_EQUAL(h1, h2);
|
||||
TEST_ASSERT_NOT_EQUAL(h1, h3);
|
||||
TEST_ASSERT_NOT_EQUAL(h2, h3);
|
||||
}
|
||||
|
||||
extern "C" void event_log_test_simulate_reboot();
|
||||
|
||||
void test_boot_recovery_after_partial_fill() {
|
||||
// Phase 1: write 5 entries before "reboot"
|
||||
event_log_init();
|
||||
for (uint16_t i = 0; i < 5; i++) event_log_write(EVT_HTTP_OK, i, 0);
|
||||
|
||||
// Phase 2: simulate reboot (clear RAM state, keep slots), re-init, verify
|
||||
event_log_test_simulate_reboot();
|
||||
event_log_init();
|
||||
|
||||
// All 5 original entries should still be readable, newest first
|
||||
EventLogEntry buf[8];
|
||||
size_t n = event_log_read_recent(buf, 8);
|
||||
TEST_ASSERT_EQUAL(5, n);
|
||||
TEST_ASSERT_EQUAL(4, buf[0].data0); // newest
|
||||
TEST_ASSERT_EQUAL(0, buf[4].data0); // oldest
|
||||
|
||||
// Phase 3: write one more — seq must continue (not restart at 0),
|
||||
// so the new entry is the newest and slot index 5 holds it
|
||||
event_log_write(EVT_HTTP_OK, 99, 0);
|
||||
n = event_log_read_recent(buf, 8);
|
||||
TEST_ASSERT_EQUAL(6, n);
|
||||
TEST_ASSERT_EQUAL(99, buf[0].data0);
|
||||
TEST_ASSERT_EQUAL(4, buf[1].data0);
|
||||
}
|
||||
|
||||
void test_boot_recovery_after_wrap() {
|
||||
// Phase 1: write 40 entries (wraps the 32-slot ring once; oldest 8 dropped)
|
||||
event_log_init();
|
||||
for (uint16_t i = 0; i < 40; i++) event_log_write(EVT_HTTP_OK, i, 0);
|
||||
|
||||
// Phase 2: simulate reboot, re-init
|
||||
event_log_test_simulate_reboot();
|
||||
event_log_init();
|
||||
|
||||
// Still 32 entries visible, newest=39, oldest=8
|
||||
EventLogEntry buf[32];
|
||||
size_t n = event_log_read_recent(buf, 32);
|
||||
TEST_ASSERT_EQUAL(32, n);
|
||||
TEST_ASSERT_EQUAL(39, buf[0].data0);
|
||||
TEST_ASSERT_EQUAL(8, buf[31].data0);
|
||||
|
||||
// Phase 3: one more write — newest becomes 100, head advances past
|
||||
// wherever the max-seq slot was, oldest drops to data0=9
|
||||
event_log_write(EVT_HTTP_OK, 100, 0);
|
||||
n = event_log_read_recent(buf, 32);
|
||||
TEST_ASSERT_EQUAL(32, n);
|
||||
TEST_ASSERT_EQUAL(100, buf[0].data0);
|
||||
TEST_ASSERT_EQUAL(9, buf[31].data0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_entry_is_32_bytes);
|
||||
RUN_TEST(test_path_hash_is_stable_and_differs);
|
||||
RUN_TEST(test_write_then_read_recent_returns_newest_first);
|
||||
RUN_TEST(test_ring_buffer_wraps_after_32_entries);
|
||||
RUN_TEST(test_empty_log_read_returns_zero);
|
||||
RUN_TEST(test_read_recent_truncates_to_max_entries);
|
||||
RUN_TEST(test_path_hash_distinguishes_real_api_paths);
|
||||
RUN_TEST(test_boot_recovery_after_partial_fill);
|
||||
RUN_TEST(test_boot_recovery_after_wrap);
|
||||
return UNITY_END();
|
||||
}
|
||||
32
firmware/test/test_net_guard/test_net_guard.cpp
Normal file
32
firmware/test/test_net_guard/test_net_guard.cpp
Normal file
@@ -0,0 +1,32 @@
|
||||
// firmware/test/test_net_guard/test_net_guard.cpp
|
||||
#include <unity.h>
|
||||
#include "net_guard.h"
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_backoff_starts_at_one_second() {
|
||||
TEST_ASSERT_EQUAL(1000, net_guard_next_backoff_ms(0));
|
||||
}
|
||||
|
||||
void test_backoff_doubles_each_attempt() {
|
||||
TEST_ASSERT_EQUAL(2000, net_guard_next_backoff_ms(1));
|
||||
TEST_ASSERT_EQUAL(4000, net_guard_next_backoff_ms(2));
|
||||
TEST_ASSERT_EQUAL(8000, net_guard_next_backoff_ms(3));
|
||||
TEST_ASSERT_EQUAL(16000, net_guard_next_backoff_ms(4));
|
||||
TEST_ASSERT_EQUAL(32000, net_guard_next_backoff_ms(5));
|
||||
}
|
||||
|
||||
void test_backoff_clamps_at_60s() {
|
||||
TEST_ASSERT_EQUAL(60000, net_guard_next_backoff_ms(6));
|
||||
TEST_ASSERT_EQUAL(60000, net_guard_next_backoff_ms(7));
|
||||
TEST_ASSERT_EQUAL(60000, net_guard_next_backoff_ms(100));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_backoff_starts_at_one_second);
|
||||
RUN_TEST(test_backoff_doubles_each_attempt);
|
||||
RUN_TEST(test_backoff_clamps_at_60s);
|
||||
return UNITY_END();
|
||||
}
|
||||
127
server/heartbeat_diagnostics_stub.py
Normal file
127
server/heartbeat_diagnostics_stub.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# server/heartbeat_diagnostics_stub.py
|
||||
# Add these models and the persistence helper to the server's main.py alongside
|
||||
# the existing heartbeat endpoint (POST /api/v1/heartbeat).
|
||||
# Requires: diagnostic columns on the heartbeats table (see migrations/005_heartbeat_diagnostics.sql)
|
||||
#
|
||||
# Firmware v1.1.0 extends the heartbeat payload with five optional diagnostic
|
||||
# fields. v1.0.0-shape payloads (without these fields) must continue to parse
|
||||
# cleanly — every new field is Optional and defaults to None.
|
||||
#
|
||||
# IMPORTANT: Adjust the table name in store_heartbeat_diagnostics to match the
|
||||
# real server's schema if it differs from "heartbeats".
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RecentEvent(BaseModel):
|
||||
t: int # EventLogTag (see EVENT_TAG_DECODER)
|
||||
d0: int # tag-specific datum 0
|
||||
d1: int # tag-specific datum 1
|
||||
ts: int # unix timestamp (seconds)
|
||||
up: int # seconds since boot when event was logged
|
||||
|
||||
|
||||
# Extend the existing HeartbeatRequest model in main.py by adding these five
|
||||
# optional fields. The rest of the heartbeat model (device_id, uptime, etc.)
|
||||
# stays as-is. Shown here as a standalone model for reference/testing.
|
||||
class HeartbeatDiagnosticsFields(BaseModel):
|
||||
reset_reason: Optional[int] = None
|
||||
heap_free: Optional[int] = None
|
||||
heap_min_free: Optional[int] = None
|
||||
last_disconnect_code: Optional[int] = None
|
||||
recent_events: Optional[List[RecentEvent]] = None
|
||||
|
||||
|
||||
# Example of the fully-extended heartbeat request model (merge into the
|
||||
# existing HeartbeatRequest in main.py rather than introducing a second class):
|
||||
class HeartbeatRequestWithDiagnostics(BaseModel):
|
||||
device_id: str
|
||||
uptime: int
|
||||
# ... existing fields from the v1.0.0 heartbeat model go here ...
|
||||
# New v1.1.0 diagnostic fields:
|
||||
reset_reason: Optional[int] = None
|
||||
heap_free: Optional[int] = None
|
||||
heap_min_free: Optional[int] = None
|
||||
last_disconnect_code: Optional[int] = None
|
||||
recent_events: Optional[List[RecentEvent]] = None
|
||||
|
||||
|
||||
# Call this inside the existing receive_heartbeat handler after the base
|
||||
# heartbeat row has been inserted/updated. It persists the diagnostic fields
|
||||
# on the same row keyed by device_id.
|
||||
def store_heartbeat_diagnostics(
|
||||
db: sqlite3.Connection,
|
||||
device_id: str,
|
||||
hb: HeartbeatRequestWithDiagnostics,
|
||||
) -> None:
|
||||
"""Persist the v1.1.0 diagnostic fields onto the heartbeats row for device_id.
|
||||
|
||||
recent_events is JSON-serialized into a TEXT column for flexibility;
|
||||
the other four fields are stored as INTEGERs. All fields are nullable
|
||||
and left untouched when the payload omits them (v1.0.0 compatibility).
|
||||
"""
|
||||
recent_events_json = (
|
||||
json.dumps([ev.model_dump() for ev in hb.recent_events])
|
||||
if hb.recent_events is not None
|
||||
else None
|
||||
)
|
||||
cursor = db.cursor()
|
||||
cursor.execute(
|
||||
"""UPDATE heartbeats
|
||||
SET reset_reason = ?,
|
||||
heap_free = ?,
|
||||
heap_min_free = ?,
|
||||
last_disconnect_code = ?,
|
||||
recent_events = ?
|
||||
WHERE device_id = ?""",
|
||||
(
|
||||
hb.reset_reason,
|
||||
hb.heap_free,
|
||||
hb.heap_min_free,
|
||||
hb.last_disconnect_code,
|
||||
recent_events_json,
|
||||
device_id,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decoders — use these in dashboards / alerting to label the integer tags the
|
||||
# firmware emits. Keep in sync with firmware/include/event_log.h.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# EventLogTag values (RecentEvent.t) -> human name.
|
||||
# Per-tag interpretation of d0/d1:
|
||||
# EVT_BOOT d0=esp_reset_reason()
|
||||
# EVT_WIFI_UP d0=RSSI (int16 cast to uint16)
|
||||
# EVT_WIFI_DOWN d0=disconnect reason (0xFF = silent-death)
|
||||
# EVT_HTTP_OK d0=path_hash, d1=elapsed_ms
|
||||
# EVT_HTTP_FAIL d0=path_hash, d1=http_status_or_errno
|
||||
# EVT_HEARTBEAT_MISS d0=consecutive_count
|
||||
# EVT_NTP_SYNC d0=seconds_since_boot (reserved, not emitted)
|
||||
# EVT_REBOOT d0=RebootReason (see REBOOT_REASON_DECODER)
|
||||
EVENT_TAG_DECODER = {
|
||||
1: "EVT_BOOT",
|
||||
2: "EVT_WIFI_UP",
|
||||
3: "EVT_WIFI_DOWN",
|
||||
4: "EVT_HTTP_OK",
|
||||
5: "EVT_HTTP_FAIL",
|
||||
6: "EVT_HEARTBEAT_MISS",
|
||||
7: "EVT_NTP_SYNC",
|
||||
8: "EVT_REBOOT",
|
||||
}
|
||||
|
||||
# EVT_REBOOT.d0 values -> human name. Firmware-initiated reboot reasons.
|
||||
REBOOT_REASON_DECODER = {
|
||||
1: "HEARTBEAT_MISS",
|
||||
2: "FACTORY_RESET",
|
||||
3: "OTA",
|
||||
4: "WIFI_REPROV",
|
||||
5: "FATAL_CONFIG",
|
||||
6: "FATAL_CAMERA",
|
||||
}
|
||||
14
server/migrations/005_heartbeat_diagnostics.sql
Normal file
14
server/migrations/005_heartbeat_diagnostics.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- migrations/005_heartbeat_diagnostics.sql
|
||||
-- Add v1.1.0 diagnostic columns to the existing heartbeats table.
|
||||
-- Adjust the table name ("heartbeats") to match the real server's schema.
|
||||
-- Apply: sqlite3 <db_file> < migrations/005_heartbeat_diagnostics.sql
|
||||
--
|
||||
-- sqlite's ALTER TABLE ADD COLUMN only takes one column per statement, so
|
||||
-- each field is added separately. All columns are nullable, so firmware
|
||||
-- v1.0.0 payloads (which omit these fields) remain accepted unchanged.
|
||||
|
||||
ALTER TABLE heartbeats ADD COLUMN reset_reason INTEGER;
|
||||
ALTER TABLE heartbeats ADD COLUMN heap_free INTEGER;
|
||||
ALTER TABLE heartbeats ADD COLUMN heap_min_free INTEGER;
|
||||
ALTER TABLE heartbeats ADD COLUMN last_disconnect_code INTEGER;
|
||||
ALTER TABLE heartbeats ADD COLUMN recent_events TEXT; -- JSON-serialized list of {t,d0,d1,ts,up}
|
||||
156
server/test_heartbeat_diagnostics_stub.py
Normal file
156
server/test_heartbeat_diagnostics_stub.py
Normal file
@@ -0,0 +1,156 @@
|
||||
# server/test_heartbeat_diagnostics_stub.py
|
||||
# Template tests for the heartbeat diagnostic-fields extension.
|
||||
# Adapt imports and fixtures to match the actual server's test structure.
|
||||
#
|
||||
# To run against the actual server (once integrated):
|
||||
# pytest server/test_heartbeat_diagnostics_stub.py -v
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _make_db() -> sqlite3.Connection:
|
||||
"""In-memory sqlite fixture matching migrations/005_heartbeat_diagnostics.sql
|
||||
applied on top of a minimal heartbeats table."""
|
||||
db = sqlite3.connect(":memory:")
|
||||
db.execute("""
|
||||
CREATE TABLE heartbeats (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
uptime INTEGER,
|
||||
reset_reason INTEGER,
|
||||
heap_free INTEGER,
|
||||
heap_min_free INTEGER,
|
||||
last_disconnect_code INTEGER,
|
||||
recent_events TEXT
|
||||
)
|
||||
""")
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
def _v10_payload() -> dict:
|
||||
"""Firmware v1.0.0-shape heartbeat: no diagnostic fields."""
|
||||
return {"device_id": "dc-test-01", "uptime": 12345}
|
||||
|
||||
|
||||
def _v11_payload() -> dict:
|
||||
"""Firmware v1.1.0-shape heartbeat: includes all five diagnostic fields."""
|
||||
return {
|
||||
"device_id": "dc-test-01",
|
||||
"uptime": 12345,
|
||||
"reset_reason": 1,
|
||||
"heap_free": 123456,
|
||||
"heap_min_free": 100000,
|
||||
"last_disconnect_code": 201,
|
||||
"recent_events": [
|
||||
{"t": 1, "d0": 1, "d1": 0, "ts": 1712000000, "up": 0},
|
||||
{"t": 3, "d0": 255, "d1": 0, "ts": 1712000050, "up": 50},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_v10_shape_parses_with_new_fields_none():
|
||||
"""A v1.0.0 heartbeat (no diagnostic fields) must parse cleanly; all new
|
||||
fields default to None."""
|
||||
from server.heartbeat_diagnostics_stub import HeartbeatRequestWithDiagnostics
|
||||
|
||||
hb = HeartbeatRequestWithDiagnostics(**_v10_payload())
|
||||
assert hb.device_id == "dc-test-01"
|
||||
assert hb.uptime == 12345
|
||||
assert hb.reset_reason is None
|
||||
assert hb.heap_free is None
|
||||
assert hb.heap_min_free is None
|
||||
assert hb.last_disconnect_code is None
|
||||
assert hb.recent_events is None
|
||||
|
||||
|
||||
def test_v11_shape_populates_new_fields():
|
||||
"""A v1.1.0 heartbeat populates each diagnostic field and the event list."""
|
||||
from server.heartbeat_diagnostics_stub import HeartbeatRequestWithDiagnostics
|
||||
|
||||
hb = HeartbeatRequestWithDiagnostics(**_v11_payload())
|
||||
assert hb.reset_reason == 1
|
||||
assert hb.heap_free == 123456
|
||||
assert hb.heap_min_free == 100000
|
||||
assert hb.last_disconnect_code == 201
|
||||
assert hb.recent_events is not None
|
||||
assert len(hb.recent_events) == 2
|
||||
assert hb.recent_events[0].t == 1
|
||||
assert hb.recent_events[1].t == 3
|
||||
assert hb.recent_events[1].d0 == 255 # 0xFF silent-death marker
|
||||
assert hb.recent_events[1].ts == 1712000050
|
||||
|
||||
|
||||
def test_store_heartbeat_diagnostics_writes_fields_and_json():
|
||||
"""store_heartbeat_diagnostics must JSON-serialize recent_events and write
|
||||
each integer field as submitted."""
|
||||
from server.heartbeat_diagnostics_stub import (
|
||||
HeartbeatRequestWithDiagnostics,
|
||||
store_heartbeat_diagnostics,
|
||||
)
|
||||
|
||||
db = _make_db()
|
||||
# Seed the heartbeats row the base handler would have inserted first.
|
||||
db.execute(
|
||||
"INSERT INTO heartbeats (device_id, uptime) VALUES (?, ?)",
|
||||
("dc-test-01", 12345),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
hb = HeartbeatRequestWithDiagnostics(**_v11_payload())
|
||||
store_heartbeat_diagnostics(db, "dc-test-01", hb)
|
||||
|
||||
row = db.execute(
|
||||
"""SELECT reset_reason, heap_free, heap_min_free,
|
||||
last_disconnect_code, recent_events
|
||||
FROM heartbeats
|
||||
WHERE device_id = ?""",
|
||||
("dc-test-01",),
|
||||
).fetchone()
|
||||
assert row[0] == 1
|
||||
assert row[1] == 123456
|
||||
assert row[2] == 100000
|
||||
assert row[3] == 201
|
||||
events = json.loads(row[4])
|
||||
assert isinstance(events, list)
|
||||
assert len(events) == 2
|
||||
assert events[0] == {"t": 1, "d0": 1, "d1": 0, "ts": 1712000000, "up": 0}
|
||||
assert events[1]["d0"] == 255
|
||||
|
||||
|
||||
def test_store_heartbeat_diagnostics_v10_leaves_fields_null():
|
||||
"""v1.0.0 payload: all diagnostic columns should remain NULL after store."""
|
||||
from server.heartbeat_diagnostics_stub import (
|
||||
HeartbeatRequestWithDiagnostics,
|
||||
store_heartbeat_diagnostics,
|
||||
)
|
||||
|
||||
db = _make_db()
|
||||
db.execute(
|
||||
"INSERT INTO heartbeats (device_id, uptime) VALUES (?, ?)",
|
||||
("dc-test-01", 12345),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
hb = HeartbeatRequestWithDiagnostics(**_v10_payload())
|
||||
store_heartbeat_diagnostics(db, "dc-test-01", hb)
|
||||
|
||||
row = db.execute(
|
||||
"""SELECT reset_reason, heap_free, heap_min_free,
|
||||
last_disconnect_code, recent_events
|
||||
FROM heartbeats
|
||||
WHERE device_id = ?""",
|
||||
("dc-test-01",),
|
||||
).fetchone()
|
||||
assert row == (None, None, None, None, None)
|
||||
|
||||
|
||||
def test_event_tag_decoder_labels():
|
||||
"""Sanity check: decoder maps firmware tag values to the expected names."""
|
||||
from server.heartbeat_diagnostics_stub import EVENT_TAG_DECODER, REBOOT_REASON_DECODER
|
||||
|
||||
assert EVENT_TAG_DECODER[1] == "EVT_BOOT"
|
||||
assert EVENT_TAG_DECODER[3] == "EVT_WIFI_DOWN"
|
||||
assert EVENT_TAG_DECODER[8] == "EVT_REBOOT"
|
||||
assert REBOOT_REASON_DECODER[1] == "HEARTBEAT_MISS"
|
||||
assert REBOOT_REASON_DECODER[4] == "WIFI_REPROV"
|
||||
105
tools/capture_frames.py
Normal file
105
tools/capture_frames.py
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
# tools/capture_frames.py
|
||||
#
|
||||
# Read framed 96x96 grayscale frames from the capture-mode firmware over serial
|
||||
# and write them to a .bin file for offline replay.
|
||||
#
|
||||
# Wire format per frame (little-endian):
|
||||
# magic u32 0xDC0FC0DE
|
||||
# frame_ix u32
|
||||
# millis u32
|
||||
# pixels 9216 bytes
|
||||
#
|
||||
# Output file is the raw concatenation of frames (same layout as the wire),
|
||||
# so replay_frames.py can stream it with identical parsing.
|
||||
#
|
||||
# Usage: python tools/capture_frames.py --port /dev/ttyUSB0 --out walk.bin --duration 60
|
||||
|
||||
import argparse
|
||||
import serial
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
MAGIC = 0x314D5246 # 'FRM1' — ascii bytes that survive the CH9102 stream
|
||||
FRAME_PIXELS = 96 * 96
|
||||
HEADER_LEN = 12
|
||||
FRAME_LEN = HEADER_LEN + FRAME_PIXELS
|
||||
|
||||
|
||||
def read_exact(ser, n):
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = ser.read(n - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def find_magic(ser):
|
||||
"""Scan serial byte-by-byte until we see the 4-byte MAGIC."""
|
||||
window = bytearray()
|
||||
magic_bytes = struct.pack('<I', MAGIC)
|
||||
while True:
|
||||
b = ser.read(1)
|
||||
if not b:
|
||||
return False
|
||||
window.extend(b)
|
||||
if len(window) > 4:
|
||||
del window[0]
|
||||
if bytes(window) == magic_bytes:
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--port', required=True)
|
||||
ap.add_argument('--baud', type=int, default=460800)
|
||||
ap.add_argument('--out', required=True)
|
||||
ap.add_argument('--duration', type=float, default=60.0,
|
||||
help='Seconds to capture (default 60)')
|
||||
args = ap.parse_args()
|
||||
|
||||
ser = serial.Serial(args.port, args.baud, timeout=1.0)
|
||||
print(f'# listening on {args.port} @ {args.baud} for {args.duration}s...',
|
||||
file=sys.stderr)
|
||||
|
||||
# Drain boot banner lines.
|
||||
deadline_banner = time.time() + 2.0
|
||||
while time.time() < deadline_banner:
|
||||
line = ser.readline()
|
||||
if line.startswith(b'#'):
|
||||
print(line.decode(errors='replace').rstrip(), file=sys.stderr)
|
||||
if b'capture-mode' in line:
|
||||
break
|
||||
|
||||
deadline = time.time() + args.duration
|
||||
frames = 0
|
||||
last_ix = None
|
||||
dropped = 0
|
||||
|
||||
with open(args.out, 'wb') as f:
|
||||
while time.time() < deadline:
|
||||
if not find_magic(ser):
|
||||
continue
|
||||
body = read_exact(ser, 8 + FRAME_PIXELS)
|
||||
if body is None:
|
||||
break
|
||||
frame_ix, ms = struct.unpack('<II', body[:8])
|
||||
if last_ix is not None and frame_ix != last_ix + 1:
|
||||
dropped += frame_ix - last_ix - 1
|
||||
last_ix = frame_ix
|
||||
f.write(struct.pack('<I', MAGIC))
|
||||
f.write(body)
|
||||
frames += 1
|
||||
if frames % 25 == 0:
|
||||
print(f'# {frames} frames, last ix={frame_ix} ms={ms} '
|
||||
f'dropped={dropped}', file=sys.stderr)
|
||||
|
||||
print(f'# done: {frames} frames written to {args.out} '
|
||||
f'({dropped} dropped)', file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
211
tools/replay_frames.py
Normal file
211
tools/replay_frames.py
Normal file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
# tools/replay_frames.py
|
||||
#
|
||||
# Offline Python port of the event-based CV detector (firmware/lib/cv/cv.cpp).
|
||||
# Reads a .bin file produced by capture_frames.py and prints events.
|
||||
#
|
||||
# Purpose: iterate algorithm changes in seconds instead of minutes. All
|
||||
# constants match cv.h so baseline behavior matches firmware.
|
||||
#
|
||||
# Usage:
|
||||
# python tools/replay_frames.py walk.bin
|
||||
# python tools/replay_frames.py walk.bin --enter 250 --exit 150 --max 25
|
||||
#
|
||||
# Output: one line per frame with fg diagnostics, plus [ENTRY]/[EXIT] lines
|
||||
# when the detector fires.
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
MAGIC = 0x314D5246 # 'FRM1'
|
||||
W = H = 96
|
||||
PIXELS = W * H
|
||||
HEADER = 12
|
||||
FRAME_LEN = HEADER + PIXELS
|
||||
|
||||
|
||||
class Detector:
|
||||
"""Mirror of firmware CV state machine. Single walker events, centroid
|
||||
trajectory direction. Only per-frame fg_count + min/max y + centroid y
|
||||
feed the decision — per-blob tracks are diagnostic in firmware, dropped
|
||||
here."""
|
||||
|
||||
def __init__(self, args):
|
||||
self.a = args
|
||||
self.bg = None
|
||||
self.ev_active = False
|
||||
self.ev_frames = 0
|
||||
self.ev_first_c = -1.0
|
||||
self.ev_last_c = -1.0
|
||||
self.ev_min_c = float(H)
|
||||
self.ev_max_c = -1.0
|
||||
self.ev_min_y = H
|
||||
self.ev_max_y = -1
|
||||
self.ev_quiet = 0
|
||||
self.last_fire = 0
|
||||
self.frame_ix = 0
|
||||
self.entries = 0
|
||||
self.exits = 0
|
||||
|
||||
def _reset_event(self):
|
||||
self.ev_active = False
|
||||
self.ev_frames = 0
|
||||
self.ev_first_c = self.ev_last_c = -1.0
|
||||
self.ev_min_c = float(H)
|
||||
self.ev_max_c = -1.0
|
||||
self.ev_min_y = H
|
||||
self.ev_max_y = -1
|
||||
self.ev_quiet = 0
|
||||
|
||||
def _finalize(self):
|
||||
a = self.a
|
||||
if self.ev_frames < a.min_frames: return None
|
||||
if self.ev_min_y > a.extent_top: return None
|
||||
if self.ev_max_y < a.extent_bot: return None
|
||||
up = self.ev_first_c - self.ev_min_c
|
||||
down = self.ev_max_c - self.ev_first_c
|
||||
winning = max(up, down)
|
||||
if winning < a.min_traj: return None
|
||||
is_entry = up >= down
|
||||
self.last_fire = self.frame_ix
|
||||
info = dict(
|
||||
kind='ENTRY' if is_entry else 'EXIT',
|
||||
first=self.ev_first_c, min=self.ev_min_c,
|
||||
max=self.ev_max_c, last=self.ev_last_c,
|
||||
dur=self.ev_frames,
|
||||
)
|
||||
if is_entry: self.entries += 1
|
||||
else: self.exits += 1
|
||||
return info
|
||||
|
||||
def step(self, frame):
|
||||
"""frame: uint8 array of shape (H, W). Returns list of fire dicts."""
|
||||
self.frame_ix += 1
|
||||
fires = []
|
||||
|
||||
if self.bg is None:
|
||||
self.bg = frame.astype(np.int16)
|
||||
return fires
|
||||
|
||||
bg = self.bg.astype(np.int16)
|
||||
diff = np.abs(frame.astype(np.int16) - bg)
|
||||
fg = (diff > self.a.diff_thresh).astype(np.uint8)
|
||||
|
||||
# Running-avg bg blend, frozen during active event.
|
||||
if not self.ev_active:
|
||||
self.bg = ((self.bg * 31 + frame.astype(np.int16)) >> 5)
|
||||
|
||||
fg_count = int(fg.sum())
|
||||
if fg_count > 0:
|
||||
row_counts = fg.sum(axis=1)
|
||||
ys = np.where(row_counts > 0)[0]
|
||||
min_y = int(ys.min())
|
||||
max_y = int(ys.max())
|
||||
centroid_y = float((row_counts * np.arange(H)).sum() / fg_count)
|
||||
else:
|
||||
min_y, max_y, centroid_y = -1, -1, -1.0
|
||||
|
||||
# Self-heal on catastrophic bg mismatch.
|
||||
if fg_count > PIXELS // 2:
|
||||
self.bg = frame.astype(np.int16)
|
||||
if self.ev_active: self._reset_event()
|
||||
return fires
|
||||
|
||||
a = self.a
|
||||
in_refractory = (self.last_fire != 0 and
|
||||
(self.frame_ix - self.last_fire) < a.refractory)
|
||||
|
||||
if not self.ev_active:
|
||||
if not in_refractory and fg_count >= a.enter_thresh:
|
||||
self.ev_active = True
|
||||
self.ev_frames = 1
|
||||
self.ev_first_c = centroid_y
|
||||
self.ev_last_c = centroid_y
|
||||
self.ev_min_c = centroid_y
|
||||
self.ev_max_c = centroid_y
|
||||
self.ev_min_y = min_y
|
||||
self.ev_max_y = max_y
|
||||
self.ev_quiet = 0
|
||||
else:
|
||||
self.ev_frames += 1
|
||||
if fg_count > 0:
|
||||
self.ev_last_c = centroid_y
|
||||
if centroid_y < self.ev_min_c: self.ev_min_c = centroid_y
|
||||
if centroid_y > self.ev_max_c: self.ev_max_c = centroid_y
|
||||
if min_y < self.ev_min_y: self.ev_min_y = min_y
|
||||
if max_y > self.ev_max_y: self.ev_max_y = max_y
|
||||
|
||||
ended = False
|
||||
if fg_count < a.exit_thresh:
|
||||
self.ev_quiet += 1
|
||||
if self.ev_quiet >= a.quiet_frames:
|
||||
ended = True
|
||||
else:
|
||||
self.ev_quiet = 0
|
||||
if self.ev_frames > a.max_frames:
|
||||
ended = True
|
||||
|
||||
if ended:
|
||||
fire = self._finalize()
|
||||
if fire: fires.append(fire)
|
||||
self._reset_event()
|
||||
self.bg = frame.astype(np.int16)
|
||||
|
||||
return fires, fg_count, min_y, max_y, centroid_y
|
||||
|
||||
|
||||
def iter_frames(path):
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
n = len(data) // FRAME_LEN
|
||||
for i in range(n):
|
||||
off = i * FRAME_LEN
|
||||
magic, ix, ms = struct.unpack('<III', data[off:off + HEADER])
|
||||
if magic != MAGIC:
|
||||
raise RuntimeError(f'bad magic at frame {i}: 0x{magic:08x}')
|
||||
frame = np.frombuffer(data, dtype=np.uint8,
|
||||
count=PIXELS, offset=off + HEADER).reshape(H, W)
|
||||
yield ix, ms, frame
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('path')
|
||||
ap.add_argument('--diff-thresh', dest='diff_thresh', type=int, default=30)
|
||||
ap.add_argument('--enter', dest='enter_thresh', type=int, default=300)
|
||||
ap.add_argument('--exit', dest='exit_thresh', type=int, default=200)
|
||||
ap.add_argument('--quiet', dest='quiet_frames', type=int, default=3)
|
||||
ap.add_argument('--min', dest='min_frames', type=int, default=5)
|
||||
ap.add_argument('--max', dest='max_frames', type=int, default=25)
|
||||
ap.add_argument('--extent-top', dest='extent_top', type=int, default=10)
|
||||
ap.add_argument('--extent-bot', dest='extent_bot', type=int, default=85)
|
||||
ap.add_argument('--min-traj', dest='min_traj', type=float, default=15.0)
|
||||
ap.add_argument('--refractory', dest='refractory', type=int, default=15)
|
||||
ap.add_argument('--quiet-log', action='store_true',
|
||||
help='Suppress per-frame fg lines')
|
||||
args = ap.parse_args()
|
||||
|
||||
det = Detector(args)
|
||||
total = 0
|
||||
for ix, ms, frame in iter_frames(args.path):
|
||||
total += 1
|
||||
out = det.step(frame)
|
||||
if out == []:
|
||||
if not args.quiet_log:
|
||||
print(f'[{ix:4d}] bg init')
|
||||
continue
|
||||
fires, fg, miny, maxy, cy = out
|
||||
if not args.quiet_log and fg > 0:
|
||||
print(f'[{ix:4d}] n={fg:4d} y={miny:2d}..{maxy:2d} c={cy:5.1f}')
|
||||
for fire in fires:
|
||||
print(f' >>> {fire["kind"]} first={fire["first"]:.1f} '
|
||||
f'min={fire["min"]:.1f} max={fire["max"]:.1f} '
|
||||
f'last={fire["last"]:.1f} dur={fire["dur"]}')
|
||||
print(f'\n# {total} frames entries={det.entries} exits={det.exits}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
186
tools/replay_logs.py
Normal file
186
tools/replay_logs.py
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
# tools/replay_logs.py
|
||||
#
|
||||
# Replay the event state machine against text serial logs captured from the
|
||||
# production firmware. Input lines of the form:
|
||||
# [F] n=<fg_count> y=<min_y>..<max_y> c=<centroid_y>
|
||||
#
|
||||
# Those four values are exactly what the firmware's event state machine
|
||||
# consumes — so we can iterate event-level params (thresholds, max_frames,
|
||||
# extent gates, trajectory cutoffs, refractory) offline without needing raw
|
||||
# frames or the device.
|
||||
#
|
||||
# Usage:
|
||||
# python tools/replay_logs.py walk.log
|
||||
# python tools/replay_logs.py walk.log --enter 250 --exit 100 --max 30 --min-traj 10
|
||||
# cat walk.log | python tools/replay_logs.py - --ground-truth 12
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
LINE_RE = re.compile(
|
||||
r'\[F\]\s+n=(?P<n>\d+)\s+y=(?P<miny>-?\d+)\.\.(?P<maxy>-?\d+)\s+c=(?P<c>-?\d+\.\d+)'
|
||||
)
|
||||
|
||||
|
||||
def parse_frames(text):
|
||||
"""Yield (fg_count, min_y, max_y, centroid_y) per [F] line, in order."""
|
||||
for line in text.splitlines():
|
||||
m = LINE_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
yield int(m['n']), int(m['miny']), int(m['maxy']), float(m['c'])
|
||||
|
||||
|
||||
class Detector:
|
||||
"""Mirror of firmware event state machine. Only uses per-frame diagnostic
|
||||
values — the same inputs the firmware feeds it."""
|
||||
|
||||
def __init__(self, a):
|
||||
self.a = a
|
||||
self.ev = False
|
||||
self.ev_n = 0
|
||||
self.ev_first = self.ev_last = -1.0
|
||||
self.ev_min = 1e9
|
||||
self.ev_max = -1.0
|
||||
self.ev_miny = 1e9
|
||||
self.ev_maxy = -1
|
||||
self.ev_quiet = 0
|
||||
self.last_fire = -10**9
|
||||
self.ix = 0
|
||||
self.entries = 0
|
||||
self.exits = 0
|
||||
self.fires = []
|
||||
|
||||
def _reset(self):
|
||||
self.ev = False
|
||||
self.ev_n = 0
|
||||
self.ev_first = self.ev_last = -1.0
|
||||
self.ev_min = 1e9; self.ev_max = -1.0
|
||||
self.ev_miny = 1e9; self.ev_maxy = -1
|
||||
self.ev_quiet = 0
|
||||
|
||||
def _finalize(self):
|
||||
a = self.a
|
||||
if self.ev_n < a.min_frames:
|
||||
return ('reject_short', None)
|
||||
if self.ev_miny > a.extent_top:
|
||||
return ('reject_extent_top', None)
|
||||
if self.ev_maxy < a.extent_bot:
|
||||
return ('reject_extent_bot', None)
|
||||
up = self.ev_first - self.ev_min
|
||||
down = self.ev_max - self.ev_first
|
||||
winning = max(up, down)
|
||||
if winning < a.min_traj:
|
||||
return ('reject_traj', None)
|
||||
timed_out = self.ev_n > a.max_frames
|
||||
if timed_out:
|
||||
is_entry = self.ev_last < self.ev_first
|
||||
else:
|
||||
is_entry = up >= down
|
||||
kind = 'ENTRY' if is_entry else 'EXIT'
|
||||
self.last_fire = self.ix
|
||||
info = dict(kind=kind, first=self.ev_first, min=self.ev_min,
|
||||
max=self.ev_max, last=self.ev_last, dur=self.ev_n,
|
||||
up=up, down=down, ix=self.ix)
|
||||
if is_entry: self.entries += 1
|
||||
else: self.exits += 1
|
||||
self.fires.append(info)
|
||||
return ('fire', info)
|
||||
|
||||
def step(self, n, miny, maxy, c):
|
||||
self.ix += 1
|
||||
a = self.a
|
||||
refractory = (self.ix - self.last_fire) < a.refractory
|
||||
|
||||
if not self.ev:
|
||||
if not refractory and n >= a.enter_thresh:
|
||||
self.ev = True
|
||||
self.ev_n = 1
|
||||
self.ev_first = self.ev_last = c
|
||||
self.ev_min = c; self.ev_max = c
|
||||
self.ev_miny = miny; self.ev_maxy = maxy
|
||||
self.ev_quiet = 0
|
||||
return None
|
||||
|
||||
self.ev_n += 1
|
||||
if n > 0:
|
||||
self.ev_last = c
|
||||
if c < self.ev_min: self.ev_min = c
|
||||
if c > self.ev_max: self.ev_max = c
|
||||
if miny < self.ev_miny: self.ev_miny = miny
|
||||
if maxy > self.ev_maxy: self.ev_maxy = maxy
|
||||
|
||||
ended = False
|
||||
if n < a.exit_thresh:
|
||||
self.ev_quiet += 1
|
||||
if self.ev_quiet >= a.quiet_frames:
|
||||
ended = True
|
||||
reason = 'quiet'
|
||||
else:
|
||||
self.ev_quiet = 0
|
||||
if self.ev_n > a.max_frames:
|
||||
ended = True
|
||||
reason = 'timeout'
|
||||
|
||||
if ended:
|
||||
result = self._finalize()
|
||||
self._reset()
|
||||
return (reason, result)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('path', help='log file, or - for stdin')
|
||||
ap.add_argument('--enter', dest='enter_thresh', type=int, default=300)
|
||||
ap.add_argument('--exit', dest='exit_thresh', type=int, default=200)
|
||||
ap.add_argument('--quiet', dest='quiet_frames', type=int, default=3)
|
||||
ap.add_argument('--min', dest='min_frames', type=int, default=5)
|
||||
ap.add_argument('--max', dest='max_frames', type=int, default=25)
|
||||
ap.add_argument('--extent-top', dest='extent_top', type=int, default=10)
|
||||
ap.add_argument('--extent-bot', dest='extent_bot', type=int, default=85)
|
||||
ap.add_argument('--min-traj', dest='min_traj', type=float, default=15.0)
|
||||
ap.add_argument('--refractory', dest='refractory', type=int, default=15)
|
||||
ap.add_argument('--ground-truth', type=int, default=0,
|
||||
help='Total expected walks for accuracy calc')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print every event end, including rejections')
|
||||
args = ap.parse_args()
|
||||
|
||||
text = sys.stdin.read() if args.path == '-' else open(args.path).read()
|
||||
|
||||
det = Detector(args)
|
||||
rejects = {}
|
||||
for n, miny, maxy, c in parse_frames(text):
|
||||
out = det.step(n, miny, maxy, c)
|
||||
if out is None:
|
||||
continue
|
||||
reason, result = out
|
||||
if result is None:
|
||||
continue
|
||||
kind, info = result
|
||||
if kind == 'fire':
|
||||
print(f' {info["kind"]:5} first={info["first"]:5.1f} '
|
||||
f'min={info["min"]:5.1f} max={info["max"]:5.1f} '
|
||||
f'last={info["last"]:5.1f} dur={info["dur"]:2d} '
|
||||
f'exit={reason}')
|
||||
else:
|
||||
rejects[kind] = rejects.get(kind, 0) + 1
|
||||
if args.verbose:
|
||||
print(f' [drop {kind}]')
|
||||
|
||||
total = det.entries + det.exits
|
||||
print(f'\n=== entries={det.entries} exits={det.exits} total={total} ===')
|
||||
print(f'rejected events: {rejects}')
|
||||
if args.ground_truth:
|
||||
gt = args.ground_truth
|
||||
acc = min(total, gt) / gt * 100
|
||||
over = max(0, total - gt)
|
||||
print(f'accuracy vs gt={gt}: {acc:.0f}% (over={over})')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
56
tools/serial_monitor.py
Executable file
56
tools/serial_monitor.py
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
# Serial monitor for ESP32. Optionally pulses RTS/DTR to reset the device
|
||||
# so we capture boot output. Prefixes each line with elapsed seconds.
|
||||
import serial, sys, time, argparse
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", default="/dev/ttyUSB0")
|
||||
ap.add_argument("--baud", type=int, default=115200)
|
||||
ap.add_argument("--seconds", type=int, default=20)
|
||||
ap.add_argument("--reset", action="store_true",
|
||||
help="Pulse RTS/DTR to reset the ESP32 before reading")
|
||||
ap.add_argument("--timestamp", action="store_true",
|
||||
help="Prefix each line with elapsed seconds since boot")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
s = serial.Serial(args.port, args.baud, timeout=0.2,
|
||||
rtscts=False, dsrdtr=False)
|
||||
except Exception as e:
|
||||
print(f"[open-fail] {e}", flush=True)
|
||||
sys.exit(2)
|
||||
|
||||
if args.reset:
|
||||
s.setDTR(False)
|
||||
s.setRTS(True)
|
||||
time.sleep(0.1)
|
||||
s.setRTS(False)
|
||||
s.reset_input_buffer()
|
||||
|
||||
t0 = time.time()
|
||||
end = t0 + args.seconds
|
||||
buf = b""
|
||||
while time.time() < end:
|
||||
chunk = s.read(512)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
text = line.decode("utf-8", errors="replace").rstrip("\r")
|
||||
if args.timestamp:
|
||||
sys.stdout.write(f"[{time.time()-t0:5.1f}s] {text}\n")
|
||||
else:
|
||||
sys.stdout.write(text + "\n")
|
||||
sys.stdout.flush()
|
||||
if buf:
|
||||
text = buf.decode("utf-8", errors="replace")
|
||||
if args.timestamp:
|
||||
sys.stdout.write(f"[{time.time()-t0:5.1f}s] {text}\n")
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
s.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user