ONVIF Device (server) library for ESP-IDF in plain C — expose a camera to NVRs over SOAP + WS-Discovery + Pull-Point events with zero third-party dependencies and a ~10 KB code footprint. Extracted from the production implementation in the MiBee Cam firmware (ESP32 family, 4 board variants), whose response XML is byte-stable against the MiBee NVR and field-proven with Hikvision-class clients.
English | 中文
- SOAP Device/Media service — the exact action set NVRs need to discover
and add a camera:
GetSystemDateAndTime,GetDeviceInformation,GetCapabilities,GetServices,GetScopes,SetSystemDateAndTime(ack),SystemReboot(protocol answer), deviceGetServiceCapabilities,GetProfiles,GetStreamUri,GetSnapshotUri,GetVideoSources, the video encoder configuration family (get/get-options/set-ack), guaranteed instance count,SetSynchronizationPoint(fires the optionalon_keyframeseam), and MediaGetServiceCapabilitieswith multicast explicitly off. - Pull-Point Events service —
tns1:VideoSource/MotionAlarmtopic (Source=CSI,State,Score 0-100); single subscription, 1 h granted TerminationTime, 120 s idle expiry, no long polling (PullMessages returns immediately — esp_http_server workers never block). - WS-Discovery responder — UDP 3702 / multicast 239.255.255.250;
answers Probe with unicast ProbeMatches, answers Resolve for its own
address with ResolveMatches, announces Hello every ~30 s, and sends a
multicast Bye from
onvif_c_stop()so NVRs drop stale XAddrs immediately. - Optional mDNS —
_onvif._tcpadvertisement; compiles out cleanly whenespressif/mdnsis not in the build. - No XML parser, no dynamic state — action detection via
strstr(), responses viasnprintf(); per-request buffers only; the motion producer hook is non-blocking and safe from sensor callback context. - Media2 minimal face (ver20, tr2) —
GetProfiles(MediaProfile with the ConfigurationSet form),GetStreamUri(the plainUriflavor),SetSynchronizationPoint(via theon_keyframeseam) on/onvif/media2_service, advertised inGetServices— the Profile-T entry path. Media1 stays the full-coverage surface. - Optional WS-Security UsernameToken (issue #17) — set
auth_passwordin the config and every action except the pre-authGetSystemDateAndTimemust carry a valid PasswordDigest token: self-contained SHA-1 + Base64 (no mbedtls), constant-time comparison, Created freshness window, bounded nonce replay cache, HTTP 401 +NotAuthorizedfault on rejection.auth_allow_password_textopts into the plaintext form (insecure without TLS). Absent by default — the open-LAN behavior is unchanged. - One-config integration seam — everything board-specific (identity, IP,
stream URI, runtime gates, HTTP port) stays behind
onvif_c_config_tcallbacks; nothing is hardcoded.
Vendor the tree (e.g. components/onvif-c), add onvif-c to main's
REQUIRES, then:
#include "onvif_c.h"
static const char *my_stream_uri(void) {
return "rtsp://192.0.2.134:554/stream"; /* or http://ip:81/stream */
}
void app_onvif_start(httpd_handle_t httpd) {
onvif_c_config_t cfg = {
.manufacturer = "MiBee",
.model = "MiBeeCam",
.hardware_id = "ESP32-S3-N16R8",
.firmware_version = "v0.1.0",
.serial = my_serial, /* stable hex string */
.uuid = my_uuid, /* no urn:uuid: prefix */
.ip = my_ip, /* NULL/"0.0.0.0" = not ready */
.stream_uri = my_stream_uri,
.frame_rate = my_fps, /* NULL -> 15 */
.events_enabled = my_events_gate, /* NULL = no events service */
.http_port = 80, /* 0 -> 80; used in every URI */
.mdns_hostname = "mibeecam-a1b2", /* NULL = skip mDNS */
};
onvif_c_start(httpd, &cfg);
}
/* From your motion detector (e.g. WiFi-CSI callback) — never blocks: */
onvif_c_motion(true, 87); /* MotionAlarm State=true Score=87 */
onvif_c_motion(false, 4);tools/onvif_probe.py <ip> is the no-hardware smoke test: it exercises every
served action and the full Pull-Point subscription cycle, exiting 0 on
success.
Full contracts are documented inline in
include/onvif_c.h — the summary:
| Function | Contract |
|---|---|
onvif_c_start(httpd, cfg) |
Registers /onvif/device_service + /onvif/media_service (+ /onvif/events_service when events_enabled is set), starts WS-Discovery (+ optional mDNS). Returns ESP_ERR_INVALID_ARG on missing required callbacks, the first registration error otherwise; re-registration after restart is tolerated (ESP_ERR_HTTPD_HANDLER_EXISTS logged and ignored). |
onvif_c_stop() |
Stops the discovery task and removes the mDNS service. SOAP handlers stay registered (esp_http_server has no unregister API). |
onvif_c_motion(active, score) |
Feed a motion transition. Never blocks (lock contention drops the event); no I/O; safe from sensor/CSI callback context. Events queue only while a subscription is alive AND events_enabled() returns true. |
onvif_c_events_subscribed() |
True while a Pull-Point subscription is alive (diagnostics). |
onvif_c_version() |
Returns ONVIF_C_VERSION (major*10000 + minor*100 + patch, e.g. v0.1.0 → 100). |
onvif_c_config_t fields (strings are referenced, not copied — they must
outlive the service):
| Field | Required | Default | Notes |
|---|---|---|---|
serial, uuid, ip, stream_uri |
yes (validated) | — | ip may answer NULL/"0.0.0.0" while connecting; discovery waits. uuid without urn:uuid: prefix. |
manufacturer, model, hardware_id, firmware_version |
no | "MiBee", "MiBeeCam", "ESP32", "v0.1.0" |
Identity strings used by GetDeviceInformation. |
frame_rate |
no | 15 | GetProfiles FrameRateLimit. |
snapshot_uri |
no | derived http://<ip>:<http_port>/api/capture |
GetSnapshotUri answer. |
events_enabled |
no | NULL = feature absent | Runtime gate; when NULL the events service is neither registered nor advertised. |
http_port |
no | 80 | Flows into every advertised URI. |
wdt_watch_discovery |
no | false | Subscribe the WS-Discovery task to the ESP-IDF task watchdog (CONFIG_ESP_TASK_WDT); a wedged discovery task stops feeding and the TWDT fires. |
mdns_hostname, mdns_instance |
no | NULL = skip mDNS | instance defaults to model. |
scopes |
no | built from model |
WS-Discovery Scopes body; resolved once at start. |
components/onvif-c— vendor this tree (anything undertests/,examples/,docs/,.github/may be dropped), addonvif-cto main'sREQUIRES.espressif/mdnsis optional: declare it to enable mDNS.- Write your port layer — every board fact stays there. A complete real-world
example is
main/onvif_port.cin the MiBee Cam firmware (~100 lines: identity from MAC/efuse, IP from the WiFi manager, stream URI from the RTSP server, a config-backed events gate). - Call
onvif_port_start()once your httpd server is up; feedonvif_c_motion()from your detector. - Verify with
tools/onvif_probe.py <ip>(exit 0 = full surface OK).
The four MiBee Cam repos (ESP32 + ESP32-S3, IDF v5.5/v6.0) carry this component in lockstep and are the upstream production users.
onvif-c does not implement ONVIF authentication. There is no
WS-UsernameToken / wsse header parsing, no HTTP basic/digest auth, no
credential callback, and no 401 path — every served action, the Pull-Point
subscription, and the WS-Discovery responder answer any host that can reach
the device. Deploy it only on a network where every host is trusted (or
behind a reverse proxy that enforces credentials), and note that an ONVIF
client configured with credentials may refuse to add the device. This is a
deliberate scope decision for a minimal device library, not an oversight; if
you need auth, treat it as a feature request for a future minor version.
Response element names, prefixes, attribute order and namespace style are
load-bearing — NVR integrators may match raw substrings. Device/Media
envelopes use soap:/tds:/trt:/tt: (lowercase utf-8 declaration),
Events envelopes use s:/tev:/wsnt: (uppercase UTF-8), matching the
field-proven firmware bytes. The exact output is pinned by the host golden
tests (tests/); a diff there is a behavior change, not a cosmetic one.
Namespace style intentionally differs between service families because each style is what real NVRs have been talking to in production — do not "unify" them.
Everything the library ships is developed test-first and gated in CI:
| Gate | Command | What it enforces |
|---|---|---|
| Host tests | tests/run.sh |
213 checks: core golden bytes + the full ESP-IDF port layer driven through stubs |
| Coverage | tests/coverage.sh |
≥80% line coverage over core/ + esp_idf/ (currently 95%) |
| Style | tools/check_style.sh |
clang-format clean (pinned clang-format==22.1.8, see .clang-format) |
| Hygiene | tools/check-repo-hygiene.sh |
no junk/secret files tracked |
The host harness (tests/) needs nothing but a C compiler and pthreads:
- Core goldens (
test_core.c) — pure C, pins every response byte. - Port layer (
test_service.c/test_events.c/test_discovery.c) — the realesp_idf/handlers against ESP-IDF stubs (tests/host_stubs/): a fake httpd capturing requests/responses, a fake clock (-Wl,--wrap=time) making subscription expiry deterministic, pthread tasks, and a virtual UDP network feeding WS-Discovery probes and capturing ProbeMatches/Hello — including socket/bind/membership failure retry paths.
Set up the pre-commit hook once per clone: tools/setup-hooks.sh.
- Core (
core/) is pure C with no ESP-IDF includes — host-testable with the systemcc; the ESP-IDF surface (esp_idf/) is a thin transport. - Builds on ESP-IDF v5.5.x and v6.0.x (both in CI).
- No logging outside
ESP_LOGx, noprintfin library code paths, no blocking in producer-context APIs. - No hardcoded endpoints:
cfg->http_portflows into every advertised URI (capabilities XAddrs, WS-Discovery XAddrs, subscription address); board specifics never leak into the library.
Semantic versioning; ONVIF_C_VERSION encodes it as
major*10000 + minor*100 + patch (read it at runtime with
onvif_c_version()). Compatibility rules:
- Pinned bytes: response XML of an unchanged action never changes within a major version — a golden-test diff is a release-blocking event.
- Core builder signatures (
core/*.h) are internal-stable: breaking changes bump the minor version; the publiconvif_c.hsurface aims to never break within a major. - Releases are tag-triggered (
v*); the release workflow re-runs the full host suite before publishing.
v0.1.0 — first release; API seam stable; production-tested daily at Mi-Bee Studio on four ESP32/ESP32-S3 camera boards against the MiBee NVR. Client counterpart (Go): onvif-go; sibling device library (Rust): onvif-rs.
Topic guides live in the docs hub: https://www.mlsbs.top/docs/mibeelibs
(docs/ here is a redirect only).
MIT — see LICENSE. Extracted from the MiBee Cam firmware (Mi-Bee Studio); the firmware repos continue under GPL-3.0-or-later with this component dual-licensed in place.