Camera Streaming

WebRTC streaming and inference pipelines for Kataglyphis.

The ObservANT in one minute. A board with a camera runs the omni-observant service: it finds the camera, looks for cats with a detection model and serves the web app on port 8080.

The detection model

DEIM-D-FINE-N, licensed Apache-2.0, fetched by checksum and never tracked (owner, 2026-10-14). It replaced YOLO26n and YOLOv10m, both AGPL-3.0, which sat in AccelerANTgine's and OxidANT's git trees. It is DEIM's COCO checkpoint: 80 classes, 4 M parameters, 7 GFLOPs, 43.0 AP, a 640x640 input, a 15 MB file.

  • Where it comes from. ModelANT builds it from DEIM's pinned commit and checkpoint, exports it with its post-processing into the [1, 300, 6] layout the Rust parser already read (x1, y1, x2, y2, score, class) and releases it with its license and notice. Its preprocessing is a plain resize, the Rust default; the ONNX names it in its metadata (kataglyphis.preprocess).
  • How a build gets it. third_party/OxidANT/resources/models/models.lock names each file with its URL and SHA-256. third_party/OxidANT/scripts/linux/fetch-models.sh puts them into resources/models (or --dest DIR) through a cache in ~/.cache/kataglyphis-models, and fails on any other bytes; on Windows, scripts\windows\Get-Models.ps1. The native lane, the Linux bundle, the ObservANT packages and OxidANT's Windows build call it, so a fresh clone needs the network once. Before you start the app from a checkout, run it yourself. Packaging and its tests take the file names from the lock's first .onnx row, so a lock naming another model needs no script edit; test-verify-gpu-candidate.sh, test-profile-gpu-packages.sh and the detector model suites run on a fixture lock that does. web.yml's package jobs also run the real packagers on such a lock (test-observant-fixture-lock.sh, Test-ObservANTFixtureLock.ps1), reusing the producer just built (about 2 minutes on Linux), and check the finished tar, .deb, AppImage and Windows zip; the flatpak's model files, the fixture MSI and MSIX, and running that model are not covered.
  • What it changed on the boards (2026-10-14, against YOLO26n, ONNX Runtime on 2 threads on a Pi 5, 12 arrival pictures from the three ObservANT cameras and one cat photo): it found the cats more often and more surely. A cat at the feeding trough went from 0.42 to 0.63; a cat lying on the sofa, which YOLO26n missed, scored 0.64. No new false cats. A run costs about 260 ms instead of 190 ms.
  • Another model. Put it into ModelANT and move the lock's lines. For a one-off, model = "..." in observant.toml or KATAGLYPHIS_ONNX_MODEL names any [1, N, 6] ONNX file. The old models left the repositories' history in the family's history rewrite of 2026-10-17.

Measuring performance

The code's benchmarks live in each project's own tooling, and CI tracks them (2026-10-14, owner): a change that costs time shows in the run's summary. The yardstick is the median of the default branch's last 20 runs on that arch, not the run before, so a slowdown that creeps in a few per cent per commit still shows (2026-10-15). Above +25 % a run warns; a gated value at more than double the median fails it, once 3 earlier runs exist. A shared runner is noisy, so only stable values are gated, and a run the report failed never joins the history.

  • Rust: OxidANT's Criterion benches under cargo bench, run by its Linux lanes: what a frame pays before the model, and a whole detection on the real model (OxidANT's AGENTS.md).
  • Flutter: integration_test/performance_test.dart, driven in profile mode by flutter drive --profile -d linux --driver=test_driver/perf_driver.dart --target=integration_test/performance_test.dart. Flutter's watchPerformance times the frames of the app's start, three rounds through every tab, and resizes across both layout breakpoints; the driver keeps them in out/perf/frontend.json, and ANTfrastructure's frontend-perf-report.py makes the table (frames, build and raster time on average, at the 90th percentile and at worst, missed build budgets). The native lane runs it under Xvfb on x64 (run_frontend_performance); the hub's frontend-perf-report action keeps the default branch's last 20 results in .perf-baseline/. Gated: the average and the 90th-percentile build time; the worst frame is one frame and only warns.

When a frame is slow, find what it builds. The report says that a scenario got slower; a profile timeline says where. Write an entry point outside the tree, say build/probe/main.dart, that sets debugProfileBuildsEnabled = true (and debugProfileBuildsEnabledUserWidgets) before calling the app's main(), run flutter run --profile --trace-startup -d linux -t build/probe/main.dart under Xvfb, then python3 third_party/ANTfrastructure/linux/scripts/05-frameworks/flutter/flutter-trace-summary.py build/start_up_timeline.json. It lists the slowest UI and raster frames and the slowest UI frame's self time per event, per widget with that flag. That found the 2026-10-15 start-up fix: StreamPage held 938 ms of a 963 ms build, because its initState enumerated cameras synchronously. Without the flag the same command still splits a frame into BUILD, LAYOUT, PAINT, SEMANTICS and garbage collection. A DevTools Export timeline file reads the same way.

Two tools measure what the boards really do, which no runner can. Repeat them after a change that could cost time there, and compare with the baseline below.

  • scripts/linux/observant/bench/model.sh PICTURE MODEL.onnx..., on a board: the installed service runs each model nonstop on a still picture on a spare port (no camera, no state, the live service untouched) and prints its time per run, runs per second, CPU and memory.
  • scripts/linux/observant/bench/viewer.sh BOARD [SECONDS], from another host: the API's response times; the service idle and while one phone-sized headless Chrome plays the stream; and that page's cold load: the files on the boot path with their times, the renderer the browser got, the first Flutter frame and the first video frame, the requests and main-thread time once it runs, and when the signalling socket opened and answered.

The baseline, 2026-10-14 (Pi 5 boards, the SpacemiT K3 board, the web client a headless Chrome on a Pi 5):

What Pi 5 SpacemiT K3 (riscv64)
A model run in the service, DEIM-D-FINE-N / YOLO26n 332 / 233 ms 592 / 404 ms
The model alone at 1 / 2 / 4 threads (DEIM-D-FINE-N) 420 / 258 / 265 ms
Preprocessing, postprocessing per run 3 ms, < 0.01 ms
Per frame: I420 to RGBA, pixelation, motion thumbnail 1.7, 0.7, 0.5 ms
A snapshot JPEG 10.7 ms
The service idle, with one viewer (% of one core) 3 to 4.5 %, 18 to 19 % 1 %, 49 %
Memory idle, with one viewer 180 to 190 MB, +30 MB 245 MB, +27 MB
/healthz, /snapshot.jpg over the LAN 0.3 to 4 ms, 0.3 to 19 ms 3 ms, 38 ms

The cold page is 4.9 MB in 105 to 112 requests: the renderer skwasm_heavy.wasm (2.2 MB) and main.dart.wasm (1.5 MB) on the boot path, then six font files (0.5 MB), then the Rust core (65 kB). The first Flutter frame came after 4.5 to 6.4 s on the board with Wi-Fi at 0.7 MB/s, the first video frame 3 to 3.5 s later. An open page then makes 48 requests a minute and keeps the main thread 1.9 % busy.

What it says:

  • The model is 99 % of a detection. Two threads are the fastest setting on a Pi 5; four are no faster.
  • Every viewer costs an encoder: webrtcsink encodes once per viewer, about 15 % of a Pi 5 core and half a K3 core each.
  • Plain HTTP costs 0.75 MB a visit: Flutter loads skwasm_heavy (2.29 MB compressed) instead of skwasm (1.54 MB), since without a secure context the browser has no ImageDecoder (What a browser downloads under The Stream page), and without cross-origin isolation it renders on one thread.

Rust-owned webcam inference (local, no WebRTC)

On Windows and Linux the Stream page runs a fully local webcam → ONNX → texture pipeline owned end-to-end by Rust: no signalling server, no browser. Video frames never cross the Dart bridge; only detection metadata does.

Linux has the same design as of 2026-09-16; a frame has crossed it since 2026-10-05. It is an addition: the WebRTC stream below stays.

The Rust core is in every build, with no feature switch (owner, 2026-10-20; here since 2026-10-21). OxidANT gates by target alone, so the page sends Windows and Linux to RustWebcamView by target, and the Linux C++ GStreamer MethodChannel fallback is gone: there the plugin only creates the texture Rust pushes into. Android and macOS, where the Rust webcam cannot run, keep the plugin's MethodChannel pipeline. listCameras() runs only when someone asks for the camera list: it starts libcamera's device monitor, which held the app's first frame 0.94 s on a Pi 5 while the page called it on start (2026-10-15).

The packaged artifacts carry their GStreamer/ONNX Runtime/model closure (Relocatable Linux bundles), and check-knt-abi.sh runs in the lane.

A test-pattern frame reaches the texture on every native lane run. integration_test/webcam_frame_test.dart starts RustWebcamView on videotestsrc under Xvfb and polls the plugin's hasPushedFrame, the flag knt_push_frame sets. A camera changes only the source, so the push path needs none. A green lane is still not a working camera: real V4L2 capture on Linux remains a manual check.

