The camera you already own, and the bill attached to it
Most consumer cameras sold today work like this: the video goes to a company's servers, the useful features sit behind a monthly plan, and the device stops getting firmware the moment the product line is retired. You bought the hardware, but the footage and the feature set aren't really yours.
The alternative used to mean buying into an ecosystem anyway — a vendor NVR, a vendor app, a vendor account. What's actually missing is the boring middle layer: something that takes a camera sensor on a board you already own and presents it to the software you already run, using the protocols that software already speaks.
That's what MiBee Eye is. It turns any Linux board into an ONVIF Profile S device with RTSP streaming, and for installations that need it, a GB/T 28181 device. There are two independent implementations, Go and Rust. No account, no cloud round-trip, no subscription. Two terminal commands and a systemd unit.
What it presents to the rest of your network
| Port | What's there |
|---|---|
| 8080 | ONVIF device service (Device / Media / Imaging) + WS-Discovery |
| 8554 | RTSP, H.264 over TCP interleaved or UDP |
| 8088 | Embedded admin UI, English and Chinese |
- ONVIF Profile S device. Device, Media and Imaging SOAP services plus WS-Discovery multicast announcements, so an NVR finds it without a manual address entry. Against an ONVIF NVR it looks like an ordinary camera.
- GB/T 28181 device (optional path). SIP registration over UDP or TCP with digest auth, catalog and device-info queries, live/playback/download PS streams, SIP INFO playback control, platform snapshot commands. Relevant if you're integrating with a Chinese standards-based video platform rather than an ONVIF VMS.
- GB 35114 A-level (compile-time opt-in). SM2 certificate registration and keyed-SM3 integrity.
-
Continuous local recording. H.264 segments on disk with an
index.jsonlindex, retention days and a storage cap. This doubles as the source for GB28181 playback. -
RTMP push to a cloud ingest, and JPEG snapshots over
GET /snapshot. The Go build also carries a pure-Go MPEG-TS segmenter, so browsers get HLS without ffmpeg. - Imaging controls for brightness, contrast, saturation, sharpness, white balance and exposure mode. The Rust build adds an OSD watermark — custom text plus a live clock — burned in pre-encode, so it appears on RTSP, RTMP, recordings and the web preview alike.
Under the hood everything hangs off one encoded frame hub:
flowchart TD
CAM["Camera<br/>CSI or USB"] --> CAP["Capture<br/>board-native path"]
CAP --> ENC["H.264 encode<br/>HW or SW"]
ENC --> HUB["Encoded frame hub"]
HUB --> RTSP["RTSP :8554"]
RTSP --> NVR["NVR or VMS"]
HUB --> REC["Local recording"]
REC --> GB["GB28181 device"]
GB --> PLAT["Platform"]
HUB --> WEB["Web UI :8088"]
WEB --> BROWSER["Browser"]
ENC -. tap .-> AI["Detection<br/>opt-in"]
Detection is a passive subscriber on that hub. If it's slow, or the model won't load, streaming is unaffected.
Detection that never leaves the board
Object detection is compile-time opt-in, and it runs locally or not at all. Nothing is uploaded, and there's no cloud inference bill because there's no cloud inference.
The Rust build taps the shared pre-encode YUV buffer, so inference never touches the capture or encoding path. The Go build subscribes to the encoded-frame hub and decodes keyframes for inference.
flowchart TD
YUV["Shared YUV<br/>pre-encode"] --> PRE["Preprocess<br/>YUV to 320 square"]
PRE --> RUN["ONNX Runtime<br/>NanoDet-Plus"]
RUN --> POST["Postprocess<br/>argmax, GFL, NMS"]
POST --> API["REST and SSE<br/>boxes in native px"]
GUARD["Guardrails<br/>memory and core"] -. controls .-> RUN
The model is NanoDet-Plus-m 320 in ONNX form, Apache-2.0 licensed. There's a small model registry with runtime hot-swap between a 320 and a 416 variant, plus a YOLOX-nano decoder if you want a different family. Uploading new models at runtime is supported but off by default behind an explicit config gate, since a model file is untrusted input to an inference engine.
Detection fails open. If the runtime library or the model file is missing, the capability reports ai:false and the API says so — it never returns fabricated boxes. The trade-off is honest: libonnxruntime.so and the model are not bundled in the release tarball, so detection is a setup step, not a checkbox.
Which build
Two codebases, same protocols, same web API, same SPEC v1 JSON envelope. NVRs can't tell them apart. The choice is about the board and about how you like to deploy.
flowchart TD
Q["Pick a build"] --> P1["Want the easiest<br/>cross-compile"]
Q --> P2["Tightest memory<br/>and flash"]
Q --> P3["Want OSD or<br/>zero subprocesses"]
P1 --> G["Go build"]
P2 --> R["Rust build"]
P3 --> R
G --> N1["Ships HLS and<br/>a bilingual UI"]
R --> N2["About 2 MB binary<br/>no subprocesses"]
| Your situation | Pick |
|---|---|
| Fastest path, no cross-compile friction | Go — zero CGO, stock cross-compile |
| Smallest RAM and flash footprint | Rust — ~2 MB binary |
| Watermark burned into every output | Rust |
| HLS playback or bilingual UI out of the box | Go |
| 32-bit board | Go |
What it costs to run
Both builds are static binaries with a systemd unit in the repo. No Docker, no runtime, no language toolchain on the device.
| Metric | Go build | Rust build |
|---|---|---|
| Binary size | ~15 MB | ~2 MB |
| Memory | 15–25 MB (+~15 MB with the AI build) | ~6–12 MB bare pipeline; 93.8 MB measured full-feature |
| Subprocesses | mtxrpicam or ffmpeg | none |
| CPU, 720p@15fps | ~15% | 124–146% measured full-feature (~139% of four cores) |
Read those conditions before drawing conclusions, because they're not the same measurement.
The Rust figures come from a run on 2026-09-14 on a Raspberry Pi 3B: 720p at 15fps, hardware M2M encoder, GB28181 registered, local recording on, OSD watermark on, per-frame INFO logging enabled, one RTSP-over-TCP client, 60 seconds, 13 samples. RSS held flat at 93.8 MB. A bare pipeline — no GB28181, no recording, no watermark — sits far lower, which is where the ~6 MB figure in the repo description comes from. Those two numbers are different measurement scopes, and I'd rather show you both than quote the flattering one.
The Go CPU figure comes from the v0.1.0 release notes, also measured on a Pi 3B. Different scope again, so I'm not going to declare a winner. Both repos ship bench/rpi-bench.sh if you want your own numbers on your own board.
For context, there's a Pi 3B — a 2016 board with 1 GB of RAM — holding up a 720p stream with recording and a standards-based registration on top. That's the repurposing argument in one line.
The repo README also carries a comparison against MediaMTX on memory and CPU for the camera-side role. MediaMTX is a media server and a good one, and it isn't trying to be a camera; the comparison only covers turning a Pi plus a sensor into a device an NVR can discover, and the numbers are indicative measurements from my own deployments rather than a controlled benchmark.
Onboards without a V4L2 M2M encoder node — x86 boxes, NAS units, most ARM SBCs — the Rust build falls back to an in-process openh264 software encoder, and the Go build to a resident ffmpeg subprocess. That's why "any Linux board" is more than a tagline, and it's also why the subprocess column is worth reading before you pick. Note the CPU budget on weak boards: 720p@15 is the sane ceiling on A53-class silicon.
The parts that were hard
The most useful file in the Rust repo isn't code. It's a 386-line interop notebook documenting ten real bugs found while getting a gortsplib-based NVR to actually stay connected. Four of them explain why cheap RTSP implementations fail where ffmpeg succeeds.
A duplicated Content-Length header. The RTSP DESCRIBE response had the header twice — added once by the response constructor and once by the handler. ffmpeg tolerates it; the NVR's parser rejected the whole response and refused to connect. Strict parsers catch what lenient ones hide.
Partial frames are worse than missing frames. One encoded frame usually becomes several RTP packets. Under TCP interleaved mode, sending them individually meant a timeout mid-frame delivered half an FU-A fragment, which a decoder can't reconstruct. Fix: batch every packet of an access unit into a single write_all, so the frame arrives whole or not at all. Then rewind the RTP sequence numbers for the packets you didn't send, because a gap in the sequence makes a strict NVR declare packet loss. Then drop subsequent P-frames until the next keyframe, because those reference the frame you just threw away.
Timestamps must be strictly monotonic. RTP timestamps computed at send time rather than capture time produced duplicates whenever frames came out of the encoder in a burst. ffmpeg reported non-monotonically increasing DTS and the NVR dropped frames. The current implementation takes the capture timestamp and enforces a minimum increment of one frame tick between consecutive packets.
Pi undervoltage corrupts H.264. An ageing Pi 3B with marginal power delivery drops voltage under encoder load, the firmware caps the clock, and the transition produces corrupted NAL units. vcgencmd get_throttled returned 0x50005 — undervoltage happening now plus a frequency cap. The encoder thread now watches firmware throttle state and skips every other frame while throttled, which halves the load, lets the clock recover, and self-corrects. After that change, a 60-second decode test reported zero errors.
One frontend fix in the same notebook: Chrome 120+ no longer renders multipart/x-mixed-replace inside an <img> tag, so the MJPEG preview went black. Replacing it with H.264 over WebSocket plus Media Source Extensions fixed it, and reworking the buffering brought preview latency from 3–9 seconds down to under a second.
Code and test volume, measured 2026-09-15: the Go repo is 93 .go files and 22,022 lines with 287 test functions across 34 test files; the Rust repo is 63 first-party .rs files and 25,387 lines with 469 test functions. Documentation is 7 markdown files in Go and 13 in Rust, four of them design documents for unbuilt features.
The protocol layer is four libraries you can use on their own
None of those fixes live inside the camera service. The ONVIF and GB28181 logic in both builds is extracted into four standalone libraries — one protocol, two languages — and all four are MIT licensed, which is a shorter licence than the Apache-2.0 the camera service itself carries. Versions and counts in this section were pulled on 2026-09-15.
flowchart TD
P["Your product"] --> O["ONVIF side"]
P --> G["GB28181 side"]
O --> OG["onvif-go<br/>client + server"]
O --> OR["onvif-rs<br/>device server"]
G --> GG["gb28181-go<br/>UAC, UAS, cascade"]
G --> GR["gb28181-rs<br/>UAC"]
| Library | Language | Latest tag | Role |
|---|---|---|---|
| onvif-go | Go | v2.0.0-rc6 | ONVIF client + virtual camera server |
| onvif-rs | Rust | v0.6.0 | ONVIF device-side server |
| gb28181-go | Go | v0.10.0 | GB28181 device + platform + cascade |
| gb28181-rs | Rust | v0.11.0 | GB28181 device side |
onvif-go works from both ends: a client you can point at someone else's camera, and a virtual camera server for testing against. Its go.mod is a module line and a Go version — zero third-party dependencies, which you can verify by opening one file. It ships prebuilt CLI tools for discovery, diagnostics, a quick-start helper and a server simulator, which is what you want when there's no hardware on the bench. Device interop is exercised against Hikvision, Dahua, Axis, Bosch and ESP32 hardware. v2 was a breaking refactor from v1, and the repo carries a MIGRATION.md.
onvif-rs is the device-side server, published on crates.io as onvif-device-rs because the onvif-rs name is held by an abandoned 2018 placeholder. Its response XML is byte-stable, pinned by golden-string tests, which is the property strict NVR parsers actually need. Production code contains no unwrap or expect and CI enforces that; auth has a UsernameToken replay guard and a lockout, and handler panics are isolated so one bad request can't take the server down.
gb28181-go covers the most ground: device (UAC), platform (UAS) and multi-level cascade. The cascade path has loop prevention — each channel carries an OriginDeviceID, so a middle platform never hands an upper platform its own channels back, and their INVITEs get a 404. The GB/T 28181-2022 alignment lives here too: five new information queries pinned to the standard's annexes, opt-in X-GB-Ver version negotiation, and a wire-format bug found by reading the spec text rather than a peer implementation (SVAC audio stream_type corrected from 0x81 to 0x9B).
gb28181-rs is the device side of the same thing, sharing golden wire-format contracts with its Go twin. It's what gives the Rust camera build its GB28181 capability.
| Library | Tests | Code |
|---|---|---|
| onvif-go | 723 (693 tests + 25 benchmarks + 5 fuzz targets) | 177 .go files, 55,284 lines |
| gb28181-go | 574 (563 + 7 + 4) | 161 .go files, 39,695 lines |
| onvif-rs | 174 | 21 .rs files, 8,922 lines |
| gb28181-rs | 218 | 40 .rs files, 16,876 lines |
That's 1,689 tests across the four. The Go line counts include their test files: 88 files and 27,156 lines in onvif-go, 89 files and 21,114 lines in gb28181-go. The Rust crates keep test modules inline as well as under tests/. Both declare MSRV 1.80; the GB 35114 feature needs 1.85 or newer.
If you're building an NVR, a platform, or a device simulator, you can take the library without taking the camera service.
What you should know before depending on them:
- onvif-go is still a release candidate. v2.0.0-rc6, and v1 to v2 was a breaking refactor.
- onvif-rs covers an NVR-pairing action set. Its own release notes say no GetSnapshotUri and no Events, and that compatibility with third-party clients beyond NVR-style consumers is untested.
- gb28181-rs is device-side only, and the Go platform side doesn't implement the 2022 HTTP media plane for recorded-video search.
- Neither GB28181 library implements subscriptions, alarm notifications, mobile-position reporting or PTZ command intake yet. That's the v0.3.0 scope, and it lands in the libraries first while the camera service only wires to it.
- All four are new. onvif-go was created in July 2026, the other three in August 2026, with between 0 and 4 stars. I'm not going to make promises about maintenance cadence.
What it doesn't do yet
Worth reading before you plan around it.
- Profile S only. No Media2, no Profile T, no Recording service, no Analytics. If your NVR needs server-side recording control or two-way audio, this isn't the device for it.
- No H.265, no WebRTC, no multi-camera. All three have design documents in the Rust repo and empty feature flags in the code. You can enable them and get a log line.
-
Motion detection is half-landed. The Rust build implements a frame-delta detector in
src/motion/but it isn't wired into the web API yet. - AI needs firmware-side setup. The ONNX runtime library and the model aren't in the release archive.
- No 32-bit artifact from the Rust repo at v0.2.0. An upstream width bug blocked it; it's fixed upstream and lands with v0.3.0. The Go repo already ships armv7.
- It's new. Both repos were created on 2026-09-12 and have one contributor. I'm not going to make claims about response times or maintenance cadence, and you shouldn't build a procurement decision on them.
- GB 35114 only goes to A-level, and I have no third-party test report to point at.
The frozen v0.3.0 scope is a complete GB/T 28181-2022 device-side capability package, published as a synchronized release across both implementations. Three things are explicitly out of scope and will stay that way: SVAC video and GB 35114 B/C levels, which would need SVAC hardware codecs; SIP over WebSocket, which isn't a device-side requirement in practice; and a platform-side (UAS) role, because the product is a device. The 2022 HTTP media plane for recorded-video search is deferred to v0.4.x rather than squeezed in.
Getting started
Go build:
git clone https://github.com/xiqing85/mibee-eye-go
cd mibee-eye-go
make build
cp configs/config.example.yaml config.yaml
# set camera parameters and onvif.username / onvif.password
sudo cp deploy/mibee-eye.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now mibee-eye
Rust build, cross-compiling from an x86 workstation to a Pi:
git clone https://github.com/xiqing85/mibee-eye-rs
cd mibee-eye-rs
# fully static, no external toolchain needed
rustup target add aarch64-unknown-linux-musl
make cross-build
./deploy/install.sh pi@192.168.1.100
Then point your NVR at it. WS-Discovery should surface it on the local network; the manual address is http://<board-ip>:8080/onvif/device_service and the stream is rtsp://<board-ip>:8554/stream.
Two config defaults worth changing before you expose anything: the ONVIF password is empty by default, and gb28181.enabled is off unless you turn it on. Environment variables with the MIBEE_EYE_ prefix override any config key, which is handy for injecting secrets from systemd rather than writing them into a file.
One caveat on integrations: the ONVIF surface is the standard one, and the only NVR I have deep interop notes for is a gortsplib-based one. Synology Surveillance Station, Blue Iris and Scrypted consume standard ONVIF Profile S devices, and there's nothing custom here, but I'm not going to claim tested-and-verified for hardware I haven't run. If you try one of them and it misbehaves, that's exactly the kind of bug report the interop notebook exists to collect.
Project links
mibee-eye-go: https://github.com/xiqing85/mibee-eye-go
mibee-eye-rs: https://github.com/xiqing85/mibee-eye-rs
The protocol libraries underneath:
onvif-go: https://github.com/mickeyzzc/onvif-go
onvif-rs: https://github.com/mickeyzzc/onvif-rs (published on crates.io as onvif-device-rs)
gb28181-go: https://github.com/mickeyzzc/gb28181-go
gb28181-rs: https://github.com/mickeyzzc/gb28181-rs
Licence: Apache-2.0 for both camera builds, MIT for the four libraries. Note that v0.1.0 shipped under CC BY-NC 4.0, which forbids commercial use, so take v0.2.0 or later; third-party terms are listed in NOTICE.
Top comments (0)