Skip to content

Commit 8e90a5f

Browse files
authored
Merge pull request #40 from GyeongHoKim/feature/rpi
feat(rpi): embed mtxrpicam under a Raspberry Pi build channel
2 parents dd41325 + 7ce40af commit 8e90a5f

51 files changed

Lines changed: 3015 additions & 130 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,70 @@ jobs:
6363

6464
- name: Run tests
6565
run: make test
66+
67+
mtxrpicam-version-pin:
68+
name: MTXRPICAM version pin
69+
runs-on: ubuntu-latest
70+
steps:
71+
- uses: actions/checkout@v6.0.2
72+
73+
# Catch drift between the three places that pin mediamtx-rpicamera:
74+
# Makefile's MTXRPICAM_VERSION, the .goreleaser.yml pre-hook, and the
75+
# header comment in scripts/mtxrpicam.sha256. They are bumped together
76+
# by hand, so a missed file should fail PR CI loudly.
77+
- name: Verify MTXRPICAM version pin is consistent
78+
run: |
79+
set -euo pipefail
80+
mk=$(sed -n 's/^MTXRPICAM_VERSION[[:space:]]*?=[[:space:]]*\(v[0-9.]*\).*/\1/p' Makefile)
81+
gr=$(grep -oE 'fetch-mtxrpicam\.sh.*v[0-9.]+' .goreleaser.yml \
82+
| grep -oE 'v[0-9.]+' | sort -u)
83+
sh=$(sed -n 's/.*mediamtx-rpicamera \(v[0-9.]*\).*/\1/p' scripts/mtxrpicam.sha256 \
84+
| sort -u)
85+
echo "Makefile=$mk goreleaser=$gr sha256=$sh"
86+
[ -n "$mk" ] && [ -n "$gr" ] && [ -n "$sh" ] || {
87+
echo "could not extract a version from one or more files" >&2; exit 1; }
88+
[ "$(echo "$gr" | wc -l)" = "1" ] || {
89+
echo ".goreleaser.yml pre-hook references multiple versions" >&2; exit 1; }
90+
[ "$(echo "$sh" | wc -l)" = "1" ] || {
91+
echo "scripts/mtxrpicam.sha256 references multiple versions:" >&2
92+
echo "$sh" >&2
93+
exit 1; }
94+
[ "$mk" = "$gr" ] && [ "$mk" = "$sh" ] || {
95+
echo "MTXRPICAM version drift between Makefile, .goreleaser.yml, and scripts/mtxrpicam.sha256" >&2
96+
exit 1; }
97+
98+
rpicam-build-check:
99+
name: RPi build check
100+
runs-on: ubuntu-latest
101+
steps:
102+
- uses: actions/checkout@v6.0.2
103+
104+
- uses: actions/setup-go@v6.4.0
105+
with:
106+
go-version-file: .go-version
107+
cache: true
108+
109+
- uses: actions/setup-node@v6.3.0
110+
with:
111+
node-version-file: .node-version
112+
cache: npm
113+
cache-dependency-path: internal/gui/frontend/package-lock.json
114+
115+
# rpicam-build-check cross-compiles ./..., which includes internal/gui;
116+
# that package's //go:embed all:frontend/dist requires the dist tree to
117+
# exist at compile time even though the rpi channel itself never ships
118+
# the GUI binary.
119+
- name: Install frontend dependencies
120+
working-directory: internal/gui/frontend
121+
run: npm ci
122+
123+
- name: Build frontend (populate internal/gui/frontend/dist for go:embed)
124+
working-directory: internal/gui/frontend
125+
run: npm run build
126+
127+
# Cross-compile the rpicam-tagged code for both Pi targets against the
128+
# placeholder mtxrpicam blob (no network fetch). This catches type drift
129+
# in internal/rpicamera/camera_rpicam.go that the disabled stub would
130+
# otherwise hide on the default linux/amd64 build.
131+
- name: Cross-compile rpicam-tagged code
132+
run: make rpicam-build-check

.github/workflows/release.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ jobs:
2222
go-version-file: .go-version
2323
cache: true
2424