Classes, backend and provider. RustWebcamView sends its class ids in WebcamStreamConfig.classes (default [0], person; the picker offers OxidANT's detectorLabels()), labels each box with its class name, and shows the event's backend and executionProvider, for example ort on cpu. KATAGLYPHIS_ORT_DEVICE=xnnpack or dnnl select OxidANT's CPU accelerators; a missing provider fails the start with ProviderUnavailable instead of running on the CPU.

omni_accelerant --detect-check IMAGE decodes the image with the engine's codecs, runs OxidANT's detect_rgba with classes [15], and exits 0 only for at least two cats at score 0.4 or more. The release lane runs it on the packaged bundle with third_party/OxidANT/crates/inference/tests/fixtures/two_cats.jpg and with a flat-colour 64x64 PNG from the hub's t_write_png, which must exit 1 (run_detect_check in scripts/linux/lib/container-steps.sh). It checks CPU inference through the package; it does not check a GPU provider.

Data flow:

crates/media (gstreamer-rs)     src/webcam_engine.rs            src/api/webcam.rs (frb)
                                                              ┌ webcam_inference_available()
  mfvideosrc → ksvideosrc     ┌ pushes RGBA into the Flutter   │ list_cameras() (async)
  → videoconvert → videoscale │ texture via the native plugin's│ start_webcam_inference()
  → RGBA appsink  ───────────►│ knt_push_frame C ABI           │   → Stream<DetectionEvent>
  (latest-frame slot)         └ runs PersonDetector (ONNX/ort) ─┘ stop_webcam_inference()
  • Rust source selection: mfvideosrc (Media Foundation) first, then ksvideosrc, then autovideosrc; videotestsrc for containers/CI (no camera). mfvideosrc needs the mediafoundation GStreamer plugin and a Windows client host (Server Core has no Media Foundation platform: platforms.md, the mediafoundation row); without it the pipeline falls back to ksvideosrc.
  • Inference: ort (ONNX Runtime) is loaded via load-dynamic (ORT_DYLIB_PATH → next-to-exe → C:\runtime\lib\onnxruntime-source\bin), refusing any file that is not the image's chain build (AGENTS.md § 4, the ONNX Runtime bullet); the DirectML execution provider on Windows with CPU fallback, and tract when ORT cannot load the model. No crate features: the rust_builder CMake → Cargokit passes no --features, and the target decides what is compiled in.
  • Display: the native plugin (packages/kataglyphis_native_inference) exports a C ABI (knt_create_texture implied via the create method, knt_push_frame, knt_api_version) that Rust resolves with libloading. The Flutter UI is a Texture(textureId) with a CustomPaint box overlay fed by the DetectionEvent stream (lib/Pages/StreamPage/rust_webcam_view.dart).

Run it: launch the app (Start-Windows.ps1), open the Stream tab, pick a camera (or Test pattern), optionally set a model path + score threshold, and press Start. The bundled GStreamer plugins must include the capture source: the build's DLL-bundling step stages gstmediafoundation.dll/gstwinks.dll + GStreamer core DLLs into the runner. mfvideosrc comes from the image's own GStreamer, which ANTfrastructure's windows/scripts/build/Build-GstreamerFromSource.ps1 builds with -Dgst-plugins-bad:mediafoundation=enabled.

Linux GPU build selection

The default build uses the CPU ONNX Runtime. For a GPU build, set ORT_LIB_LOCATION=/usr/local/lib/onnxruntime-gpu/lib and KATAGLYPHIS_ORT_PROVIDER=cuda or migraphx while configuring the app. The build stages the matching provider, links AccelerANTgine to that same runtime and sets it as the app's default device. A conflicting ONNXRUNTIME_ROOT fails configuration; an explicit KATAGLYPHIS_ORT_DEVICE still overrides that default. An explicit GPU request defaults the inference backend to ORT, so an ORT failure does not silently select tract. A GPU package accepts cpu, auto, or its own provider (cuda or migraphx); other device values stop the app with an error instead of silently selecting CPU.

MIGraphX requires an x64 Linux build and an OxidANT dependency that advertises its migraphx device capability. The recorded OxidANT pin does not yet have that capability, so an AMD build stops at configuration until the dependency is updated. The build matrix allows CPU and CUDA on x64 and arm64. GPU packages and their host CUDA or ROCm runtime dependencies are not yet verified for distribution; staging the provider alone does not make an installable GPU artifact. The package scripts require the newer shared host-runtime helpers, use -nvidia or -amd names, and reject Flatpak for these host-runtime variants. The current ANTfrastructure pin does not yet contain those helpers. The dispatch-only Linux GPU candidates (unverified) workflow covers NVIDIA x64 and arm64 and AMD x64. It requires both strict-provider capabilities from the recorded OxidANT pin and the GPU packaging helpers from ANTfrastructure before it pulls an image; the current pins fail that preflight. After those pins are updated, it verifies the image's core and selected provider against one wheel, builds tar, DEB and AppImage candidates, and checks the finished GPU runtime and notices against that wheel. It uploads them with unverified-omni- artifact names and does not publish releases. The clean-host checks run --onnx-check with ONNX Runtime CPU selected on each finished package, then check normal startup. The self-check loads the packaged model and runs a blank frame through the desktop detector bridge; it returns a nonzero exit status on failure. It does not inspect provider placement or prove GPU acceleration. An explicit KATAGLYPHIS_ONNX_MODEL still overrides the packaged model. After the required dependency updates, a GPU lane in the matching registry image must select formats that use the host runtime, for example:

export ORT_LIB_LOCATION=/usr/local/lib/onnxruntime-gpu/lib
export KATAGLYPHIS_ORT_PROVIDER=cuda  # migraphx on Linux x64 only
bash scripts/linux/ci/lane-native-linux.sh --package-formats tar,appimage,deb

The lane runs its application smoke and integration checks with the CPU ORT device for GPU builds, so they can run on hosted runners without a GPU. Those checks do not verify GPU inference; that requires a separate profile on a machine with the matching GPU and host runtime.

Checking the knt ABI

scripts/linux/check-knt-abi.sh verifies the C ABI the Rust webcam engine depends on: knt_api_version and knt_push_frame are exported from the built plugin, callable from outside the library, and return their documented error codes (-1 bad arguments, -2 unknown texture id). Its Windows twin, scripts/windows/Test-KntAbi.ps1, loads the plugin DLL into pwsh on the device: windows-x64.yml runs it on the host after the container build, windows-arm64.yml on windows-11-arm.

Rust resolves that ABI by name at runtime with libloading, so a rename, a dropped export or a visibility change is no compile error on either side: the app builds, ships, and silently never shows a frame. The script dlopens the plugin exactly as Rust does, so a failure here is one Rust would hit too.

It cannot check that a real frame reaches the screen: frames end in a GTK texture. The native lane has a display since 2026-10-01 (it starts the bundle and drives the integration test under the image's xvfb-run) and pushes a test-pattern frame through it since 2026-10-05 (above). A real camera's frame on screen is a manual check; on the Windows dev box through the usbipd attach --wsl route in AGENTS.md § 4.

Run it after a native build. The artefact is an ELF .so, so on a Windows host it runs in a container with the lane's build volume mounted:

nerdctl run --rm --platform linux/amd64 `
  -v "C:\GitHub\OmniAccelerANT:/workspace" -w /workspace `
  --mount "type=volume,source=kataglyphis-lane-native-x64-workspace-build,target=/workspace/build" `
  ghcr.io/kataglyphis/kataglyphis_beschleuniger:latest `
  bash scripts/linux/check-knt-abi.sh

Expected output:

ok   knt_api_version   = 1
ok   knt_push_frame    bad args -> -1
ok   knt_push_frame    unknown texture -> -2
knt ABI OK

Relocatable Linux bundles

The native lane packages four formats from one bundle tree. Until 2026-09-17 that tree assumed the host had the image's GStreamer: the plugin DT_NEEDed seven libgst* sonames, bundle/lib carried none of them, and the .deb declared only libc6, libstdc++6, libgtk-3-0. No lane caught it, because the image's ld.so cache has everything.

What travels in the bundle now (scripts/linux/bundle-runtime-closure.sh, run by the lane for release builds, before packaging). Since 2026-10-19 the walk is the hub's elf-closure.sh with its Flutter-bundle options; the ObservANT packagers call it with --ldd:

  • the GStreamer closure, resolved from DT_NEEDED against pkg-config's libdir (never ldd: the image also carries a distro GStreamer, and ldd may pick that one), plus the pipeline plugins listed in scripts/linux/lib/bundle-runtime.sh;
  • ONNX Runtime, the image's chain build and nothing else (owner rule 2026-09-23). libonnxruntime* resolves only from ORT_LIB_LOCATION, else /usr/local/lib/onnxruntime-cpu/lib, and only once that directory's libonnxruntime.so proves to be the chain build: ORT embeds its source path through __FILE__, the chain's is /opt/onnxruntime/onnxruntime/core/, and the variable alone proves nothing. Never from the ld.so cache, where /opt/opencv5/lib carries a second copy that sorts first. libAccelerANTgine.so needs libonnxruntime.so.1 and the Rust core dlopens libonnxruntime.so, so it is already there when the packer starts (rust_builder/linux/CMakeLists.txt stages both sonames from the same chain directory on every flutter build linux): the packer copies nothing for it and prints Kept (already in bundle/lib, not copied) with each file's sha256, so its N file(s) copied list never shows ORT;
  • the 15 MB detector model at data/resources/models/deim_dfine_n_coco.onnx, with its license and notice, fetched and checked from OxidANT's models.lock (The detection model): the standard model, bundled by default (owner decision 2026-10-01). KATAGLYPHIS_BUNDLE_MODEL=0 drops it for an artifact that then needs KATAGLYPHIS_ONNX_MODEL at run time;
  • an $ORIGIN rpath on every bundled ELF, since RUNPATH is not transitive (linux-build-basics.md § What a package carries; this bundle is the measurement). The runner carries $ORIGIN/lib:$ORIGIN/../lib because the flatpak manifest installs the binary into /app/bin with the libraries in /app/lib;
  • GCC 16.2's libstdc++ and libgcc_s (2026-10-16), from the compiler's own runtime directory: the image has the distro's older copy too, and the code needs GLIBCXX_3.4.36, which no distro ships yet;
  • the image's loader and glibc (2026-10-17): its ORT and orc need glibc 2.43. The hub's bundle-loader.sh --beside leaves the runner as omni_accelerant.bin with ld-linux-*.so.1 beside it (so Flutter still finds data/), puts glibc in bundle/lib/glibc and makes omni_accelerant the launcher. The closure gate leaves glibc to --library-path and checks the .bin behind the launcher.

The launcher takes the bundled glibc only when the host's is older (linux-build-basics.md § glibc travels, and a newer host glibc wins; this app's flatpak is the case that forced it). The native lane starts the bundle 20 s on both paths (KATAGLYPHIS_BUNDLED_GLIBC=1 forces the second) and the app flatpak 20 s on its runtime. On a real desktop under the bundled loader (2026-10-17, the 2.2.0 dry run's arm64 tarball unpacked on himbeere2: Debian 13, glibc 2.41, labwc on Wayland) the app drew its window and pages with Impeller on OpenGL ES; the process mapped the bundled libc.so.6 beside the host's libEGL_mesa and libgallium-26.2.2, so the host's Mesa runs under the newer bundled glibc.

The closure gate runs inside the image, where every library is present anyway; the outside view is the clean-host job after each Linux lane, the hub's clean-host-package-check on a bare ubuntu-26.04 (-arm) runner (§ The proof).

At runtime the plugin's ELF constructor (runtime_paths.cc) points GST_PLUGIN_PATH, ORT_DYLIB_PATH and KATAGLYPHIS_ONNX_MODEL at those siblings, each only when the file exists and the environment names none, so a user override always wins. For ONNX Runtime that override must still be a chain build: OxidANT's loader (ort_runtime.rs) refuses any file without the chain's source path.

Only the bundled GStreamer plugins load (owner, 2026-10-17), by the hub's four settings (§ GStreamer loads from the package alone): runtime_paths.cc sets the plugin paths and GST_REGISTRY_FORK=no wherever it finds the bundled plugins, each unless set; AccelerANTgine's library does the same from its own constructor and adds GST_REGISTRY, one per installed copy (AccelerANTgine 24de58f). The lane's launch smokes fail on GStreamer's External plugin loader failed warning. This plugin's bundle has no scanner and no webrtcsink, and check-bundle-gst-elements.sh refuses libgstrswebrtc.so there: an in-process scan of a fresh registry can leave webrtcsink without codecs. The ObservANT and producer bundles carry webrtcsink, so they must ship the scanner. scripts/linux/check-bundle-gst-elements.sh hands the hub's gstreamer-bundle.sh check every element the pipelines name (GST_PIPELINE_ELEMENTS in scripts/linux/lib/bundle-runtime.sh). test/pinned_artefacts_test.dart keeps that list in step with the Dart pipelines' Linux sources; OxidANT's capture.rs adds videoscale. A new element goes into that list and its plugin into GST_BUNDLED_PLUGIN_NAMES, or the lane fails.

The GStreamer-element and knt-ABI check CLIs share scripts/linux/lib/bundle-options.sh, which uses the shared flag loop; cli-common.sh owns their architecture and build-mode verdicts. The native lane tests help, invalid values and a mutated option target with scripts/linux/tests/test-bundle-options.sh before it builds.

scripts/linux/check-bundle-closure.sh grades the result headlessly (one second, no display, no container-in-container):

  • every DT_NEEDED of the runner and of every bundle lib is bundled or in the documented system allowlist, and every bundled dependency is reachable through an $ORIGIN-relative RUNPATH;
  • the pipeline plugins exist;
  • ANTfrastructure's G6 census (linux/scripts/06-packaging/check-ort-provenance.sh <bundle>) finds every ONNX Runtime binary in the bundle byte-identical to the image's chain ORT, and every importer's ld.so lookup (RUNPATH, $ORIGIN expanded) landing on it. A dlopen-only user such as liboxidant.so gets an $ORIGIN RUNPATH from the packer for exactly that.

liboxidant.so also holds the chain directory /opt/onnxruntime/onnxruntime/core/ as a string, because OxidANT's loader checks the ORT it loads for it. That makes it an importer, never an ORT build, but only to a census that takes a whole NUL-terminated __FILE__ source path as the ORT fingerprint. A hub whose census still counts the bare directory reads the text rustc packs before it as a relative build root and fails the correct bundle with UNPROVEN /lib/liboxidant.so (run 35928030957, both arches). This repo's hub pin has read whole paths only since 502b9089 (2026-09-24); test-check-bundle-closure.sh carries the bytes around that string in the real liboxidant.so. The census runs whenever a bundled file is ORT-named or names the ORT ABI (OrtGetApiBase and G6's other markers), not only when a libonnxruntime* file is present: an ORT user with nothing beside it, or an ORT under another name, is exactly what G6's verdicts exist to refuse. Which copy a process loads at run time it does not decide; runtime_paths.cc (above) points ORT_DYLIB_PATH at the bundled one.

A missing GStreamer lib or a foreign ONNX Runtime fails the lane here instead of on the first target machine. Both gates run in lane-native-linux.sh after flutter build linux; scripts/linux/tests/test-check-bundle-closure.sh, which mutation-tests when the census runs and that its verdict decides, runs in the same lane's code-quality batch before the build. Its synthetic ELF records and the GStreamer element test's gst-inspect output now come from ANTfrastructure's shared fixtures; the local tests still set DT_STRSZ and Name-field variants that exercise their bundle-specific failure paths.

The system allowlist is the GTK desktop stack the .deb's Depends stand for; GStreamer, ONNX Runtime and the camera stack are deliberately not in it, they are the bundle's job. So the .deb declares only the desktop libraries, which assumes the target has a desktop stack. Not covered by any of this: a frame from a real camera (the lane pushes a test pattern, Rust-owned webcam inference above).

WebRTC pipelines (Linux / web)

The ObservANT package

scripts/linux/observant/package-observant.sh + package/ (2026-10-05) turns the producer below (Cat detection stream (Rust, native)) into one installable service: the producer, its GStreamer plugins, the chain ORT (G6-proved), the model, the web build and the image's loader, glibc and libstdc++ in /opt/omni-observant (What the bundle carries, under Installing it). Run it inside :latest with the web lane's build/web as its web root, privileged (nerdctl run --privileged): it also builds a flatpak, and flatpak-builder runs in bubblewrap.

bash scripts/linux/observant/package-observant.sh --web-root build/web
# out/omni-observant_<version>_<deb-arch>.deb            Debian, Ubuntu, Raspberry Pi OS
# out/omni-observant-<version>-<uname-arch>.AppImage     any glibc distro
# out/omni-observant-<version>-<uname-arch>.flatpak      any flatpak desktop; USB cameras, sign-in start
# out/omni-observant-<version>-linux-<deb-arch>.tar.gz   the same bundle, unpacked

The Flatpak build uses ANTfrastructure's committed OSTree ref as its verdict: flatpak-builder can return success without an app or fail after committing one. The packager also requires a nonempty copied bundle. The stubbed scripts/linux/tests/test-observant-flatpak-verdict.sh checks both exit/ref combinations and an empty bundle without building an image.

  • The .deb carries a systemd unit, enabled at install, and a ufw profile. /etc/omni-accelerant/observant.toml is not a conffile, on purpose (why: What the .deb does on install, under Installing it).
  • The unit is sandboxed; its comment names what stays open for the camera, the RAM check and GStreamer's ORC (its score: Tests).
  • On riscv64 it writes the .deb and the tarball only, built natively on the board in that board's own riscv64 :latest (a 16 KiB-page Pi cannot emulate it): the freedesktop runtime has no riscv64 build and no AppImage runtime is staged for one. No CI lane builds it; web.yml uploads all four for x64 and arm64.
  • package/* is pinned to LF in .gitattributes, because those files land on the target verbatim.

Install it with sudo apt install ./omni-observant_<version>_<arch>.deb, or anywhere else with sudo ./omni-observant-<version>-<arch>.AppImage --install, then open http://<host>:8080/ from any browser on the network (Installing it).

Everything it serves is on that one port, plain HTTP:

Path Answers
/ the web app; its Stream page plays the camera and shows the cat's status, visits and last arrival
/healthz JSON: ok, or false with the problem; the seconds since the last frame and model run (frame_age_s, model_age_s); the build (version), the browser tabs on the stream (viewers), the other ObservANT cameras (peers), the privacy filter (privacy: off or pixelate), whether the picture is too dark to detect anything (dark: the model then idles, and ok stays) and stats, sampled every 2 s: capture_fps, model_ms per run, model_runs_per_s, cpu_percent of one core, cores, rss_mb, load_1m, temperature_c and uptime_s (the CPU, memory, load and temperature from /proc and /sys, so null off Linux); the live camera, its frame size and rate, model_runs, rotate, score, and under detection the watched in_view, last_seen, visits, last_score, recent visits, the newest clip's arrival and every kept one's (clips), and the boxes drawn now, as fractions of the picture
/visits JSON: every visit of the last 30 days, newest first, as [arrival, stay in seconds] (null while still in view), and days; read by the Stream page's Visits panel, which counts them per day and per hour in the viewer's time zone (2026-10-15). Kept in visits.json beside recent, at most 10 000 visits; a file from before starts from its recent
/snapshot.jpg the latest frame, boxes drawn; encoded once per frame however many ask
/arrival.jpg the frame the last arrival was confirmed on; ?t=<arrival time> that arrival's own, of the last five; 404 until a cat has come, or once that arrival's is dropped
/arrival.mp4 the last arrival as an H.264 clip, 3 s before it to 7 s after; ?t=<arrival time> that arrival's own, of the last five; byte ranges answered; 404 until one is encoded, or once that arrival's is dropped
/settings GET: the name, rotate, score, ignore and classes the settings page can change, and the privacy filter, which locks classes. POST any of them as JSON (application/json only), e.g. {"rotate": 90}: 0, 90, 180 or 270 degrees clockwise, a score from 0.05 to 0.95, a name of up to 64 characters, up to 16 spots marked "not a cat" as [x1, y1, x2, y2] fractions of the picture ({"ignore": []} forgets them), and the watched classes, one to all 80 COCO names ({"classes": ["cat", "dog"]})
/webrtc-ws the WebRTC signalling, forwarded to the producer's own server on loopback :8443; at most max_viewers tabs at once (8), 503 for one more; a tab that vanished without closing, a laptop asleep, frees its place within about 2.5 min (TCP keepalive, 2026-10-14)

Only ObservANT's own page may act (2026-10-13). /webrtc-ws and a POST /settings answer 403 when a browser's Origin is not the address the request was sent to, so another site's page cannot open the camera or change it. A tool such as curl, which sends no Origin, passes, and reads stay open to the other ObservANT cameras' pages.

Every request must name the board by a local address (2026-10-14): an IP, a name without a dot, or one ending in .local, .lan, .home, .internal, .home.arpa or .fritz.box, else 403. Before, only acting requests were checked, so a website that pointed a public name at the board (DNS rebinding) could read /snapshot.jpg and the clips as its own. A reverse proxy under a public name goes into allowed_hosts in observant.toml.

At most 128 connections at once, and a request head within 10 s (2026-10-14), so a device that opens more, or holds sockets without sending, no longer uses up the service's files; the 129th is closed unanswered until one frees.

A model that keeps failing shows (2026-10-14). After three failed runs in a row /healthz says ok: false with problem: "the model fails: …", so the Home Assistant outage alert fires. Each failed run clears the drawn boxes, so none stays sharp under the privacy filter; the journal gets the first failure and then one line a minute.

The Camera page changes ObservANT while it runs (2026-10-13): the web app's own page in the bottom bar, for a board that is easily moved or a spot with false alarms. The Stream page carries the same ⟲ and ⟳ beside the camera's name (2026-10-16), so a picture on its side is turned where it is seen; both post {"rotate": …} to /settings, which the camera keeps in its settings.json, so every device keeps its own.

  • ⟲ and ⟳ turn the picture a quarter each way. ObservANT turns it itself, before the model sees it, so detection, /arrival.jpg and the clips turn with the stream. The running capture follows on its next frame, with no restart and no reconnect. At 90 and 270 the upright picture is bordered into the frame, black at its sides, instead of stretched: until then a quarter turn squeezed a 4:3 picture into 4:3 the other way round, and a cupboard looked square. A Pi camera turned a quarter (rpicam-vid or libcamera) captures at 480x360, which its ISP scales for free, so only the borders are added (2026-10-14): resampling the turned 480x640 cost a Pi 20 % of a core, the borders 3 %. Turning such a camera between landscape and portrait restarts its capture once, about a second; a USB webcam keeps its size and turns live.
  • A slider sets the detection score, how sure the model must be before a cat counts, from the next model run. Higher means fewer false alarms, lower finds a cat in poor light. Under it the page names the last detection's score ("Last detection: 34 %"), real or false, so the slider can go just above a false one.
  • "Not a cat" (2026-10-14) appears under the preview while a box is drawn. It marks the boxed spot, such as a drying rack, as no cat: a detection that overlaps it by more than half (intersection over union 0.5) is then dropped before it counts, is drawn or scores. A real cat in front of it still counts, since its box differs. The marked spots show grey over the preview; "Watch again" forgets them all, and so does turning the picture, as they no longer fit it.
  • "Recent detections" (2026-10-14) lists the latest five arrivals with their pictures, each with "Not a cat", since a false one is seldom on screen when it happens: himbeere2's drying rack counted once at 09:09 for under three minutes. Each visit in /healthz's recent keeps the boxes it arrived with, and the button adds those as ignored spots; one already ignored reads "Ignored". Visits from before that keep no boxes and are not listed.
  • A field renames the camera. The next webhook event, /healthz and the web page (within 5 s) carry the new name; the WebRTC stream's own name, which a GStreamer viewer shows, stays the one observant.toml gave it at start.
  • A preview of /snapshot.jpg, reloaded every 3 s, shows the result, and a table what the camera runs: its sensor, frame size and rate, whether detection runs, the model's runs and the visits counted.

Each change is kept in the state directory (settings.json) and wins over the same key in observant.toml, which the service cannot write; a key the page never set still follows the file. /settings takes JSON only, so another site's page cannot change the camera with a plain form post, and it checks a whole post before it applies any of it. On a page that is not an ObservANT camera's own, the Camera page says no ObservANT answers.

The web build ships compressed. The packager runs the producer's --precompress over its copy of the web build, so each file the server gzips has a zopfli .gz beside it: about 5 % smaller than the server's own gzip, and nothing to compress on the board. A first visit from a phone on the LAN is 4.46 MB on the wire, about 4.62 MB before (himbeere2, 2026-10-12); over loopback 3.75 MB, 3.93 before. The gap is Flutter's renderer: skwasm_heavy (2.2 MB gzipped, its own codecs inside) instead of skwasm (1.5 MB), because plain HTTP at a LAN address is no secure context (What a browser downloads); only a certificate the phones trust would change that. The copy leaves out what no browser fetches from this build: the *.symbols debug maps, and the wimp and webparagraph renderers, which Flutter's loader fetches only when the _flutter.loader.load call in flutter_bootstrap.js asks for them; the packager stops if it ever does. The .deb is 88 MB, 81 MB before.

Where it runs

The owner's cameras on the ObservANT package, as their /healthz reported them on 2026-10-15 (himbeergsaelzweckle on 2026-10-16). Each serves its page on http://<host>:8080/ (http://himbeergsaelz.local:8080/ and so on) and keeps its own rotation in its settings.json: turn a camera with ⟲/⟳ on its own Stream page, and the table's "turned" follows.

Host Board Camera Version Updates One detection
himbeere2 ("Cat Cam Büro") Raspberry Pi 5, Debian 13 imx219 (CSI), turned 180° 2.1.0 nightly, from the releases 0.26 s
tabblscatcam ("Cat Cam Futtertrog") Raspberry Pi 5, Debian 13 Camera Module 3, imx708 (CSI), turned 270° 2.1.0 nightly, from the releases 0.30 s
himbeerGsaelz (since 2026-10-15) Raspberry Pi 3 Model B, Debian 13 Camera Module 3 Wide, imx708_wide (CSI), turned 180° 2.1.0 nightly, from the releases 2.05 s
himbeergsaelzweckle (since 2026-10-16) Raspberry Pi 4 Model B (8 GB), Debian 13 Camera Module 3 Wide, imx708_wide (CSI), turned 180° 2.1.0 nightly, from the releases, as a user install (no sudo) 0.91 s
mintberrycrunch ("Mintberry Cat Cam") SpacemiT K3 (riscv64), Ubuntu 26.04 Logitech C270 (USB) 2.1.0 by hand: built on the board, since no release carries riscv64 0.59 s
  • The first release reached a camera by itself. v2.1.0 was published at 22:32 on 2026-10-15. himbeere2's omni-observant-update.service, started by hand a minute later, downloaded its .deb, checked it against SHA256SUMS, installed it over 2.0.0, and the service came back with its 155 visits; a second run said "2.1.0 is up to date". tabblscatcam did the same from its timer alone at 03:37 on 2026-10-16 and came back with all 224 visits.
  • himbeerGsaelz's config says inference = "on". A 1 GB Pi 3 reports 905 MiB, and 2.1.0's auto skips the model below 1024 MiB; the next release's auto takes 768 MiB (OxidANT f8a2dfc), so a new Pi 3 detects without the setting. Its model runs at 2 s, about seven times the Pi 5's, but only while something moves.
  • himbeergsaelzweckle is a user install, because no sudo password was at hand (Without sudo under Installing it; its field test below). The first real release to reach it will be the one after 2.1.0.
  • A Pi Zero 2 W ran the earlier container shape, not this package, and so did a Pi 4 before himbeergsaelzweckle (the board table under Cat detection stream).
  • Every camera lists the other four as peers (2026-10-16), so each one's Stream page shows all five. The list sits in each camera's observant.toml; a new camera goes into all of them.
  • Two updates, two owners. ObservANT updates itself nightly from the releases (Updates from the releases); the boards' operating systems update weekly through ANTfrastructure's Ansible run (linux/ansible/README.md there), which reboots a board only when its kernel asks for it. The Pi 4's camera came back by itself after such a reboot on 2026-10-16.
  • A camera added or moved goes into this table, with what its /healthz says.

Installing it

A new camera in five steps, on a Raspberry Pi or any Debian or Ubuntu board (arm64 or amd64):

  1. Flash the board, give it a name, and copy your SSH key to it (ssh-copy-id <user>@<host>.local). Attach the camera.
  2. Fetch the newest release's package and check it, on the board:
    v=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/Kataglyphis/OmniAccelerANT/releases/latest | sed 's|.*/tag/v||')
    arch=$(dpkg --print-architecture)
    base=https://github.com/Kataglyphis/OmniAccelerANT/releases/download/v$v
    curl -fsSLO "$base/omni-observant_${v}_${arch}.deb" && curl -fsSLO "$base/SHA256SUMS"
    sha256sum --check --ignore-missing SHA256SUMS
    
  3. sudo apt install ./omni-observant_${v}_${arch}.deb. The service starts, finds the camera by itself (camera = "auto", below) and comes back after every reboot; the nightly update timer keeps it on the newest release from then on (Updates from the releases). With ufw on, sudo ufw allow OmniObservANT.
  4. Open http://<host>.local:8080/. Turn the picture upright with ⟲/⟳ beside the camera's name on the Stream page (from the release after 2.1.0; on 2.1.0, on the Camera page), and name the camera on the Camera page; until then it shows its host name (from the release after 2.1.0; 2.1.0 calls every unnamed camera "ObservANT"). Each camera keeps both in its settings.json.
  5. Add the camera to Where it runs above.

Without sudo (from the release after 2.1.0), the tarball beside the .deb installs for the calling user instead of step 3. The user needs the video group, which Raspberry Pi OS and Debian give the first user:

curl -fsSLO "$base/omni-observant-${v}-linux-${arch}.tar.gz"
sha256sum --check --ignore-missing SHA256SUMS
tar -xzf "omni-observant-${v}-linux-${arch}.tar.gz"
./omni-observant/observant --install --user

It copies the bundle to ~/.local/opt/omni-observant and sets up omni-observant.service and the nightly omni-observant-update.timer under the user's systemd, with linger, so both start at boot without a login. The settings are the user's own ~/.config/omni-observant/observant.toml (mode 600); the visits and settings.json live in ~/.local/state/omni-observant/. --no-autostart and ~/.local/opt/omni-observant/observant --uninstall --user [--purge] work as for the system install. It refuses root, and refuses beside a system-wide install, since both would serve port 8080. Moving to the .deb later: --uninstall --user, step 3, and copy settings.json and visits.json to /var/lib/omni-observant/ (owner omni-observant).

The AppImage either runs or installs. Started plainly, it runs ObservANT in the foreground as the calling user, which needs the video group. --install copies it to /opt/omni-observant and sets up the same service as the .deb:

  • the unit goes to /etc/systemd/system, and omni-observant to /usr/local/bin;
  • --no-autostart leaves the service disabled;
  • running it again with a newer AppImage upgrades, keeping the settings and the enabled or disabled state;
  • omni-observant --uninstall [--purge] removes it;
  • --install --user installs for the calling user instead, with no root (Without sudo above).

The installer is libexec/observant-install in the bundle (package/observant-install in the tree), so the tarball's sudo ./observant --install does the same. It refuses while the .deb is installed; the .deb's postinst calls its setup step too, and uses it to create the user. Without FUSE, set APPIMAGE_EXTRACT_AND_RUN=1.

The flatpak starts at sign-in, not at boot, and takes USB cameras only. Install it with flatpak install ./omni-observant-<version>-<arch>.flatpak; a desktop without the freedesktop runtime it names fetches that from Flathub. Start it with flatpak run org.kataglyphis.omni-observant.

  • It runs the same bundle, from /app/observant on the image's freedesktop runtime, in a sandbox that has the network, every device (--device=all, as the app's flatpak has) and the host's ~/.config/autostart.
  • Its command, package/flatpak-omni-observant, seeds a per-user ~/.var/app/org.kataglyphis.omni-observant/config/omni-accelerant/observant.toml from the template on the first run and writes the sign-in entry. flatpak run --command=omni-observant org.kataglyphis.omni-observant --autostart off|on switches it.
  • A flatpak cannot carry a systemd unit, so it starts when the user who ran it once signs in, never at boot.
  • It cannot use a Raspberry Pi camera: rpicam-vid is a host tool the sandbox cannot see, and the image's libcamera cannot drive a Pi 5. On a Pi, use the .deb or the AppImage.

What the bundle carries. Everything lives in /opt/omni-observant:

  • the producer;
  • the GStreamer plugins it uses (rswebrtc and its WebRTC/DTLS/SRTP stack included), and NSS's crypto modules beside libnss3;
  • the image's chain ONNX Runtime, proved by the hub's G6 census before packaging;
  • deim_dfine_n_coco.onnx (Apache-2.0; its license and notice in share/doc/omni-observant/);
  • the web build;
  • the image's own loader, glibc and GCC 16 libstdc++. The chain ORT needs GLIBC_2.43 and GLIBCXX_3.4.36, which no current distro ships.

The observant launcher runs the producer through that loader with --library-path, never LD_LIBRARY_PATH, so a child it starts, such as the host's rpicam-vid, keeps the host's libraries. libcamera is the one library left to the host, because its IPA and tuning files must match the host kernel.

What the .deb does on install.

  • It creates the omni-observant system user and adds it to video and render.
  • It enables and starts omni-observant.service, so ObservANT comes back after a reboot and after any exit of its own (Restart=always). The unit starts after time-sync.target, so on a board without a clock, where a time-wait-sync service is enabled, the first visit is not stamped with yesterday's time.
    • systemctl disable --now omni-observant turns that off, and upgrades keep the choice.
    • OMNI_OBSERVANT_AUTOSTART=0 on the first install leaves the service disabled.
  • setup writes /etc/omni-accelerant/observant.toml only when it is absent (the first install), with every setting commented out; nothing overwrites it. omni-observant --print-config shows the settings in effect.
    • Only root and the service's group read it (640 root:omni-observant), because a notify_url can carry a webhook's secret. An install before 2026-10-13 wrote it world-readable; the next upgrade takes other users' read away and keeps its content.
    • It is deliberately not a conffile. dpkg asks about an edited conffile on every upgrade that changes it (keep or replace), and over SSH or in an unattended upgrade nobody answers, so the install hangs. Found on himbeere2, 2026-10-05.
    • apt purge removes it.
  • sudo ufw allow OmniObservANT opens 8080/tcp and the WebRTC range 40000:40099/udp.

Cameras

How it picks a camera (camera = "auto"), in this order:

  1. A Raspberry Pi camera through the host's rpicam-vid. The image's libcamera cannot drive a Pi 5, which is why the host tool goes first.
  2. libcamerasrc.
  3. A USB webcam (uvcvideo), MJPEG when it offers no raw format.
  4. A test pattern as a stand-in, which re-probes every 30 s, so a camera plugged in later is picked up.

Detection, visits and /healthz

/healthz (fields: the endpoint table under The ObservANT package) gives last_seen in Unix seconds and the latest 20 visits, newest first (recent: arrived, and stayed_s once the cat has left). A quick look without WebRTC: curl -o cat.jpg http://<host>:8080/snapshot.jpg.

ok is false when the camera does not deliver (2026-10-13), and problem says why: "waiting for the first frame", "no frame for 25 s" after 20 s without one, or "no camera found, streaming a test pattern". Until then ok was always true, so a monitor saw an ObservANT whose camera had gone as healthy. The Stream page and the Camera page show the problem in orange above the cat.

A box over nearly the whole picture is not a cat (2026-10-17, owner): a detection that covers more than max_box_area of the frame (default 0.9) is dropped before it can count, as one over a "Not a cat" spot is. Eight of himbeere2's last 20 visits on 2026-10-16/17 were such boxes, 0.95 to 0.99 of the frame at scores near 0.3; a cat close to the lens fills about 0.7. max_box_area = 1 keeps every box.

Counting. A cat counts after two detections in a row and is out of view after about 3 s without one, so a lone hit near the score threshold draws no box and logs nothing. Its visit ends a minute after its last sighting (2026-10-14): a cat back sooner, from behind the sofa or the feeding bowl, continues the visit and is not announced again. At the feeding trough one cat walking about had made 21 visits in an hour, 10 to 90 s apart. The departure event and stayed_s come a minute later now, and stayed_s counts from the first sighting to the last.

Model pacing. While nothing moves and no cat is in sight the model runs only every 5 s (idle_inference_fps), and not at all while the picture is black; motion brings back inference_fps, and /healthz counts the runs (model_runs). On a Pi 5 that took a still, empty scene from 74 % to 12 % of a core, and a dark room at night to 2 %. A cat in view that does not move, asleep or a false one, is looked at half as often (2026-10-13): himbeere2's drying rack, counted as a cat at 0.34 all afternoon, went from 69 % to 36 % of a core. Leaving is motion, so the cat is out of view as fast as before. inference = "auto" turns the model off below 768 MiB of RAM: a 512 MB Pi Zero 2 W reports about 430 MiB, a 1 GB Pi 3 905 MiB. It was 1 GiB until 2026-10-15, which kept the Pi 3 from detecting at all.

A cat must move to arrive (2026-10-14). A detection starts a visit only where the picture changed in the last 10 s, in at least two 8-pixel blocks inside its box or one block around it, so a still object the model takes for a cat, like himbeere2's drying rack (92 "visits" by 13:40 on 2026-10-13), can no longer start one. Once a cat is in view, every detection keeps its visit going, so a cat that sits down or falls asleep stays counted. What the camera sees as it starts counts as just arrived, so a cat already there when the service starts is counted then. The motion comes from the same 2 Hz looks that pace the model; with that pacing off (idle_inference_fps at or above inference_fps, or inference_fps = 0), every detection may start a visit, as before.

A live /healthz from himbeere2 (2026-10-14), its lists cut to two entries each and the peers' addresses left out:

{
  "camera": "Raspberry Pi camera via rpicam-vid (imx219 [3280x2464 10-bit RGGB] (...))",
  "detection": {
    "boxes": [],
    "clip": 1791965878,
    "clips": [1791965878, 1791961744],
    "in_view": false,
    "last_score": 0.6694751977920532,
    "last_seen": 1791965880,
    "recent": [
      { "arrived": 1791965878, "boxes": [[0.001, 0.693, 0.608, 1.0]], "stayed_s": 0 },
      { "arrived": 1791961744, "stayed_s": 172 }
    ],
    "visits": 154
  },
  "frame": { "fps": 30, "height": 480, "width": 640 },
  "frame_age_s": 0,
  "inference": true,
  "model_age_s": 0,
  "model_runs": 2146,
  "name": "Cat Cam Büro",
  "ok": true,
  "peers": ["http://<other-cat-cam>:8080", "http://<third-cat-cam>:8080"],
  "problem": null,
  "rotate": 180,
  "score": 0.25,
  "version": "2.0.0 (e7a7957)",
  "viewers": 0
}

Before the first cat, last_score, last_seen and clip are null, and clips and recent are empty.

model_runs counts since the service started. visits, last_seen, last_score, recent and /arrival.jpg survive a restart: the service keeps them in /var/lib/omni-observant, its systemd state directory, on every arrival and departure and once more when it stops. A visit the stop cut short is closed at the cat's last sighting when the service starts again; after a power cut, which writes nothing, at the last arrival or departure written. The flatpak keeps them in its own ~/.var/app/…/.local/state, the Windows ObservANT in %LOCALAPPDATA%\omni-observant.

The Stream page reads the same /healthz every 5 s while it shows, and at once when it comes back from another tab (CatStatusLine in lib/Pages/StreamPage/cat_status.dart). Under the video it puts ObservANT's name; "Cat in view", "Cat last seen: 14:32" or "No cat seen yet", in English or German; the three latest visits ("Recent visits: 14:32 (3 min) · 11:05 (10 min)"; one still going on has no stay yet); and from the first visit on the last arrival's picture. Its URL carries the visit count (/arrival.jpg?visit=3), not last_seen, which moves on every model run while a cat stays, so the page fetches the picture once per visit. On the photo, with the cat in view for 35 s, headless Chrome sent seven /healthz requests and one /arrival.jpg (2026-10-12). Served from anywhere else, where /healthz is no ObservANT's answer, it shows only the title.

Since 2026-10-14 the page also:

  • says "Camera unreachable since 14:32" in orange once an ObservANT camera that answered stops answering, and keeps what it said last, its name, visits and picture;
  • folds out the whole visit log ("All 20 visits") under the three latest;
  • shows each earlier video with its arrival's picture (/arrival.jpg?t=), so the right one is found at a glance;
  • closes the video in a tab hidden for a minute. ObservANT encodes for each viewer, so a forgotten tab cost a core's share all night; a glance at another tab keeps the picture, and showing the tab again opens a new session at once.

A tab you come back to plays again (2026-10-14). The owner's old tab stayed on "Der Videostream wird ausgehandelt" while a new one played: after a laptop sleeps or the Wi-Fi changes, the browser can hold a signalling socket that looks open and carries nothing, and gstwebrtc-api reconnects only on a close event, which never came, so the page asked a dead socket for a stream. Reproduced in headless Chrome through a proxy that silences the page's open connections and keeps new ones working. Three guards now:

  • a tab that was hidden long enough to close its session rebuilds the signalling connection when it shows again, before it asks for a stream;
  • a session that gets no answer within 15 s rebuilds it too;
  • a picture whose decoded-frame count stands still for 10 s while streaming in a visible tab (the camera restarted behind the dead socket) closes the session and rebuilds it.

The cat line's and the overview's requests give up after 8 s, so one on a dead connection no longer waits for minutes. Measured: back after 75 s with the socket silenced, the video played again within 3 s; with the service restarted after the return, 9 s after it.

An overview of the other ObservANT cameras (2026-10-14). List them in observant.toml as peers, each one's origin, on every ObservANT:

peers = ["http://192.168.1.51:8080", "http://192.168.1.45:8080"]

The Stream page then shows a card per peer under its own cat: the peer's name, a picture refreshed every 10 s, and its cat line, or "Not reachable"; a tap opens that ObservANT's page. A peer answers this page's reads (/healthz, /snapshot.jpg, /arrival.jpg, /arrival.mp4) with Access-Control-Allow-Origin and Cross-Origin-Resource-Policy: cross-origin only when it lists this page's origin among its own peers; nothing else, and no page outside the list, gets those headers. So list them both ways, and by the address the browser uses.

Arrival picture, video and webhook

It can tell you the cat came. Set notify_url in observant.toml and the service POSTs a JSON event there whenever a cat arrives or leaves:

{"event": "arrived", "name": "ObservANT", "time": 1791763273, "class": "cat",
 "detections": 2, "best_score": 0.76, "snapshot": "/arrival.jpg"}
{"event": "clip", "name": "ObservANT", "time": 1791763273,
 "best_score": 0.76, "clip": "/arrival.mp4", "snapshot": "/arrival.jpg"}
{"event": "left", "name": "ObservANT", "time": 1791763375, "stayed_s": 42,
 "arrived": 1791763273, "best_score": 0.76}

The departure is posted a minute after the cat was last seen, so its time lies that much after arrived plus stayed_s.

/arrival.jpg is the frame the arrival was confirmed on, boxes included, so a notification can show what triggered it. Each of the last five arrivals keeps its own, /arrival.jpg?t=<time> (2026-10-13), as the clips do: until then a false visit a minute later replaced the picture a cat's notification showed.

A Home Assistant webhook trigger takes the events as they are: its URL http://<home-assistant>:8123/api/webhook/<id> is the notify_url, and trigger.json.event tells arrival from departure. https works too. Deliveries run on a thread of their own with a 5 s timeout. A failed one is tried again after 5 s, 15 s, 45 s and 2 min, so a Home Assistant restart loses no visit; the events stay in order, and one older than ten minutes is dropped, since a notification that late helps nobody. The log names only the URL's scheme and host, because a webhook's path is often its secret.

Each arrival is a 10 s video too. The capture thread hands every annotated frame to clip.rs, which keeps one in three (10 fps of the 30) for the last 3 s as buffer references, so nothing is copied. An arrival takes those and the next 7 s to a thread of its own, which encodes them with openh264enc at 800 kbit/s (about 1 MB a clip at 640x480), h264parse and mp4mux faststart=true, so the index comes first and a browser starts playing at once. /arrival.mp4 serves it with byte ranges, which Safari's <video> needs; /healthz names the newest one's arrival as detection.clip; and notify_url gets a clip event once it is written, with the arrival's time and best_score. A second arrival during a clip's 7 s gets a clip of its own (2026-10-13): up to three record at once and share their frames, and two finished ones wait for the encoder.

The last five clips stay addressable by arrival, as clip-<time>.mp4 in the state directory, across a restart too: /arrival.mp4?t=<time> serves that arrival's own, and 404 once it is dropped, never a newer one. A false visit right after the cat (33 of them on himbeere2's morning of 2026-10-12) would otherwise have put its clip behind the cat's notification. The arrival event carries the visit log's own time, the one the clip is kept under. The Stream page links the newest clip by its time once detection.clip is the newest visit, so it never offers the previous cat's clip, and lists the other kept ones beside it ("Earlier videos: ▶ 18:07 ▶ 18:05", from detection.clips).

Measured on himbeere2. The packaged service, run on the cat photo at 30 fps (2026-10-12), confirmed the arrival at 16:21:40 and served the clip at 16:21:47: seven seconds of post-roll, and encoding and writing in under one. It was H.264 Constrained Baseline, which every phone and browser plays, 640x480 at 10 fps, 78 frames (the pre-roll was short, as the instance had just started) and 603 kB; /arrival.mp4 answered Range: bytes=0-99 with 206. With its own camera that evening (18:03 to 18:08) six arrivals of the drying rack at the bottom of the frame made one clip each, the full 100 frames, of 108 to 182 kB: a still room compresses far below the bitrate's ceiling. Memory plateaus rather than grows: forty 100-frame 640x480 encodes in a row (a release build, 0.6 s each) held the process at 70 MB from the sixth on, glibc keeping the freed frames for the next clip, and the live service sat at 282 MB for 20 minutes after fourteen arrivals, that plateau plus the model's.

clip_seconds sets the length (0 records none). Frames wait in memory until encoded, about 46 MB for 10 s, which is why the setting stops at 60. The package bundles isomp4 for mp4mux (openh264 and videoparsersbad were in it already); a GStreamer without one of the three records no clip and says which at start. test-observant-package.sh checks that the photo's arrival yields one, that it is an MP4 that answers a byte range, that its event reaches the webhook, and that a restart keeps it.

Home Assistant

A phone notification with the picture, through Home Assistant. This automation sends an arrival to the Home Assistant companion app, with /arrival.jpg as its image and the live page behind a tap. Running on himbeere2 since 2026-10-12 (Home Assistant 2026.10.1):

# automations.yaml; both values live in secrets.yaml, so this file can be committed.
- id: catcam_arrival_notification
  alias: Katze ist da (Cat Cam)
  mode: parallel        # the clip's update must not wait behind the arrival's run
  triggers:
    - trigger: webhook
      webhook_id: !secret catcam_webhook_id   # notify_url ends in this
      allowed_methods: [POST]
      local_only: true
  variables:
    catcam: !secret catcam_base_url           # e.g. http://<cat-cam-host>:8080
    video: "{{ trigger.json.event == 'clip' }}"
  conditions:
    - condition: template
      value_template: >-
        {{ trigger.json.event in ['arrived', 'clip']
           and (trigger.json.best_score | float(0)) >= 0.45 }}
  actions:
    # The arrival sends the picture; its clip, some seconds later, updates the same notification.
    - action: notify.mobile_app_<phone>
      data:
        title: "🐱 Katze ist da"
        message: >-
          {{ trigger.json.name }}: um
          {{ trigger.json.time | timestamp_custom('%H:%M') }} gesehen,
          {{ ((trigger.json.best_score | float(0)) * 100) | round(0) | int }} % sicher.
        data:
          image: "{{ catcam }}/arrival.jpg?t={{ trigger.json.time }}"
          clickAction: >-
            {{ catcam ~ ('/arrival.mp4?t=' ~ trigger.json.time if video else '/') }}
          tag: catcam-arrival
          alert_once: true
  • A webhook id is a password. Generate it (python3 -c 'import secrets; print("catcam-" + secrets.token_urlsafe(24))') and keep it out of git.
  • Notify above 0.45, count from score. On 2026-10-12 a white object at the bottom of himbeere2's frame made 33 "visits" between 0.26 and 0.44, and a real cat at the feeding trough scored 0.72 (2026-10-14). The condition keeps those off the phone while the visit log still counts them; raising score instead would drop them from both. It is not enough on its own: at 21:57 the same object scored 0.65 and reached the phone (BACKLOG, False arrivals). For a spot like that, raise that one camera's score above the false object's on the web app's Camera page (since 2026-10-13): it applies at the next model run, needs no SSH, and leaves the other cameras as they are.
  • The companion app's minimal build has no push service. It receives notifications only over its own connection to Home Assistant, so set Persistent connection to Always in its server settings; the full build gets them through Firebase. A push while the app is not connected fails with not connected to local push notifications and is gone, so himbeere2's automations send each notification three times a minute apart under one tag with alert_once: true, and continue_on_error on the call: a repeat replaces a delivered one silently and reaches a phone that reconnected. Each run then lasts about two minutes, so give mode: parallel a max well above its default of 10: on the morning of 2026-10-14 three cameras' arrivals, clips and departures overran it and Home Assistant dropped 30 events (Maximum number of runs exceeded), their counts included. himbeere2 runs max: 40.
  • The picture loads from the phone, so catcam_base_url must be an address the phone can reach: an IP or a name its network resolves, not loopback.

A test arrival posted to that webhook reached the owner's phone on 2026-10-12.

The same notification learns when the cat left. The departure (a minute after the last sighting, Detection, visits and /healthz) names the arrival it ends (arrived) and that arrival's best_score, so an automation that lets left through its condition too (himbeere2's does; the one above does not) takes it under the same 0.45 and updates the notification by its tag: "Katze war da … War 4 Min. da." A clip event carries stayed_s too when the visit was over before its clip was encoded; with a visit lasting a minute past the last sighting and a clip of at most 60 s, that hardly happens. himbeere2's automation keeps the last departure in an input_text ("<arrival time> <seconds>"), so its minute-apart tries of an arrival keep the stay a departure in between added.

