This file provides guidance to coding agents when working with code in this repository.
It describes patterns, boundaries, and conventions — not concrete implementations. Use the symbol/grep tools to discover file names, type names, and constants; they are not documented here on purpose, because they rot.
A software simulator of an ONVIF device. Three interchangeable front-ends are offered so the same simulator core can be driven by different users:
- GUI — for human operators; built with Wails (Go backend + web frontend).
- TUI — for human operators in a terminal; built with Bubble Tea.
- CLI — for scripting and AI-agent-driven usage; plain flags/subcommands, non-interactive.
All three share the same internal/ core; none of them owns simulator logic.
Use just recipes rather than calling go, golangci-lint, or wails directly — they encode the correct output paths, build tags, and defaults. The legacy Makefile is a deprecation wrapper that forwards to the same just recipes; prefer just <recipe> for new work.
| Task | Command |
|---|---|
| Build CLI/TUI binary | just cli |
| Build GUI binary (requires Wails CLI) | just gui |
| Build CLI for Raspberry Pi (arm + arm64) | just cli-rpi |
| Cross-compile rpicam-tagged code (no fetch) | just rpicam-build-check |
| Format | just format |
| Lint | just lint |
| Unit tests (race detector) | just test |
| Coverage | just coverage |
| E2E suite (speaks SOAP against a running simulator) | just e2e |
| Browse Go doc in browser | just manual / DOCS_PORT=3000 just manual |
| One-time repo setup (hooks + commitlint) | just setup |
Run a single Go test: go test -race -run TestName ./internal/<pkg>/....
Code quality gate (required after every code change): run just format followed by just lint before handing the task back. Do not skip either step.
just e2e honors ONVIF_HOST, ONVIF_USERNAME, ONVIF_PASSWORD — point them at a running simulator.
Toolchain versions are pinned in mise.toml (including just itself). Run mise install after cloning.
GUI frontend lives in internal/gui/frontend/ (React + Vite + Tailwind, shadcn registry), is built by npm into internal/gui/frontend/dist, and is hosted by Wails from cmd/gui. Run wails dev inside cmd/gui for the dev harness; Wails invokes the frontend build for production. The TUI is a Bubble Tea program in internal/tui with no web assets; launch it with onvif-simulator tui (same binary as the CLI).
The repo ships two Go binary build channels:
- default — built with
just cliand goreleaser build idcli. Runs on linux/darwin/windows × amd64/arm64. No platform-specific embedded assets. Producesonvif-simulator. - rpi — built with
just cli-rpiand goreleaser build idcli-rpi. Runs on linux/arm (Pi Zero/2/3 32-bit) and linux/arm64 (Pi 3/4/5 64-bit). The build pipeline fetchesmtxrpicam_32.tar.gzandmtxrpicam_64.tar.gzfrom the pinned mediamtx-rpicamera release (a separate repo from mediamtx itself) intointernal/rpicamera/mtxrpicam_{32,64}/andgo build -tags rpicamembeds them via//go:embed. Producesonvif-simulator-rpi-arm/onvif-simulator-rpi-arm64. The default channel never carries this asset andkind=rpicamprofiles fail fast withrpicamera.ErrUnsupportedon non-rpi builds.
The rpicam build tag controls every rpicam runtime path. PR CI does not download the binary — it cross-compiles -tags rpicam against a 1-byte placeholder file checked into each mtxrpicam_* directory (just rpicam-build-check). The pinned mediamtx-rpicamera version lives in Justfile's mtxrpicam_version; tarball checksums in scripts/mtxrpicam.sha256 are bumped in lockstep so the fetch step (scripts/fetch-mtxrpicam.sh) refuses unverified blobs. Those hashes are cross-checkable against mediamtx's own internal/staticsources/rpicamera/mtxrpicamdownloader/HASH_MTXRPICAM_*_TAR_GZ, giving an out-of-band verification source independent of the release page itself. GUI is intentionally CLI/TUI-only on the rpi channel — there is no just gui-rpi and there will not be one.
Responsibilities are split into tightly separated layers. Folder names are intentionally omitted here — some of today's packages are placeholders and will be reorganized. Locate the layers by role with the symbol/grep tools.
-
Configuration — owns the on-disk configuration schema (
onvif-simulator.json). The rootConfigstruct contains:DeviceConfig— static device identity (UUID, manufacturer, model, serial, scopes).NetworkConfig— HTTP port, RTSP port for the embedded RTSP listener (0 means use the default 8554 viaRTSPPortOrDefault), optional bindinterface, and WS-Discovery XAddr list.MediaConfig— ONVIF media profiles. Each profile declares akind(default"file", also"rpicam"). Forkind=file,media_file_pathpoints at a local H.264/H.265 MP4 the simulator loops; width, height, FPS, and encoding are probed from the file at startup and published in the live config snapshot (persisted JSON may still carry prior values for those fields). Forkind=rpicam,rpicamcarries capture parameters (camera_id, width, height, fps, bitrate, idr_period, hflip/vflip, sharpness/contrast/brightness/saturation) and the simulator captures live H.264 from a Raspberry Pi camera through the embeddedmtxrpicamhelper; this kind is only available on binaries built with therpicamtag (the dedicated Pi build channel — see Build channels below). Optionalsnapshot_uri(HTTP(S) URL returned byGetSnapshotUri; the process does not render snapshots itself), optional metadata configuration entries, andMaxVideoEncoderInstancesare profile-level fields shared by both kinds.AuthConfig— authentication switch, user credentials, Digest and JWT tuning.RuntimeConfig— device state that ONVIF Device Management Set* operations mutate at runtime (discovery mode, hostname, DNS, default gateway, network protocols, system date/time). Persisted so the simulator retains the last applied values across restarts.EventsConfig— event service parameters (max pull points, max notification producers, default subscription timeout, topic list). Pull-point and push subscriptions count against separate capacity caps per ONVIF Core §9.5; setting max notification producers to zero disables WS-BaseNotification push entirely. EachTopicConfigentry has anEnabledflag; disabled topics are hidden fromGetEventPropertiesbut still routable by the broker.- Saves are atomic (write-to-temp + rename) and validation must pass before any write. Mutations are exposed as targeted, field-level helpers (e.g.
config.SetDiscoveryMode,config.SetTopicEnabled) so callers never rewrite the whole file.
-
Auth — the shared authentication and authorization primitives every ONVIF service handler consumes.
- Authentication is a pluggable scheme chain. HTTP-level schemes evaluate before WS-level schemes (ONVIF Core §5.9.1). Missing credentials fall through to the next scheme; any other failure aborts the chain and produces a challenge response that handlers copy onto the HTTP reply.
- Authorization applies the ONVIF access-class / user-level matrix (ONVIF Core §5.9.4). Unknown operations default to the most restrictive class.
- The runtime user store is thread-safe and live-editable. A single controller keeps it in sync with the persisted config — callers never mutate either side directly.
-
ONVIF service handlers — one per ONVIF service (Device Management, Media, Events, PTZ, Imaging, …). Each is a pure dispatcher: domain data comes from an injected data provider, authorization from an injected auth hook, and the handler itself only parses SOAP envelopes, enforces a request-size cap, and maps SOAP faults to HTTP status codes.
-
Event Broker (
internal/event) — the concreteeventsvc.Provider. It manages both pull-point and WS-BaseNotification push subscriptions in one keyed-by-UUID registry. Pull-point subscriptions hold an in-memory event queue; push subscriptions hold a consumer EPR and, on Publish, fan out a Notify SOAP POST to that consumer asynchronously so a slow consumer cannot block the publish path. Per-subscription consecutive-failure tracking force-expires unresponsive push consumers once a configurable threshold is reached. Subscription expiry runs lazily on access and proactively via a background goroutine. GUI/TUI code calls typed helpers on the broker to publish events without touching raw XML:broker.MotionAlarm(sourceToken, state)—tns1:VideoSource/MotionAlarmbroker.ImageTooBlurry / ImageTooDark / ImageTooBright— image quality alertsbroker.DigitalInput(inputToken, logicalState)—tns1:Device/Trigger/DigitalInputbroker.SyncProperty(...)— re-emit "Initialized" for any topic afterSetSynchronizationPointbroker.Publish(topic, rawXML)— low-level escape hatch for topics without a typed helperbroker.UpdateConfig(BrokerConfig)— hot-swap max-pull-points, timeout, and topic list without restart
-
WS-Discovery — message encoding/decoding (Hello, Bye, Probe/ProbeMatch, Resolve/ResolveMatch), scope matching, and UDP multicast transport. Discovery Proxy is out of scope.
-
Embedded RTSP (
internal/rtsp) — in-process RTSP server (gortsplib) onNetworkConfig's RTSP port. It starts when at least one profile has a usable source —kind=filewith non-emptymedia_file_path, orkind=rpicamwithrpicamconfigured. Each such profile is served atrtsp://<advertised-host>:<port>/<profile-token>.GetStreamUrialways returns that form of URI for this device even when no source is registered (clients then see no media on that path). Sources implement the smallrtsp.Sourceinterface (Describe/AttachStream/Ready/Run); file-backed sources loop the MP4 (looper), live sources push access units throughLiveSource.Readylets the live path holdDESCRIBEuntil the first IDR so clients never see a pre-keyframe SDP. Video tracks are limited to H.264 and H.265 packetization today. -
Raspberry Pi camera (
internal/rpicamera) — vendored and adapted from mediamtx's rpicamera static source. On a binary built with therpicamtag (linux/arm or linux/arm64),rpicamera.Openextracts the embeddedmtxrpicamhelper into/dev/shm, fork+execs it, and surfaces H.264 access units through anOnDatacallback. On every other build the package compiles to a disabled stub whoseOpenreturnsrpicamera.ErrUnsupportedso the simulator can fail with a clear "this build does not support the Raspberry Pi camera" message. Capture parameters live inrpicamera.Params; the upstream wire-format struct stays internal so the public surface tracks only what the simulator'sMediaConfig.RPICamexposes. -
Simulator lifecycle + front-ends —
internal/simulatoris the composition root: it starts the HTTP server (ONVIF SOAP), optional embedded RTSP, WS-Discovery, and the event broker. CLI entrypoint iscmd/cli(serveby default, plustui,config,event). GUI entrypoint iscmd/gui(Wails app ininternal/gui). TUI isinternal/tui, driven from the CLItuisubcommand. -
Observability — every long-lived component (auth, event broker, ONVIF service handlers, RTSP server, WS-Discovery listener, simulator lifecycle) accepts a
*slog.Loggervia aWithLoggerfunctional option. Nil falls back to a discard logger so unit tests can construct components without any wiring. The composition root creates a single root logger and derives child loggers via.With("component", "<name>"). SOAP HTTP endpoints are wrapped in a request middleware that attaches a request id, a request-scoped logger, and emits one summary log line per request (level scaled by HTTP status). The logger is owned by the simulator, not the front-ends: when constructed without an explicit logger, the simulator readsLoggingConfigfrom the on-disk config and builds a single file-only JSON sink (defaulting to a path underos.UserCacheDir) plus an internal hot-reload State that the reload path mutates wheneverLoggingConfigchanges. Front-ends only forward optional flag/env overrides throughOptions.LogLevel/Options.LogFileand stay otherwise oblivious. There is no stderr/stdout sink: stdout is reserved for user-facing CLI program output, and stderr is only ever written by pre-logger fatal paths. Tests that need to capture records inject anslog.HandlerviaOptions.LogExtras.
Adding a new ONVIF service means creating a sibling handler alongside the existing ones with its own data provider, auth hook, and WithLogger option, and reusing the shared authorization primitive via a one-line adapter.
Authoritative ONVIF specifications and WSDLs are vendored in-tree — find them by searching for *.wsdl / the ONVIF spec PDFs and treat them as the primary reference when adding or changing service behavior.
The repo uses GitButler; the working branch is usually gitbutler/workspace. The pre-commit hook blocks direct git commit on that branch — use but commit (or the GitButler app) instead. The but skill wraps all common git operations.