Configuration¶
DrowsyGuard has five places where behaviour is configured. Nothing else in the tree holds a tunable number.
| Layer | Lives in | Applied |
|---|---|---|
| Training | configs/*.yaml |
at train / evaluate / export-onnx |
| Firmware pins | firmware/esp32s3/main/board_*.h |
at compile time |
| Firmware behaviour | main.cpp, risk_filter.h, face_gate.h, presence.h, behavior.h |
at compile time |
| Build | sdkconfig.defaults |
at idf.py set-target |
| MQTT alerting | the device's NVS partition | at runtime, from the web UI |
The last row is the only one that is not in the repository, and that is deliberate: it holds a broker password. See Persisted settings.
Several of these are mirrored, and the mirrors are tested
behavior.h, face_gate.h, presence.h and risk_filter.h each have a Python
counterpart in src/drowsyguard/, because the dashboard is where thresholds get
tuned and a threshold tuned against different logic is worse than no tuning at
all. tests/test_firmware_parity.py compares the constants;
tests/test_facegate_parity.py and tests/test_presence.py go further and drive
both implementations through identical sequences, requiring identical answers.
Change one side only and ./plxy.sh test fails.
Plus the environment variables plxy.sh reads, and the
handful of runtime settings the device accepts over HTTP.
Training configuration¶
configs/train.yaml is the default; configs/train_ddd.yaml is the DDD variant.
seed: 42
image_size: 64
batch_size: 64
epochs: 20
learning_rate: 0.001
weight_decay: 0.0001
num_workers: 0
train_dir: data/processed/train
val_dir: data/processed/val
test_dir: data/processed/test
checkpoint: models/best.pt
onnx_output: models/drowsyguard.onnx
subject_split:
train: 0.70
val: 0.15
test: 0.15
class_names: [alert, drowsy]
| Field | Notes |
|---|---|
image_size |
side of the square grayscale input to the whole-face model |
num_workers |
0 is right for small sets; DDD uses 8 because PNG decoding dominates on CPU |
subject_split |
fractions of subjects, not of images — that is the whole point |
class_names |
order defines the label indices; alert is class 0 |
The DDD config differs only in batch_size: 128, epochs: 15, num_workers: 8
and the output paths (models/ddd_best.pt, models/drowsyguard_ddd.onnx).
Firmware pins¶
Pin assignments live in exactly two headers, and nowhere else in the firmware.
Camera — main/board_camera.h¶
| Signal | GPIO | Signal | GPIO | |
|---|---|---|---|---|
| XCLK | 15 | D7 (Y9) | 16 | |
| SIOD (SCCB SDA) | 4 | D6 (Y8) | 17 | |
| SIOC (SCCB SCL) | 5 | D5 (Y7) | 18 | |
| VSYNC | 6 | D4 (Y6) | 12 | |
| HREF | 7 | D3 (Y5) | 10 | |
| PCLK | 13 | D2 (Y4) | 8 | |
| PWDN | not routed | D1 (Y3) | 9 | |
| RESET | not routed (reset via SCCB) | D0 (Y2) | 11 |
Frame geometry and orientation live in the same header:
| Define | Default | Meaning |
|---|---|---|
CAM_FRAME_W / CAM_FRAME_H |
240 × 240 |
capture size |
CAM_RGB565_BYTE_SWAP |
1 |
the sensor's byte order vs. the DL stack's |
CAM_ROTATE_180 |
1 |
how the module is mounted |
CAM_SELFIE_MIRROR |
1 |
mirror the preview only |
Audio — main/board_audio.h¶
| Amplifier pin | GPIO |
|---|---|
| BCLK (bit clock) | 41 |
| LRC (word select) | 42 |
| DIN (data, S3 → amp) | 2 |
| Define | Default |
|---|---|
AUDIO_SAMPLE_RATE_HZ |
16000 |
AUDIO_TONE_AMPLITUDE |
0.35 |
Wiring these is step 6 of the hardware tutorial.
Firmware behaviour¶
Risk and timing — main/main.cpp¶
static constexpr float PERCLOS_WINDOW_S = 3.0f;
static constexpr float RISK_REQUIRED_S = 0.55f;
static constexpr float RISK_COOLDOWN_S = 4.0f;
static constexpr float RISK_TRIGGER = 0.55f;
These are durations in seconds, deliberately. RiskFilter counts frames, and
frame counts silently couple the alarm's sensitivity to the frame rate: 8 frames
is half a second at 15 fps and a third of a second at 25, so making the capture
loop faster made the alarm twitchier without anyone editing a threshold.
retune_for_fps() converts these durations to frame counts once a second from
the measured rate, so the intended half second stays half a second.
RISK_TRIGGER is a level of the fused behaviour score, not a raw model
probability. Tune it in the live dashboard and
paste it here.
The filter itself — main/risk_filter.h¶
Load-bearing text
tests/test_firmware_parity.py parses this constructor signature and
compares it with src/drowsyguard/risk.py. The defaults above are not just
defaults — changing one without the other fails the test suite. Run
./plxy.sh test after any edit.
MODEL_SELFTEST in main.cpp (default 0) runs the detector over the fixed
frames in test_frames.h at boot, which separates "the ESP-DL binding is wrong"
from "nobody was in front of the camera". Leave it off: /api/snapshot returns
the actual frame the detector was handed, which is strictly better.
The detection gate — main/face_gate.h¶
What counts as a face, and when a sequence of detections counts as a driver. Every
constant is mirrored in src/drowsyguard/facegate.py, and
tests/test_facegate_parity.py drives both implementations through the same
sequences and requires the same answers — so these cannot be changed on one side
alone.
| Constant | Default | What it rejects |
|---|---|---|
FACE_MIN_SCORE |
0.55 |
Weak candidates. The coarse detector stage runs at 0.10 on this camera (it has to — see model_adapter.cpp), so genuinely weak boxes arrive; a real face on this board scores 1.00. This is also what rejects most low-light false positives, because low light collapses confidence rather than distorting geometry. |
FACE_MIN_SIDE_FRAC / FACE_MAX_SIDE_FRAC |
0.10 / 0.95 |
Faces too small for the eye crop to contain an eye, and things up against the lens. At 240 px the floor is a 24 px box, whose eye patch is already at eyestate.py's 8 px minimum. |
FACE_ASPECT_MIN / FACE_ASPECT_MAX |
0.55 / 1.80 |
Boxes that are not head-shaped. This is what catches a raised hand even when its keypoints happen to land plausibly. |
FACE_YAW_MAX |
0.75 |
A nose outside the eye pair horizontally. No head pose does this; a landmark set fitted to a hand does it readily. |
FACE_MOUTH_MIN / FACE_MOUTH_MAX |
0.25 / 1.60 |
A collapsed mouth (a headrest — nothing in the image separates the corners) and a mouth wider than the head (a bright rectangle). |
FACE_EYE_DIST_*, FACE_MAX_ROLL_DEG, FACE_JAW_*, FACE_NOSE_FRAC_* |
see header | The original checks: interocular distance, tilt, and the mouth-below-eyes / nose-between ordering. |
Temporal, and these are the ones that decide presence:
| Constant | Default | Effect |
|---|---|---|
FACE_CONFIRM_DETECTIONS |
2 |
Consecutive accepted detections before a driver counts as present. One frame is not evidence: the static checks are geometric and a single frame of landmark noise on a non-face can satisfy all of them. At DETECT_EVERY = 3 and 15 fps this costs about 0.4 s at acquisition. |
FACE_HOLD_DETECTIONS |
5 |
Detection attempts a confirmed track survives with nothing accepted — about a second. This is what covers the moment the eyes close, which is when detectors drop faces and is exactly the moment of interest. |
FACE_JUMP_MAX_FRAC |
1.20 |
Centre movement between detections, as a fraction of box size. Refuses a candidate that has teleported: that is a different object, and accepting it is how a track steps off the driver onto a passenger. |
FACE_SCALE_MAX_RATIO |
1.80 |
Same idea for size. A face does not double in 200 ms. |
FACE_REACQUIRE_AFTER |
2 |
Misses after which a discontinuous candidate is allowed in — the driver may genuinely have moved. It starts a new, pending track: presence is re-earned, never inherited. |
FACE_GATE_ENFORCE (default 1) makes the checks advisory when set to 0: they
still run and still log, they just stop excluding. That is the first thing to try if
detection ever dies after a change here — it happened once, and the cause was a
mirrored frame rather than a bad limit. The escape hatch is built and tested, not a
branch nobody has run.
The no-driver alert — main/presence.h¶
| Constant | Default | Meaning |
|---|---|---|
PRESENCE_ALERT_S |
3.0 |
Continuous absence, after the tracking hold has already expired, before the alert fires. The wall-clock delay from a driver leaving is therefore the hold (~1 s) plus this. |
PRESENCE_CLEAR_S |
0.5 |
Continuous presence before the alert re-arms. Much shorter than the above, and it only has to outlast a single flickering detection — without it, one spurious detection every couple of seconds resets the countdown forever and the alert never fires, silently. |
PRESENCE_REPEAT_S |
0.0 |
Seconds between repeat announcements while the seat stays empty. Zero means exactly one per absence episode, which is the documented behaviour. Set it non-zero only where a continuously unattended camera is itself a fault condition. |
PRESENCE_WARMUP_S |
5.0 |
Healthy seconds required before any absence is believed. Covers boot, where the camera, the models and the auto-exposure are all still settling, and covers recovery from a fault for the same reason. |
The monitor takes a health argument as well as a presence boolean, and that is
the point of the module: a camera that has stopped and a cabin with nobody in it are
the same observation and opposite conclusions. In camera-fault or model-fault the
absence episode is discarded rather than frozen, so a countdown that started before
the fault cannot be resumed against a cabin that may now hold something else.
On the desktop these are runtime-settable — no_driver_after and no_driver_alert
on the dashboard's POST /config, which is how you turn the alert off for a bench
session where an empty seat is the normal state.
Wi-Fi — main/board_wifi.h¶
| Define | Default | Meaning |
|---|---|---|
WIFI_AP_SSID_PREFIX |
"DrowsyGuard" |
the MAC suffix is appended |
WIFI_AP_PASSWORD |
"drowsyguard" |
change this before any road test |
WIFI_AP_CHANNEL |
6 |
|
WIFI_AP_MAX_CLIENTS |
4 |
|
WIFI_STA_SSID |
"" |
set to join an existing network instead |
WIFI_STA_PASSWORD |
"" |
./plxy.sh wifi reads these straight out of the header, so it always prints what
the firmware will actually do. See Security.
WIFI_STA_SSID is a compile-time default, not the only route: station credentials
stored in NVS take precedence, and the Wi-Fi card on the device page writes them. That
is what MQTT needed — the board's own access point has no route to the internet, so a
device that could only be its own AP could only ever publish to something on the same
island, and a demonstration has to be able to point it at a phone hotspot in the room
without a rebuild.
The access point comes up either way and never comes down. A wrong hotspot password costs you the broker, never the dashboard and never the alarm.
Wi-Fi provisioning¶
Set from the device's own page and stored in NVS. Nothing here is compiled in — one of the values is somebody's home Wi-Fi password.
Full walkthrough: Using the device.
API: GET/POST /api/wifi.
| Setting | Default | Range / notes |
|---|---|---|
| enabled | off | Set by saving a network; cleared by Forget network or the BOOT button. Off means access-point-only, which is a working device |
| SSID | empty | ≤ 32 bytes, not characters — a name in Khmer or with an accent costs two or three bytes each. Any byte except a control character: 802.11 says an SSID is 32 arbitrary octets, and a validator that refused them would refuse the network and blame the operator |
| password | empty | empty (open network) or 8–63 characters of ASCII, which is what WPA2-PSK itself allows. 1–7 is always a typo, and the radio would otherwise reject it with a reason code that reads like a bad SSID |
| Constant | Value | Where |
|---|---|---|
WIFI_SCAN_MAX |
24 | wifi_provision.h. Networks kept from one scan, strongest first, de-duplicated by name. A busy office produces about fifteen |
WIFI_BUTTON_HOLD_MS |
5000 | wifi_provision.h. The BOOT hold that erases the credentials, with a warning at 2000 ms |
WIFI_BUTTON_ARM_AFTER_MS |
3000 | Nothing counts in the first three seconds after boot |
WIFI_BUTTON_STUCK_MS |
30000 | A press that never releases stops counting, so a wedged button cannot erase the credentials on every boot |
BUTTON_GPIO |
0 | board_button.h. The BOOT button; the only one on the board, and the application uses no other GPIO |
| retry backoff | 2 s → 60 s | board_wifi.cpp. Doubling with jitter, reset by a successful join. Reconnect now on the page skips the wait |
The reset button clears Wi-Fi and only Wi-Fi
settings_clear_wifi() erases one key by name from a namespace that holds four.
The broker settings, the device and fleet identity and the CA certificate survive
a physical reset, and the device does not reboot — the access point, the camera
and the detector keep running while it happens. There is deliberately no
factory-reset path on this button: an accidental five-second press must not cost
somebody their broker configuration.
MQTT alerting¶
Every field here is set from the device's web UI and stored in its NVS partition. None of it is compiled in, and none of it is in this repository — one of the values is a broker password.
Full walkthrough: Fleet alerting over MQTT. API:
POST /api/mqtt.
Connection¶
| Setting | Default | Range / notes |
|---|---|---|
| enabled | off | Off by default and deliberately: switching it on sends a named driver's alertness state to a third party, which has to be an act rather than an inherited setting |
| transport | tls |
tcp, tls, ws, wss. Changing it moves the port, but only when the port was still the default for the old transport |
| protocol | 3.1.1 |
or 5. Both are compiled in (CONFIG_MQTT_PROTOCOL_5) |
| host | broker.emqx.io |
hostname or IPv4, ≤ 95 chars. IPv6 is not supported and says so rather than failing at connect time |
| port | 8883 |
1–65535. EMQX: 1883 TCP, 8883 TLS, 8083 WS, 8084 WSS |
| WebSocket path | /mqtt |
required for ws/wss, must start with / |
| client ID | derived | drowsyguard-{device_id} when blank. Two devices sharing one knock each other off the broker in turn, and both look intermittently broken rather than misconfigured |
| username / password | empty | write-only through the API; an empty field on save keeps the stored value |
| QoS | 1 |
0 or 1. 2 is refused: the alert path is at-least-once with event-ID de-duplication at both ends, which is what QoS 2 would be paying for |
| keepalive | 30 s |
5–300 |
| Last Will | on | the retained online/offline document. The only way a dashboard can tell "no alerts because the driver is fine" from "no alerts because the device is in a tunnel" |
| skip TLS verification | off | encrypted but unauthenticated. Reachable on purpose, labelled in red everywhere — see Security |
| CA certificate | none | one PEM root, ≤ 4 kB. With none, TLS verifies against the Mozilla root bundle in flash |
Identity¶
| Setting | Default | Notes |
|---|---|---|
| device ID | a slug of the SoftAP name | e.g. drowsyguard-c5e019. Already MAC-derived, so unique per board with no provisioning step |
| fleet ID | demo-fleet |
the level a dashboard subscribes to with one wildcard |
| remark | Driver A |
printable ASCII, ≤ 47 chars. The only field that says who is being monitored, and not part of any topic |
device_id and fleet_id are constrained to lowercase letters, digits, -, _ and
., starting and ending alphanumeric. That is not pedantry: they become topic
segments, and a /, + or # in either one silently restructures the tree. A topic
is the one field where a wrong value does not fail — it goes somewhere else.
Topics¶
| Setting | Default |
|---|---|
| topic mode | auto |
| topic (manual mode) | empty |
Auto mode builds plxy/drowsyguard/{fleet-id}/{device-id}/alerts and /status, plus
the +-wildcard forms a dashboard subscribes to. Manual mode takes a topic verbatim,
refusing + and # (subscription syntax), a leading $ (broker-reserved), a leading
or trailing /, and empty levels. It also refuses a topic long enough that the
derived /status topic would not fit — the Last Will uses it, and a silently
truncated will topic means a retained message on a topic nobody subscribes to.
Wi-Fi station¶
Not part of POST /api/mqtt — it has its own form and its own endpoint, above under
Wi-Fi provisioning. One NVS record, one owner: two forms writing
it meant that saving the broker from a page opened before a network change quietly put
the old SSID back.
Not settable, on purpose¶
| Constant | Value | Where |
|---|---|---|
MQTT_OUTBOX_DEPTH |
16 | mqtt_config.h. About eight minutes of drowsiness alerts at the 30 s channel cooldown, and 1.6 kB. Past it the oldest is discarded: after twenty minutes offline what an operator needs is the last few minutes of a deteriorating driver, not the first |
MQTT_DEDUP_SLOTS |
24 | mqtt_config.h. Full strings rather than hashes — under a kilobyte, and a hash collision would silently drop a real alert |
MQTT_BACKOFF_BASE_MS / MQTT_BACKOFF_MAX_MS |
1000 / 60000 | mqtt_config.h. Doubling, capped, with up to 25 % additive jitter. esp-mqtt's own auto-reconnect is switched off in favour of it |
MQTT_TOPIC_ROOT |
plxy/drowsyguard |
mqtt_config.h. Two fixed levels so a shared broker cannot collide with us by accident |
MQTT_PAYLOAD_MAX |
768 | mqtt_config.h. A document that will not fit produces nothing rather than half of one |
These are compile-time because they are safety and memory bounds rather than
preferences, and because a bound that can drift at runtime cannot be reported in a
thesis. Every one of them is exercised by tests/test_mqtt_config.py.
Persisted settings¶
Three records and a certificate, in the NVS namespace dgsettings — kept separate
from voice_alert.cpp's drowsyguard namespace so that erasing one subsystem to
recover it cannot silently reset the others.
| Key | Holds |
|---|---|
device |
device ID, fleet ID, remark |
wifi |
station enabled, SSID, passphrase |
mqtt |
everything in Connection above except the certificate |
mqtt_ca |
the CA certificate, up to 4 kB |
Each record is a versioned byte stream with a magic and a CRC, not a struct
memcpy. A raw struct in flash is a promise never to reorder a field, never to change
a capacity and never to compile with different padding, and the first time one of
those is broken the device reads a plausible-looking hostname out of the middle of a
password. A record that fails any of the three checks — or that no longer passes
validation — is discarded and the defaults are used, which is also what a fresh board
does. tests/test_mqtt_config.py flips every byte of each record and requires
every one to be rejected.
NVS is not encrypted on this build: anybody holding the board holds the passwords.
./plxy.sh erase clears them.
Build configuration¶
firmware/esp32s3/sdkconfig.defaults seeds sdkconfig at
idf.py set-target esp32s3. Delete sdkconfig — not this file — to start over,
or run ./plxy.sh fullclean.
The settings that are not optional:
| Setting | Why |
|---|---|
CONFIG_SPIRAM=y, CONFIG_SPIRAM_MODE_OCT=y |
camera framebuffers (CAMERA_FB_IN_PSRAM) and the ESP-DL models both live in PSRAM. Get MODE_OCT wrong and the board boots but finds 0 bytes of PSRAM, which surfaces later as an unexplained esp_camera_init() failure |
CONFIG_ESPTOOLPY_FLASHSIZE_16MB |
the N16R8 module |
CONFIG_PARTITION_TABLE_CUSTOM |
partitions.csv |
CONFIG_OV5640_SUPPORT + CONFIG_OV3660_SUPPORT |
the board is sold with an OV3660 but ships an OV5640; both are pinned so a driver change cannot look like a dead camera |
CONFIG_MQTT_TRANSPORT_SSL, CONFIG_MQTT_TRANSPORT_WEBSOCKET, CONFIG_MQTT_TRANSPORT_WEBSOCKET_SECURE |
three of the four transports are behind Kconfig options that default to off, and a transport that is compiled out fails at connect time with a generic transport error rather than "not built" |
CONFIG_MQTT_PROTOCOL_5 |
MQTT 5 is offered in the settings modal, so it has to be in the binary |
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE |
the Mozilla root bundle in flash. This is what makes the preconfigured public broker reachable over TLS with verification and nothing pasted in — the alternative is shipping a default that only works with verification off |
The ov3660: Mismatch PID=0x5640 line on the way to a working camera is
expected, not a fault.
Environment variables¶
Read by plxy.sh:
| Variable | Default | Effect |
|---|---|---|
PLXY_PORT |
auto-detected | force a serial port, e.g. COM9 |
PLXY_HOST |
192.168.4.1 |
talk to the board over station mode |
PLXY_DOCS_PYTHON |
discovered | interpreter used for the docs commands |
PLXY_DOCS_VENV |
.venv-docs |
where the docs toolchain is installed |
PLXY_DOCS_PORT |
8001 |
docs-preview port — not 8000, which the live dashboard uses |
PLXY_DOCS_NO_VENV |
unset | set to 1 to use the ambient interpreter and never create a venv (this is what CI does) |
PLXY_DOCS_SITE_URL |
the published Pages URL | base URL the site is built for; docs-preview sets it to the local address so the preview serves at / |
Runtime settings¶
The only things settable on a running device, over HTTP:
| Setting | Range | Endpoint |
|---|---|---|
| JPEG quality | 10–95, higher is better | POST /api/settings?quality=N |
| Stream rate | 1–20 fps | POST /api/settings?fps=N |
| Mute | 0 / 1 |
POST /api/settings?muted=N |
| Alert language | en or km |
POST /api/settings?lang=xx |
| Everything in MQTT alerting | see the tables above | POST /api/mqtt (form-encoded body) |
| One test publish | — | POST /api/mqtt/test |
Risk thresholds are not runtime-settable — they are compiled in, by design: a threshold that can drift at runtime cannot be reported in a thesis. Full parameter detail in the Device HTTP API.
The desktop dashboard, which is a development tool rather than a device, is more
permissive: POST /config accepts every threshold plus no_driver_after and
no_driver_alert. See the
Dashboard HTTP API.