At 21:00 a summary. Each notified arrival appends its time to another input_text, which the midnight reset clears with the day's counter, and a time trigger sends "Heute 2 Katzenbesuche: 07:12, 18:30." or "Heute keine Katze gesehen." under a tag of its own.

More than one ObservANT. Each posts to a webhook of its own, and one automation takes them all: a webhook trigger per camera with an id, and variables that map that id to the camera's base URL (both in secrets.yaml), its video sensor and the place a notification names ("Katze ist am Futtertrog"). The tag carries the id (catcam-arrival-<id>), so one camera's notification never replaces another's, and the stay is kept as "<id> <arrival> <seconds>". The counters count both, and the summary names the place with each time ("21:57 Büro, 22:30 Futtertrog"). A second rest entry polls the other camera's /healthz, and the outage alert watches both online sensors, under one tag per camera. Since 2026-10-12 himbeere2's Home Assistant takes its own cat cam ("Cat Cam Büro", on loopback) and tabblscatcam's ("Cat Cam Futtertrog", a second Pi 5 with a Camera Module 3); a departure posted from tabblscatcam to its webhook updated that camera's notification and stay.

Statistics and an outage alert, from /healthz. Home Assistant's rest integration polls http://127.0.0.1:8080/healthz every 30 s when it runs on ObservANT's host: detection.in_view, detection.last_seen, detection.visits, detection.recent[0].stayed_s and ok become sensors, and with ObservANT unreachable they all turn unavailable. On himbeere2 (2026-10-12):

  • the arrival automation increments two counters, today and total, so they count only what reached the phone; a template sensor with state_class: total_increasing mirrors total, which lets a statistics-graph card draw visits per day, and a time trigger resets today at midnight;

  • binary_sensor.cat_cam_online unavailable or off for five minutes sends "ObservANT offline", and its return after such an outage sends "ObservANT wieder da"; a Home Assistant restart's few seconds stay quiet. Stopping omni-observant for 5 min 45 s sent the first at 5 min, on the dot.

  • Off and unavailable say different things (2026-10-14). Since /healthz reports ok: false for a camera without a picture, the online sensor is off while ObservANT answers but sees nothing, and unavailable only when it does not answer. A sensor per camera keeps the reason, and the alert names it: "hat seit 5 Minuten kein Bild (no frame for 300 s)" for the first, "antwortet seit 5 Minuten nicht" for the second.

        sensor:
          - name: ObservANT Problem
            unique_id: catcam_problem
            icon: mdi:camera-off
            value_template: "{{ value_json.problem or 'ok' }}"
    
  • A sensor that has no value yet needs availability, not none. A rest template that returns none renders the text "None", which a timestamp sensor logs as an invalid timestamp on every poll and a duration sensor refuses at load (ValueError: ... has the non-numeric value: 'None'). A new camera has no sighting yet, and a visit in progress has a null stayed_s, so last_seen and the stay are guarded with availability templates ({{ value_json.detection.last_seen is number }}) and show unavailable instead.