25+
# Cache the fetched mtxrpicam binaries across releases. Keyed on the
26+
# SHA-256 file so a version bump invalidates the cache automatically.
27+
- name: Cache mtxrpicam binaries
28+
uses: actions/cache@v4
29+
with:
30+
path: |
31+
internal/rpicamera/mtxrpicam_32
32+
internal/rpicamera/mtxrpicam_64
33+
key: mtxrpicam-${{ hashFiles('scripts/mtxrpicam.sha256') }}
34+
2535
- uses: goreleaser/goreleaser-action@v7.0.0
2636
with:
2737
version: v2.15.3

.gitignore

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,13 @@ coverage/
2424
# Web
2525

2626
node_modules
27-
internal/gui/frontend/package.json.md5
27+
internal/gui/frontend/package.json.md5
28+
29+
# Raspberry Pi build channel — the real mtxrpicam binaries are fetched at
30+
# release time and verified against scripts/mtxrpicam.sha256. The directories
31+
# themselves stay in git with a `placeholder` file checked in so //go:embed
32+
# stays satisfied during PR CI.
33+
internal/rpicamera/mtxrpicam_32/*
34+
!internal/rpicamera/mtxrpicam_32/placeholder
35+
internal/rpicamera/mtxrpicam_64/*
36+
!internal/rpicamera/mtxrpicam_64/placeholder

.goreleaser.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,33 @@ builds:
2121
- -X github.com/GyeongHoKim/onvif-simulator/internal/version.Commit={{.Commit}}
2222
- -X github.com/GyeongHoKim/onvif-simulator/internal/version.Date={{.Date}}
2323

24+
# Raspberry Pi build channel. Embeds mtxrpicam (32+64) so the artifact runs
25+
# standalone on Raspberry Pi OS with libcamera. The pre hook fetches the
26+
# binary blob from the pinned mediamtx release; the rpicam build tag selects
27+
# the rpicamera runtime in internal/rpicamera.
28+
- id: cli-rpi
29+
main: ./cmd/cli
30+
binary: onvif-simulator-rpi
31+
env:
32+
- CGO_ENABLED=0
33+
flags:
34+
- -tags=rpicam
35+
goos:
36+
- linux
37+
goarch:
38+
- arm
39+
- arm64
40+
goarm:
41+
- "7"
42+
hooks:
43+
pre:
44+
- cmd: ./scripts/fetch-mtxrpicam.sh {{ if eq .Arch "arm" }}32{{ else }}64{{ end }} v2.4.3 internal/rpicamera/mtxrpicam_{{ if eq .Arch "arm" }}32{{ else }}64{{ end }}
45+
ldflags:
46+
- -s -w
47+
- -X github.com/GyeongHoKim/onvif-simulator/internal/version.Version={{.Version}}
48+
- -X github.com/GyeongHoKim/onvif-simulator/internal/version.Commit={{.Commit}}
49+
- -X github.com/GyeongHoKim/onvif-simulator/internal/version.Date={{.Date}}
50+
2451
archives:
2552
- id: cli
2653
ids:
@@ -33,6 +60,13 @@ archives:
3360
- zip
3461
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
3562

63+
- id: cli-rpi
64+
ids:
65+
- cli-rpi
66+
formats:
67+
- tar.gz
68+
name_template: "{{ .ProjectName }}-rpi_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
69+
3670
checksum:
3771
name_template: "checksums.txt"
3872
algorithm: sha256
@@ -66,3 +100,5 @@ release:
66100
extra_files:
67101
- glob: ./scripts/install.sh
68102
- glob: ./scripts/install.ps1
103+
- glob: ./onvif-simulator.example.json
104+
- glob: ./onvif-simulator.example.rpi.json

AGENTS.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Use `make` targets rather than calling `go`, `golangci-lint`, or `wails` directl
2222
| --- | --- |
2323
| Build CLI/TUI binary | `make cli` |
2424
| Build GUI binary (requires Wails CLI) | `make gui` |
25+
| Build CLI for Raspberry Pi (arm + arm64) | `make cli-rpi` |
26+
| Cross-compile rpicam-tagged code (no fetch) | `make rpicam-build-check` |
2527
| Format | `make format` |
2628
| Lint | `make lint` |
2729
| Unit tests (race detector) | `make test` |
@@ -38,16 +40,25 @@ Run a single Go test: `go test -race -run TestName ./internal/<pkg>/...`.
3840

3941
Toolchain versions are pinned in `mise.toml`. Run `mise install` after cloning.
4042

41-
GUI frontend lives in `frontend/` (React + Vite + Tailwind, shadcn registry) and is hosted by Wails. `wails dev` starts the dev harness; Wails invokes the frontend build for production. The TUI is a Bubble Tea program and has no web assets.
43+
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).
44+
45+
## Build channels
46+
47+
The repo ships two Go binary build channels:
48+
49+
- **default** — built with `make cli` and goreleaser build id `cli`. Runs on linux/darwin/windows × amd64/arm64. No platform-specific embedded assets. Produces `onvif-simulator`.
50+
- **rpi** — built with `make cli-rpi` and goreleaser build id `cli-rpi`. Runs on linux/arm (Pi Zero/2/3 32-bit) and linux/arm64 (Pi 3/4/5 64-bit). The build pipeline fetches `mtxrpicam_32.tar.gz` and `mtxrpicam_64.tar.gz` from the pinned mediamtx-rpicamera release (a separate repo from mediamtx itself) into `internal/rpicamera/mtxrpicam_{32,64}/` and `go build -tags rpicam` embeds them via `//go:embed`. Produces `onvif-simulator-rpi-arm` / `onvif-simulator-rpi-arm64`. The default channel never carries this asset and `kind=rpicam` profiles fail fast with `rpicamera.ErrUnsupported` on non-rpi builds.
51+
52+
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 (`make rpicam-build-check`). The pinned mediamtx-rpicamera version lives in `Makefile`'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 `make gui-rpi` and there will not be one.
4253

4354
## Architecture
4455

4556
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.
4657

4758
- **Configuration** — owns the on-disk configuration schema (`onvif-simulator.json`). The root `Config` struct contains:
4859
- `DeviceConfig` — static device identity (UUID, manufacturer, model, serial, scopes).
49-
- `NetworkConfig` — HTTP port and WS-Discovery XAddr list.
50-
- `MediaConfig` — list of pass-through media profiles (RTSP/snapshot URIs, codec, resolution).
60+
- `NetworkConfig` — HTTP port, **RTSP port** for the embedded RTSP listener (0 means use the default 8554 via `RTSPPortOrDefault`), optional bind `interface`, and WS-Discovery XAddr list.
61+
- `MediaConfig` — ONVIF media **profiles**. Each profile declares a **`kind`** (default `"file"`, also `"rpicam"`). For `kind=file`, **`media_file_path`** points 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). For `kind=rpicam`, **`rpicam`** carries 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 embedded `mtxrpicam` helper; this kind is only available on binaries built with the `rpicam` tag (the dedicated Pi build channel — see *Build channels* below). Optional **`snapshot_uri`** (HTTP(S) URL returned by `GetSnapshotUri`; the process does not render snapshots itself), optional **metadata** configuration entries, and **`MaxVideoEncoderInstances`** are profile-level fields shared by both kinds.
5162
- `AuthConfig` — authentication switch, user credentials, Digest and JWT tuning.
5263
- `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.
5364
- `EventsConfig` — event service parameters (max pull points, default subscription timeout, topic list). Each `TopicConfig` entry has an `Enabled` flag; disabled topics are hidden from `GetEventProperties` but still routable by the broker.
@@ -70,7 +81,11 @@ Responsibilities are split into tightly separated layers. Folder names are inten
7081

7182
- **WS-Discovery** — message encoding/decoding (Hello, Bye, Probe/ProbeMatch, Resolve/ResolveMatch), scope matching, and UDP multicast transport. Discovery Proxy is out of scope.
7283

73-
- **Simulator lifecycle + front-ends** — the composition root that assembles the layers above into a runnable simulator, plus the GUI, TUI, and CLI surfaces. These exist as stubs today and will be wired up later.
84+
- **Embedded RTSP** (`internal/rtsp`) — in-process RTSP server (gortsplib) on **`NetworkConfig`'s RTSP port**. It starts when **at least one** profile has a usable source — `kind=file` with non-empty `media_file_path`, **or** `kind=rpicam` with `rpicam` configured. Each such profile is served at **`rtsp://<advertised-host>:<port>/<profile-token>`**. **`GetStreamUri`** always 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 small `rtsp.Source` interface (`Describe`/`AttachStream`/`Ready`/`Run`); file-backed sources loop the MP4 (`looper`), live sources push access units through `LiveSource`. `Ready` lets the live path hold `DESCRIBE` until the first IDR so clients never see a pre-keyframe SDP. Video tracks are limited to **H.264 and H.265** packetization today.
85+
86+
- **Raspberry Pi camera** (`internal/rpicamera`) — vendored and adapted from mediamtx's [rpicamera static source](https://github.com/bluenviron/mediamtx/tree/main/internal/staticsources/rpicamera). On a binary built with the `rpicam` tag (linux/arm or linux/arm64), `rpicamera.Open` extracts the embedded `mtxrpicam` helper into `/dev/shm`, fork+execs it, and surfaces H.264 access units through an `OnData` callback. On every other build the package compiles to a disabled stub whose `Open` returns `rpicamera.ErrUnsupported` so the simulator can fail with a clear "this build does not support the Raspberry Pi camera" message. Capture parameters live in `rpicamera.Params`; the upstream wire-format struct stays internal so the public surface tracks only what the simulator's `MediaConfig.RPICam` exposes.
87+
88+
- **Simulator lifecycle + front-ends**`internal/simulator` is the composition root: it starts the HTTP server (ONVIF SOAP), optional embedded RTSP, WS-Discovery, and the event broker. **CLI** entrypoint is **`cmd/cli`** (`serve` by default, plus `tui`, `config`, `event`). **GUI** entrypoint is **`cmd/gui`** (Wails app in **`internal/gui`**). **TUI** is **`internal/tui`**, driven from the CLI **`tui`** subcommand.
7489

7590
- **Observability** — every long-lived component (auth, event broker, ONVIF service handlers, RTSP server, WS-Discovery listener, simulator lifecycle) accepts a `*slog.Logger` via a `WithLogger` functional 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 reads `LoggingConfig` from the on-disk config and builds a single **file-only JSON sink** (defaulting to a path under `os.UserCacheDir`) plus an internal hot-reload State that the reload path mutates whenever `LoggingConfig` changes. Front-ends only forward optional flag/env overrides through `Options.LogLevel` / `Options.LogFile` and 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 an `slog.Handler` via `Options.LogExtras`.
7691

Makefile

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,19 @@ endif
1818

1919
FRONTEND_DIST := internal/gui/frontend/dist
2020

21-
.PHONY: build cli gui gui-windows gui-darwin gui-linux format lint test test-go test-frontend coverage e2e clean setup manual
21+
# Pinned mediamtx-rpicamera release tag. mediamtx-rpicamera lives in its own
22+
# repo (bluenviron/mediamtx-rpicamera) with versioning independent from
23+
# mediamtx itself; mediamtx v1.13.1 internally pins this same v2.4.3 (see
24+
# its internal/staticsources/rpicamera/mtxrpicamdownloader/VERSION). Bump
25+
# together with scripts/mtxrpicam.sha256 so the Pi build channel ships a
26+
# reviewable, reproducible binary blob.
27+
MTXRPICAM_VERSION ?= v2.4.3
28+
RPICAM_DIR_32 := internal/rpicamera/mtxrpicam_32
29+
RPICAM_DIR_64 := internal/rpicamera/mtxrpicam_64
30+
31+
.PHONY: build cli gui gui-windows gui-darwin gui-linux \
32+
cli-rpi cli-rpi-arm cli-rpi-arm64 rpicam-fetch rpicam-build-check \
33+
format lint test test-go test-frontend coverage e2e clean setup manual
2234

2335
build: cli gui
2436

@@ -37,6 +49,38 @@ gui-darwin: $(FRONTEND_DIST)
3749
gui-linux: $(FRONTEND_DIST)
3850
cd cmd/gui && wails build -platform linux/amd64 -tags webkit2_41
3951

52+
# Raspberry Pi build channel. Embeds mtxrpicam_{32,64} into the simulator
53+
# binary so a Pi user gets a single self-contained artifact. The fetch step
54+
# is idempotent: it skips the download when the on-disk blob already matches
55+
# the checksum pinned in scripts/mtxrpicam.sha256.
56+
rpicam-fetch: $(RPICAM_DIR_32)/mtxrpicam $(RPICAM_DIR_64)/mtxrpicam
57+
58+
$(RPICAM_DIR_32)/mtxrpicam:
59+
./scripts/fetch-mtxrpicam.sh 32 $(MTXRPICAM_VERSION) $(RPICAM_DIR_32)
60+
61+
$(RPICAM_DIR_64)/mtxrpicam:
62+
./scripts/fetch-mtxrpicam.sh 64 $(MTXRPICAM_VERSION) $(RPICAM_DIR_64)
63+
64+
cli-rpi: cli-rpi-arm cli-rpi-arm64
65+
66+
cli-rpi-arm: rpicam-fetch
67+
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 \
68+
$(GO) build -tags rpicam -o bin/$(BINARY)-rpi-arm ./cmd/cli
69+
70+
cli-rpi-arm64: rpicam-fetch
71+
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
72+
$(GO) build -tags rpicam -o bin/$(BINARY)-rpi-arm64 ./cmd/cli
73+
74+
# rpicam-build-check verifies that the rpicam-tagged code paths still compile
75+
# without spending time downloading the upstream binary. PR CI calls this; it
76+
# relies on the placeholder file checked into mtxrpicam_{32,64}/ to satisfy
77+
# //go:embed and cross-compiles for both Pi targets.
78+
rpicam-build-check:
79+
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 \
80+
$(GO) build -tags rpicam ./...
81+
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
82+
$(GO) build -tags rpicam ./...
83+
4084
format:
4185
golangci-lint fmt ./...
4286

NOTICE

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
onvif-simulator
2+
Copyright (c) 2025 GyeongHo Kim and contributors
3+
4+
Licensed under the MIT License (see LICENSE).
5+
6+
================================================================================
7+
Third-party software embedded in this distribution
8+
================================================================================
9+
10+
The Raspberry Pi build channel (binaries with the rpicam build tag, named
11+
onvif-simulator-rpi-arm and onvif-simulator-rpi-arm64) embeds the mtxrpicam
12+
capture helper from the mediamtx project:
13+
14+
Project: mediamtx
15+
URL: https://github.com/bluenviron/mediamtx
16+
License: MIT
17+
18+
The mtxrpicam helper dynamically links against libcamera at runtime:
19+
20+
Project: libcamera
21+
URL: https://libcamera.org
22+
License: LGPL-2.1-or-later
23+
24+
End-user replacement of libcamera is possible through the standard library-
25+
replacement provisions of the LGPL by replacing the libcamera*.so files on the
26+
target Raspberry Pi OS image. mediamtx and libcamera license texts apply
27+
unchanged when distributed inside the Raspberry Pi build channel artifacts.
28+
29+
The default build channel (linux/darwin/windows × amd64/arm64) does NOT embed
30+
mtxrpicam; this notice is shipped in every archive for legal hygiene.
31+
32+
The Go-language portions of internal/rpicamera/ are adapted from
33+
github.com/bluenviron/mediamtx/internal/staticsources/rpicamera (MIT). The
34+
adaptation strips mediamtx-internal types and replaces them with simulator-
35+
local equivalents but keeps the wire protocol with mtxrpicam unchanged so the
36+
upstream-released binary stays compatible.

0 commit comments

Comments
 (0)