The Stream page

An open page survives a restart of the service. The page sees its producer go and come back and consumes the new one, with no reload: on himbeere2 (2026-10-12) headless Chrome's video was playing at 30 fps again about 10 s after systemctl restart omni-observant, as an upgrade does it.

A language or theme change keeps the picture. Either remounts the video box, and the stream's state lives outside it: one WebRTCStreamController per page load (lib/Pages/StreamPage/stream_controller.dart) registers the signalling listeners once. A remounted view attaches to the session as it was. A view that comes back after the session's 30 s keep-alive opens a new one at once from the producers the client already knows, because gstwebrtc-api never repeats a connected or producerAdded that has fired. A failed session is retried after 3 s. In headless Chrome on a test pattern (2026-10-12) the video kept counting about 33 frames a second through a switch to English and back, a theme toggle and two page changes. Before, a language switch left the box at "Connecting to the signalling server".

What a browser downloads (measured on himbeere2, 2026-10-14, Chrome 154 on the LAN): the first visit 4.5 MB in about 60 requests, the video playing after 3.6 s; every later visit 0.11 MB, the video after 2.9 s, because the server answers each file with an ETag and Cache-Control: no-cache, and an unchanged one costs a 304 without a body. Half of the first visit is Flutter's renderer, skwasm_heavy.wasm (2.2 MB gzipped). Flutter takes the heavy one when the browser has no native image decoder, and Chrome offers WebCodecs' ImageDecoder only in a secure context, which plain HTTP on a LAN address is not. So over http://<board>:8080 every browser gets it; served over HTTPS or from localhost, Chrome would take the lighter skwasm.wasm. The app itself, main.dart.wasm, is 1.5 MB, the six Roboto files about 0.5 MB.

Visit statistics (2026-10-15). A "Visits" button shows the visits of the last two weeks, today's count, the average stay, and bars per day and per hour of the day, counted from /visits in the viewer's time zone (lib/Pages/StreamPage/visit_stats.dart).

One port reaches it. The page and the /webrtc-ws signalling share :8080: the service forwards the WebSocket upgrade to its own signalling server on loopback :8443. Media goes over host candidates only unless stun_server is set, so the stream stays on the LAN. No TLS, no serve.sh, no container.

A TURN relay needs its credentials (2026-10-21). Each turnServers entry in assets/settings/webrtc_settings.json is {"urls": "turn:<host>:3478", "username": "<user>", "credential": "<password>"}, urls a string or a list. Chrome refuses every peer connection over a TURN URL without both, so an entry missing one is skipped with a log line. The committed list stays empty: real credentials go into the served copy, assets/assets/settings/webrtc_settings.json under the web root (build/web before a deploy, /opt/omni-observant/web on a board, where a package upgrade replaces it), never into a repository. Every viewer's browser receives them, so give the relay a user of its own.

Questions on the Stream page

When /healthz says "ask": true, the Stream page under Visits offers Ask about the visits: a question field from ANThology's chat widgets and a Summarise today button. They call ObservANT's /ask and /summary on the page's own origin, with the browser's offset from UTC and the app's language.

  • A camera without a model shows nothing. So does an older camera, and every native build, whose Stream page plays the local webcam and has no ObservANT to ask.
  • The answer comes from the visit figures, not the picture, and a language model can be wrong. The panel says so, and What the model was told shows the figures behind the last answer.
  • Failures read as the camera's words: no model answering, a question already running, a model that failed. The stop button closes the request, which cancels the answer on the camera.
  • A camera needs a model named in its config. An ObservANT package carries no GenAI library and no weights, so name an endpoint (llm_base_url, llm_model) or a chain install through ONNX_GENAI_ROOT and llm_model_dir.
  • A camera's overview of its peers offers no questions: /ask takes only its own page.

Tests: test/visit_questions_test.dart (the conversation and the panel on a fake camera) and, in Chrome, test/observant_api_web_test.dart (the POST, its status and the cancel).

What it detects

Cats by default, any of the 80 COCO classes on request (2026-10-14). classes in observant.toml names them (["cat"] unless set); the Camera page's "What it detects" adds one from a menu that filters as you type, or removes one by its chip; the last cannot go. A change applies at the next model run and stays across restarts. Everything that counts a cat counts any of them: visits, the webhook events, the arrival photos and clips, the boxes. Each visit in /healthz's recent and each arrived event names its class, the class of its best detection; /healthz lists classes. all_classes = true still counts every class. The privacy filter keeps every watched class sharp, so watching person shows people. While it is on, only observant.toml sets the classes: the Camera page shows them without the editor, POST /settings refuses other ones, and classes a page saved earlier give way at the next start (2026-10-14).

Stats for developers

A "Stats" button under the camera's name (2026-10-14) opens a panel that refreshes every 2 s while open: what ObservANT delivers and costs (/healthz's stats: frames per second against the configured rate, the model's time per run and runs per second, its CPU share of one core, the cores, memory, the 1-minute load, the board's temperature, how long it has run, the viewers) and what this browser makes of the stream (frames per second received, the picture's size, frames dropped so far). Closed, it polls nothing.

The privacy filter

For showing ObservANT in public without showing the home (2026-10-14). With privacy = "pixelate" in observant.toml, everything but the detected animals leaves the camera as coarse blocks: 32 pixels at 640 wide, each its own mean colour. Only the boxes stay sharp, each grown by 15 % on every side, since the model's boxes are up to half a second old. With no box the whole picture is blocks, people included.

  • It applies to everything that leaves the camera: the live stream, /snapshot.jpg, the arrival photos and the clips. The model still sees the whole picture, so detection works as before.
  • Only the settings file sets it, then sudo systemctl restart omni-observant. No page can turn it off, so a visitor at a demo cannot either; nor can one unblur people by watching person, since the filter locks the classes to observant.toml's (What it detects). /healthz says "privacy": "pixelate"; the Stream page shows "Privacy filter on" under the camera's name, and the Camera page names it in its table.
  • Arrival photos and clips recorded before it was turned on stay as they were. The other ObservANT cameras in peers show their own pictures, filtered only where they set it too.
  • It costs little: one pass over each frame, no second model.

Tests

test-observant-package.sh checks all three packages, and CI runs it: web.yml's observant jobs, on x64 and arm64, right after packaging. It runs as root in a privileged :latest, prints one PASS or FAIL per check with its seconds, counts them on the hub's lib/test-harness.sh and exits 0 only when all pass. A package without debugutilsbad reds five of its checks. Locally:

bash scripts/linux/observant/test-observant-package.sh   # root, inside :latest; finds out/'s packages

What it checks, for the .deb and the AppImage each installed in :latest and run as the unit runs it, with a scrubbed environment as omni-observant:

  • no shared object maps from outside the bundle, idle or with a viewer connected;
  • the package's own files: the service user is in the video group, the settings file is 640 root:omni-observant (an older world-readable one is made private), and the unit restarts whatever ended it and starts once the clock is set;
  • /healthz says ok with no problem while frames come, and names the installed package's version;
  • the Camera page's settings: /settings turns the picture and sets the score and the name, a restart keeps them, and it refuses a form post, a bad value and another site's page;
  • "Not a cat" over the photo's boxes ends its visit, and the still photo watched again starts none;
  • apt remove masks the unit and a reinstall unmasks and enables it; the AppImage's --install refuses a non-root caller, and its installed copy refuses --install;
  • every setting the shipped observant.toml documents, uncommented, is one the service reads (--print-config takes them all);
  • the installed unit loads without a warning (systemd-analyze verify) and keeps its sandbox: systemd-analyze security rates it 2.5 or less (1.8 on 2026-10-12, 8.7 before the unit was sandboxed);
  • headless Chrome played the stream over the LAN address;
  • a tab put in the background, its connections then silenced by a proxy that keeps them open and carries nothing (lib/silencing-proxy.py, as a sleeping laptop leaves them), plays again after it shows (lib/cdp-tab-return.mjs, 2026-10-14);
  • the bundled model found the cats in ANThology's Summy&Thundy photo (best score 0.41 since 2026-10-13, 0.76 before: the portrait photo is bordered into the 4:3 frame now instead of stretched, so the cats are smaller), /healthz counted the visit and /snapshot.jpg served the frame as a JPEG;
  • the arrival reached a stand-in webhook as a JSON POST, and /arrival.jpg served the frame it was seen on;
  • the arrival's clip was encoded and named first in /healthz's clips, /arrival.mp4 served it as an MP4 and answered a byte range, ?t= served that arrival's own and 404 for another, and the clip's event reached the stand-in webhook;
  • the visit log timed the stay while the cat was in view, and a restart kept the visit, the arrival frame and the clip, and closed that visit at its last sighting;
  • the web build left out the wimp and webparagraph renderers and the *.symbols debug maps, every file the server gzips came with its zopfli .gz, and the served main.dart.wasm was that .gz byte for byte;
  • SIGTERM exits 0, and no inference fails on the way out;
  • a first start on a fresh registry still streams when the plugin directory lists libgstrswebrtc.so first (a tmpfs bind-mounted over the bundle's lib/gstreamer-1.0). An in-process registry scan registers webrtcsink before its encoders in that order, its codec list stays empty and the stream fails with No codec available for encoding stream video_0; which order a host gets depends on its filesystem, so the other checks pass or fail by chance. The launchers name the bundled scanner in GST_PLUGIN_SCANNER, which keeps OxidANT's ensure_gst_initialized from switching to the in-process scan (OxidANT's docs/observant.md § A fresh registry and webrtcsink);
  • the AppImage's --install, a second --install over it, and --uninstall --purge left the expected files, and then none;
  • the .deb installed over an edited observant.toml with no terminal, kept the edit, and dpkg --purge removed it;
  • the flatpak, installed system-wide (a user install needs a D-Bus session the container lacks), answered /healthz from its sandbox, kept the cat's visits in its own state directory, and headless Chrome played its stream over the LAN address. Its first start wrote the sign-in entry, --autostart off removed it, and flatpak uninstall left nothing.

Results: on amd64 in :latest (2026-10-06) all 32 checks passed, and Chrome played 572 frames from the sandboxed stream; the flatpak bundle is 63 MB. On arm64 on himbeere2 (2026-10-12) all 43 checks passed, with about 510 frames to Chrome each time, and all 47 once the arrival clip added four; all 51 on 2026-10-13, three of them on the precompressed web build; all 69 on 2026-10-14, in 244 s (each line names its seconds; the tab that comes back takes 113 of them, as it must stay hidden past the page's 60 s). CI passed the 41 from before the visit log on both architectures (web.yml run 38197312619).

Field tests on real boards

What each board showed when a package or install path first ran on it; today's cameras are in Where it runs.

On a Raspberry Pi 5 (himbeere2: imx219, Debian 13, kernel 6.18), 2026-10-05. The arm64 packages were built natively on the board in :latest (CI's web.yml builds them on ubuntu-26.04-arm). The .deb ran as the systemd unit:

  • the camera came through rpicam-vid, with rotate = 180 from the settings;
  • exactly one rpicam-vid child ran, and no shared object mapped from outside the bundle;
  • headless Chrome on another LAN host played 821 frames in 28 s;
  • with inference_fps = 2 the service used 132 % of one core, and the board ran at 63 °C;
  • the same path, fed ANThology's photo, delivered the green YOLO boxes to that browser;
  • after a reboot the unit was active about 11 s after the kernel started, on the Pi camera again, and the browser played about 30 fps;
  • a firewall needs rules: with ufw active, sudo ufw allow OmniObservANT opens 8080 and the WebRTC UDP range; himbeere2's existing rules already covered both;
  • a dark room is a black stream: the imx219 has no IR, and the sensor itself read a mean Y of 0.5 out of 255 at night.

On a SpacemiT riscv64 board (mintberrycrunch: Ubuntu 26.04, 8 cores, 16 GB, Logitech C270), 2026-10-13. package-observant.sh built the riscv64 .deb natively on the board in the riscv64 :latest in 7 min 53 s (the producer's cargo build 6 min 10 s); a Raspberry Pi 5 cannot emulate it, because its 16 KiB pages break QEMU on the image's libraries. The unit:

  • found the webcam on its own (v4l2src /dev/video9 (C270 HD WEBCAM)) among the SoC's codec and ISP nodes;
  • needed sudo ufw allow OmniObservANT, as the package says, before the LAN reached it;
  • streamed to headless Chrome on himbeere2 at about 24 fps (767 frames in 35 s);
  • used 15 % of one core and 282 MB with the room empty, the model running every 5 s.

The AppImage on himbeere2, 2026-10-06:

  • run plainly as the login user, it mounted through FUSE, and Chrome on another host played 844 frames in 28 s;
  • the switch a user might make, .deb → apt remove → AppImage --install → --uninstall → .deb, ended enabled and running at every step, on the Pi camera, with rotate = 180 kept. It took two fixes: observant-install treats the removed .deb's mask as a first install, and it clears that package's deb-systemd-helper record, setting DPKG_MAINTSCRIPT_PACKAGE, since the tool refuses to run outside dpkg otherwise. Without that, the returning .deb read the AppImage's --uninstall as the admin's disable and stayed off;
  • --install wrote a unit byte-identical to the .deb's.

On a real desktop (Ubuntu, GNOME, amd64), 2026-10-08. CI's flatpak from web run 37664412593 installed as a user install. flatpak install --user fetched its runtime from Flathub: freedesktop 24.08, which Flathub marks end-of-life, because that run's :latest predates the 26.08 refs (hub CON82). Its first run seeded the per-user observant.toml, wrote ~/.config/autostart/org.kataglyphis.omni-observant.desktop, loaded the chain ORT and the model from /app/observant, and answered /healthz (test pattern, no camera on that host). The host's Chrome played 582 frames from the LAN address.

The user install on a Raspberry Pi 4 (himbeergsaelzweckle: 8 GB, Debian 13, Camera Module 3 Wide), 2026-10-16. 2.1.0's tarball predates --install --user, so its libexec/ scripts and share/omni-observant/ units were the repository's, plus a version file. Under the user's real systemd:

  • --install --user replaced a hand-written user unit, kept rotate = 180 from settings.json, turned linger on and armed the update timer;
  • the camera came through rpicam-vid at 30 fps, with 7 % CPU, 216 MB and 0.91 s per detection;
  • the timer's own omni-observant-update.service took a local stand-in release "2.1.1": checked its tarball against SHA256SUMS, installed it, restarted the camera and left no download.

What no board has shown yet, such as the flatpak's start at a real sign-in or a USB webcam on amd64 or arm64, is in BACKLOG.md under Open — ObservANT.

Updates from the releases

Since 2026-10-15 the .deb updates itself. omni-observant-update.timer, enabled at install like the service, runs /opt/omni-observant/libexec/omni-observant-update as root every night at 03:30, spread over 45 minutes. It follows GitHub's releases/latest redirect to the newest v<version> tag, and only when that version is newer than the installed one (never a downgrade) downloads omni-observant_<version>_<arch>.deb, checks it against the release's SHA256SUMS and installs it with dpkg -i; the package's postinst restarts the service. No release yet, a latest release from before the v tags, or one without this architecture's package (riscv64, which no CI lane builds) leaves the camera as it is.

  • Off: sudo systemctl disable --now omni-observant-update.timer, which an upgrade respects.
  • Check at once: sudo systemctl start omni-observant-update.service; journalctl -u omni-observant-update shows what it did.
  • scripts/linux/observant/test-observant-update.sh runs it in web.yml's package jobs against a local stand-in for GitHub with fake packages: an update, no downgrade, a failing checksum and a missing architecture.

A user install updates itself the same way, without root (2026-10-16). Its user timer runs ~/.local/opt/omni-observant/libexec/omni-observant-update --user, which compares the newest release with the bundle's share/omni-observant/version, downloads the release's tarball (omni-observant-<version>-linux-<arch>.tar.gz; no FUSE needed, unlike the AppImage), checks it against SHA256SUMS and runs its observant --install --user. The download goes to ~/.cache/omni-observant/update and is removed after: /tmp can be RAM, and a Pi Zero 2 W has 512 MB. Without dpkg the versions compare with sort -V. systemctl --user start omni-observant-update.service checks at once. test-observant-update.sh covers it too, as uid 1001 and once with no dpkg on the PATH; test-observant-package.sh installs the AppImage's --install --user as a plain user, runs it, upgrades it and purges it.

The Windows ObservANT

scripts/windows/observant/, built and tested in :winamd64 (2026-10-06), is the Windows twin of the package above. scripts/windows/observant/Package-ObservANT.ps1 builds one install folder and the zip and the MSI around it (observant.wxs through the hub's Invoke-MsiPackage). Like package-observant.sh it stamps the build into the exe as OBSERVANT_VERSION (the version and the short commit), which /healthz and the Camera page show. Run it inside :winamd64 with the web lane's build:

pwsh -File scripts\windows\observant\Package-ObservANT.ps1 -WebRoot <build\web>
# out\omni-observant-<version>-windows-x64.msi   (one click: double-click it)
# out\omni-observant-<version>-windows-x64.zip   (the same folder, portable)
# with -Msix also:
# out\omni-observant-<version>-windows-x64.msix           (signed; see "The MSIX" below)
# out\omni-observant-<version>-windows-x64-testcert.cer   (only without -MsixPfx)

Windows arm64 (since 2026-10-16). -Arch arm64 cross-builds the producer for aarch64-pc-windows-msvc inside the image's arm64 bundle (:winarm64), takes its DLLs from the arm64 runtime and the VC++ runtime from Visual Studio's arm64 redist, never the x64 container's System32, and names the files …-windows-arm64.*. An x64 host cannot run them, so web.yml's observant-windows-arm64-test installs the MSI on windows-11-arm: first the hub's arch gate over the unzipped package (67 binaries, all arm64, every import resolved), then the same Test-ObservANTWindows.ps1, which there also checks the real firewall rule. That host has no GStreamer of its own, so the viewer checks say SKIP; the x64 run plays one. Media Foundation on a desktop loads Microsoft's own Store codecs (HEVC, VP9), which the module check counts as Windows. ONNX Runtime's own imports are part of the DLL closure: its msvcp140_1.dll was missing from the first arm64 package, and from the x64 one on a PC without the VC++ redist.

One folder. C:\Program Files\ObservANT holds:

  • kataglyphis_observant.exe;
  • the DLLs it and its plugins import (the hub's Copy-PeImportClosure), plus the VC++ runtime, which a clean Windows lacks;
  • the GStreamer plugin subset in lib\gstreamer-1.0, with Media Foundation and kernel streaming for the camera, and openh264 as the stream's codec. openh264 is a required plugin on Windows, because the image builds no vpx;
  • the plugin scanner;
  • the chain ONNX Runtime from Copy-ChainOrtBeside, proved by the hub's G6 census (New-OrtProvenPayload) before packaging;
  • models\deim_dfine_n_coco.onnx (its license and notice in share\omni-observant\) and the web build.

The web build is cut and compressed as on Linux (2026-10-17; § The web build ships compressed). Package-ObservANT.ps1 leaves out the wimp and webparagraph renderers and the *.symbols maps, about 18 of the build's 55 MB, and stops if flutter_bootstrap.js ever asks for those renderers. On x64 it runs the producer's --precompress over its copy, so the service sends zopfli .gz files. The arm64 package has none: its producer cannot run on the amd64 host that packages it, so that service gzips at run time, as every Windows one did before. Test-ObservANTWindows.ps1 checks the cut, that main.dart.wasm goes out gzipped and unzips to the installed file, and on x64 that it is the packaged .gz byte for byte and every file the server gzips has one.

The exe points GStreamer, ONNX Runtime and the model at that folder itself (OxidANT's install.rs), so it needs no launcher and nothing on PATH.

The MSI installs per machine. It adds:

  • a Start menu folder, ObservANT, with ObservANT and ObservANT page;
  • an entry (shortcut) in the all-users Startup folder, so ObservANT starts minimized at every logon. A logon task, by owner decision on 2026-10-06: a boot-time service would run in Session 0, and nobody has yet shown that a service can open a webcam there.

Both entries are features: deselect them in the installer, or switch autostart off later in Task Manager under Startup apps. Settings go in %ProgramData%\omni-accelerant\observant.toml; the template ships in share\omni-observant.

The MSI opens Windows Firewall to the local subnet, so Windows does not ask. The rule is bound to the exe, not to ports, so a changed http_port or ICE range in observant.toml stays covered. It carries IgnoreFailure: a Windows without the firewall service, such as the image's process-isolated container, still installs. Packaging needs WiX's firewall extension, which the hub's final stage installs (WIX_FIREWALL_EXT_VERSION).

Test-ObservANTWindows.ps1 checks the MSI inside :winamd64 as admin. It and Test-ObservANTMsix.ps1 use ANTfrastructure's WindowsSmokeTest.Common assertions and exit with the failed check count. In the image, 14 checks passed on 2026-10-06:

  • the MSI's firewall rule: LAN-only, bound to the exe, installing where it cannot apply;
  • the silent install, its files, the Startup entry and the shortcuts;
  • the installed exe run with a scrubbed environment, only System32 on PATH: /healthz and the page answer, and every loaded module comes from the install folder or Windows, idle and with a viewer;
  • a webrtcsrc viewer decoding 60 frames: its EOS, which only identity eos-after=60 sends, is the proof. After it the signaller can lose its peer before gst-launch tears down and exit 1 with send failed because receiver is gone (once in the 2.2.0 dry run, 2026-10-18); that error alone after the EOS still passes, any other fails;
  • the bundled model on the bundled ORT finding the cat;
  • a silent uninstall that leaves nothing.

The container has no firewall service, so the test prints SKIP for the rule itself; on a host whose firewall runs, two more checks run: the rule exists after the install and is gone after the uninstall. web.yml's observant-windows job packages and tests the MSI around each run's web build.

On a real Windows 11 host (the dev box, no camera, 2026-10-06), the MSI installed and uninstalled cleanly through UAC:

  • its firewall rule was an inbound allow for the exe, limited to LocalSubnet;
  • started from the Startup shortcut, the exe served the page and a test pattern (no camera found), with all 128 modules from its folder or Windows;
  • a browser on another device on the LAN played the stream through that rule.

The MSIX (-Msix) packs the same folder, which G6 has already proved, with AppxManifest.xml. It installs per user, needs no admin, and adds three things:

  • a desktop:StartupTask for the logon start, which Startup apps turns off;
  • the omni-observant alias, the Linux packages' command name;
  • two firewall rules, TCP 8080 and UDP 40000-40099, on private and domain networks. MSIX rules take ports and profiles but no remote scope, so unlike the MSI's rule they are not limited to the local subnet, and a changed http_port or ICE range is not covered.

An MSIX installs only when Windows trusts its signer:

  • -MsixPfx signs with your certificate; its password goes in MSIX_PFX_PASSWORD, and -MsixPublisher must equal its subject.
  • Without it, a certificate made for that build signs, and its .cer lands beside the package. Import it into LocalMachine\TrustedPeople (admin), then double-click the .msix.
  • Package-ObservANT.ps1 refuses an unsigned result, because the hub's signing step only warns when it fails.

Test-ObservANTMsix.ps1 checks the MSIX.

  • Everywhere, in CI too: the signature against the publisher, the manifest's startup task, alias and firewall rules, and the payload; six checks.
  • With -Install, on a desktop host: it installs for the current user, finds the rules in Windows Firewall, starts the alias, reads /healthz, checks that every module comes from the package or Windows, and removes the package again.

Server Core cannot deploy an MSIX (0x80073D19), so CI runs only the first half. On the dev box (2026-10-06), with the test certificate trusted: all 12 checks passed, himbeere2 reached the page through the MSIX's rules, and the certificate came out of the store afterwards.

Not verified yet: a camera on Windows, the start at a real sign-in (the MSI's Startup entry and the MSIX's startup task), and arm64.

Cat detection stream (Rust, native)

third_party/OxidANT/crates/observant (kataglyphis_observant) is the maintained producer: V4L2 or libcamera capture → ONNX detector (cats = COCO class 15) → boxes burned into the RGBA frames → webrtcsink. It runs its own signalling server (run-signalling-server=true, plain ws://, default port 8443), so no separate signalling process is needed.

cd third_party/OxidANT
cargo build --release -p kataglyphis_observant
# The image's chain ONNX Runtime - OxidANT's loader refuses any other (2026-09-23),
# so this runs in the :latest image, where the path is the loader's fallback too.
ORT_DYLIB_PATH=/usr/local/lib/onnxruntime-cpu/lib/libonnxruntime.so \
  target/release/kataglyphis_observant --v4l2 /dev/video0

Since OxidANT d788e1a this binary is the service the package above installs:

  • It also answers on :8080: the web build from --web-root, plus /webrtc-ws and /healthz. --http-port 0 turns it off.
  • Its signalling server listens on loopback only. A serve.sh on the same host still reaches it; a serve.sh --producer-host on another host needs the producer started with --signalling-host 0.0.0.0, which run-producer-pi.sh passes.
  • The old camera flags keep working; --camera auto, --config and --print-config are added beside them.

Flags.

  • --test streams a videotestsrc pattern; --image <file> (or $KATAGLYPHIS_CAT_IMAGE) loops a still image. There is no default picture any more (OxidANT 4e68a02, 2026-09-15; ANThology's Thundy.jpg is only named in the error hint). Without one of these the producer takes camera from its settings, "auto" by default: a Raspberry Pi camera if attached, else a USB webcam. The model default still resolves relative to the crate, so any checkout works.
  • --score, --width/--height/--fps, --all-classes and --name tune the stream.
  • --cert/--key enable WSS on the built-in server, but terminate TLS in a proxy instead, as serve.sh does (why: No secure context needed).
  • --libcamera captures through libcamerasrc instead of v4l2src; use it for the Raspberry Pi CSI camera, whose rp1-cfe V4L2 nodes carry raw Bayer that videoconvert cannot process.
  • --no-inference skips the model entirely (no ORT, no detector, frames published unannotated), for camera/WebRTC bring-up and for hosts too weak to run the model.
  • --rotate 90|180|270 turns the stream clockwise with videoflip (180 for a camera mounted upside down), and so do the page's ⟲ and ⟳ while it runs; inference sees the turned frame, so the boxes still line up.

Inference runs on a background thread, so the WebRTC stream keeps camera rate while the boxes lag one inference behind (seconds per frame on a Pi 5 CPU).

Serve the web build with the /webrtc-ws proxy. serve.sh also sends COOP/COEP, which only the optional Rust core needs (No secure context needed); the Stream page streams without them, so --http drops TLS and the certificate warning with it:

# once, in the repo root: the web frontend. web/pkg/ is a generated frb
# artefact (gitignored); this is what ci-container-run-web-linux.sh runs, with the
# image's dated nightly (the floating one breaks in :latest, AGENTS.md § 4)
nightly="$(sed -n 's/^RUST_NIGHTLY_TOOLCHAIN=//p' third_party/ANTfrastructure/linux/scripts/01-core/versions.env)"
rustup toolchain install "$nightly" --component rust-src --target wasm32-unknown-unknown  # :latest has it
command -v flutter_rust_bridge_codegen || cargo install --locked --version 2.13.0 flutter_rust_bridge_codegen  # the pin in third_party/OxidANT/Cargo.toml
flutter_rust_bridge_codegen build-web --release --rust-root third_party/OxidANT --wasm-pack-rustup-toolchain "$nightly"
flutter build web --release --wasm --no-web-resources-cdn

scripts/linux/observant/serve.sh          # :8444 TLS, proxies to :8443
scripts/linux/observant/serve.sh --http   # :8444 plain HTTP, no certificate

One native Linux host does everything, a Raspberry Pi included: the producer above, this web build and serve.sh. serve.sh also takes --producer-host/--producer-port to front a producer on another board, and --state-dir so several instances (one per producer) can run side by side.

The boards run the ObservANT package (the current list: Where it runs): producer, web build and page together on http://<board>:8080/. The producer on :8443 with serve.sh + the web build on :8444 is the manual shape, for a board without the package or an ad-hoc look at another board via --producer-host. The boards that shape was brought up on, before the package:

Board Arch Camera Producer Board-side quirks
Raspberry Pi 5 arm64 imx219 (CSI) Rust, inference host-libcamera swap (rp1/pisp entity rename + libpisp 1.7)
Raspberry Pi Zero 2 W arm64 imx708 (CSI, mounted upside down) gst-launch, no AI (512 MB) host swap (vc4), videoflip method=rotate-180
Raspberry Pi 4 arm64 imx708 (CSI, mounted upside down) Rust, inference host swap (vc4), /dev/dma_heap ACLs, GCC 16 libs for ORT, --rotate 180
SpacemiT X100 riscv64 Logitech C270 (USB) Rust, inference no libcamera; camera ACL + udev rule, UFW 8443/8444 + UDP

All four run the same :latest (a multi-arch index) and the same web build; the board-side pieces are the producer, serve.sh and nginx.

Starting is one command, and autostart is opt-in. Each board carries a small local ~/cat-cam.sh start|stop|status that brings up (or tears down) its producer and its nginx together and prints the board's own URL; after a reboot that script is the whole procedure. A board that should come up on boot opts in with a systemd unit whose ExecStart is that script: the Zero runs cat-cam.service (Type=oneshot, RemainAfterExit=yes, User=<user>, ExecStop=~/cat-cam.sh stop), the others deliberately have none. start removes a stale container first: one left in Created state after a crash blocks the named run with name-store error and would fail the boot start.

signalingServerUrl in assets/settings/webrtc_settings.json is host-relative by default (/webrtc-ws); the web client resolves it against the page's origin, so the same build works on localhost, a LAN IP and a Raspberry Pi. Open https://<host>:8444/, on the phone too, and accept the certificate warning, or run serve.sh --http and open http://<host>:8444/, which has none.

USB cameras work with --v4l2 /dev/videoX. For a Pi's CSI camera use --libcamera; on a Raspberry Pi 5 running the :latest image the container route is:

# The producer runner lives in OxidANT, which owns crates/observant.
third_party/OxidANT/scripts/linux/observant/run-producer-pi.sh --build
scripts/linux/observant/serve.sh   # :8444 TLS, proxies to :8443

Why the Pi 5 needs a runner script. The image ships upstream libcamera 0.7.2 / libpisp 1.5, which cannot drive a Pi 5 on kernel 6.18: the kernel renamed the rp1-cfe media entities to underscores (rp1-cfe-fe_image0) and moved to the libpisp 1.7 uAPI, so the pipeline handler cannot acquire the CFE and the upstream IPA segfaults when isolation is forced. third_party/OxidANT/scripts/linux/observant/run-producer-pi.sh collects the host's Raspberry Pi OS libcamera stack (0.7.2+rpt, which matches the kernel) plus its library closure into OxidANT's own third_party/OxidANT/build/observant/hostlibs, mounts that ahead of the image's copy, grants the rootless container ACL access to /dev/{video,media,dma_heap}*, and runs with seccomp=unconfined (the IPA proxy forks). GStreamer 1.29, webrtcsink, ONNX Runtime and the Rust binary still come from the image.

Build with the runner (--build): it mounts OxidANT at /workspace, and a binary built from the superproject layout instead carries a compiled-in model path pointing at /workspace/third_party/OxidANT/resources/..., which does not exist under that mount; the producer then dies with No ONNX backend available. Cargo caches by source mtime, so after switching contexts touch crates/observant/src/main.rs forces the recompile.

Raspberry Pi Zero 2 W. The image runs there too, but the Rust producer does not fit the board (512 MB RAM, no practical way to build on it), and the image's libcamera cannot drive the Zero's camera either: with rpi/vc4 on the imx708 its isolated IPA process worker dies on start (Failed to call start: -110, then the socket is unreachable), while the host's rpt build runs the threaded proxy and works. The working no-AI recipe is the container plus the same host-libcamera swap, driven by gst-launch. Copy the closure collected by OxidANT's third_party/OxidANT/scripts/linux/observant/run-producer-pi.sh (or refresh it there with --libs-only) to the Zero, then:

# On the Zero. ~/cat-cam/hostlibs is the dev host's
# third_party/OxidANT/build/observant/hostlibs, and IMAGE is the family CI
# reference — composed on the dev host by
#   bash third_party/ANTfrastructure/linux/scripts/ci-image-ref.sh
# and carried over, never typed out, so a tag bump in the hub's versions.env
# reaches this recipe too.
IMAGE=<the line that command printed>
sudo nerdctl run --rm --name zero-producer --user 0:0 --privileged \
  --network host -v /dev:/dev -v /run/udev:/run/udev:ro \
  -v /usr/lib/aarch64-linux-gnu/libcamera:/usr/lib/aarch64-linux-gnu/libcamera:ro \
  -v /usr/share/libcamera:/usr/share/libcamera:ro \
  -v "$HOME/cat-cam/hostlibs":/hostlibs:ro \
  -e LD_LIBRARY_PATH=/hostlibs:/opt/gstreamer/lib/multiarch:/opt/gstreamer/lib:/usr/local/lib:/opt/opencv5/lib:/usr/lib/aarch64-linux-gnu \
  --entrypoint bash "${IMAGE}" \
  -lc 'exec gst-launch-1.0 -e \
    webrtcsink name=ws run-signalling-server=true signalling-server-host=0.0.0.0 signalling-server-port=8443 meta="meta,name=Zero-Cat-Cam" \
    libcamerasrc ! video/x-raw,format=RGB,width=640,height=480,framerate=15/1 ! videoconvert ! videoflip method=rotate-180 ! video/x-raw,format=I420 ! vp8enc deadline=1 ! ws.'

The videoflip is there because this Zero's camera is mounted upside down (drop it, or change the method, for an upright camera; --rotate is the producer's equivalent).

Two traps bite anyone wiring this by hand:

  • the image's entrypoint.sh sources libcamera-env.sh, which re-prepends /opt/libcamera/lib and silently overrides the LD_LIBRARY_PATH above, hence --entrypoint bash. :latest since 2026-09-29 appends its libcamera after a caller's path instead (hub CON23), so the bypass should no longer be needed; it stays here until a board has run the recipe without it;
  • webrtcsink's meta must be a space-free structure in gst-launch (meta="meta,name=Zero-Cat-Cam"; a space fails to parse).

For its own homepage, install nginx on the board (sudo apt install nginx; it lands in /usr/sbin, which is not on the user's PATH), open UFW (8444/tcp plus the WebRTC UDP range), copy the web build and serve.sh over, and run it there:

# from the dev host (<board> is the target's ssh name)
rsync -a build/web/ <board>:cat-cam/build/web/
rsync -a scripts/linux/observant/serve.sh \
  <board>:cat-cam/scripts/linux/observant/serve.sh
# on the Zero (serve.sh derives its repo root from its own path, so the
# scripts/linux/observant/ layout under ~/cat-cam is deliberate)
cd ~/cat-cam && PATH=/usr/sbin:$PATH bash scripts/linux/observant/serve.sh

Media is WebRTC UDP, browser ↔ Zero, direct on the LAN. --no-inference is the next step up from this gst-launch bring-up. If the container is too heavy for the board, scripts/linux/observant/package-producer-bundle.sh exports a container-less aarch64 bundle (producer + pruned GStreamer + the image's glibc, ~180 MB, in build/observant/pi-bundle/) that runs against the host's libcamera with no container and no toolchain; --deploy HOST rsyncs it to HOST:observant/. Its ONNX Runtime is the image's chain copy (ORT_LIB_LOCATION), and the assembling image run ends with ANTfrastructure's G6 census over the finished bundle: every ORT binary byte-identical to that image's chain ORT, and the producer, given an $ORIGIN/../lib RUNPATH (the same directory run.sh passes to the bundled loader), resolving to it. Its binary probe and container Cargo build reuse OxidANT's producer helper. The packager builds a missing binary automatically and forwards --platform; OxidANT's Pi runner still requires --build before compiling.

Other VC4/unicam Pis (e.g. Pi 4). The same host-libcamera swap applies, but the Rust producer fits there, and it needs no board-specific runner: the Pi 5's runner works as-is. It collects the host libcamera closure, ACLs /dev/{video,media,dma_heap}* (the dma_heap nodes matter here, or libcamera reports Could not open any dma-buf provider and registration fails with -12/ENOMEM) and takes the image's loader paths from its own media-env.sh, which put the source-built GCC's lib64 (/opt/gcc-16.2.0/lib64 today) on LD_LIBRARY_PATH, as the image's ONNX Runtime needs (GLIBCXX_3.4.36 not found otherwise). The Pi 4's camera here is mounted upside down, so:

third_party/OxidANT/scripts/linux/observant/run-producer-pi.sh --build --rotate 180

Its homepage works like the Zero's: nginx is preinstalled on Pi OS, and serve.sh runs with PATH=/usr/sbin:$PATH from a repo checkout (~/OmniAccelerANT here; its board-local producer wrapper is ~/zweckle-producer.sh).

RISC-V SoC (SpacemiT X100). :latest is a multi-arch index (amd64/arm64/riscv64), so the same tag runs there natively and the producer builds inside the container in minutes on 8 cores; GStreamer, v4l2src and ONNX Runtime are all riscv64 builds in the image. A USB webcam (e.g. a Logitech C270) needs no libcamera: run with --v4l2 /dev/videoN. On Ubuntu the host-level work is permissions and firewall: the node is root:video 660 and the user is usually not in video, so grant an ACL (sudo setfacl -m u:$USER:rw /dev/videoN), and UFW needs 8443/tcp plus the WebRTC UDP range (sudo ufw allow 32768:60999/udp), because the browser connects directly to the board for media. The udev rule keeps the ACL across a re-plug (the C270 re-enumerates, and a hand-set ACL dies with the old node):

printf 'SUBSYSTEM=="video4linux", RUN+="/usr/bin/setfacl -m u:%s:rw /dev/%%k"\n' "$USER" \
  | sudo tee /etc/udev/rules.d/99-catcam-acl.rules
sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=video4linux

For its own homepage: sudo apt install nginx, UFW 8444/tcp plus the WebRTC UDP range, then the same web-build + serve.sh copy as the Zero. Its producer, from an OxidANT checkout carried over to ~/OxidANT (IMAGE as above):

# build once (model paths resolve because OxidANT is mounted at /workspace)
nerdctl run --rm --user 0:0 --network host -v "$HOME/OxidANT":/workspace \
  -v kataglyphis-cat-target:/cargo-target -v kataglyphis-cat-cargo:/cargo-home \
  -e CARGO_TARGET_DIR=/cargo-target -e CARGO_HOME=/cargo-home \
  --entrypoint bash "$IMAGE" -lc \
  'cd /workspace && cargo build --release --locked -p kataglyphis_observant'

# run (detached; the board's wrapper is ~/x100-producer.sh)
nerdctl run -d --rm --name x100-producer --user 0:0 --privileged --network host \
  -v /dev:/dev -v "$HOME/OxidANT":/workspace \
  -v kataglyphis-cat-target:/cargo-target \
  -e ORT_DYLIB_PATH=/usr/local/lib/onnxruntime-cpu/lib/libonnxruntime.so -e RUST_LOG=info \
  --entrypoint /cargo-target/release/kataglyphis_observant "$IMAGE" \
  --v4l2 /dev/video9 --listen-port 8443 --name "Cat Cam"

To front it from another host instead (serve.sh --producer-host), add --signalling-host 0.0.0.0 to the producer and give serve.sh its IP, not its mDNS name: nginx resolves proxy_pass hostnames once at startup, so a DHCP or mDNS address change leaves it proxying into the void, with a 101 in the access log and no connection on the producer.

Without the Rust producer, any GStreamer pipeline can feed the page: webrtcsink runs the signalling server itself, and serve.sh proxies /webrtc-ws to it, as the Zero's recipe above does. A USB webcam, in :latest (checked there with videotestsrc on 2026-10-16):

gst-launch-1.0 -e webrtcsink name=ws run-signalling-server=true signalling-server-host=127.0.0.1 \
  signalling-server-port=8443 meta="meta,name=USB-Cam" \
  v4l2src device=/dev/video0 ! image/jpeg,width=640,height=360,framerate=30/1 ! jpegdec ! videoconvert ! ws.

Then serve.sh, or flutter run -d web-server as getting-started.md shows. A standalone signalling server, if one is wanted, is the image's /opt/gstreamer/bin/gst-webrtc-signalling-server.

No secure context needed

The Stream page needs no secure context; only the Rust wasm core wants isolation, and the web boots without it. The page is receive-only gstwebrtc-api JS. pkg/oxidant_bg.wasm is a threads build whose shared memory Firefox and Safari refuse unless the page is cross-origin isolated (COOP/COEP, honoured only on HTTPS or localhost); Chromium creates it anyway. On web main() therefore treats a failed RustLib.init() as optional (lib/src/boot/rust_core.dart): the app starts, and only the About page's Rust demo goes dark. No code ever checked crossOriginIsolated, whatever older notes said.

Measured 2026-10-05 on the web lane's build with :latest's Chrome for Testing 154, over plain HTTP from a non-loopback address: the app boots with the core, and boots without it when oxidant_bg.wasm is blocked, the path Firefox and Safari take (neither was run).

serve.sh --http serves that plain-HTTP page, no certificate warning. Without --http it terminates TLS on 8444, because the GStreamer signalling server cannot end TLS for this repo's certificates (the signaller's rustls rejects a self-signed CA certificate with CaUsedAsEndEntity); the producer's --cert/--key exist, the proxy is the supported path. Either way it proxies /webrtc-ws to the producer's plain ws:// server (port 8443).

Troubleshooting

  • An ObservANT camera reports cats that are not there. Open its page, Camera in the bottom bar. While the false box shows in the preview, press "Not a cat": ObservANT ignores that spot for good. A false arrival from earlier is under "Recent detections", with the same button. Since 2026-10-14 only a detection where something moved starts a visit, so a still object rarely does. For a false cat that moves, the line under the detection slider names the last detection's score: move the slider above it. Too high and a cat in poor light is missed too: a real cat at the feeding trough scored 0.72, the drying rack 0.26 to 0.65.
  • The picture is sideways after the board was moved. ⟲ and ⟳ on the same page turn it a quarter at a time; detection, the arrival picture and the clips turn with it.
  • GST_DEBUG=2 or GST_DEBUG=3 shows pipeline performance and caps negotiation.
  • A stream that fails to start: check the camera device permissions (/dev/video*).
  • The port serve.sh proxies to (--producer-port, 8443 by default) must be the one the producer's signalling server listens on.
  • The page loads but the app never starts: check the main.dart.mjs Content-Type. Flutter's wasm bootstrap loads it with a dynamic import(), which browsers reject for application/octet-stream; serve.sh maps .mjs to application/javascript for exactly that reason.
  • The same trap one extension over, likelier on an older board: nginx added application/wasm to its bundled mime.types in 1.21.x, so Debian bullseye, Raspberry Pi OS bullseye and Ubuntu 22.04 (all nginx 1.18.0) serve main.dart.wasm as application/octet-stream, and WebAssembly.compileStreaming() refuses it: a blank page with nothing in the console naming the cause. serve.sh declares .wasm itself, so the nginx version stops mattering; if you serve the build some other way, check it with curl -kI https://<host>:8444/main.dart.wasm | grep -i content-type.
  • ERR_CERT_COMMON_NAME_INVALID with no click-through: the browser holds a certificate generated before serve.sh started writing a subjectAltName. The cert is cached in --state-dir, and serve.sh now regenerates a CN-only one on sight; if you pinned or exported the old one, delete build/observant/tls/ and let it rebuild.
  • On a host firewall (e.g. UFW on Raspberry Pi OS), allow 8444/tcp and the WebRTC media UDP range (32768:60999/udp, LAN-scoped is enough); otherwise the browser connects for signalling but ICE never completes.
  • Each serve.sh instance (one per board) listens on its own port, and the dev host's firewall has to allow every one of them, not just the first: a second board's page stays unreachable while the first board's still works, which reads like a producer problem but is a missing ufw allow 8446/tcp.