From 5b053101a8713400f78ec664674b768906e61d4c Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 19:38:53 -0400 Subject: [PATCH 01/20] Fix RTMP stream not recovering after websocket reconnection The upstream container reconnects to Nanit's cloud websocket after disconnections but never re-requests the camera to push its RTMP stream, leaving the RTMP server running with no publisher indefinitely. Root cause: StreamState and StreamRequestState are not reset on websocket disconnect, so the reconnection handler sees the stream as "Alive" and skips the re-request. Changes: - Reset StreamState and StreamRequestState on websocket disconnect - Add /health HTTP endpoint (port 8080) for stream liveness checks - Add Docker HEALTHCHECK (3min interval, 2 retries) for auto-recovery - Fix nil pointer bug in GetIsWebsocketAlive (checked wrong field) - Add GitHub Actions CI to build and push to GHCR - Update README with fork attribution and changelog --- .github/workflows/build.yml | 45 +++++++++++++++++++++++++++++++++++++ Dockerfile | 3 +++ README.md | 15 +++++++++---- pkg/app/app.go | 3 +++ pkg/app/health.go | 44 ++++++++++++++++++++++++++++++++++++ pkg/baby/state.go | 2 +- pkg/baby/state_manager.go | 12 ++++++++++ pkg/client/websocket.go | 8 ++++++- 8 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 pkg/app/health.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..cedd181 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: Build and push Docker image + +on: + push: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + build-args: | + CI_COMMIT_SHORT_SHA=${{ github.sha }} diff --git a/Dockerfile b/Dockerfile index d42b76b..3e2c0d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,7 @@ RUN mkdir -p /data && \ chmod +x /app/scripts/*.sh WORKDIR /app +EXPOSE 1935 8080 +HEALTHCHECK --interval=180s --timeout=5s --start-period=60s --retries=2 \ + CMD curl -f http://localhost:8080/health || exit 1 ENTRYPOINT ["/app/bin/nanit"] diff --git a/README.md b/README.md index 691e3fb..6295491 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # Background -This is a fork of a no-longer-maintained project (https://gitlab.com/adam.stanek/nanit) with added support for Nanit's (now required) 2FA authentication. +This is a fork of [indiefan/home_assistant_nanit](https://github.com/indiefan/home_assistant_nanit), which is itself a fork of the original [nanit project](https://gitlab.com/adam.stanek/nanit) by Adam Stanek. Credit to both for the foundational work. + +## What this fork changes + +- **Automatic stream recovery on reconnection**: The upstream container maintains a websocket to Nanit's cloud and asks the camera to push its RTMP stream locally. After websocket disconnections, it would reconnect but never re-request the stream — leaving the RTMP server running with no publisher. This fork resets stream state on disconnect so the stream is always re-requested after reconnection. +- **Health endpoint**: Adds an HTTP health endpoint on port 8080 (`/health`) that reports whether the websocket is connected and the RTMP stream is alive. Used by the Docker HEALTHCHECK to auto-restart the container if the stream dies. +- **Docker HEALTHCHECK**: The Dockerfile includes a built-in health check (every 3 minutes, 2 retries) so Docker's restart policy can recover from any failure mode automatically. +- **Bugfix**: Fixed a nil pointer dereference in `GetIsWebsocketAlive()` where it checked `StreamState` instead of `IsWebsocketAlive`. # Installation (Docker) @@ -8,7 +15,7 @@ This is a fork of a no-longer-maintained project (https://gitlab.com/adam.stanek While it is possible to build the image locally from the included Dockerfile, it is recommended to install and update by pulling the official image directly from Docker Hub. To pull the image manually without running the container, run: -`docker pull indiefan/nanit` +`docker pull ghcr.io/stuart22/home_assistant_nanit` ## Authentication @@ -18,7 +25,7 @@ Because Nanit requires 2FA authentication, before we can start we need to acquir Run the bundled init-nanit.sh utility directly via the Docker command line to acquire the token (replace `/path/to/data` with the local path you'd like the container to use for storing session data): -`docker run -it -v /path/to/data:/data --entrypoint=/app/scripts/init-nanit.sh indiefan/nanit` +`docker run -it -v /path/to/data:/data --entrypoint=/app/scripts/init-nanit.sh ghcr.io/stuart22/home_assistant_nanit` ** Important Note regarding Security** The refresh token provides complete access to your Nanit account without requiring any additional account information, so be sure to protect your system from access by unauthorized parties, and proceed at your own risk. @@ -38,7 +45,7 @@ docker run \ -e NANIT_RTMP_ADDR=xxx.xxx.xxx.xxx:1935 \ -e NANIT_LOG_LEVEL=trace \ -p 1935:1935 \ - indiefan/nanit + ghcr.io/stuart22/home_assistant_nanit ``` If this is your initial run, you may want to omit the `-d` flag so you can observe the output to find your `baby_uid` (which will be needed later if you plan on connecting anything to the feed, like Home Assistant). After getting the baby id (which won't change) you can stop the container and restart it with the `-d` flag. diff --git a/pkg/app/app.go b/pkg/app/app.go index ed5ac2f..0f4180f 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -59,6 +59,9 @@ func (app *App) Run(ctx utils.GracefulContext) { go rtmpserver.StartRTMPServer(app.Opts.RTMP.ListenAddr, app.BabyStateManager) } + // Health endpoint for Docker HEALTHCHECK + go startHealthServer(app.BabyStateManager) + // MQTT if app.MQTTConnection != nil { ctx.RunAsChild(func(childCtx utils.GracefulContext) { diff --git a/pkg/app/health.go b/pkg/app/health.go new file mode 100644 index 0000000..59606a5 --- /dev/null +++ b/pkg/app/health.go @@ -0,0 +1,44 @@ +package app + +import ( + "fmt" + "net/http" + + "github.com/indiefan/home_assistant_nanit/pkg/baby" + "github.com/rs/zerolog/log" +) + +func startHealthServer(babyStateManager *baby.StateManager) { + mux := http.NewServeMux() + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + states := babyStateManager.GetAllBabyStates() + + if len(states) == 0 { + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprint(w, "no babies registered") + return + } + + for uid, state := range states { + if !state.GetIsWebsocketAlive() { + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprintf(w, "websocket disconnected for %s", uid) + return + } + if state.GetStreamState() == baby.StreamState_Unhealthy { + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprintf(w, "stream unhealthy for %s", uid) + return + } + } + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + }) + + log.Info().Msg("Health server started on :8080") + if err := http.ListenAndServe(":8080", mux); err != nil { + log.Error().Err(err).Msg("Health server failed") + } +} diff --git a/pkg/baby/state.go b/pkg/baby/state.go index beda106..0b63a23 100644 --- a/pkg/baby/state.go +++ b/pkg/baby/state.go @@ -214,7 +214,7 @@ func (state *State) SetTemperature(value bool) *State { // GetIsWebsocketAlive - safely returns value func (state *State) GetIsWebsocketAlive() bool { - if state.StreamState != nil { + if state.IsWebsocketAlive != nil { return *state.IsWebsocketAlive } diff --git a/pkg/baby/state_manager.go b/pkg/baby/state_manager.go index 79f652c..84a9e50 100644 --- a/pkg/baby/state_manager.go +++ b/pkg/baby/state_manager.go @@ -77,6 +77,18 @@ func (manager *StateManager) GetBabyState(babyUID string) *State { return &babyState } +// GetAllBabyStates - returns a copy of all baby states +func (manager *StateManager) GetAllBabyStates() map[string]State { + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + + result := make(map[string]State, len(manager.babiesByUID)) + for uid, state := range manager.babiesByUID { + result[uid] = state + } + return result +} + func (manager *StateManager) NotifyMotionSubscribers(babyUID string, time time.Time) { timestamp := new(int32) *timestamp = int32(time.Unix()) diff --git a/pkg/client/websocket.go b/pkg/client/websocket.go index 747fb42..78cb03d 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -144,7 +144,13 @@ func (manager *WebsocketConnectionManager) run(attempt utils.AttemptContext) { // Handle lost connection socket.OnDisconnected = func(err error, socket gowebsocket.Socket) { once.Do(func() { - manager.BabyStateManager.Update(manager.BabyUID, *baby.NewState().SetWebsocketAlive(false)) + // Reset stream state so reconnection will re-request local streaming. + // Without this, StreamState stays "Alive" from the previous connection + // and the stream request is skipped after reconnection. + manager.BabyStateManager.Update(manager.BabyUID, *baby.NewState(). + SetWebsocketAlive(false). + SetStreamState(baby.StreamState_Unknown). + SetStreamRequestState(baby.StreamRequestState_NotRequested)) if err != nil { log.Error().Err(err).Msg("Disconnected from server") From 12355a58b2fbc7e13838d02324eadc57f576becc Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 19:40:31 -0400 Subject: [PATCH 02/20] Update base image from debian:buster to debian:bookworm-slim Buster is EOL and its apt repositories are no longer available, causing build failures. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3e2c0d1..a83d8bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ ARG CI_COMMIT_SHORT_SHA ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-X main.GitCommit=$CI_COMMIT_SHORT_SHA" -o ./bin/nanit ./cmd/nanit/*.go -FROM debian:buster +FROM debian:bookworm-slim COPY --from=build /app/bin/nanit /app/bin/nanit COPY --from=build /app/scripts /app/scripts From c060090fe8fe882b985d626f4e783556227b3de5 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 21:24:11 -0400 Subject: [PATCH 03/20] Add grace period to health check before triggering restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the Go reconnection fix 10 minutes to recover the stream before reporting unhealthy. The health endpoint tracks how long the stream has been down and returns healthy during the grace period. After the grace period, the HEALTHCHECK kills PID 1 so restart: unless-stopped restarts the container. Timeline on stream drop: - 0-10min: Go fix retries (30s, 2min backoff) — health reports OK - 10-16min: Grace expired, HEALTHCHECK fails twice, container restarts --- Dockerfile | 4 ++-- pkg/app/health.go | 52 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index a83d8bc..41a7e6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,6 @@ RUN mkdir -p /data && \ WORKDIR /app EXPOSE 1935 8080 -HEALTHCHECK --interval=180s --timeout=5s --start-period=60s --retries=2 \ - CMD curl -f http://localhost:8080/health || exit 1 +HEALTHCHECK --interval=180s --timeout=5s --start-period=120s --retries=2 \ + CMD curl -f http://localhost:8080/health || (kill 1 && exit 1) ENTRYPOINT ["/app/bin/nanit"] diff --git a/pkg/app/health.go b/pkg/app/health.go index 59606a5..9a21004 100644 --- a/pkg/app/health.go +++ b/pkg/app/health.go @@ -3,12 +3,19 @@ package app import ( "fmt" "net/http" + "sync" + "time" "github.com/indiefan/home_assistant_nanit/pkg/baby" "github.com/rs/zerolog/log" ) +const healthGracePeriod = 10 * time.Minute + func startHealthServer(babyStateManager *baby.StateManager) { + var mu sync.Mutex + var unhealthySince *time.Time + mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { @@ -20,21 +27,50 @@ func startHealthServer(babyStateManager *baby.StateManager) { return } + healthy := true + var reason string for uid, state := range states { if !state.GetIsWebsocketAlive() { - w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintf(w, "websocket disconnected for %s", uid) - return + healthy = false + reason = fmt.Sprintf("websocket disconnected for %s", uid) + break } if state.GetStreamState() == baby.StreamState_Unhealthy { - w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintf(w, "stream unhealthy for %s", uid) - return + healthy = false + reason = fmt.Sprintf("stream unhealthy for %s", uid) + break } } - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "ok") + mu.Lock() + defer mu.Unlock() + + if healthy { + unhealthySince = nil + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + return + } + + // Track when the stream first became unhealthy + now := time.Now() + if unhealthySince == nil { + unhealthySince = &now + log.Warn().Str("reason", reason).Msg("Stream unhealthy, starting grace period for recovery") + } + + elapsed := now.Sub(*unhealthySince) + if elapsed < healthGracePeriod { + // Still within grace period — report healthy so the Go fix can attempt recovery + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "recovering (%.0fs/%s): %s", elapsed.Seconds(), healthGracePeriod, reason) + return + } + + // Grace period expired — report unhealthy to trigger restart + log.Error().Str("reason", reason).Dur("down_for", elapsed).Msg("Stream unhealthy beyond grace period") + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprintf(w, "unhealthy for %s: %s", elapsed.Round(time.Second), reason) }) log.Info().Msg("Health server started on :8080") From 2c0a4b6416a2462dd134d59d5bfb6eecca87b662 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 21:24:21 -0400 Subject: [PATCH 04/20] Reduce health grace period to 3 minutes --- pkg/app/health.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/app/health.go b/pkg/app/health.go index 9a21004..8610551 100644 --- a/pkg/app/health.go +++ b/pkg/app/health.go @@ -10,7 +10,7 @@ import ( "github.com/rs/zerolog/log" ) -const healthGracePeriod = 10 * time.Minute +const healthGracePeriod = 3 * time.Minute func startHealthServer(babyStateManager *baby.StateManager) { var mu sync.Mutex From 734f5aec2ef05004ca3649419b181b2e5ee84be0 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 21:25:47 -0400 Subject: [PATCH 05/20] Update README changelog with grace period and base image changes --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6295491..ecfbac3 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ This is a fork of [indiefan/home_assistant_nanit](https://github.com/indiefan/ho ## What this fork changes - **Automatic stream recovery on reconnection**: The upstream container maintains a websocket to Nanit's cloud and asks the camera to push its RTMP stream locally. After websocket disconnections, it would reconnect but never re-request the stream — leaving the RTMP server running with no publisher. This fork resets stream state on disconnect so the stream is always re-requested after reconnection. -- **Health endpoint**: Adds an HTTP health endpoint on port 8080 (`/health`) that reports whether the websocket is connected and the RTMP stream is alive. Used by the Docker HEALTHCHECK to auto-restart the container if the stream dies. -- **Docker HEALTHCHECK**: The Dockerfile includes a built-in health check (every 3 minutes, 2 retries) so Docker's restart policy can recover from any failure mode automatically. +- **Health endpoint with grace period**: Adds an HTTP health endpoint on port 8080 (`/health`) that reports whether the websocket is connected and the RTMP stream is alive. Includes a 3-minute grace period after stream loss to allow the reconnection fix to recover before triggering a restart. +- **Docker HEALTHCHECK**: The Dockerfile includes a built-in health check (every 3 minutes, 2 retries). If the stream doesn't recover within the grace period, the container is restarted automatically. +- **Updated base image**: Upgraded from `debian:buster` (EOL) to `debian:bookworm-slim`. - **Bugfix**: Fixed a nil pointer dereference in `GetIsWebsocketAlive()` where it checked `StreamState` instead of `IsWebsocketAlive`. # Installation (Docker) From ea2e8c1cf95980df970388e12d8eaddabe4b499e Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 22:42:15 -0400 Subject: [PATCH 06/20] Add design spec for nanit fork enhancements Covers PersistentBroadcaster, go2rtc bundling for UniFi Protect, multi-camera MQTT routing, device control, auto-discovery, notification events, and sleep tracking. --- .../2026-04-11-nanit-enhancements-design.md | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-11-nanit-enhancements-design.md diff --git a/docs/superpowers/specs/2026-04-11-nanit-enhancements-design.md b/docs/superpowers/specs/2026-04-11-nanit-enhancements-design.md new file mode 100644 index 0000000..d581cbb --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-nanit-enhancements-design.md @@ -0,0 +1,313 @@ +# Nanit Fork Enhancements — Design Spec + +**Date:** 2026-04-11 +**Repo:** stuart22/home_assistant_nanit +**Related issues:** stuart22/homelab#46, #47, #48 + +## Context + +Our fork of `indiefan/home_assistant_nanit` currently fixes the RTMP stream recovery bug, adds a health endpoint, and supports multiple cameras. We want to pull in the best features from three community forks (scgreenhalgh, tanvach, combmag) and add UniFi Protect support, making this a comprehensive Nanit integration. + +Home Assistant runs at 192.168.1.166 and bridges cameras to Apple Home via HomeKit. An MQTT broker is not yet set up but all MQTT features will be gated behind env vars so they can be enabled later. + +Two Nanit cameras are active: +- Abby: baby_uid `ddedb8f1`, camera `N301CMN23260RE`, IP 192.168.1.119 +- Emilia: baby_uid `6086057e`, camera `N301BKN211184B`, IP 192.168.1.149 + +Nanit limits concurrent app connections per camera. An iPad with the Nanit app is always connected, so we cannot add a cloud relay (rules out scgreenhalgh's HybridStreamPool). + +## Architecture Overview + +``` +Nanit Cloud (wss://api.nanit.com) + │ + ▼ +┌─────────────────────────────────────────┐ +│ nanit-abby container │ +│ │ +│ Go binary: │ +│ ├─ Websocket mgr (per camera) │ +│ ├─ RTMP server (:1935) │ +│ │ └─ PersistentBroadcaster │ +│ ├─ Health server (:8080) │ +│ ├─ MQTT client (optional) │ +│ │ ├─ Auto-discovery │ +│ │ ├─ Device control │ +│ │ └─ Notification events │ +│ └─ REST API poller (optional) │ +│ │ +│ go2rtc binary: │ +│ ├─ RTSP server (:8554) │ +│ └─ ONVIF/HomeKit server │ +│ │ +│ Entrypoint script: │ +│ └─ Starts both processes │ +└────────────────────────────��────────────┘ + │ │ + ▼ ▼ + HA (RTMP/RTSP) UniFi Protect (ONVIF) + 192.168.1.166 192.168.1.1 +``` + +## Feature 1: PersistentBroadcaster + +**Source:** scgreenhalgh/home_assistant_nanit — `pkg/rtmpserver/persistent_broadcaster.go` + +**Problem:** When the camera's RTMP publisher disconnects and reconnects (even briefly during our websocket reconnect fix), all RTMP subscribers (HA/Apple Home) get disconnected and must reconnect. This causes a visible stream interruption. + +**Solution:** Insert a PersistentBroadcaster between publishers and subscribers in the RTMP server. Subscribers connect to the PersistentBroadcaster once and stay connected across publisher reconnections. + +**Behavior:** +- On new publisher: waits for first keyframe (I-frame) before forwarding to subscribers +- On publisher disconnect: holds subscriber connections open, stops forwarding packets +- On publisher reconnect: waits for keyframe, resumes forwarding with remapped timestamps (monotonically increasing across switches) +- Stall detection: if no packets arrive for 3 seconds, marks stream as unhealthy + +**Files to create/modify:** +- `pkg/rtmpserver/persistent_broadcaster.go` — new, adapted from scgreenhalgh +- `pkg/rtmpserver/server.go` — modify `handleConnection` to use PersistentBroadcaster instead of direct broadcaster +- `pkg/rtmpserver/broadcaster.go` — may need interface adjustments + +**No new env vars.** Always active. + +## Feature 2: go2rtc Bundling (RTSP + ONVIF for UniFi Protect) + +**Source:** Inspired by tombaxley/nanit-unifi-protect, but integrated into our container. + +**Problem:** UniFi Protect requires ONVIF or RTSP cameras. Our container only outputs RTMP. + +**Solution:** Bundle the go2rtc binary in the Docker image. go2rtc reads from the local RTMP server and exposes RTSP and ONVIF endpoints. A wrapper entrypoint script starts both the nanit Go binary and go2rtc. + +**Dockerfile changes:** +- Download go2rtc binary in build stage (static binary, ~15MB) +- Add go2rtc config template +- New entrypoint script that starts both processes +- Expose ports 8554 (RTSP) and 2121 (ONVIF, or configurable) + +**go2rtc config** (generated at startup from env vars): +```yaml +streams: + nanit-abby: + - rtmp://127.0.0.1:1935/local/{baby_uid_1} + nanit-emilia: + - rtmp://127.0.0.1:1935/local/{baby_uid_2} + +rtsp: + listen: ":8554" + +homekit: {} # optional, disabled by default +``` + +The config is generated dynamically by reading baby UIDs from `session.json` at container startup. + +**Entrypoint script** (`scripts/entrypoint.sh`): +```bash +#!/bin/bash +# Generate go2rtc config from session.json +python3 /app/scripts/gen-go2rtc-config.py || true + +# Start go2rtc in background +/app/bin/go2rtc -config /data/go2rtc.yaml & + +# Start nanit (foreground, PID 1 for signal handling) +exec /app/bin/nanit +``` + +Wait — we don't have Python in the image. Use jq instead (already installed): +```bash +#!/bin/bash +# Generate go2rtc config from session.json baby UIDs +BABIES=$(jq -r '.babies[].uid' /data/session.json 2>/dev/null) +CONFIG="streams:\n" +for uid in $BABIES; do + CONFIG+=" nanit-${uid}:\n - rtmp://127.0.0.1:1935/local/${uid}\n" +done +CONFIG+="rtsp:\n listen: \":8554\"\n" +echo -e "$CONFIG" > /data/go2rtc.yaml + +# Start go2rtc in background +/app/bin/go2rtc -config /data/go2rtc.yaml & +GO2RTC_PID=$! + +# Start nanit as main process +/app/bin/nanit & +NANIT_PID=$! + +# Wait for either to exit +wait -n $GO2RTC_PID $NANIT_PID +# If either exits, kill the other and exit +kill $GO2RTC_PID $NANIT_PID 2>/dev/null +exit 1 +``` + +**New env vars:** +- `NANIT_RTSP_ENABLED` (default: true) — enable go2rtc RTSP output +- `NANIT_GO2RTC_CONFIG` (optional) — path to custom go2rtc config + +**Compose changes** (`stacks/nanit/docker-compose.yml`): +```yaml +ports: + - "1935:1935" # RTMP + - "8554:8554" # RTSP +``` + +**UniFi Protect setup:** +After deploy, add the cameras in Protect via ONVIF discovery or manually with `rtsp://192.168.1.121:8554/nanit-{baby_uid}`. + +## Feature 3: Multi-camera MQTT Routing (tanvach) + +**Source:** tanvach/home_assistant_nanit — PR #33 + +**Problem:** Upstream registers MQTT command handlers inside each websocket connection callback. In multi-camera setups, only the last-connected camera receives commands. + +**Solution:** Global connection registry that routes MQTT commands to the correct camera by baby UID. + +**Changes:** +- `pkg/app/app.go` — add `babyConnections` map (baby UID → websocket connection), `babyManagers` map (baby UID → connection manager), `pendingRetries` map. Move MQTT handler registration from `runWebsocket` to `Run()`. Handlers now take `(babyUID string, enabled bool)` instead of `(enabled bool)`. +- `pkg/mqtt/mqtt.go` — handler signatures change to include `babyUID`. Parse baby UID from MQTT topic. +- `pkg/app/websocket_handlers.go` — add `sendLightCommandWithReset`, `sendStandbyCommandWithReset` with timeout-based failure detection and auto-reconnect. +- `pkg/client/websocket.go` — add `ForceReconnect()`, `ShouldSkipCooldown` callback. +- `pkg/client/websocket_conn.go` — add `Close()` method. +- `pkg/utils/attempter.go` — add `ShouldSkipCooldown` to `PerseverenceOpts`. +- `pkg/app/opts.go` — add `WebSocketResetOpts` struct. + +**New env vars:** +- `NANIT_MQTT_RESET_WHEN_FAILED` (default: false) — enable auto-reconnect on failed MQTT commands +- `NANIT_WEBSOCKET_TIMEOUT` (default: 500ms) — command response timeout + +**Gated behind** `NANIT_MQTT_ENABLED=true`. + +## Feature 4: MQTT Device Control (combmag) + +**Source:** combmag/home_assistant_nanit + +**Problem:** Can't control night light brightness, sound playback, or volume from HA. + +**Solution:** Add MQTT handlers for playback, volume, and night light brightness. Extend protobuf schema. + +**Changes:** +- `pkg/mqtt/mqtt.go` — new subscription handlers for `{prefix}/babies/{uid}/playback`, `{prefix}/babies/{uid}/volume`, `{prefix}/babies/{uid}/light` +- `pkg/app/websocket_handlers.go` — new command senders: `sendPlaybackCommand`, `sendVolumeCommand`, extended `sendLightCommand` with brightness +- Protobuf schema — add `brightness` field to `Settings`, `duration` and `Soundtrack` to `Playback`, `SoundtrackStorage` enum + +**Important:** combmag uses a global websocket accessor. We refactor this onto tanvach's connection registry pattern instead. + +**Gated behind** `NANIT_MQTT_ENABLED=true`. + +## Feature 5: HA MQTT Auto-Discovery (scgreenhalgh) + +**Source:** scgreenhalgh/home_assistant_nanit — `pkg/mqtt/discovery.go` + +**Problem:** Users must manually configure HA YAML for each sensor/switch. Multi-camera makes this tedious. + +**Solution:** On MQTT connect, publish auto-discovery messages to `homeassistant/` topics. HA automatically creates entities. + +**Entities registered per camera:** +- **Sensors:** Temperature, Humidity, Last Motion, Last Sound, Stream URL +- **Binary Sensors:** Crying, Standing, Camera Online, Left Bed, Alert Zone, Temp/Humidity/Breathing Alerts, Low Battery, Stream Active +- **Switches:** Night Light, Standby Mode + +**New env var:** `NANIT_MQTT_DISCOVERY` (default: true when MQTT enabled) + +**Gated behind** `NANIT_MQTT_ENABLED=true`. + +## Feature 6: Notification Event Polling (scgreenhalgh) + +**Source:** scgreenhalgh/home_assistant_nanit — `pkg/notification/` + +**Problem:** No visibility into camera events (crying, motion, standing, etc.) in HA. + +**Solution:** Poll Nanit REST API for events, publish to MQTT. Includes message deduplication and jitter. + +**Changes:** +- `pkg/notification/` — new package (poller, event types, dedup) +- `pkg/client/rest.go` — expanded with new API methods (`FetchLastEvent`, `FetchBabyWithCamera`, `FetchNotificationSettings`, etc.) +- `pkg/app/app.go` — start notification poller in `Run()` + +**New env vars:** +- `NANIT_NOTIFICATIONS_ENABLED` (default: false) +- `NANIT_NOTIFICATIONS_POLL_INTERVAL` (default: 10s) +- `NANIT_NOTIFICATIONS_JITTER` (default: 0.3) +- `NANIT_NOTIFICATIONS_MAX_BACKOFF` (default: 300s) + +## Feature 7: Sleep Tracking (scgreenhalgh) + +**Source:** Part of scgreenhalgh's `pkg/notification/` package. + +**Problem:** No sleep data in HA. + +**Solution:** Poll Nanit API for sleep events and statistics. Publish `is_asleep`, `in_bed`, and sleep stats to MQTT. + +**Changes:** +- Part of `pkg/notification/` — `SleepEventPoller`, `StatsPoller`, `SleepStateTracker` +- `pkg/client/rest.go` — `FetchSleepEvents`, `FetchSleepStats` + +**Gated behind** `NANIT_NOTIFICATIONS_ENABLED=true`. + +## Implementation Order + +1. **PersistentBroadcaster** — no dependencies, improves stream stability immediately +2. **Multi-camera MQTT routing** (tanvach) — foundation for all MQTT features +3. **MQTT device control** (combmag, refactored) — builds on tanvach's registry +4. **MQTT auto-discovery** (scgreenhalgh) — builds on MQTT infrastructure +5. **Notification events + sleep tracking** (scgreenhalgh) — builds on MQTT + REST client +6. **go2rtc bundling** — independent, can be done in parallel with MQTT work +7. **Compose + README updates** — final step + +## Env Var Summary + +| Variable | Default | Feature | +|----------|---------|---------| +| `NANIT_RTSP_ENABLED` | `true` | go2rtc RTSP/ONVIF output | +| `NANIT_MQTT_ENABLED` | `false` | All MQTT features | +| `NANIT_MQTT_BROKER_URL` | — | MQTT broker address | +| `NANIT_MQTT_CLIENT_ID` | `nanit` | MQTT client ID | +| `NANIT_MQTT_USERNAME` | — | MQTT auth | +| `NANIT_MQTT_PASSWORD` | — | MQTT auth | +| `NANIT_MQTT_PREFIX` | `nanit` | MQTT topic prefix | +| `NANIT_MQTT_DISCOVERY` | `true` | HA auto-discovery | +| `NANIT_MQTT_RESET_WHEN_FAILED` | `false` | Auto-reconnect on failed commands | +| `NANIT_WEBSOCKET_TIMEOUT` | `500ms` | Command response timeout | +| `NANIT_NOTIFICATIONS_ENABLED` | `false` | Event polling + sleep tracking | +| `NANIT_NOTIFICATIONS_POLL_INTERVAL` | `10s` | Polling interval | +| `NANIT_NOTIFICATIONS_JITTER` | `0.3` | Polling jitter | +| `NANIT_NOTIFICATIONS_MAX_BACKOFF` | `300s` | Max backoff on API errors | + +## Compose Changes + +```yaml +version: '3' +services: + nanit: + container_name: nanit-abby + image: ghcr.io/stuart22/home_assistant_nanit:latest + volumes: + - /docker-fs/nanit/data:/data + - /etc/localtime:/etc/localtime:ro + environment: + - NANIT_RTMP_ADDR=192.168.1.121:1935 + - NANIT_LOG_LEVEL=info + - TZ=America/New_York + # MQTT (enable when broker is ready) + # - NANIT_MQTT_ENABLED=true + # - NANIT_MQTT_BROKER_URL=mqtt://192.168.1.166:1883 + # - NANIT_NOTIFICATIONS_ENABLED=true + ports: + - "1935:1935" + - "8554:8554" + restart: unless-stopped +``` + +## Verification + +1. **PersistentBroadcaster:** Restart camera or simulate disconnect → Apple Home stream should not drop +2. **go2rtc:** Access `rtsp://192.168.1.121:8554/nanit-ddedb8f1` from VLC → stream plays. Add to UniFi Protect → camera shows up. +3. **MQTT (when enabled):** Check HA → auto-discovered sensors/switches appear. Toggle night light via HA → camera responds. +4. **Notifications (when enabled):** Trigger motion → HA binary sensor updates. + +## What We're NOT Pulling In + +- **HybridStreamPool / RTMPRelay** — adds cloud connection, would trigger Nanit rate limits (iPad already uses one connection) +- **RTMP IP access control** — unnecessary on trusted LAN +- **Separate publisher/subscriber ports** — no benefit on flat network +- **Non-root user** (scgreenhalgh) — would require volume permission changes on existing `/docker-fs/nanit/data` From 95621dc71ce864d5315367e8a369fba8dc8bafbb Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 22:47:01 -0400 Subject: [PATCH 07/20] Add implementation plans for three parallel work streams --- ...6-04-11-stream-a-persistent-broadcaster.md | 206 ++++++++++++++++ .../2026-04-11-stream-b-go2rtc-bundling.md | 231 +++++++++++++++++ .../2026-04-11-stream-c-mqtt-features.md | 232 ++++++++++++++++++ 3 files changed, 669 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-11-stream-a-persistent-broadcaster.md create mode 100644 docs/superpowers/plans/2026-04-11-stream-b-go2rtc-bundling.md create mode 100644 docs/superpowers/plans/2026-04-11-stream-c-mqtt-features.md diff --git a/docs/superpowers/plans/2026-04-11-stream-a-persistent-broadcaster.md b/docs/superpowers/plans/2026-04-11-stream-a-persistent-broadcaster.md new file mode 100644 index 0000000..6010d4a --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-stream-a-persistent-broadcaster.md @@ -0,0 +1,206 @@ +# Stream A: PersistentBroadcaster Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the simple broadcaster in the RTMP server with a PersistentBroadcaster that keeps subscribers connected across publisher reconnections. + +**Architecture:** The PersistentBroadcaster sits between RTMP publishers (cameras) and subscribers (HA/Apple Home). When a publisher disconnects and reconnects, subscribers stay connected. Timestamps are remapped to be monotonically increasing across reconnections. Adapted from scgreenhalgh/home_assistant_nanit. + +**Tech Stack:** Go, `github.com/notedit/rtmp/av` for packet types + +**Spec:** `docs/superpowers/specs/2026-04-11-nanit-enhancements-design.md` — Feature 1 + +**Reference implementation:** `scgreenhalgh/home_assistant_nanit` — fetch with `gh api repos/scgreenhalgh/home_assistant_nanit/contents/{path} --jq '.content' | base64 -d` + +--- + +### Task 1: Add PersistentBroadcaster + +**Files:** +- Create: `pkg/rtmpserver/persistent_broadcaster.go` + +We are adapting scgreenhalgh's PersistentBroadcaster but simplified — we do NOT need hot standby, source switching, or stall detection (we're not using HybridStreamPool). We only need: +- Subscriber persistence across publisher reconnections +- Timestamp remapping for stream continuity +- Header packet storage for new subscribers +- Keyframe waiting on new publisher connection + +- [ ] **Step 1: Create persistent_broadcaster.go** + +Fetch scgreenhalgh's implementation for reference: +```bash +gh api repos/scgreenhalgh/home_assistant_nanit/contents/pkg/rtmpserver/persistent_broadcaster.go --jq '.content' | base64 -d > /tmp/scg_pb.go +``` + +Create a simplified version at `pkg/rtmpserver/persistent_broadcaster.go`. Keep: +- `PersistentBroadcaster` struct with subscribers map, headerPkts, timestamp remapping state +- `Subscription` struct with packet channel +- `Subscribe()`, `Unsubscribe()`, `Broadcast()`, `Close()`, `SubscriberCount()` +- Timestamp remapping via `remapTimestamp()` +- Header packet handling via `handleHeaderPacket()` + +Remove (not needed without HybridStreamPool): +- All `activeSourceID` / `activeSourceSet` / source filtering logic +- `RequestSourceSwitch()` and keyframe-aligned switching between sources +- `StallConfig` and stall detection +- `SetActiveSource()`, `IsPendingSwitch()`, `ActiveSourceID()` +- `GetBroadcastFunc()` (we'll call Broadcast directly) +- Source IDs — use a single constant `SourceIDLocal = 1` + +The simplified `Broadcast()` should: +1. Handle header packets (type > 2) by storing them +2. Wait for first keyframe before forwarding to subscribers +3. Remap timestamps for continuity after publisher reconnections +4. Distribute to all subscribers, dropping packets if buffer full + +- [ ] **Step 2: Verify it compiles** + +```bash +cd /tmp/home_assistant_nanit && docker run --rm -v $(pwd):/app -w /app golang:1.24.0 go build ./pkg/rtmpserver/ +``` + +- [ ] **Step 3: Commit** + +```bash +git add pkg/rtmpserver/persistent_broadcaster.go +git commit -m "Add PersistentBroadcaster for subscriber persistence across reconnections" +``` + +--- + +### Task 2: Integrate PersistentBroadcaster into RTMP Server + +**Files:** +- Modify: `pkg/rtmpserver/server.go` + +The current server creates a `broadcaster` per publisher connection and destroys it on disconnect. We need to change it to use a persistent broadcaster per baby UID that outlives individual publisher connections. + +- [ ] **Step 1: Modify server.go** + +Fetch scgreenhalgh's server.go for reference: +```bash +gh api repos/scgreenhalgh/home_assistant_nanit/contents/pkg/rtmpserver/server.go --jq '.content' | base64 -d > /tmp/scg_server.go +``` + +Changes to `rtmpHandler`: +- Change `broadcastersByUID map[string]*broadcaster` to `broadcastersByUID map[string]*PersistentBroadcaster` +- Remove `getNewPublisher()` (no longer creates/replaces broadcasters) +- Remove `closePublisher()` (persistent broadcasters don't close on publisher disconnect) + +Changes to `handleConnection()` for publishers (`c.Publishing`): +1. Get or create a `PersistentBroadcaster` for the baby UID +2. Set `StreamState_Alive` on the baby state manager +3. Read packets in a loop and call `pb.Broadcast(pkt, SourceIDLocal)` +4. On publisher disconnect: set `StreamState_Unhealthy` (the PersistentBroadcaster keeps subscribers connected) + +Changes to `handleConnection()` for subscribers: +1. Get the `PersistentBroadcaster` for the baby UID (return nil/"no broadcaster" if none exists) +2. Call `pb.Subscribe()` to get a `Subscription` +3. Read from `subscription.Packets()` channel and write to RTMP connection +4. On disconnect, call `subscription.Unsubscribe()` + +- [ ] **Step 2: Verify it compiles** + +```bash +cd /tmp/home_assistant_nanit && docker run --rm -v $(pwd):/app -w /app golang:1.24.0 go build ./cmd/nanit/ +``` + +- [ ] **Step 3: Commit** + +```bash +git add pkg/rtmpserver/server.go +git commit -m "Integrate PersistentBroadcaster into RTMP server" +``` + +--- + +### Task 3: Update StartRTMPServer Signature + +**Files:** +- Modify: `pkg/rtmpserver/server.go` +- Modify: `pkg/app/app.go` + +The current `StartRTMPServer` is a standalone blocking function. We need it to return an `*RTMPServer` handle so the health endpoint can query broadcaster state later (and for go2rtc integration in Stream B). + +- [ ] **Step 1: Update server.go** + +Add an `RTMPServer` wrapper struct: +```go +type RTMPServer struct { + handler *rtmpHandler +} +``` + +Change `StartRTMPServer` to return `*RTMPServer`: +```go +func StartRTMPServer(addr string, babyStateManager *baby.StateManager) *RTMPServer { + srv := &RTMPServer{handler: newRtmpHandler(babyStateManager)} + // ... existing listen + serve code using srv.handler.handleConnection ... + return srv // Note: this blocks, so caller must use `go` +} +``` + +Actually, since it blocks, split into create + run: +```go +func NewRTMPServer(babyStateManager *baby.StateManager) *RTMPServer { ... } +func (srv *RTMPServer) Run(addr string) { ... } // blocking +``` + +- [ ] **Step 2: Update app.go** + +Change: +```go +go rtmpserver.StartRTMPServer(app.Opts.RTMP.ListenAddr, app.BabyStateManager) +``` +To: +```go +rtmpServer := rtmpserver.NewRTMPServer(app.BabyStateManager) +go rtmpServer.Run(app.Opts.RTMP.ListenAddr) +``` + +- [ ] **Step 3: Verify it compiles and push** + +```bash +cd /tmp/home_assistant_nanit && docker run --rm -v $(pwd):/app -w /app golang:1.24.0 go build ./cmd/nanit/ +git add pkg/rtmpserver/server.go pkg/app/app.go +git commit -m "Refactor RTMP server to return handle for health/go2rtc integration" +git push origin main +``` + +--- + +### Task 4: Verify End-to-End + +- [ ] **Step 1: Push and wait for CI build** + +```bash +git push origin main +gh run watch --repo stuart22/home_assistant_nanit +``` + +- [ ] **Step 2: Wait for Portainer to deploy** + +Portainer polls every 5 minutes. Check: +```bash +ssh linuxserver "docker inspect nanit-abby --format '{{.Config.Image}} {{.Created}}'" +``` + +- [ ] **Step 3: Verify streams are working** + +```bash +ssh linuxserver "docker logs nanit-abby --tail 20" +``` + +Look for: +- `Created persistent broadcaster` for each baby UID +- `New local stream publisher connected` for each camera +- `Local streaming successfully requested` for each camera + +- [ ] **Step 4: Verify subscriber persistence** + +The real test is when a camera reconnects. Monitor logs for the next natural websocket disconnection, or force one: +```bash +ssh linuxserver "docker exec nanit-abby curl -s http://localhost:8080/health" +``` + +After a reconnection, Apple Home should NOT show a stream interruption. diff --git a/docs/superpowers/plans/2026-04-11-stream-b-go2rtc-bundling.md b/docs/superpowers/plans/2026-04-11-stream-b-go2rtc-bundling.md new file mode 100644 index 0000000..fdc06d3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-stream-b-go2rtc-bundling.md @@ -0,0 +1,231 @@ +# Stream B: go2rtc Bundling for RTSP/ONVIF Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bundle go2rtc into the nanit Docker image so the container exposes RTSP and ONVIF endpoints for UniFi Protect integration, in addition to RTMP. + +**Architecture:** go2rtc runs as a second process inside the container, reading from the local RTMP server and exposing RTSP on port 8554. A wrapper entrypoint script starts both processes and generates go2rtc config from `session.json` baby UIDs. The nanit Go binary remains PID 1 behavior via `exec`. + +**Tech Stack:** go2rtc binary (static, downloaded in Dockerfile), bash entrypoint script, jq for config generation + +**Spec:** `docs/superpowers/specs/2026-04-11-nanit-enhancements-design.md` — Feature 2 + +**Reference:** [tombaxley/nanit-unifi-protect](https://github.com/tombaxley/nanit-unifi-protect) for go2rtc config patterns + +--- + +### Task 1: Create Entrypoint Script + +**Files:** +- Create: `scripts/entrypoint.sh` + +- [ ] **Step 1: Write entrypoint.sh** + +```bash +#!/bin/bash +set -e + +# Generate go2rtc config from session.json baby UIDs +SESSION_FILE="${NANIT_SESSION_FILE:-/data/session.json}" +GO2RTC_CONFIG="/data/go2rtc.yaml" + +if [ -f "$SESSION_FILE" ] && [ "${NANIT_RTSP_ENABLED:-true}" = "true" ]; then + echo "Generating go2rtc config from session..." + + # Build streams config from baby UIDs and names + echo "streams:" > "$GO2RTC_CONFIG" + jq -r '.babies[] | "\(.uid) \(.name // .uid)"' "$SESSION_FILE" 2>/dev/null | while read -r uid name; do + # Sanitize name for use as stream key (lowercase, replace spaces with hyphens) + key=$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-') + echo " ${key}:" >> "$GO2RTC_CONFIG" + echo " - rtmp://127.0.0.1:1935/local/${uid}" >> "$GO2RTC_CONFIG" + done + + # RTSP listener + echo "" >> "$GO2RTC_CONFIG" + echo "rtsp:" >> "$GO2RTC_CONFIG" + echo " listen: \":8554\"" >> "$GO2RTC_CONFIG" + + # API listener (needed for ONVIF and health checks) + echo "" >> "$GO2RTC_CONFIG" + echo "api:" >> "$GO2RTC_CONFIG" + echo " listen: \":1984\"" >> "$GO2RTC_CONFIG" + + cat "$GO2RTC_CONFIG" + + # Start go2rtc in background + echo "Starting go2rtc..." + /app/bin/go2rtc -config "$GO2RTC_CONFIG" & + GO2RTC_PID=$! +else + echo "RTSP disabled or no session file, skipping go2rtc" +fi + +# Start nanit as main process +echo "Starting nanit..." +/app/bin/nanit & +NANIT_PID=$! + +# Handle signals — forward to both processes +trap 'kill $NANIT_PID $GO2RTC_PID 2>/dev/null; exit 0' SIGTERM SIGINT + +# Wait for either to exit +wait -n $NANIT_PID ${GO2RTC_PID:-} 2>/dev/null +EXIT_CODE=$? + +echo "Process exited with code $EXIT_CODE, shutting down..." +kill $NANIT_PID $GO2RTC_PID 2>/dev/null +wait +exit $EXIT_CODE +``` + +- [ ] **Step 2: Commit** + +```bash +chmod +x scripts/entrypoint.sh +git add scripts/entrypoint.sh +git commit -m "Add entrypoint script for nanit + go2rtc process management" +``` + +--- + +### Task 2: Update Dockerfile + +**Files:** +- Modify: `Dockerfile` + +- [ ] **Step 1: Add go2rtc download and update entrypoint** + +The Dockerfile needs to: +1. Download the go2rtc static binary in the build stage +2. Copy it to the runtime image +3. Change ENTRYPOINT from the nanit binary to the entrypoint script +4. Expose port 8554 for RTSP +5. Update HEALTHCHECK to also account for the entrypoint wrapper + +```dockerfile +FROM --platform=$BUILDPLATFORM golang:1.24.0 AS build +ADD cmd /app/cmd +ADD pkg /app/pkg +ADD go.mod /app/ +ADD go.sum /app/ +ADD scripts /app/scripts +WORKDIR /app +ARG CI_COMMIT_SHORT_SHA +ARG TARGETOS TARGETARCH +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-X main.GitCommit=$CI_COMMIT_SHORT_SHA" -o ./bin/nanit ./cmd/nanit/*.go + +# Download go2rtc +FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS go2rtc-download +ARG TARGETARCH +RUN apt-get -yqq update && apt-get install -yq --no-install-recommends curl ca-certificates && \ + GO2RTC_VERSION="1.9.8" && \ + ARCH=$(case "$TARGETARCH" in "amd64") echo "amd64" ;; "arm64") echo "arm64" ;; *) echo "$TARGETARCH" ;; esac) && \ + curl -fsSL "https://github.com/AlexxIT/go2rtc/releases/download/v${GO2RTC_VERSION}/go2rtc_linux_${ARCH}" -o /go2rtc && \ + chmod +x /go2rtc + +FROM debian:bookworm-slim + +COPY --from=build /app/bin/nanit /app/bin/nanit +COPY --from=build /app/scripts /app/scripts +COPY --from=go2rtc-download /go2rtc /app/bin/go2rtc + +RUN apt-get -yqq update && \ + apt-get install -yq --no-install-recommends ca-certificates ffmpeg bash curl jq && \ + apt-get autoremove -y && \ + apt-get clean -y + +RUN mkdir -p /data && \ + chmod +x /app/scripts/*.sh + +WORKDIR /app +EXPOSE 1935 8080 8554 +HEALTHCHECK --interval=180s --timeout=5s --start-period=120s --retries=2 \ + CMD curl -f http://localhost:8080/health || (kill 1 && exit 1) +ENTRYPOINT ["/app/scripts/entrypoint.sh"] +``` + +Note: The HEALTHCHECK `kill 1` now targets the entrypoint script (PID 1), which traps SIGTERM and shuts down both processes. + +- [ ] **Step 2: Verify Dockerfile builds** + +Test locally if Docker is available, or push and let CI build: +```bash +git add Dockerfile +git commit -m "Bundle go2rtc for RTSP/ONVIF output, update entrypoint" +git push origin main +gh run watch --repo stuart22/home_assistant_nanit +``` + +--- + +### Task 3: Update Compose File + +**Files:** +- Modify (in homelab repo): `stacks/nanit/docker-compose.yml` + +- [ ] **Step 1: Add RTSP port** + +```yaml +version: '3' + +services: + nanit: + container_name: nanit-abby + image: ghcr.io/stuart22/home_assistant_nanit:latest + volumes: + - /docker-fs/nanit/data:/data + - /etc/localtime:/etc/localtime:ro + environment: + - NANIT_RTMP_ADDR=192.168.1.121:1935 + - NANIT_LOG_LEVEL=info + - TZ=America/New_York + ports: + - "1935:1935" + - "8554:8554" + restart: unless-stopped +``` + +- [ ] **Step 2: Commit to homelab repo** + +```bash +cd /Users/stuarthall/_dev/worktrees/nanit-2qp +git add stacks/nanit/docker-compose.yml +git commit -m "Expose RTSP port 8554 for go2rtc/UniFi Protect" +``` + +--- + +### Task 4: Verify End-to-End + +- [ ] **Step 1: Wait for CI and Portainer deploy** + +- [ ] **Step 2: Check go2rtc is running** + +```bash +ssh linuxserver "docker logs nanit-abby 2>&1 | head -20" +``` + +Look for: +- `Generating go2rtc config from session...` +- `Starting go2rtc...` +- `Starting nanit...` + +- [ ] **Step 3: Test RTSP stream** + +```bash +ssh linuxserver "docker exec nanit-abby curl -s http://localhost:1984/api/streams" +``` + +Should show the configured streams. Then test with ffprobe: +```bash +ssh linuxserver "docker exec nanit-abby ffprobe -v quiet -print_format json -show_streams rtsp://localhost:8554/abby" +``` + +- [ ] **Step 4: Add to UniFi Protect** + +In the UniFi Protect UI, add a new camera via RTSP: +- URL: `rtsp://192.168.1.121:8554/abby` +- URL: `rtsp://192.168.1.121:8554/emilia` + +The stream names come from the baby names in session.json (lowercased, hyphenated). diff --git a/docs/superpowers/plans/2026-04-11-stream-c-mqtt-features.md b/docs/superpowers/plans/2026-04-11-stream-c-mqtt-features.md new file mode 100644 index 0000000..263f5c2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-stream-c-mqtt-features.md @@ -0,0 +1,232 @@ +# Stream C: MQTT Features Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add multi-camera MQTT routing, device control (night light brightness, playback, volume), HA auto-discovery, notification event polling, and sleep tracking. + +**Architecture:** All MQTT features are gated behind `NANIT_MQTT_ENABLED=true`. When enabled, the app connects to an MQTT broker and publishes state/discovery messages. Device control subscribes to command topics and routes them to the correct camera via a connection registry. Notification polling hits the Nanit REST API on an interval and publishes events to MQTT. + +**Tech Stack:** Go, Eclipse Paho MQTT, Nanit REST API, Home Assistant MQTT Discovery protocol + +**Spec:** `docs/superpowers/specs/2026-04-11-nanit-enhancements-design.md` — Features 3-7 + +**Reference implementations** (fetch with `gh api repos/{owner}/home_assistant_nanit/contents/{path} --jq '.content' | base64 -d`): +- tanvach: multi-camera routing, websocket reset (PR #33) +- combmag: device control (playback, volume, brightness) +- scgreenhalgh: MQTT discovery, notification polling, sleep tracking + +**MQTT broker:** `mqtt://192.168.1.166:1883` (Mosquitto on Home Assistant) + +**Important context:** +- Two cameras: Abby (uid `ddedb8f1`) and Emilia (uid `6086057e`) +- MQTT is already running on HA at 192.168.1.166 +- Do NOT add Claude as a coauthor on commits + +--- + +### Task 1: Multi-camera MQTT Routing (from tanvach) + +**Files:** +- Modify: `pkg/app/app.go` — add connection registry, manager registry, pending retries, global MQTT handlers +- Modify: `pkg/app/opts.go` — add WebSocketResetOpts +- Modify: `pkg/app/websocket_handlers.go` — add command-with-reset wrappers +- Modify: `pkg/mqtt/mqtt.go` — change handler signatures to include babyUID +- Modify: `pkg/client/websocket.go` — add ForceReconnect(), ShouldSkipCooldown +- Modify: `pkg/client/websocket_conn.go` — add Close() method +- Modify: `pkg/utils/attempter.go` — add ShouldSkipCooldown callback + +This is the foundation for all MQTT features. Fetch tanvach's full PR diff for reference: +```bash +gh api repos/indiefan/home_assistant_nanit/pulls/33/files --jq '.[].patch' +``` + +Key changes: +1. **Connection registry** in `app.go`: `babyConnections map[string]*client.WebsocketConnection` with mutex. Register in `runWebsocket()`, unregister on context done. +2. **Manager registry** in `app.go`: `babyManagers map[string]*client.WebsocketConnectionManager` with mutex. Register in `handleBaby()`. +3. **Global MQTT handlers** in `app.go:Run()`: Move light/standby handler registration from `runWebsocket()` (per-connection) to `Run()` (global). Handlers look up connection via registry. +4. **Handler signatures** in `mqtt.go`: `func(babyUID string, enabled bool)` — parse baby UID from MQTT topic. +5. **ForceReconnect()** in `websocket.go`: Sets forced flag, closes connection. Attempter skips cooldown on forced reconnect. +6. **Command retry** in `app.go`: `pendingRetries` map queues failed commands. `processPendingRetries()` replays them after reconnection. + +- [ ] Step 1: Apply tanvach's changes across all files listed above +- [ ] Step 2: Verify compilation: `docker run --rm -v $(pwd):/app -w /app golang:1.24.0 go build ./cmd/nanit/` +- [ ] Step 3: Commit: `git commit -m "Add multi-camera MQTT routing and websocket reset (from tanvach PR #33)"` + +--- + +### Task 2: MQTT Device Control (from combmag, refactored) + +**Files:** +- Modify: `pkg/mqtt/mqtt.go` — add playback, volume, brightness handlers +- Modify: `pkg/app/websocket_handlers.go` — add sendPlaybackCommand, sendVolumeCommand, extended sendLightCommand with brightness +- Modify: protobuf generated files — extend schemas (or modify proto source if available) + +Fetch combmag's changes for reference: +```bash +gh api repos/combmag/home_assistant_nanit/contents/pkg/mqtt/mqtt.go --jq '.content' | base64 -d > /tmp/combmag_mqtt.go +gh api repos/combmag/home_assistant_nanit/contents/pkg/app/websocket_handlers.go --jq '.content' | base64 -d > /tmp/combmag_handlers.go +``` + +Key changes: +1. **Night light brightness**: Extend light command to accept brightness value (0-100). Add `brightness` field to protobuf Settings message. +2. **Sound playback**: Subscribe to `{prefix}/babies/{uid}/playback` topic. Parse JSON payload with `action` (start/stop), `soundtrack` (filename), `duration` (seconds). Send via websocket. +3. **Volume control**: Subscribe to `{prefix}/babies/{uid}/volume` topic. Send volume level via websocket. + +**Important:** Do NOT use combmag's global `GetWebsocketConnection()` pattern. Route all commands through the connection registry from Task 1. + +- [ ] Step 1: Add MQTT subscriptions for playback, volume, brightness +- [ ] Step 2: Add websocket command senders +- [ ] Step 3: Update protobuf schemas if needed (check `pkg/client/*.proto` or generated `.go` files) +- [ ] Step 4: Verify compilation +- [ ] Step 5: Commit: `git commit -m "Add MQTT device control: night light brightness, playback, volume"` + +--- + +### Task 3: HA MQTT Auto-Discovery (from scgreenhalgh) + +**Files:** +- Create: `pkg/mqtt/discovery.go` +- Modify: `pkg/mqtt/mqtt.go` — call discovery on connect + +Fetch scgreenhalgh's discovery implementation: +```bash +gh api repos/scgreenhalgh/home_assistant_nanit/contents/pkg/mqtt/discovery.go --jq '.content' | base64 -d > /tmp/scg_discovery.go +``` + +Key changes: +1. **Discovery publisher**: On MQTT connect, publish discovery configs to `homeassistant/{component}/nanit_{baby_uid}_{entity}/config` topics. +2. **Entity registration per camera**: + - Sensors: temperature, humidity, last_motion, last_sound, stream_url + - Binary sensors: crying, standing, camera_online, left_bed, alert_zone, temp_alert, humidity_alert, breathing_alert, low_battery, stream_active + - Switches: night_light, standby +3. **Device info**: Each baby is a device with name, manufacturer "Nanit", model "Baby Monitor" +4. **Gated behind** `NANIT_MQTT_DISCOVERY` env var (default true when MQTT enabled) + +Adapt the import paths from `scgreenhalgh/home_assistant_nanit` to `indiefan/home_assistant_nanit` (our fork keeps the original module path). + +- [ ] Step 1: Create discovery.go adapted from scgreenhalgh +- [ ] Step 2: Wire into mqtt.go — call publishAllDiscovery() on connect +- [ ] Step 3: Add `NANIT_MQTT_DISCOVERY` env var to opts and main.go +- [ ] Step 4: Verify compilation +- [ ] Step 5: Commit: `git commit -m "Add HA MQTT auto-discovery for sensors, switches, and binary sensors"` + +--- + +### Task 4: Notification Event Polling (from scgreenhalgh) + +**Files:** +- Create: `pkg/notification/types.go` — event type definitions +- Create: `pkg/notification/poller.go` — event poller with backoff/jitter +- Create: `pkg/notification/manager.go` — coordinates all pollers +- Create: `pkg/notification/publisher.go` — publishes events to MQTT +- Create: `pkg/notification/dedup.go` — message deduplication +- Modify: `pkg/client/rest.go` — add FetchLastEvent, FetchBabyWithCamera, FetchNotificationSettings +- Modify: `pkg/app/app.go` — start notification manager in Run() +- Modify: `pkg/app/opts.go` — add notification opts +- Modify: `cmd/nanit/main.go` — add notification env vars + +Fetch scgreenhalgh's notification package: +```bash +for f in types.go poller.go manager.go publisher.go dedup.go mqtt_adapter.go event.go; do + gh api repos/scgreenhalgh/home_assistant_nanit/contents/pkg/notification/$f --jq '.content' | base64 -d > /tmp/scg_notif_$f +done +gh api repos/scgreenhalgh/home_assistant_nanit/contents/pkg/client/rest.go --jq '.content' | base64 -d > /tmp/scg_rest.go +``` + +Key changes: +1. **Event types**: MOTION, SOUND, STANDING, CRYING, LEFT_BED, ALERT_ZONE, CAMERA_ONLINE/OFFLINE, TEMP_ALERT, HUMIDITY_ALERT, BREATHING_ALERT, LOW_BATTERY +2. **Poller**: Polls Nanit REST API `/events` endpoint on configurable interval with jitter and exponential backoff on errors +3. **Deduplication**: Track seen message IDs to avoid double-publishing +4. **Publisher**: Maps event types to MQTT topics and publishes state updates +5. **Manager**: Creates per-baby pollers, coordinates startup/shutdown + +Adapt import paths from `scgreenhalgh` to `indiefan`. + +New env vars: `NANIT_NOTIFICATIONS_ENABLED`, `NANIT_NOTIFICATIONS_POLL_INTERVAL`, `NANIT_NOTIFICATIONS_JITTER`, `NANIT_NOTIFICATIONS_MAX_BACKOFF` + +- [ ] Step 1: Create notification package files +- [ ] Step 2: Expand REST client with new API methods +- [ ] Step 3: Wire notification manager into app.go +- [ ] Step 4: Add env vars to opts.go and main.go +- [ ] Step 5: Verify compilation +- [ ] Step 6: Commit: `git commit -m "Add notification event polling: motion, sound, crying, standing, alerts"` + +--- + +### Task 5: Sleep Tracking (from scgreenhalgh) + +**Files:** +- Create: `pkg/notification/sleep_event.go` — sleep event types +- Create: `pkg/notification/sleep_event_poller.go` — sleep event poller +- Create: `pkg/notification/sleep_state_tracker.go` — consolidated sleep state +- Create: `pkg/notification/stats.go` — sleep stats types +- Create: `pkg/notification/stats_poller.go` — sleep stats poller +- Modify: `pkg/notification/manager.go` — add sleep pollers +- Modify: `pkg/client/rest.go` — add FetchSleepEvents, FetchSleepStats + +Fetch scgreenhalgh's sleep tracking files: +```bash +for f in sleep_event.go sleep_event_poller.go sleep_state_tracker.go stats.go stats_poller.go; do + gh api repos/scgreenhalgh/home_assistant_nanit/contents/pkg/notification/$f --jq '.content' | base64 -d > /tmp/scg_$f +done +``` + +Key changes: +1. **Sleep event poller**: Polls `/events` for sleep-specific events (FELL_ASLEEP, WOKE_UP, PUT_IN_BED, REMOVED) +2. **State tracker**: Maintains `is_asleep`, `in_bed`, `times_woke_up`, `interventions`, `last_event` state +3. **Stats poller**: Polls `/stats/latest` for daily sleep statistics with change detection +4. **Integration**: Sleep pollers are created per-baby by the notification manager + +- [ ] Step 1: Create sleep tracking files +- [ ] Step 2: Add REST client methods for sleep endpoints +- [ ] Step 3: Wire into notification manager +- [ ] Step 4: Verify compilation +- [ ] Step 5: Commit: `git commit -m "Add sleep tracking: events, state, and statistics"` + +--- + +### Task 6: Update Compose and README + +**Files:** +- Modify (homelab repo): `stacks/nanit/docker-compose.yml` — add MQTT env vars +- Modify: `README.md` — document new features and env vars + +- [ ] **Step 1: Update compose with MQTT vars** + +```yaml +environment: + - NANIT_RTMP_ADDR=192.168.1.121:1935 + - NANIT_LOG_LEVEL=info + - TZ=America/New_York + - NANIT_MQTT_ENABLED=true + - NANIT_MQTT_BROKER_URL=mqtt://192.168.1.166:1883 + - NANIT_NOTIFICATIONS_ENABLED=true +``` + +- [ ] **Step 2: Update README with new features and env var table** + +- [ ] **Step 3: Commit both repos and push** + +--- + +### Task 7: Verify End-to-End + +- [ ] **Step 1: Wait for CI build and Portainer deploy** + +- [ ] **Step 2: Check MQTT connection in logs** + +```bash +ssh linuxserver "docker logs nanit-abby 2>&1 | grep -i mqtt" +``` + +- [ ] **Step 3: Check HA for auto-discovered entities** + +In HA, go to Settings → Devices & Services → MQTT. The Nanit devices should appear with all sensors and switches. + +- [ ] **Step 4: Test device control** + +Toggle the night light switch in HA → verify camera responds. + +- [ ] **Step 5: Test notifications** + +Trigger motion in front of camera → check HA for motion sensor state change. From cbb3e09348af14545e5c1ae099e76dd14bc14f1a Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 22:50:25 -0400 Subject: [PATCH 08/20] Add entrypoint script for nanit + go2rtc process management Wrapper script that: - Generates go2rtc config from session.json baby UIDs/names - Starts go2rtc in background for RTSP/ONVIF output - Starts nanit as main process - Forwards SIGTERM/SIGINT to both processes - Exits if either process dies go2rtc is gated behind NANIT_RTSP_ENABLED (default: true) and requires a session.json file to be present. --- scripts/entrypoint.sh | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100755 scripts/entrypoint.sh diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..c213850 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -e + +# Generate go2rtc config from session.json baby UIDs +SESSION_FILE="${NANIT_SESSION_FILE:-/data/session.json}" +GO2RTC_CONFIG="/data/go2rtc.yaml" + +if [ -f "$SESSION_FILE" ] && [ "${NANIT_RTSP_ENABLED:-true}" = "true" ]; then + echo "Generating go2rtc config from session..." + + # Build streams config from baby UIDs and names + echo "streams:" > "$GO2RTC_CONFIG" + jq -r '.babies[] | "\(.uid) \(.name // .uid)"' "$SESSION_FILE" 2>/dev/null | while read -r uid name; do + # Sanitize name for use as stream key (lowercase, replace spaces with hyphens) + key=$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-') + echo " ${key}:" >> "$GO2RTC_CONFIG" + echo " - rtmp://127.0.0.1:1935/local/${uid}" >> "$GO2RTC_CONFIG" + done + + # RTSP listener + echo "" >> "$GO2RTC_CONFIG" + echo "rtsp:" >> "$GO2RTC_CONFIG" + echo " listen: \":8554\"" >> "$GO2RTC_CONFIG" + + # API listener (needed for ONVIF and health checks) + echo "" >> "$GO2RTC_CONFIG" + echo "api:" >> "$GO2RTC_CONFIG" + echo " listen: \":1984\"" >> "$GO2RTC_CONFIG" + + cat "$GO2RTC_CONFIG" + + # Start go2rtc in background + echo "Starting go2rtc..." + /app/bin/go2rtc -config "$GO2RTC_CONFIG" & + GO2RTC_PID=$! +else + echo "RTSP disabled or no session file, skipping go2rtc" +fi + +# Start nanit as main process +echo "Starting nanit..." +/app/bin/nanit & +NANIT_PID=$! + +# Handle signals — forward to both processes +trap 'kill $NANIT_PID $GO2RTC_PID 2>/dev/null; exit 0' SIGTERM SIGINT + +# Wait for either to exit +wait -n $NANIT_PID ${GO2RTC_PID:-} 2>/dev/null +EXIT_CODE=$? + +echo "Process exited with code $EXIT_CODE, shutting down..." +kill $NANIT_PID $GO2RTC_PID 2>/dev/null +wait +exit $EXIT_CODE From 969985b08656a9cf1c046e47f198859382815e58 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 22:50:47 -0400 Subject: [PATCH 09/20] Bundle go2rtc for RTSP/ONVIF output, update entrypoint - Add go2rtc v1.9.8 download stage (multi-arch: amd64/arm64) - Copy go2rtc binary to /app/bin/go2rtc in runtime image - Switch ENTRYPOINT from nanit binary to entrypoint.sh wrapper - Expose port 8554 for RTSP go2rtc reads from the local RTMP server and exposes RTSP/ONVIF endpoints, enabling UniFi Protect integration. --- Dockerfile | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 41a7e6a..86d84b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,20 @@ ARG CI_COMMIT_SHORT_SHA ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-X main.GitCommit=$CI_COMMIT_SHORT_SHA" -o ./bin/nanit ./cmd/nanit/*.go +# Download go2rtc +FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS go2rtc-download +ARG TARGETARCH +RUN apt-get -yqq update && apt-get install -yq --no-install-recommends curl ca-certificates && \ + GO2RTC_VERSION="1.9.8" && \ + ARCH=$(case "$TARGETARCH" in "amd64") echo "amd64" ;; "arm64") echo "arm64" ;; *) echo "$TARGETARCH" ;; esac) && \ + curl -fsSL "https://github.com/AlexxIT/go2rtc/releases/download/v${GO2RTC_VERSION}/go2rtc_linux_${ARCH}" -o /go2rtc && \ + chmod +x /go2rtc + FROM debian:bookworm-slim COPY --from=build /app/bin/nanit /app/bin/nanit COPY --from=build /app/scripts /app/scripts +COPY --from=go2rtc-download /go2rtc /app/bin/go2rtc RUN apt-get -yqq update && \ apt-get install -yq --no-install-recommends ca-certificates ffmpeg bash curl jq && \ @@ -24,7 +34,7 @@ RUN mkdir -p /data && \ chmod +x /app/scripts/*.sh WORKDIR /app -EXPOSE 1935 8080 +EXPOSE 1935 8080 8554 HEALTHCHECK --interval=180s --timeout=5s --start-period=120s --retries=2 \ CMD curl -f http://localhost:8080/health || (kill 1 && exit 1) -ENTRYPOINT ["/app/bin/nanit"] +ENTRYPOINT ["/app/scripts/entrypoint.sh"] From f334f416bf84fbbc9d9d15da1e7cbe612e6d7984 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 22:50:57 -0400 Subject: [PATCH 10/20] Add PersistentBroadcaster for subscriber persistence across reconnections Adapted from scgreenhalgh/home_assistant_nanit. Keeps subscribers connected when the camera's RTMP publisher disconnects and reconnects. Timestamps are remapped to be monotonically increasing across reconnections. Simplified from the reference: no hot standby, source switching, or stall detection. --- pkg/rtmpserver/persistent_broadcaster.go | 262 +++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 pkg/rtmpserver/persistent_broadcaster.go diff --git a/pkg/rtmpserver/persistent_broadcaster.go b/pkg/rtmpserver/persistent_broadcaster.go new file mode 100644 index 0000000..b57c0fd --- /dev/null +++ b/pkg/rtmpserver/persistent_broadcaster.go @@ -0,0 +1,262 @@ +package rtmpserver + +import ( + "sync" + "time" + + "github.com/notedit/rtmp/av" + "github.com/rs/zerolog/log" +) + +// SourceIDLocal is the source ID for the local camera publisher. +const SourceIDLocal = 1 + +// PersistentBroadcaster keeps subscribers connected across publisher reconnections. +// When a publisher disconnects and reconnects, subscribers stay connected and +// timestamps are remapped to be monotonically increasing. +type PersistentBroadcaster struct { + babyUID string + + mu sync.RWMutex + subscribers map[*Subscription]struct{} + headerPkts []av.Packet + closed bool + + // Timestamp remapping state + tsMu sync.Mutex + lastSourceID int + lastTimestamp time.Duration + timestampOffset time.Duration + initialized bool + + // Keyframe tracking — don't forward until we have a keyframe from the publisher + keyframeReceived bool + + // Diagnostic tracking + lastPacketWallTime time.Time + droppedPackets int64 +} + +// Subscription represents a subscriber to the broadcaster. +type Subscription struct { + pktChan chan av.Packet + broadcaster *PersistentBroadcaster + closed bool + closeMu sync.Mutex +} + +// NewPersistentBroadcaster creates a new persistent broadcaster for the given baby. +func NewPersistentBroadcaster(babyUID string) *PersistentBroadcaster { + return &PersistentBroadcaster{ + babyUID: babyUID, + subscribers: make(map[*Subscription]struct{}), + headerPkts: make([]av.Packet, 0), + } +} + +// Subscribe creates a new subscription to receive packets. +func (pb *PersistentBroadcaster) Subscribe() *Subscription { + pb.mu.Lock() + defer pb.mu.Unlock() + + if pb.closed { + return nil + } + + sub := &Subscription{ + pktChan: make(chan av.Packet, 500), + broadcaster: pb, + } + + pb.subscribers[sub] = struct{}{} + + // Send stored header packets to new subscriber + for _, hdr := range pb.headerPkts { + select { + case sub.pktChan <- hdr: + default: + } + } + + return sub +} + +// Packets returns the channel to receive packets from. +func (s *Subscription) Packets() <-chan av.Packet { + return s.pktChan +} + +// Unsubscribe removes this subscription from the broadcaster. +func (s *Subscription) Unsubscribe() { + s.closeMu.Lock() + if s.closed { + s.closeMu.Unlock() + return + } + s.closed = true + close(s.pktChan) + s.closeMu.Unlock() + + s.broadcaster.removeSubscriber(s) +} + +func (pb *PersistentBroadcaster) removeSubscriber(sub *Subscription) { + pb.mu.Lock() + defer pb.mu.Unlock() + delete(pb.subscribers, sub) +} + +// Broadcast sends a packet to all subscribers with timestamp remapping. +func (pb *PersistentBroadcaster) Broadcast(pkt av.Packet, sourceID int) { + // Handle header packets (type > 2) + if pkt.Type > 2 { + pb.handleHeaderPacket(pkt, sourceID) + return + } + + pb.tsMu.Lock() + + // Track if we've received a keyframe + if pkt.IsKeyFrame && !pb.keyframeReceived { + pb.keyframeReceived = true + log.Debug(). + Str("baby_uid", pb.babyUID). + Int("source_id", sourceID). + Msg("First keyframe received from publisher") + } + + // Don't forward until we have a keyframe (prevents decoder errors) + if !pb.keyframeReceived { + pb.tsMu.Unlock() + return + } + + // Diagnostic: check for long gaps (camera stalls) + now := time.Now() + if !pb.lastPacketWallTime.IsZero() { + gap := now.Sub(pb.lastPacketWallTime) + if gap > 2*time.Second { + log.Warn(). + Str("baby_uid", pb.babyUID). + Dur("gap", gap). + Bool("is_keyframe", pkt.IsKeyFrame). + Msg("Long gap between packets detected (camera stall?)") + } + } + pb.lastPacketWallTime = now + pb.tsMu.Unlock() + + // Remap timestamp for continuity + pkt = pb.remapTimestamp(pkt, sourceID) + + // Distribute to all subscribers + pb.mu.RLock() + defer pb.mu.RUnlock() + + droppedThisRound := 0 + for sub := range pb.subscribers { + sub.closeMu.Lock() + if !sub.closed { + select { + case sub.pktChan <- pkt: + default: + droppedThisRound++ + } + } + sub.closeMu.Unlock() + } + + if droppedThisRound > 0 { + pb.tsMu.Lock() + pb.droppedPackets += int64(droppedThisRound) + total := pb.droppedPackets + pb.tsMu.Unlock() + log.Warn(). + Str("baby_uid", pb.babyUID). + Int("dropped_now", droppedThisRound). + Int64("total_dropped", total). + Bool("is_keyframe", pkt.IsKeyFrame). + Msg("Packet(s) dropped due to full subscriber buffer") + } +} + +func (pb *PersistentBroadcaster) handleHeaderPacket(pkt av.Packet, sourceID int) { + pb.tsMu.Lock() + sourceChanged := pb.initialized && sourceID != pb.lastSourceID + if sourceChanged { + pb.mu.Lock() + pb.headerPkts = make([]av.Packet, 0) + pb.mu.Unlock() + log.Debug().Str("baby_uid", pb.babyUID).Int("source_id", sourceID).Msg("Cleared headers for source reconnection") + } + pb.lastSourceID = sourceID + // Reset keyframe tracking on source change so we wait for a new keyframe + if sourceChanged { + pb.keyframeReceived = false + } + pb.tsMu.Unlock() + + pb.mu.Lock() + pb.headerPkts = append(pb.headerPkts, pkt) + pb.mu.Unlock() +} + +func (pb *PersistentBroadcaster) remapTimestamp(pkt av.Packet, sourceID int) av.Packet { + pb.tsMu.Lock() + defer pb.tsMu.Unlock() + + if !pb.initialized { + pb.initialized = true + pb.lastSourceID = sourceID + pb.lastTimestamp = pkt.Time + pb.timestampOffset = 0 + return pkt + } + + if sourceID != pb.lastSourceID { + // Source changed — calculate offset to maintain continuity + frameInterval := 33 * time.Millisecond // ~30fps + pb.timestampOffset = pb.lastTimestamp + frameInterval - pkt.Time + pb.lastSourceID = sourceID + } + + // Apply offset + remappedPkt := pkt + remappedPkt.Time = pkt.Time + pb.timestampOffset + + // Ensure monotonic (safety check) + if remappedPkt.Time <= pb.lastTimestamp { + remappedPkt.Time = pb.lastTimestamp + time.Millisecond + } + + pb.lastTimestamp = remappedPkt.Time + return remappedPkt +} + +// Close shuts down the broadcaster and closes all subscriber channels. +func (pb *PersistentBroadcaster) Close() { + pb.mu.Lock() + defer pb.mu.Unlock() + + if pb.closed { + return + } + pb.closed = true + + for sub := range pb.subscribers { + sub.closeMu.Lock() + if !sub.closed { + sub.closed = true + close(sub.pktChan) + } + sub.closeMu.Unlock() + } + pb.subscribers = make(map[*Subscription]struct{}) +} + +// SubscriberCount returns the current number of subscribers. +func (pb *PersistentBroadcaster) SubscriberCount() int { + pb.mu.RLock() + defer pb.mu.RUnlock() + return len(pb.subscribers) +} From cb8fdc3d2d7c888b37ee4b1906f9d892468121e0 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 22:50:57 -0400 Subject: [PATCH 11/20] Add PersistentBroadcaster for subscriber persistence across reconnections Adapted from scgreenhalgh/home_assistant_nanit. Keeps subscribers connected when the camera's RTMP publisher disconnects and reconnects. Timestamps are remapped to be monotonically increasing across reconnections. Simplified from the reference: no hot standby, source switching, or stall detection. --- pkg/rtmpserver/persistent_broadcaster.go | 262 +++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 pkg/rtmpserver/persistent_broadcaster.go diff --git a/pkg/rtmpserver/persistent_broadcaster.go b/pkg/rtmpserver/persistent_broadcaster.go new file mode 100644 index 0000000..b57c0fd --- /dev/null +++ b/pkg/rtmpserver/persistent_broadcaster.go @@ -0,0 +1,262 @@ +package rtmpserver + +import ( + "sync" + "time" + + "github.com/notedit/rtmp/av" + "github.com/rs/zerolog/log" +) + +// SourceIDLocal is the source ID for the local camera publisher. +const SourceIDLocal = 1 + +// PersistentBroadcaster keeps subscribers connected across publisher reconnections. +// When a publisher disconnects and reconnects, subscribers stay connected and +// timestamps are remapped to be monotonically increasing. +type PersistentBroadcaster struct { + babyUID string + + mu sync.RWMutex + subscribers map[*Subscription]struct{} + headerPkts []av.Packet + closed bool + + // Timestamp remapping state + tsMu sync.Mutex + lastSourceID int + lastTimestamp time.Duration + timestampOffset time.Duration + initialized bool + + // Keyframe tracking — don't forward until we have a keyframe from the publisher + keyframeReceived bool + + // Diagnostic tracking + lastPacketWallTime time.Time + droppedPackets int64 +} + +// Subscription represents a subscriber to the broadcaster. +type Subscription struct { + pktChan chan av.Packet + broadcaster *PersistentBroadcaster + closed bool + closeMu sync.Mutex +} + +// NewPersistentBroadcaster creates a new persistent broadcaster for the given baby. +func NewPersistentBroadcaster(babyUID string) *PersistentBroadcaster { + return &PersistentBroadcaster{ + babyUID: babyUID, + subscribers: make(map[*Subscription]struct{}), + headerPkts: make([]av.Packet, 0), + } +} + +// Subscribe creates a new subscription to receive packets. +func (pb *PersistentBroadcaster) Subscribe() *Subscription { + pb.mu.Lock() + defer pb.mu.Unlock() + + if pb.closed { + return nil + } + + sub := &Subscription{ + pktChan: make(chan av.Packet, 500), + broadcaster: pb, + } + + pb.subscribers[sub] = struct{}{} + + // Send stored header packets to new subscriber + for _, hdr := range pb.headerPkts { + select { + case sub.pktChan <- hdr: + default: + } + } + + return sub +} + +// Packets returns the channel to receive packets from. +func (s *Subscription) Packets() <-chan av.Packet { + return s.pktChan +} + +// Unsubscribe removes this subscription from the broadcaster. +func (s *Subscription) Unsubscribe() { + s.closeMu.Lock() + if s.closed { + s.closeMu.Unlock() + return + } + s.closed = true + close(s.pktChan) + s.closeMu.Unlock() + + s.broadcaster.removeSubscriber(s) +} + +func (pb *PersistentBroadcaster) removeSubscriber(sub *Subscription) { + pb.mu.Lock() + defer pb.mu.Unlock() + delete(pb.subscribers, sub) +} + +// Broadcast sends a packet to all subscribers with timestamp remapping. +func (pb *PersistentBroadcaster) Broadcast(pkt av.Packet, sourceID int) { + // Handle header packets (type > 2) + if pkt.Type > 2 { + pb.handleHeaderPacket(pkt, sourceID) + return + } + + pb.tsMu.Lock() + + // Track if we've received a keyframe + if pkt.IsKeyFrame && !pb.keyframeReceived { + pb.keyframeReceived = true + log.Debug(). + Str("baby_uid", pb.babyUID). + Int("source_id", sourceID). + Msg("First keyframe received from publisher") + } + + // Don't forward until we have a keyframe (prevents decoder errors) + if !pb.keyframeReceived { + pb.tsMu.Unlock() + return + } + + // Diagnostic: check for long gaps (camera stalls) + now := time.Now() + if !pb.lastPacketWallTime.IsZero() { + gap := now.Sub(pb.lastPacketWallTime) + if gap > 2*time.Second { + log.Warn(). + Str("baby_uid", pb.babyUID). + Dur("gap", gap). + Bool("is_keyframe", pkt.IsKeyFrame). + Msg("Long gap between packets detected (camera stall?)") + } + } + pb.lastPacketWallTime = now + pb.tsMu.Unlock() + + // Remap timestamp for continuity + pkt = pb.remapTimestamp(pkt, sourceID) + + // Distribute to all subscribers + pb.mu.RLock() + defer pb.mu.RUnlock() + + droppedThisRound := 0 + for sub := range pb.subscribers { + sub.closeMu.Lock() + if !sub.closed { + select { + case sub.pktChan <- pkt: + default: + droppedThisRound++ + } + } + sub.closeMu.Unlock() + } + + if droppedThisRound > 0 { + pb.tsMu.Lock() + pb.droppedPackets += int64(droppedThisRound) + total := pb.droppedPackets + pb.tsMu.Unlock() + log.Warn(). + Str("baby_uid", pb.babyUID). + Int("dropped_now", droppedThisRound). + Int64("total_dropped", total). + Bool("is_keyframe", pkt.IsKeyFrame). + Msg("Packet(s) dropped due to full subscriber buffer") + } +} + +func (pb *PersistentBroadcaster) handleHeaderPacket(pkt av.Packet, sourceID int) { + pb.tsMu.Lock() + sourceChanged := pb.initialized && sourceID != pb.lastSourceID + if sourceChanged { + pb.mu.Lock() + pb.headerPkts = make([]av.Packet, 0) + pb.mu.Unlock() + log.Debug().Str("baby_uid", pb.babyUID).Int("source_id", sourceID).Msg("Cleared headers for source reconnection") + } + pb.lastSourceID = sourceID + // Reset keyframe tracking on source change so we wait for a new keyframe + if sourceChanged { + pb.keyframeReceived = false + } + pb.tsMu.Unlock() + + pb.mu.Lock() + pb.headerPkts = append(pb.headerPkts, pkt) + pb.mu.Unlock() +} + +func (pb *PersistentBroadcaster) remapTimestamp(pkt av.Packet, sourceID int) av.Packet { + pb.tsMu.Lock() + defer pb.tsMu.Unlock() + + if !pb.initialized { + pb.initialized = true + pb.lastSourceID = sourceID + pb.lastTimestamp = pkt.Time + pb.timestampOffset = 0 + return pkt + } + + if sourceID != pb.lastSourceID { + // Source changed — calculate offset to maintain continuity + frameInterval := 33 * time.Millisecond // ~30fps + pb.timestampOffset = pb.lastTimestamp + frameInterval - pkt.Time + pb.lastSourceID = sourceID + } + + // Apply offset + remappedPkt := pkt + remappedPkt.Time = pkt.Time + pb.timestampOffset + + // Ensure monotonic (safety check) + if remappedPkt.Time <= pb.lastTimestamp { + remappedPkt.Time = pb.lastTimestamp + time.Millisecond + } + + pb.lastTimestamp = remappedPkt.Time + return remappedPkt +} + +// Close shuts down the broadcaster and closes all subscriber channels. +func (pb *PersistentBroadcaster) Close() { + pb.mu.Lock() + defer pb.mu.Unlock() + + if pb.closed { + return + } + pb.closed = true + + for sub := range pb.subscribers { + sub.closeMu.Lock() + if !sub.closed { + sub.closed = true + close(sub.pktChan) + } + sub.closeMu.Unlock() + } + pb.subscribers = make(map[*Subscription]struct{}) +} + +// SubscriberCount returns the current number of subscribers. +func (pb *PersistentBroadcaster) SubscriberCount() int { + pb.mu.RLock() + defer pb.mu.RUnlock() + return len(pb.subscribers) +} From ccbfe4dfe4ae789e16f5780e6d3fd0ee8981bfa0 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 22:52:24 -0400 Subject: [PATCH 12/20] Integrate PersistentBroadcaster into RTMP server Replace per-connection broadcaster with a persistent broadcaster per baby UID. Subscribers now stay connected across publisher reconnections. Remove old broadcaster.go (superseded by PersistentBroadcaster). --- pkg/rtmpserver/broadcaster.go | 67 ---------------------------- pkg/rtmpserver/server.go | 83 +++++++++++++++++------------------ 2 files changed, 40 insertions(+), 110 deletions(-) delete mode 100644 pkg/rtmpserver/broadcaster.go diff --git a/pkg/rtmpserver/broadcaster.go b/pkg/rtmpserver/broadcaster.go deleted file mode 100644 index 4986b65..0000000 --- a/pkg/rtmpserver/broadcaster.go +++ /dev/null @@ -1,67 +0,0 @@ -package rtmpserver - -import ( - "sync" - - "github.com/notedit/rtmp/av" -) - -type subscriber struct { - initialized bool - pktC chan av.Packet -} - -type broadcaster struct { - headerPkts []av.Packet - subscribers sync.Map -} - -func newBroadcaster() *broadcaster { - return &broadcaster{} -} - -func (b *broadcaster) newSubscriber() *subscriber { - sub := &subscriber{ - initialized: false, - pktC: make(chan av.Packet, 10), - } - - b.subscribers.Store(sub, sub) - return sub -} - -func (b *broadcaster) unsubscribe(sub *subscriber) { - b.subscribers.Delete(sub) -} - -func (b *broadcaster) broadcast(pkt av.Packet) { - // Audio / Video packets - if pkt.Type <= 2 { - b.subscribers.Range(func(key, value interface{}) bool { - sub := value.(*subscriber) - - // Send header packets before sending any data - if !sub.initialized { - sub.initialized = true - for _, headerPkt := range b.headerPkts { - sub.pktC <- headerPkt - } - } - - sub.pktC <- pkt - - return true - }) - } else { - // Header packets - b.headerPkts = append(b.headerPkts, pkt) - } -} - -func (b *broadcaster) closeSubscribers() { - b.subscribers.Range(func(key, value interface{}) bool { - sub := value.(*subscriber) - close(sub.pktC) - return true - }) -} diff --git a/pkg/rtmpserver/server.go b/pkg/rtmpserver/server.go index 4ebbe2f..a427e82 100644 --- a/pkg/rtmpserver/server.go +++ b/pkg/rtmpserver/server.go @@ -11,14 +11,26 @@ import ( "github.com/indiefan/home_assistant_nanit/pkg/baby" ) +// RTMPServer wraps the RTMP server and provides access to broadcasters. +type RTMPServer struct { + handler *rtmpHandler +} + type rtmpHandler struct { babyStateManager *baby.StateManager broadcastersMu sync.RWMutex - broadcastersByUID map[string]*broadcaster + broadcastersByUID map[string]*PersistentBroadcaster } -// StartRTMPServer - Blocking server -func StartRTMPServer(addr string, babyStateManager *baby.StateManager) { +// NewRTMPServer creates a new RTMP server. +func NewRTMPServer(babyStateManager *baby.StateManager) *RTMPServer { + return &RTMPServer{ + handler: newRtmpHandler(babyStateManager), + } +} + +// Run starts the RTMP server (blocking). +func (srv *RTMPServer) Run(addr string) { lis, err := net.Listen("tcp", addr) if err != nil { log.Fatal().Str("addr", addr).Err(err).Msg("Unable to start RTMP server") @@ -28,7 +40,7 @@ func StartRTMPServer(addr string, babyStateManager *baby.StateManager) { log.Info().Str("addr", addr).Msg("RTMP server started") s := rtmp.NewServer() - s.HandleConn = newRtmpHandler(babyStateManager).handleConnection + s.HandleConn = srv.handler.handleConnection for { nc, err := lis.Accept() @@ -42,7 +54,7 @@ func StartRTMPServer(addr string, babyStateManager *baby.StateManager) { func newRtmpHandler(babyStateManager *baby.StateManager) *rtmpHandler { return &rtmpHandler{ - broadcastersByUID: make(map[string]*broadcaster), + broadcastersByUID: make(map[string]*PersistentBroadcaster), babyStateManager: babyStateManager, } } @@ -63,8 +75,8 @@ func (s *rtmpHandler) handleConnection(c *rtmp.Conn, nc net.Conn) { sublog = sublog.With().Str("baby_uid", babyUID).Logger() if c.Publishing { - sublog.Info().Msg("New stream publisher connected") - publisher := s.getNewPublisher(babyUID) + sublog.Info().Msg("New local stream publisher connected") + pb := s.getOrCreateBroadcaster(babyUID) s.babyStateManager.Update(babyUID, *baby.NewState().SetStreamState(baby.StreamState_Alive)) @@ -73,19 +85,18 @@ func (s *rtmpHandler) handleConnection(c *rtmp.Conn, nc net.Conn) { if err != nil { sublog.Warn().Err(err).Msg("Publisher stream closed unexpectedly") s.babyStateManager.Update(babyUID, *baby.NewState().SetStreamState(baby.StreamState_Unhealthy)) - s.closePublisher(babyUID, publisher) return } - publisher.broadcast(pkt) + pb.Broadcast(pkt, SourceIDLocal) } } else { sublog.Debug().Msg("New stream subscriber connected") - subscriber, unsubscribe := s.getNewSubscriber(babyUID) + subscription := s.getNewSubscriber(babyUID) - if subscriber == nil { - sublog.Warn().Msg("No stream publisher registered yet, closing subscriber stream") + if subscription == nil { + sublog.Warn().Msg("No broadcaster registered yet, closing subscriber stream") nc.Close() return } @@ -93,9 +104,9 @@ func (s *rtmpHandler) handleConnection(c *rtmp.Conn, nc net.Conn) { closeC := c.CloseNotify() for { select { - case pkt, open := <-subscriber.pktC: + case pkt, open := <-subscription.Packets(): if !open { - sublog.Debug().Msg("Closing subscriber because publisher quit") + sublog.Debug().Msg("Subscription closed") nc.Close() return } @@ -104,50 +115,36 @@ func (s *rtmpHandler) handleConnection(c *rtmp.Conn, nc net.Conn) { case <-closeC: sublog.Debug().Msg("Stream subscriber disconnected") - unsubscribe() + subscription.Unsubscribe() + return } } } } -func (s *rtmpHandler) getNewPublisher(babyUID string) *broadcaster { - broadcaster := newBroadcaster() - +func (s *rtmpHandler) getOrCreateBroadcaster(babyUID string) *PersistentBroadcaster { s.broadcastersMu.Lock() - existingBroadcaster, hadExistingBroadcaster := s.broadcastersByUID[babyUID] - s.broadcastersByUID[babyUID] = broadcaster - s.broadcastersMu.Unlock() + defer s.broadcastersMu.Unlock() - if hadExistingBroadcaster { - log.Warn().Msg("Baby already has active publisher, closing existing subscribers") - go existingBroadcaster.closeSubscribers() + existing, exists := s.broadcastersByUID[babyUID] + if exists { + return existing } - return broadcaster + pb := NewPersistentBroadcaster(babyUID) + s.broadcastersByUID[babyUID] = pb + log.Info().Str("baby_uid", babyUID).Msg("Created persistent broadcaster") + return pb } -func (s *rtmpHandler) getNewSubscriber(babyUID string) (*subscriber, func()) { +func (s *rtmpHandler) getNewSubscriber(babyUID string) *Subscription { s.broadcastersMu.RLock() - broadcaster, hasBroadcaster := s.broadcastersByUID[babyUID] + pb, hasBroadcaster := s.broadcastersByUID[babyUID] s.broadcastersMu.RUnlock() if !hasBroadcaster { - return nil, nil - } - - sub := broadcaster.newSubscriber() - - return sub, func() { broadcaster.unsubscribe(sub) } -} - -func (s *rtmpHandler) closePublisher(babyUID string, b *broadcaster) { - s.broadcastersMu.Lock() - if currBroadcaster, hasExistingBroadcaster := s.broadcastersByUID[babyUID]; hasExistingBroadcaster { - if currBroadcaster == b { - delete(s.broadcastersByUID, babyUID) - } + return nil } - s.broadcastersMu.Unlock() - b.closeSubscribers() + return pb.Subscribe() } From 50ab300ff7af5e068434d3cae1cfdbad7fdbab8e Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 22:53:11 -0400 Subject: [PATCH 13/20] Refactor RTMP server to return handle for health/go2rtc integration Split StartRTMPServer into NewRTMPServer + Run so callers can hold a reference to the server for querying broadcaster state. --- pkg/app/app.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index 0f4180f..adb0a43 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -56,7 +56,8 @@ func (app *App) Run(ctx utils.GracefulContext) { // RTMP if app.Opts.RTMP != nil { - go rtmpserver.StartRTMPServer(app.Opts.RTMP.ListenAddr, app.BabyStateManager) + rtmpServer := rtmpserver.NewRTMPServer(app.BabyStateManager) + go rtmpServer.Run(app.Opts.RTMP.ListenAddr) } // Health endpoint for Docker HEALTHCHECK From 1c364ed54cb4cd4dbd65493e6b2a79dcab4610e1 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 23:07:53 -0400 Subject: [PATCH 14/20] Add multi-camera MQTT routing and websocket reset (from tanvach PR #33) - Connection registry routes MQTT commands to correct camera by baby UID - Manager registry tracks websocket connection managers per baby - Global MQTT handler registration (moved from per-connection to app-level) - Handler signatures now include babyUID for proper multi-camera routing - ForceReconnect() with cooldown skip for failed command recovery - Pending retry queue replays failed commands after reconnection - New env vars: NANIT_MQTT_RESET_WHEN_FAILED, NANIT_WEBSOCKET_TIMEOUT --- cmd/nanit/main.go | 17 ++-- pkg/app/app.go | 141 +++++++++++++++++++++++++++------- pkg/app/opts.go | 7 ++ pkg/app/websocket_handlers.go | 78 +++++++++++++++++++ pkg/client/websocket.go | 30 ++++++++ pkg/client/websocket_conn.go | 7 ++ pkg/mqtt/mqtt.go | 69 +++++++++-------- pkg/mqtt/opts.go | 4 +- pkg/utils/attempter.go | 8 +- 9 files changed, 294 insertions(+), 67 deletions(-) diff --git a/cmd/nanit/main.go b/cmd/nanit/main.go index 631b908..d4e683c 100644 --- a/cmd/nanit/main.go +++ b/cmd/nanit/main.go @@ -52,11 +52,18 @@ func main() { if utils.EnvVarBool("NANIT_MQTT_ENABLED", false) { opts.MQTT = &mqtt.Opts{ - BrokerURL: utils.EnvVarReqStr("NANIT_MQTT_BROKER_URL"), - ClientID: utils.EnvVarStr("NANIT_MQTT_CLIENT_ID", "nanit"), - Username: utils.EnvVarStr("NANIT_MQTT_USERNAME", ""), - Password: utils.EnvVarStr("NANIT_MQTT_PASSWORD", ""), - TopicPrefix: utils.EnvVarStr("NANIT_MQTT_PREFIX", "nanit"), + BrokerURL: utils.EnvVarReqStr("NANIT_MQTT_BROKER_URL"), + ClientID: utils.EnvVarStr("NANIT_MQTT_CLIENT_ID", "nanit"), + Username: utils.EnvVarStr("NANIT_MQTT_USERNAME", ""), + Password: utils.EnvVarStr("NANIT_MQTT_PASSWORD", ""), + TopicPrefix: utils.EnvVarStr("NANIT_MQTT_PREFIX", "nanit"), + DiscoveryEnabled: utils.EnvVarBool("NANIT_MQTT_DISCOVERY", true), + RTMPAddr: utils.EnvVarStr("NANIT_RTMP_ADDR", ""), + } + + opts.WebSocketReset = app.WebSocketResetOpts{ + Enabled: utils.EnvVarBool("NANIT_MQTT_RESET_WHEN_FAILED", false), + CommandTimeout: utils.EnvVarSeconds("NANIT_WEBSOCKET_TIMEOUT", 1*time.Second), } } diff --git a/pkg/app/app.go b/pkg/app/app.go index adb0a43..7d0a25a 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -3,6 +3,7 @@ package app import ( "fmt" "strings" + "sync" "time" "github.com/indiefan/home_assistant_nanit/pkg/baby" @@ -12,8 +13,13 @@ import ( "github.com/indiefan/home_assistant_nanit/pkg/rtmpserver" "github.com/indiefan/home_assistant_nanit/pkg/session" "github.com/indiefan/home_assistant_nanit/pkg/utils" + "github.com/rs/zerolog/log" ) +type commandRetry struct { + Command func(conn *client.WebsocketConnection) +} + // App - application container type App struct { Opts Opts @@ -21,6 +27,15 @@ type App struct { BabyStateManager *baby.StateManager RestClient *client.NanitClient MQTTConnection *mqtt.Connection + + babyConnectionsMu sync.RWMutex + babyConnections map[string]*client.WebsocketConnection + + babyManagersMu sync.RWMutex + babyManagers map[string]*client.WebsocketConnectionManager + + pendingRetriesMu sync.Mutex + pendingRetries map[string]commandRetry } // NewApp - constructor @@ -37,6 +52,9 @@ func NewApp(opts Opts) *App { RefreshToken: opts.NanitCredentials.RefreshToken, SessionStore: sessionStore, }, + babyConnections: make(map[string]*client.WebsocketConnection), + babyManagers: make(map[string]*client.WebsocketConnectionManager), + pendingRetries: make(map[string]commandRetry), } if opts.MQTT != nil { @@ -65,6 +83,14 @@ func (app *App) Run(ctx utils.GracefulContext) { // MQTT if app.MQTTConnection != nil { + // Register global MQTT command handlers (routes to correct baby via registry) + app.MQTTConnection.RegisterLightHandler(func(babyUID string, enabled bool) { + app.sendLightCommandWithReset(babyUID, enabled) + }) + app.MQTTConnection.RegisterStandbyHandler(func(babyUID string, enabled bool) { + app.sendStandbyCommandWithReset(babyUID, enabled) + }) + ctx.RunAsChild(func(childCtx utils.GracefulContext) { app.MQTTConnection.Run(app.BabyStateManager, childCtx) }) @@ -86,17 +112,21 @@ func (app *App) Run(ctx utils.GracefulContext) { <-ctx.Done() } -func (app *App) handleBaby(baby baby.Baby, ctx utils.GracefulContext) { +func (app *App) handleBaby(b baby.Baby, ctx utils.GracefulContext) { if app.Opts.RTMP != nil || app.MQTTConnection != nil { // Websocket connection - ws := client.NewWebsocketConnectionManager(baby.UID, baby.CameraUID, app.SessionStore.Session, app.RestClient, app.BabyStateManager) + ws := client.NewWebsocketConnectionManager(b.UID, b.CameraUID, app.SessionStore.Session, app.RestClient, app.BabyStateManager) + + app.registerBabyManager(b.UID, ws) ws.WithReadyConnection(func(conn *client.WebsocketConnection, childCtx utils.GracefulContext) { - app.runWebsocket(baby.UID, conn, childCtx) + app.registerBabyConnection(b.UID, conn) + app.runWebsocket(b.UID, conn, childCtx) + app.unregisterBabyConnection(b.UID) }) if app.Opts.EventPolling.Enabled { - go app.pollMessages(baby.UID, app.BabyStateManager) + go app.pollMessages(b.UID, app.BabyStateManager) } ctx.RunAsChild(func(childCtx utils.GracefulContext) { @@ -127,6 +157,9 @@ func (app *App) pollMessages(babyUID string, babyStateManager *baby.StateManager } func (app *App) runWebsocket(babyUID string, conn *client.WebsocketConnection, childCtx utils.GracefulContext) { + // Process any pending retries from failed commands + app.processPendingRetries(babyUID, conn) + // Reading sensor data conn.RegisterMessageHandler(func(m *client.Message, conn *client.WebsocketConnection) { // Sensor request initiated by us on start (or some other client, we don't care) @@ -153,15 +186,6 @@ func (app *App) runWebsocket(babyUID string, conn *client.WebsocketConnection, c } }) - if app.Opts.MQTT != nil && app.MQTTConnection != nil { - app.MQTTConnection.RegisterLightHandler(func(enabled bool) { - sendLightCommand(enabled, conn) - }) - app.MQTTConnection.RegisterStandyHandler(func(enabled bool) { - sendStandbyCommand(enabled, conn) - }) - } - // Get the initial state of the light conn.SendRequest(client.RequestType_GET_CONTROL, &client.Request{GetControl_: &client.GetControl{ NightLight: utils.ConstRefBool(true), @@ -174,20 +198,6 @@ func (app *App) runWebsocket(babyUID string, conn *client.WebsocketConnection, c }, }) - // Ask for status - // conn.SendRequest(client.RequestType_GET_STATUS, &client.Request{ - // GetStatus_: &client.GetStatus{ - // All: utils.ConstRefBool(true), - // }, - // }) - - // Ask for logs - // conn.SendRequest(client.RequestType_GET_LOGS, &client.Request{ - // GetLogs: &client.GetLogs{ - // Url: utils.ConstRefStr("http://192.168.3.234:8080/log"), - // }, - // }) - var cleanup func() // Local streaming @@ -245,3 +255,80 @@ func (app *App) getLocalStreamURL(babyUID string) string { return "" } + +// Connection registry methods + +func (app *App) registerBabyConnection(babyUID string, conn *client.WebsocketConnection) { + app.babyConnectionsMu.Lock() + app.babyConnections[babyUID] = conn + app.babyConnectionsMu.Unlock() + log.Debug().Str("baby_uid", babyUID).Msg("Registered baby connection") +} + +func (app *App) unregisterBabyConnection(babyUID string) { + app.babyConnectionsMu.Lock() + delete(app.babyConnections, babyUID) + app.babyConnectionsMu.Unlock() + log.Debug().Str("baby_uid", babyUID).Msg("Unregistered baby connection") +} + +func (app *App) getBabyConnection(babyUID string) *client.WebsocketConnection { + app.babyConnectionsMu.RLock() + conn := app.babyConnections[babyUID] + app.babyConnectionsMu.RUnlock() + return conn +} + +// Manager registry methods + +func (app *App) registerBabyManager(babyUID string, manager *client.WebsocketConnectionManager) { + app.babyManagersMu.Lock() + app.babyManagers[babyUID] = manager + app.babyManagersMu.Unlock() +} + +func (app *App) getBabyManager(babyUID string) *client.WebsocketConnectionManager { + app.babyManagersMu.RLock() + manager := app.babyManagers[babyUID] + app.babyManagersMu.RUnlock() + return manager +} + +// Pending retry methods + +func (app *App) shouldAttemptReset(babyUID string) bool { + app.pendingRetriesMu.Lock() + defer app.pendingRetriesMu.Unlock() + // Don't queue more retries if we already have one pending for this baby + for key := range app.pendingRetries { + if strings.HasPrefix(key, babyUID+":") { + return false + } + } + return true +} + +func (app *App) addPendingRetry(babyUID string, commandType string, command func(conn *client.WebsocketConnection)) { + key := fmt.Sprintf("%s:%s", babyUID, commandType) + app.pendingRetriesMu.Lock() + app.pendingRetries[key] = commandRetry{Command: command} + app.pendingRetriesMu.Unlock() + log.Debug().Str("baby_uid", babyUID).Str("command", commandType).Msg("Queued command for retry after reconnect") +} + +func (app *App) processPendingRetries(babyUID string, conn *client.WebsocketConnection) { + app.pendingRetriesMu.Lock() + var retries []commandRetry + for key, retry := range app.pendingRetries { + if strings.HasPrefix(key, babyUID+":") { + retries = append(retries, retry) + delete(app.pendingRetries, key) + } + } + app.pendingRetriesMu.Unlock() + + for _, retry := range retries { + log.Debug().Str("baby_uid", babyUID).Msg("Processing pending retry after reconnect") + retry.Command(conn) + } +} diff --git a/pkg/app/opts.go b/pkg/app/opts.go index d95a347..b64f83c 100644 --- a/pkg/app/opts.go +++ b/pkg/app/opts.go @@ -14,6 +14,7 @@ type Opts struct { MQTT *mqtt.Opts RTMP *RTMPOpts EventPolling EventPollingOpts + WebSocketReset WebSocketResetOpts } // NanitCredentials - user credentials for Nanit account @@ -44,3 +45,9 @@ type EventPollingOpts struct { PollingInterval time.Duration MessageTimeout time.Duration } + +// WebSocketResetOpts - options for auto-reconnecting websocket on failed MQTT commands +type WebSocketResetOpts struct { + Enabled bool + CommandTimeout time.Duration +} diff --git a/pkg/app/websocket_handlers.go b/pkg/app/websocket_handlers.go index eba4039..b72087d 100644 --- a/pkg/app/websocket_handlers.go +++ b/pkg/app/websocket_handlers.go @@ -103,6 +103,47 @@ func sendLightCommand(nightLightState bool, conn *client.WebsocketConnection) { }) } +func sendLightCommandWithTimeout(nightLightState bool, conn *client.WebsocketConnection, timeout time.Duration) error { + nightLight := client.Control_LIGHT_OFF + if nightLightState { + nightLight = client.Control_LIGHT_ON + } + awaitResponse := conn.SendRequest(client.RequestType_PUT_CONTROL, &client.Request{ + Control: &client.Control{ + NightLight: &nightLight, + }, + }) + _, err := awaitResponse(timeout) + return err +} + +func (app *App) sendLightCommandWithReset(babyUID string, nightLightState bool) { + conn := app.getBabyConnection(babyUID) + if conn == nil { + log.Warn().Str("baby_uid", babyUID).Msg("No active connection for baby, cannot send light command") + return + } + + if !app.Opts.WebSocketReset.Enabled { + sendLightCommand(nightLightState, conn) + return + } + + err := sendLightCommandWithTimeout(nightLightState, conn, app.Opts.WebSocketReset.CommandTimeout) + if err != nil { + log.Warn().Str("baby_uid", babyUID).Err(err).Msg("Light command failed, attempting websocket reset") + if app.shouldAttemptReset(babyUID) { + app.addPendingRetry(babyUID, "light", func(c *client.WebsocketConnection) { + sendLightCommand(nightLightState, c) + }) + manager := app.getBabyManager(babyUID) + if manager != nil { + manager.ForceReconnect() + } + } + } +} + func processStandby(babyUID string, settings *client.Settings, stateManager *baby.StateManager) { if settings.SleepMode != nil { stateUpdate := baby.State{} @@ -118,3 +159,40 @@ func sendStandbyCommand(standbyState bool, conn *client.WebsocketConnection) { }, }) } + +func sendStandbyCommandWithTimeout(standbyState bool, conn *client.WebsocketConnection, timeout time.Duration) error { + awaitResponse := conn.SendRequest(client.RequestType_PUT_SETTINGS, &client.Request{ + Settings: &client.Settings{ + SleepMode: &standbyState, + }, + }) + _, err := awaitResponse(timeout) + return err +} + +func (app *App) sendStandbyCommandWithReset(babyUID string, standbyState bool) { + conn := app.getBabyConnection(babyUID) + if conn == nil { + log.Warn().Str("baby_uid", babyUID).Msg("No active connection for baby, cannot send standby command") + return + } + + if !app.Opts.WebSocketReset.Enabled { + sendStandbyCommand(standbyState, conn) + return + } + + err := sendStandbyCommandWithTimeout(standbyState, conn, app.Opts.WebSocketReset.CommandTimeout) + if err != nil { + log.Warn().Str("baby_uid", babyUID).Err(err).Msg("Standby command failed, attempting websocket reset") + if app.shouldAttemptReset(babyUID) { + app.addPendingRetry(babyUID, "standby", func(c *client.WebsocketConnection) { + sendStandbyCommand(standbyState, c) + }) + manager := app.getBabyManager(babyUID) + if manager != nil { + manager.ForceReconnect() + } + } + } +} diff --git a/pkg/client/websocket.go b/pkg/client/websocket.go index 78cb03d..bde7e05 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -33,6 +33,9 @@ type WebsocketConnectionManager struct { mu sync.RWMutex readyState *readyState readySubscribers []WebsocketConnectionHandler + + forcedMutex sync.RWMutex + forcedReconnect bool } // NewWebsocketConnectionManager - constructor @@ -77,6 +80,22 @@ func (manager *WebsocketConnectionManager) WithReadyConnection(handler Websocket } } +// ForceReconnect - forces an immediate websocket reconnection (skips cooldown) +func (manager *WebsocketConnectionManager) ForceReconnect() { + manager.forcedMutex.Lock() + manager.forcedReconnect = true + manager.forcedMutex.Unlock() + + manager.mu.RLock() + readyState := manager.readyState + manager.mu.RUnlock() + + if readyState != nil && readyState.Connection != nil { + log.Info().Str("baby_uid", manager.BabyUID).Msg("Force reconnecting websocket") + readyState.Connection.Close() + } +} + // RunWithinContext - starts websocket connection attempt loop func (manager *WebsocketConnectionManager) RunWithinContext(ctx utils.GracefulContext) { utils.RunWithPerseverance(manager.run, ctx, utils.PerseverenceOpts{ @@ -89,10 +108,21 @@ func (manager *WebsocketConnectionManager) RunWithinContext(ctx utils.GracefulCo 15 * time.Minute, 1 * time.Hour, }, + ShouldSkipCooldown: func() bool { + manager.forcedMutex.RLock() + forced := manager.forcedReconnect + manager.forcedMutex.RUnlock() + return forced + }, }) } func (manager *WebsocketConnectionManager) run(attempt utils.AttemptContext) { + // Reset forced reconnect flag + manager.forcedMutex.Lock() + manager.forcedReconnect = false + manager.forcedMutex.Unlock() + // Reauthorize if it is not a first try or we assume we don't have a valid token manager.API.MaybeAuthorize(attempt.GetTry() > 1) diff --git a/pkg/client/websocket_conn.go b/pkg/client/websocket_conn.go index cb582b5..a7308bd 100644 --- a/pkg/client/websocket_conn.go +++ b/pkg/client/websocket_conn.go @@ -39,6 +39,13 @@ func NewWebsocketConnection(socket *gowebsocket.Socket) *WebsocketConnection { } } +// Close - closes the underlying websocket connection +func (conn *WebsocketConnection) Close() { + if conn.socket != nil { + conn.socket.Close() + } +} + // RegisterMessageHandler - registers handler which will be called whenever new message is received func (conn *WebsocketConnection) RegisterMessageHandler(handler WebsocketMessageHandler) { conn.msgHandlersMu.Lock() diff --git a/pkg/mqtt/mqtt.go b/pkg/mqtt/mqtt.go index 06aa6e3..4c9c6b3 100644 --- a/pkg/mqtt/mqtt.go +++ b/pkg/mqtt/mqtt.go @@ -11,8 +11,8 @@ import ( "github.com/rs/zerolog/log" ) -type SendLightCommandHandler func(nightLightState bool) -type SendStandbyCommandHandler func(standbyState bool) +type SendLightCommandHandler func(babyUID string, nightLightState bool) +type SendStandbyCommandHandler func(babyUID string, standbyState bool) // Connection - MQTT context type Connection struct { @@ -67,7 +67,7 @@ func (conn *Connection) subscribeToLightCommand() { Msg("Subscribing to command topic") lightMessageHandler := func(mqttConn MQTT.Client, msg MQTT.Message) { - // Extract baby UID and command from topic + // Extract baby UID from topic parts := strings.Split(msg.Topic(), "/") if len(parts) < 4 { log.Error().Str("topic", msg.Topic()).Msg("Invalid command topic format") @@ -75,24 +75,19 @@ func (conn *Connection) subscribeToLightCommand() { } babyUID := parts[2] - command := parts[4] // Validate baby UID baby.EnsureValidBabyUID(babyUID) - // Handle different commands - switch command { - case "switch": - enabled := string(msg.Payload()) == "true" - log.Debug(). - Str("baby", babyUID). - Bool("enabled", enabled). - Str("payload", string(msg.Payload())). - Msg("Received light command") - - conn.sendLightCommandHandler(enabled) - default: - log.Warn().Str("command", command).Msg("Unknown command received") + enabled := string(msg.Payload()) == "true" + log.Debug(). + Str("baby", babyUID). + Bool("enabled", enabled). + Str("payload", string(msg.Payload())). + Msg("Received light command") + + if conn.sendLightCommandHandler != nil { + conn.sendLightCommandHandler(babyUID, enabled) } } @@ -101,7 +96,7 @@ func (conn *Connection) subscribeToLightCommand() { } } -func (conn *Connection) RegisterStandyHandler(sendStandbyCommandHandler SendStandbyCommandHandler) { +func (conn *Connection) RegisterStandbyHandler(sendStandbyCommandHandler SendStandbyCommandHandler) { conn.sendStandbyCommandHandler = sendStandbyCommandHandler } @@ -112,7 +107,7 @@ func (conn *Connection) subscribeToStandbyCommand() { Msg("Subscribing to command topic") standbyMessageHandler := func(mqttConn MQTT.Client, msg MQTT.Message) { - // Extract baby UID and command from topic + // Extract baby UID from topic parts := strings.Split(msg.Topic(), "/") if len(parts) < 4 { log.Error().Str("topic", msg.Topic()).Msg("Invalid command topic format") @@ -120,24 +115,19 @@ func (conn *Connection) subscribeToStandbyCommand() { } babyUID := parts[2] - command := parts[4] // Validate baby UID baby.EnsureValidBabyUID(babyUID) - // Handle different commands - switch command { - case "switch": - enabled := string(msg.Payload()) == "true" - log.Debug(). - Str("baby", babyUID). - Bool("enabled", enabled). - Str("payload", string(msg.Payload())). - Msg("Received standby command") - - conn.sendStandbyCommandHandler(enabled) - default: - log.Warn().Str("command", command).Msg("Unknown command received") + enabled := string(msg.Payload()) == "true" + log.Debug(). + Str("baby", babyUID). + Bool("enabled", enabled). + Str("payload", string(msg.Payload())). + Msg("Received standby command") + + if conn.sendStandbyCommandHandler != nil { + conn.sendStandbyCommandHandler(babyUID, enabled) } } @@ -146,6 +136,19 @@ func (conn *Connection) subscribeToStandbyCommand() { } } +// Publish - publishes a message to an MQTT topic +func (conn *Connection) Publish(topic string, payload interface{}) { + token := conn.client.Publish(topic, 0, false, fmt.Sprintf("%v", payload)) + if token.Wait(); token.Error() != nil { + log.Error().Err(token.Error()).Str("topic", topic).Msg("Unable to publish MQTT message") + } +} + +// GetClient - returns the underlying MQTT client +func (conn *Connection) GetClient() MQTT.Client { + return conn.client +} + func runMqtt(conn *Connection, attempt utils.AttemptContext) { if token := conn.client.Connect(); token.Wait() && token.Error() != nil { diff --git a/pkg/mqtt/opts.go b/pkg/mqtt/opts.go index 31c8d42..77bc4f1 100644 --- a/pkg/mqtt/opts.go +++ b/pkg/mqtt/opts.go @@ -8,5 +8,7 @@ type Opts struct { Username string Password string - TopicPrefix string + TopicPrefix string + DiscoveryEnabled bool + RTMPAddr string } diff --git a/pkg/utils/attempter.go b/pkg/utils/attempter.go index 8b64ee2..5964540 100644 --- a/pkg/utils/attempter.go +++ b/pkg/utils/attempter.go @@ -27,6 +27,9 @@ type PerseverenceOpts struct { // RunnerID - optional string name for the runner for debugging purposes RunnerID string + + // ShouldSkipCooldown - optional callback that returns true to skip cooldown (e.g. for forced reconnects) + ShouldSkipCooldown func() bool } var lastPerseveranceRunnerID int32 = 0 @@ -69,7 +72,10 @@ func RunWithPerseverance(handler func(AttemptContext), ctx GracefulContext, opts sublog.Trace().Err(err).Msg("Attempt finished with error") - if opts.ResetThreshold > 0 && timeTaken > opts.ResetThreshold { + if opts.ShouldSkipCooldown != nil && opts.ShouldSkipCooldown() { + sublog.Trace().Msg("Cooldown skipped (forced reconnect)") + timer.Reset(0) + } else if opts.ResetThreshold > 0 && timeTaken > opts.ResetThreshold { sublog.Trace().Msgf("Previous attempt was %v ago, resetting tries", timeTaken) try = 1 timer.Reset(0) From 37eb92c2c818394fab167f8418f22ffef6d25e99 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 23:10:52 -0400 Subject: [PATCH 15/20] Add Stream D plan: UniFi Protect ONVIF adoption via macvlan sidecars --- ...2026-04-11-stream-d-unifi-protect-onvif.md | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-11-stream-d-unifi-protect-onvif.md diff --git a/docs/superpowers/plans/2026-04-11-stream-d-unifi-protect-onvif.md b/docs/superpowers/plans/2026-04-11-stream-d-unifi-protect-onvif.md new file mode 100644 index 0000000..8b240ef --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-stream-d-unifi-protect-onvif.md @@ -0,0 +1,381 @@ +# Stream D: UniFi Protect ONVIF Adoption Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Get both Nanit cameras adopted into UniFi Protect as ONVIF cameras so they appear in the Protect app, dashboards, and ViewPort devices. + +**Architecture:** UniFi Protect requires each camera to have its own IP address and speak ONVIF on port 80. We add two ONVIF sidecar containers to the nanit compose stack, each with a dedicated IP via Docker macvlan networking. Each ONVIF container points to the go2rtc RTSP stream for its camera. Protect discovers and adopts them by their individual IPs. + +**Tech Stack:** Docker macvlan networking, `danimal4326/onvif-server` container, iptables NAT (port 80→8081) + +**Spec:** `docs/superpowers/specs/2026-04-11-nanit-enhancements-design.md` — Feature 2 (UniFi Protect portion) + +**Reference:** [tombaxley/nanit-unifi-protect](https://github.com/tombaxley/nanit-unifi-protect) + +**Prerequisites:** Stream B (go2rtc bundling) must be deployed so RTSP is available at `rtsp://192.168.1.121:8554/{stream_name}` + +**Network context:** +- Linux VM (linuxserver): 192.168.1.121 +- UDM Pro: 192.168.1.1 +- LAN subnet: 192.168.1.0/24 +- Gateway: 192.168.1.1 +- Two cameras: + - Abby: baby_uid `ddedb8f1`, camera_uid `N301CMN23260RE`, go2rtc stream name `abby` + - Emilia: baby_uid `6086057e`, camera_uid `N301BKN211184B`, go2rtc stream name `emilia` + +--- + +### Task 1: Reserve IP Addresses for ONVIF Endpoints + +Before any Docker changes, we need 2 static IPs on the LAN for the ONVIF containers. These should be outside the DHCP range to avoid conflicts. + +- [ ] **Step 1: Pick 2 IPs** + +Check the UDM Pro DHCP range to find available static IPs. Suggested: +- `192.168.1.201` — ONVIF endpoint for Abby camera +- `192.168.1.202` — ONVIF endpoint for Emilia camera + +Verify these are unused: +```bash +ssh linuxserver "ping -c 1 -W 1 192.168.1.201 && echo IN USE || echo AVAILABLE" +ssh linuxserver "ping -c 1 -W 1 192.168.1.202 && echo IN USE || echo AVAILABLE" +``` + +Optionally reserve them as fixed IPs in the UDM Pro DHCP settings so nothing else grabs them. + +- [ ] **Step 2: Identify the host network interface** + +```bash +ssh linuxserver "ip route | grep default" +``` + +Note the interface name (likely `ens18` or `eth0`). This is needed for the macvlan network. + +--- + +### Task 2: Create Docker Macvlan Network + +Docker macvlan lets containers have their own IPs on the physical LAN, so Protect's UNVR can reach them directly. + +- [ ] **Step 1: Create the macvlan network on linuxserver** + +```bash +ssh linuxserver "docker network create -d macvlan \ + --subnet=192.168.1.0/24 \ + --gateway=192.168.1.1 \ + -o parent= \ + nanit-onvif-net" +``` + +Replace `` with the interface from Task 1 Step 2. + +**Important:** Containers on a macvlan network cannot communicate with the Docker host by default. Since the ONVIF containers need to reach go2rtc on the host (port 8554), we need a macvlan shim interface on the host: + +```bash +ssh linuxserver "ip link add nanit-shim link type macvlan mode bridge" +ssh linuxserver "ip addr add 192.168.1.200/32 dev nanit-shim" +ssh linuxserver "ip link set nanit-shim up" +ssh linuxserver "ip route add 192.168.1.201/32 dev nanit-shim" +ssh linuxserver "ip route add 192.168.1.202/32 dev nanit-shim" +``` + +The ONVIF containers will use `192.168.1.200` (the shim) as the route back to the host's go2rtc. Actually, they can just use `192.168.1.121` (the host's real IP) since macvlan containers can reach other IPs on the LAN — the restriction is only host-to-container on the same parent interface. But the shim lets the host reach the ONVIF containers for debugging. + +To make this persist across reboots, add to `/etc/rc.local` or a systemd service on linuxserver. + +- [ ] **Step 2: Verify the network exists** + +```bash +ssh linuxserver "docker network inspect nanit-onvif-net --format '{{.IPAM.Config}}'" +``` + +--- + +### Task 3: Create ONVIF Server Configs + +Each ONVIF server needs a config file with unique serial numbers and the correct RTSP URL. + +- [ ] **Step 1: Create config directories on linuxserver** + +```bash +ssh linuxserver "mkdir -p /docker-fs/nanit/onvif-abby /docker-fs/nanit/onvif-emilia" +``` + +- [ ] **Step 2: Create Abby ONVIF config** + +```bash +ssh linuxserver "cat > /docker-fs/nanit/onvif-abby/config.yaml << 'EOF' +server: + SerialNumber: \"N301CMN23260RE\" + HardwareID: \"N301CMN23260RE\" + Manufacturer: \"Nanit\" + Model: \"Baby Monitor\" + FirmwareVersion: \"1.0\" + port: 8081 + +streams: + - name: \"abby\" + rtsp_url: \"rtsp://192.168.1.121:8554/abby\" + snapshot_url: \"http://192.168.1.121:1984/api/frame.jpeg?src=abby\" +EOF" +``` + +- [ ] **Step 3: Create Emilia ONVIF config** + +```bash +ssh linuxserver "cat > /docker-fs/nanit/onvif-emilia/config.yaml << 'EOF' +server: + SerialNumber: \"N301BKN211184B\" + HardwareID: \"N301BKN211184B\" + Manufacturer: \"Nanit\" + Model: \"Baby Monitor\" + FirmwareVersion: \"1.0\" + port: 8081 + +streams: + - name: \"emilia\" + rtsp_url: \"rtsp://192.168.1.121:8554/emilia\" + snapshot_url: \"http://192.168.1.121:1984/api/frame.jpeg?src=emilia\" +EOF" +``` + +Note: The serial numbers use the actual Nanit camera UIDs for uniqueness. + +--- + +### Task 4: Update Compose File + +**Files:** +- Modify (homelab repo): `stacks/nanit/docker-compose.yml` + +- [ ] **Step 1: Add ONVIF sidecar containers** + +```yaml +version: '3' + +services: + nanit: + container_name: nanit-abby + image: ghcr.io/stuart22/home_assistant_nanit:latest + volumes: + - /docker-fs/nanit/data:/data + - /etc/localtime:/etc/localtime:ro + environment: + - NANIT_RTMP_ADDR=192.168.1.121:1935 + - NANIT_LOG_LEVEL=info + - TZ=America/New_York + ports: + - "1935:1935" + - "8554:8554" + - "1984:1984" + restart: unless-stopped + + onvif-abby: + container_name: nanit-onvif-abby + image: danimal4326/onvif-server + volumes: + - /docker-fs/nanit/onvif-abby/config.yaml:/config/config.yaml + networks: + nanit-onvif-net: + ipv4_address: 192.168.1.201 + cap_add: + - NET_ADMIN + entrypoint: ["/bin/sh", "-c", "iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8081 && exec /app/onvif-server"] + restart: unless-stopped + + onvif-emilia: + container_name: nanit-onvif-emilia + image: danimal4326/onvif-server + volumes: + - /docker-fs/nanit/onvif-emilia/config.yaml:/config/config.yaml + networks: + nanit-onvif-net: + ipv4_address: 192.168.1.202 + cap_add: + - NET_ADMIN + entrypoint: ["/bin/sh", "-c", "iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8081 && exec /app/onvif-server"] + restart: unless-stopped + +networks: + nanit-onvif-net: + external: true +``` + +**Notes:** +- `cap_add: NET_ADMIN` is needed for the iptables NAT rule inside the container +- The entrypoint wraps the ONVIF server with iptables port redirect (80→8081) since Protect expects ONVIF on port 80 +- The macvlan network is external (created in Task 2) because Portainer can't create macvlan networks via compose +- The nanit container stays on the default bridge network (no change needed) +- Port 1984 is exposed for go2rtc's API (needed for ONVIF snapshot URLs) + +- [ ] **Step 2: Verify the danimal4326/onvif-server image and entrypoint** + +Before committing, check the actual image: +```bash +ssh linuxserver "docker pull danimal4326/onvif-server" +ssh linuxserver "docker inspect danimal4326/onvif-server --format '{{.Config.Entrypoint}} {{.Config.Cmd}}'" +``` + +Adjust the entrypoint command if the binary path differs. Also check if the image has iptables: +```bash +ssh linuxserver "docker run --rm --cap-add NET_ADMIN danimal4326/onvif-server iptables --version" +``` + +If iptables isn't available, we'll need a different approach (custom Dockerfile or a wrapper image). + +- [ ] **Step 3: Commit to homelab repo** + +```bash +cd /Users/stuarthall/_dev/worktrees/nanit-2qp +git add stacks/nanit/docker-compose.yml +git commit -m "Add ONVIF sidecar containers for UniFi Protect adoption" +``` + +--- + +### Task 5: Deploy and Test + +- [ ] **Step 1: Push and let Portainer deploy** + +```bash +git push origin +# Create PR and merge, or push to main +``` + +- [ ] **Step 2: Verify containers are running** + +```bash +ssh linuxserver "docker ps --filter name=nanit" +``` + +Should show 3 containers: `nanit-abby`, `nanit-onvif-abby`, `nanit-onvif-emilia` + +- [ ] **Step 3: Verify ONVIF endpoints are reachable** + +From linuxserver (via the shim interface): +```bash +ssh linuxserver "curl -s http://192.168.1.201:8081/onvif/device_service || echo 'not reachable on 8081'" +ssh linuxserver "curl -s http://192.168.1.201:80/onvif/device_service || echo 'not reachable on 80'" +``` + +- [ ] **Step 4: Adopt in UniFi Protect** + +In the UniFi Protect UI: +1. Go to **Settings → Devices → Add Device** +2. Select **Add Third-Party Camera (ONVIF)** +3. Enter IP: `192.168.1.201` +4. If prompted for credentials, check danimal4326/onvif-server docs for defaults +5. Protect should discover "Nanit Baby Monitor" with serial `N301CMN23260RE` +6. Adopt the camera +7. Repeat with `192.168.1.202` for Emilia + +- [ ] **Step 5: Verify streams in Protect** + +Both cameras should appear in the Protect dashboard with live video. Check: +- Live view works +- Timeline recording works (if enabled) +- ViewPort displays work (may need reboot of ViewPort device) + +--- + +### Task 6: Persist Macvlan Shim Across Reboots + +The macvlan shim interface from Task 2 is lost on reboot. Make it persistent. + +- [ ] **Step 1: Create a systemd service** + +```bash +ssh linuxserver "cat > /etc/systemd/system/nanit-macvlan-shim.service << 'EOF' +[Unit] +Description=Macvlan shim for nanit ONVIF containers +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/bash -c '\ + ip link add nanit-shim link type macvlan mode bridge; \ + ip addr add 192.168.1.200/32 dev nanit-shim; \ + ip link set nanit-shim up; \ + ip route add 192.168.1.201/32 dev nanit-shim; \ + ip route add 192.168.1.202/32 dev nanit-shim' +ExecStop=/bin/bash -c 'ip link del nanit-shim' + +[Install] +WantedBy=multi-user.target +EOF" +``` + +Replace `` with the actual interface. + +- [ ] **Step 2: Enable the service** + +```bash +ssh linuxserver "systemctl daemon-reload && systemctl enable nanit-macvlan-shim.service" +``` + +--- + +--- + +### Critical Details from tombaxley's README + +These specifics are essential — missing any of them will cause silent failures: + +**1. ONVIF SerialNumber is CRITICAL:** +The `SerialNumber` and `HardwareID` fields in onvif-server config are **undocumented** but fully supported. Without them, both default to `0.0.0` and `1.0.0`, causing Protect to see ALL instances as the same camera. Use each camera's Nanit camera UID (e.g., `N301CMN23260RE`) — NOT the baby UID. + +**2. ONVIF must be on port 80:** +Protect expects ONVIF on port 80 (standard). The onvif-server runs on 8081. The iptables NAT rule redirecting 80→8081 on the container's IP is mandatory. Without it, Protect won't discover the camera. + +**3. ONVIF credentials:** +When adding the camera in Protect, it asks for username/password. Check `danimal4326/onvif-server` docs for how auth is configured — typically set in the ONVIF config YAML. If not configured, try `admin`/blank. + +**4. Each camera MUST have its own IP:** +Protect conflates multiple ONVIF cameras served from the same host. This is not optional — each camera needs a dedicated IP address that Protect uses for ONVIF discovery. + +**5. Use LAN subnet for RTMP firewall rules:** +Nanit cameras get IPs via DHCP. If you use per-IP firewall rules and the camera's IP changes, the stream silently breaks. Always use `192.168.1.0/24` for RTMP allow rules. + +**6. go2rtc API port 1984 must be accessible:** +The ONVIF server uses `http://:1984/api/frame.jpeg?src=` for snapshot URLs. Protect uses these for thumbnails. If port 1984 is blocked, you get video but no thumbnails. + +**7. ViewPort quirks:** +ViewPort devices occasionally show "Unable to stream". Fix: reboot the ViewPort device and ensure its IP has firewall access to ports 8554 and 1984 on the ONVIF container. + +--- + +### Troubleshooting + +**Verify ONVIF is responding (SOAP test):** +```bash +curl -s http://192.168.1.201:80/onvif/device_service \ + -H 'Content-Type: application/soap+xml' \ + -d '' +``` +Should return XML with your configured SerialNumber — not `0.0.0`. + +**Protect says "Unable to connect":** +- Verify the ONVIF container can reach go2rtc: `docker exec nanit-onvif-abby curl -s http://192.168.1.121:8554/` +- Check ONVIF server logs: `docker logs nanit-onvif-abby` +- Verify iptables NAT is active: `docker exec nanit-onvif-abby iptables -t nat -L` +- Ensure UNVR (192.168.1.1) can reach ports 80, 8081, 8554, and 1984 on the ONVIF container IP + +**Protect adopts but no video:** +- Verify RTSP stream works: `docker exec nanit-onvif-abby ffprobe rtsp://192.168.1.121:8554/abby` +- Check go2rtc stream health: `curl -s http://192.168.1.121:1984/api/streams` — look for active `producers` (RTMP from nanit) and `consumers` (RTSP to UNVR) +- Check go2rtc logs: `docker logs nanit-abby 2>&1 | grep go2rtc` + +**Protect shows duplicate cameras:** +- Ensure each ONVIF config has unique SerialNumber and HardwareID (use camera_uid, NOT baby_uid) +- Remove both cameras from Protect and re-adopt + +**Macvlan containers can't reach host:** +- Verify the shim interface is up: `ip addr show nanit-shim` +- Test from container: `docker exec nanit-onvif-abby ping 192.168.1.121` +- The shim is only needed for host→container communication; container→host should work via 192.168.1.121 + +**Stream breaks after camera IP changes (DHCP):** +- Use LAN subnet `192.168.1.0/24` for all firewall rules, not individual camera IPs +- Find camera's current IP: `ssh linuxserver "tcpdump -i port 1935 -n -c 5"` From 02e5f1fc17d051f3fc5accf8ee3373dd2caeeee8 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 23:11:05 -0400 Subject: [PATCH 16/20] Add MQTT device control: night light brightness, playback, volume - Extend protobuf schema with brightness (Settings), duration/soundtrack (Playback) - Add MQTT subscriptions for brightness, playback (start/stop), and volume commands - Route all commands through connection registry for multi-camera support - JSON payloads for brightness, playback, and volume topics --- pkg/app/app.go | 9 + pkg/app/websocket_handlers.go | 78 +++ pkg/client/websocket.pb.go | 1074 +++++++++++++++++---------------- pkg/client/websocket.proto | 13 + pkg/mqtt/mqtt.go | 200 ++++-- 5 files changed, 814 insertions(+), 560 deletions(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index 7d0a25a..835c5d2 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -90,6 +90,15 @@ func (app *App) Run(ctx utils.GracefulContext) { app.MQTTConnection.RegisterStandbyHandler(func(babyUID string, enabled bool) { app.sendStandbyCommandWithReset(babyUID, enabled) }) + app.MQTTConnection.RegisterBrightnessHandler(func(babyUID string, brightness int32) { + app.sendBrightnessCommandRouted(babyUID, brightness) + }) + app.MQTTConnection.RegisterPlaybackHandler(func(babyUID string, action string, soundtrack string, duration int32) { + app.sendPlaybackCommandRouted(babyUID, action, soundtrack, duration) + }) + app.MQTTConnection.RegisterVolumeHandler(func(babyUID string, volume int32) { + app.sendVolumeCommandRouted(babyUID, volume) + }) ctx.RunAsChild(func(childCtx utils.GracefulContext) { app.MQTTConnection.Run(app.BabyStateManager, childCtx) diff --git a/pkg/app/websocket_handlers.go b/pkg/app/websocket_handlers.go index b72087d..be23ae5 100644 --- a/pkg/app/websocket_handlers.go +++ b/pkg/app/websocket_handlers.go @@ -196,3 +196,81 @@ func (app *App) sendStandbyCommandWithReset(babyUID string, standbyState bool) { } } } + +// sendBrightnessCommand sends a brightness setting to the camera +func sendBrightnessCommand(brightness int32, conn *client.WebsocketConnection) { + conn.SendRequest(client.RequestType_PUT_SETTINGS, &client.Request{ + Settings: &client.Settings{ + Brightness: &brightness, + }, + }) +} + +func (app *App) sendBrightnessCommandRouted(babyUID string, brightness int32) { + conn := app.getBabyConnection(babyUID) + if conn == nil { + log.Warn().Str("baby_uid", babyUID).Msg("No active connection for baby, cannot send brightness command") + return + } + sendBrightnessCommand(brightness, conn) +} + +// sendPlaybackCommand sends a playback command (start/stop) to the camera +func sendPlaybackCommand(action string, soundtrack string, duration int32, conn *client.WebsocketConnection) { + var status client.Playback_Status + switch action { + case "start": + status = client.Playback_STARTED + case "stop": + status = client.Playback_STOPPED + default: + log.Warn().Str("action", action).Msg("Unknown playback action") + return + } + + req := &client.Request{ + Playback: &client.Playback{ + Status: &status, + }, + } + + if action == "start" && soundtrack != "" { + storage := client.SoundtrackStorage_FACTORY + req.Playback.Soundtrack = &client.Soundtrack{ + Filename: &soundtrack, + Storage: &storage, + } + if duration > 0 { + req.Playback.Duration = &duration + } + } + + conn.SendRequest(client.RequestType_PUT_PLAYBACK, req) +} + +func (app *App) sendPlaybackCommandRouted(babyUID string, action string, soundtrack string, duration int32) { + conn := app.getBabyConnection(babyUID) + if conn == nil { + log.Warn().Str("baby_uid", babyUID).Msg("No active connection for baby, cannot send playback command") + return + } + sendPlaybackCommand(action, soundtrack, duration, conn) +} + +// sendVolumeCommand sends a volume setting to the camera +func sendVolumeCommand(volume int32, conn *client.WebsocketConnection) { + conn.SendRequest(client.RequestType_PUT_SETTINGS, &client.Request{ + Settings: &client.Settings{ + Volume: &volume, + }, + }) +} + +func (app *App) sendVolumeCommandRouted(babyUID string, volume int32) { + conn := app.getBabyConnection(babyUID) + if conn == nil { + log.Warn().Str("baby_uid", babyUID).Msg("No active connection for baby, cannot send volume command") + return + } + sendVolumeCommand(volume, conn) +} diff --git a/pkg/client/websocket.pb.go b/pkg/client/websocket.pb.go index c2d5090..1d11afe 100644 --- a/pkg/client/websocket.pb.go +++ b/pkg/client/websocket.pb.go @@ -1,8 +1,9 @@ +go: downloading google.golang.org/protobuf v1.36.11 // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 -// protoc v5.29.3 -// source: pkg/client/websocket.proto +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: websocket.proto package client @@ -177,11 +178,11 @@ func (x RequestType) String() string { } func (RequestType) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[0].Descriptor() + return file_websocket_proto_enumTypes[0].Descriptor() } func (RequestType) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[0] + return &file_websocket_proto_enumTypes[0] } func (x RequestType) Number() protoreflect.EnumNumber { @@ -200,7 +201,7 @@ func (x *RequestType) UnmarshalJSON(b []byte) error { // Deprecated: Use RequestType.Descriptor instead. func (RequestType) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{0} + return file_websocket_proto_rawDescGZIP(), []int{0} } type SensorType int32 @@ -245,11 +246,11 @@ func (x SensorType) String() string { } func (SensorType) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[1].Descriptor() + return file_websocket_proto_enumTypes[1].Descriptor() } func (SensorType) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[1] + return &file_websocket_proto_enumTypes[1] } func (x SensorType) Number() protoreflect.EnumNumber { @@ -268,7 +269,7 @@ func (x *SensorType) UnmarshalJSON(b []byte) error { // Deprecated: Use SensorType.Descriptor instead. func (SensorType) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{1} + return file_websocket_proto_rawDescGZIP(), []int{1} } type StreamIdentifier int32 @@ -304,11 +305,11 @@ func (x StreamIdentifier) String() string { } func (StreamIdentifier) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[2].Descriptor() + return file_websocket_proto_enumTypes[2].Descriptor() } func (StreamIdentifier) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[2] + return &file_websocket_proto_enumTypes[2] } func (x StreamIdentifier) Number() protoreflect.EnumNumber { @@ -327,7 +328,7 @@ func (x *StreamIdentifier) UnmarshalJSON(b []byte) error { // Deprecated: Use StreamIdentifier.Descriptor instead. func (StreamIdentifier) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{2} + return file_websocket_proto_rawDescGZIP(), []int{2} } type MountingMode int32 @@ -363,11 +364,11 @@ func (x MountingMode) String() string { } func (MountingMode) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[3].Descriptor() + return file_websocket_proto_enumTypes[3].Descriptor() } func (MountingMode) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[3] + return &file_websocket_proto_enumTypes[3] } func (x MountingMode) Number() protoreflect.EnumNumber { @@ -386,7 +387,63 @@ func (x *MountingMode) UnmarshalJSON(b []byte) error { // Deprecated: Use MountingMode.Descriptor instead. func (MountingMode) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{3} + return file_websocket_proto_rawDescGZIP(), []int{3} +} + +type SoundtrackStorage int32 + +const ( + SoundtrackStorage_FACTORY SoundtrackStorage = 0 + SoundtrackStorage_USER SoundtrackStorage = 1 +) + +// Enum value maps for SoundtrackStorage. +var ( + SoundtrackStorage_name = map[int32]string{ + 0: "FACTORY", + 1: "USER", + } + SoundtrackStorage_value = map[string]int32{ + "FACTORY": 0, + "USER": 1, + } +) + +func (x SoundtrackStorage) Enum() *SoundtrackStorage { + p := new(SoundtrackStorage) + *p = x + return p +} + +func (x SoundtrackStorage) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SoundtrackStorage) Descriptor() protoreflect.EnumDescriptor { + return file_websocket_proto_enumTypes[4].Descriptor() +} + +func (SoundtrackStorage) Type() protoreflect.EnumType { + return &file_websocket_proto_enumTypes[4] +} + +func (x SoundtrackStorage) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Do not use. +func (x *SoundtrackStorage) UnmarshalJSON(b []byte) error { + num, err := protoimpl.X.UnmarshalJSONEnum(x.Descriptor(), b) + if err != nil { + return err + } + *x = SoundtrackStorage(num) + return nil +} + +// Deprecated: Use SoundtrackStorage.Descriptor instead. +func (SoundtrackStorage) EnumDescriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{4} } type Control_NightLight int32 @@ -419,11 +476,11 @@ func (x Control_NightLight) String() string { } func (Control_NightLight) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[4].Descriptor() + return file_websocket_proto_enumTypes[5].Descriptor() } func (Control_NightLight) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[4] + return &file_websocket_proto_enumTypes[5] } func (x Control_NightLight) Number() protoreflect.EnumNumber { @@ -442,7 +499,7 @@ func (x *Control_NightLight) UnmarshalJSON(b []byte) error { // Deprecated: Use Control_NightLight.Descriptor instead. func (Control_NightLight) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{3, 0} + return file_websocket_proto_rawDescGZIP(), []int{3, 0} } type Settings_AntiFlicker int32 @@ -475,11 +532,11 @@ func (x Settings_AntiFlicker) String() string { } func (Settings_AntiFlicker) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[5].Descriptor() + return file_websocket_proto_enumTypes[6].Descriptor() } func (Settings_AntiFlicker) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[5] + return &file_websocket_proto_enumTypes[6] } func (x Settings_AntiFlicker) Number() protoreflect.EnumNumber { @@ -498,7 +555,7 @@ func (x *Settings_AntiFlicker) UnmarshalJSON(b []byte) error { // Deprecated: Use Settings_AntiFlicker.Descriptor instead. func (Settings_AntiFlicker) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{4, 0} + return file_websocket_proto_rawDescGZIP(), []int{4, 0} } type Settings_WifiBand int32 @@ -534,11 +591,11 @@ func (x Settings_WifiBand) String() string { } func (Settings_WifiBand) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[6].Descriptor() + return file_websocket_proto_enumTypes[7].Descriptor() } func (Settings_WifiBand) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[6] + return &file_websocket_proto_enumTypes[7] } func (x Settings_WifiBand) Number() protoreflect.EnumNumber { @@ -557,7 +614,7 @@ func (x *Settings_WifiBand) UnmarshalJSON(b []byte) error { // Deprecated: Use Settings_WifiBand.Descriptor instead. func (Settings_WifiBand) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{4, 1} + return file_websocket_proto_rawDescGZIP(), []int{4, 1} } type Status_ConnectionToServer int32 @@ -590,11 +647,11 @@ func (x Status_ConnectionToServer) String() string { } func (Status_ConnectionToServer) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[7].Descriptor() + return file_websocket_proto_enumTypes[8].Descriptor() } func (Status_ConnectionToServer) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[7] + return &file_websocket_proto_enumTypes[8] } func (x Status_ConnectionToServer) Number() protoreflect.EnumNumber { @@ -613,7 +670,7 @@ func (x *Status_ConnectionToServer) UnmarshalJSON(b []byte) error { // Deprecated: Use Status_ConnectionToServer.Descriptor instead. func (Status_ConnectionToServer) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{5, 0} + return file_websocket_proto_rawDescGZIP(), []int{5, 0} } type Playback_Status int32 @@ -646,11 +703,11 @@ func (x Playback_Status) String() string { } func (Playback_Status) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[8].Descriptor() + return file_websocket_proto_enumTypes[9].Descriptor() } func (Playback_Status) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[8] + return &file_websocket_proto_enumTypes[9] } func (x Playback_Status) Number() protoreflect.EnumNumber { @@ -669,7 +726,7 @@ func (x *Playback_Status) UnmarshalJSON(b []byte) error { // Deprecated: Use Playback_Status.Descriptor instead. func (Playback_Status) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{6, 0} + return file_websocket_proto_rawDescGZIP(), []int{7, 0} } type Stream_Type int32 @@ -708,11 +765,11 @@ func (x Stream_Type) String() string { } func (Stream_Type) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[9].Descriptor() + return file_websocket_proto_enumTypes[10].Descriptor() } func (Stream_Type) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[9] + return &file_websocket_proto_enumTypes[10] } func (x Stream_Type) Number() protoreflect.EnumNumber { @@ -731,7 +788,7 @@ func (x *Stream_Type) UnmarshalJSON(b []byte) error { // Deprecated: Use Stream_Type.Descriptor instead. func (Stream_Type) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{7, 0} + return file_websocket_proto_rawDescGZIP(), []int{8, 0} } type Streaming_Status int32 @@ -767,11 +824,11 @@ func (x Streaming_Status) String() string { } func (Streaming_Status) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[10].Descriptor() + return file_websocket_proto_enumTypes[11].Descriptor() } func (Streaming_Status) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[10] + return &file_websocket_proto_enumTypes[11] } func (x Streaming_Status) Number() protoreflect.EnumNumber { @@ -790,7 +847,7 @@ func (x *Streaming_Status) UnmarshalJSON(b []byte) error { // Deprecated: Use Streaming_Status.Descriptor instead. func (Streaming_Status) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{8, 0} + return file_websocket_proto_rawDescGZIP(), []int{9, 0} } type Message_Type int32 @@ -826,11 +883,11 @@ func (x Message_Type) String() string { } func (Message_Type) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_client_websocket_proto_enumTypes[11].Descriptor() + return file_websocket_proto_enumTypes[12].Descriptor() } func (Message_Type) Type() protoreflect.EnumType { - return &file_pkg_client_websocket_proto_enumTypes[11] + return &file_websocket_proto_enumTypes[12] } func (x Message_Type) Number() protoreflect.EnumNumber { @@ -849,7 +906,7 @@ func (x *Message_Type) UnmarshalJSON(b []byte) error { // Deprecated: Use Message_Type.Descriptor instead. func (Message_Type) EnumDescriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{13, 0} + return file_websocket_proto_rawDescGZIP(), []int{14, 0} } type SensorData struct { @@ -865,7 +922,7 @@ type SensorData struct { func (x *SensorData) Reset() { *x = SensorData{} - mi := &file_pkg_client_websocket_proto_msgTypes[0] + mi := &file_websocket_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -877,7 +934,7 @@ func (x *SensorData) String() string { func (*SensorData) ProtoMessage() {} func (x *SensorData) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[0] + mi := &file_websocket_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -890,7 +947,7 @@ func (x *SensorData) ProtoReflect() protoreflect.Message { // Deprecated: Use SensorData.ProtoReflect.Descriptor instead. func (*SensorData) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{0} + return file_websocket_proto_rawDescGZIP(), []int{0} } func (x *SensorData) GetSensorType() SensorType { @@ -941,7 +998,7 @@ type GetSensorData struct { func (x *GetSensorData) Reset() { *x = GetSensorData{} - mi := &file_pkg_client_websocket_proto_msgTypes[1] + mi := &file_websocket_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -953,7 +1010,7 @@ func (x *GetSensorData) String() string { func (*GetSensorData) ProtoMessage() {} func (x *GetSensorData) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[1] + mi := &file_websocket_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -966,7 +1023,7 @@ func (x *GetSensorData) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSensorData.ProtoReflect.Descriptor instead. func (*GetSensorData) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{1} + return file_websocket_proto_rawDescGZIP(), []int{1} } func (x *GetSensorData) GetAll() bool { @@ -1016,7 +1073,7 @@ type GetControl struct { func (x *GetControl) Reset() { *x = GetControl{} - mi := &file_pkg_client_websocket_proto_msgTypes[2] + mi := &file_websocket_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1028,7 +1085,7 @@ func (x *GetControl) String() string { func (*GetControl) ProtoMessage() {} func (x *GetControl) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[2] + mi := &file_websocket_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1041,7 +1098,7 @@ func (x *GetControl) ProtoReflect() protoreflect.Message { // Deprecated: Use GetControl.ProtoReflect.Descriptor instead. func (*GetControl) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{2} + return file_websocket_proto_rawDescGZIP(), []int{2} } func (x *GetControl) GetPtz() bool { @@ -1084,7 +1141,7 @@ type Control struct { func (x *Control) Reset() { *x = Control{} - mi := &file_pkg_client_websocket_proto_msgTypes[3] + mi := &file_websocket_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1096,7 +1153,7 @@ func (x *Control) String() string { func (*Control) ProtoMessage() {} func (x *Control) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[3] + mi := &file_websocket_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1109,7 +1166,7 @@ func (x *Control) ProtoReflect() protoreflect.Message { // Deprecated: Use Control.ProtoReflect.Descriptor instead. func (*Control) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{3} + return file_websocket_proto_rawDescGZIP(), []int{3} } func (x *Control) GetForceConnectToServer() bool { @@ -1152,13 +1209,14 @@ type Settings struct { MountingMode *int32 `protobuf:"varint,15,opt,name=mountingMode" json:"mountingMode,omitempty"` WifiBand *Settings_WifiBand `protobuf:"varint,18,opt,name=wifiBand,enum=client.Settings_WifiBand" json:"wifiBand,omitempty"` MicMuteOn *bool `protobuf:"varint,20,opt,name=micMuteOn" json:"micMuteOn,omitempty"` + Brightness *int32 `protobuf:"varint,24,opt,name=brightness" json:"brightness,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Settings) Reset() { *x = Settings{} - mi := &file_pkg_client_websocket_proto_msgTypes[4] + mi := &file_websocket_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1170,7 +1228,7 @@ func (x *Settings) String() string { func (*Settings) ProtoMessage() {} func (x *Settings) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[4] + mi := &file_websocket_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1183,7 +1241,7 @@ func (x *Settings) ProtoReflect() protoreflect.Message { // Deprecated: Use Settings.ProtoReflect.Descriptor instead. func (*Settings) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{4} + return file_websocket_proto_rawDescGZIP(), []int{4} } func (x *Settings) GetNightVision() bool { @@ -1256,6 +1314,13 @@ func (x *Settings) GetMicMuteOn() bool { return false } +func (x *Settings) GetBrightness() int32 { + if x != nil && x.Brightness != nil { + return *x.Brightness + } + return 0 +} + type Status struct { state protoimpl.MessageState `protogen:"open.v1"` UpgradeDownloaded *bool `protobuf:"varint,1,opt,name=upgradeDownloaded" json:"upgradeDownloaded,omitempty"` @@ -1271,7 +1336,7 @@ type Status struct { func (x *Status) Reset() { *x = Status{} - mi := &file_pkg_client_websocket_proto_msgTypes[5] + mi := &file_websocket_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1283,7 +1348,7 @@ func (x *Status) String() string { func (*Status) ProtoMessage() {} func (x *Status) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[5] + mi := &file_websocket_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1296,7 +1361,7 @@ func (x *Status) ProtoReflect() protoreflect.Message { // Deprecated: Use Status.ProtoReflect.Descriptor instead. func (*Status) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{5} + return file_websocket_proto_rawDescGZIP(), []int{5} } func (x *Status) GetUpgradeDownloaded() bool { @@ -1348,16 +1413,70 @@ func (x *Status) GetHardwareVersion() string { return "" } +type Soundtrack struct { + state protoimpl.MessageState `protogen:"open.v1"` + Filename *string `protobuf:"bytes,1,opt,name=filename" json:"filename,omitempty"` + Storage *SoundtrackStorage `protobuf:"varint,2,opt,name=storage,enum=client.SoundtrackStorage" json:"storage,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Soundtrack) Reset() { + *x = Soundtrack{} + mi := &file_websocket_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Soundtrack) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Soundtrack) ProtoMessage() {} + +func (x *Soundtrack) ProtoReflect() protoreflect.Message { + mi := &file_websocket_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Soundtrack.ProtoReflect.Descriptor instead. +func (*Soundtrack) Descriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{6} +} + +func (x *Soundtrack) GetFilename() string { + if x != nil && x.Filename != nil { + return *x.Filename + } + return "" +} + +func (x *Soundtrack) GetStorage() SoundtrackStorage { + if x != nil && x.Storage != nil { + return *x.Storage + } + return SoundtrackStorage_FACTORY +} + type Playback struct { state protoimpl.MessageState `protogen:"open.v1"` Status *Playback_Status `protobuf:"varint,1,req,name=status,enum=client.Playback_Status" json:"status,omitempty"` + Duration *int32 `protobuf:"varint,2,opt,name=duration" json:"duration,omitempty"` + Soundtrack *Soundtrack `protobuf:"bytes,3,opt,name=soundtrack" json:"soundtrack,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Playback) Reset() { *x = Playback{} - mi := &file_pkg_client_websocket_proto_msgTypes[6] + mi := &file_websocket_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1369,7 +1488,7 @@ func (x *Playback) String() string { func (*Playback) ProtoMessage() {} func (x *Playback) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[6] + mi := &file_websocket_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1382,7 +1501,7 @@ func (x *Playback) ProtoReflect() protoreflect.Message { // Deprecated: Use Playback.ProtoReflect.Descriptor instead. func (*Playback) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{6} + return file_websocket_proto_rawDescGZIP(), []int{7} } func (x *Playback) GetStatus() Playback_Status { @@ -1392,6 +1511,20 @@ func (x *Playback) GetStatus() Playback_Status { return Playback_STARTED } +func (x *Playback) GetDuration() int32 { + if x != nil && x.Duration != nil { + return *x.Duration + } + return 0 +} + +func (x *Playback) GetSoundtrack() *Soundtrack { + if x != nil { + return x.Soundtrack + } + return nil +} + type Stream struct { state protoimpl.MessageState `protogen:"open.v1"` Type *Stream_Type `protobuf:"varint,1,req,name=type,enum=client.Stream_Type" json:"type,omitempty"` @@ -1403,7 +1536,7 @@ type Stream struct { func (x *Stream) Reset() { *x = Stream{} - mi := &file_pkg_client_websocket_proto_msgTypes[7] + mi := &file_websocket_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1415,7 +1548,7 @@ func (x *Stream) String() string { func (*Stream) ProtoMessage() {} func (x *Stream) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[7] + mi := &file_websocket_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1428,7 +1561,7 @@ func (x *Stream) ProtoReflect() protoreflect.Message { // Deprecated: Use Stream.ProtoReflect.Descriptor instead. func (*Stream) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{7} + return file_websocket_proto_rawDescGZIP(), []int{8} } func (x *Stream) GetType() Stream_Type { @@ -1464,7 +1597,7 @@ type Streaming struct { func (x *Streaming) Reset() { *x = Streaming{} - mi := &file_pkg_client_websocket_proto_msgTypes[8] + mi := &file_websocket_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1476,7 +1609,7 @@ func (x *Streaming) String() string { func (*Streaming) ProtoMessage() {} func (x *Streaming) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[8] + mi := &file_websocket_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1489,7 +1622,7 @@ func (x *Streaming) ProtoReflect() protoreflect.Message { // Deprecated: Use Streaming.ProtoReflect.Descriptor instead. func (*Streaming) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{8} + return file_websocket_proto_rawDescGZIP(), []int{9} } func (x *Streaming) GetId() StreamIdentifier { @@ -1529,7 +1662,7 @@ type GetLogs struct { func (x *GetLogs) Reset() { *x = GetLogs{} - mi := &file_pkg_client_websocket_proto_msgTypes[9] + mi := &file_websocket_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1541,7 +1674,7 @@ func (x *GetLogs) String() string { func (*GetLogs) ProtoMessage() {} func (x *GetLogs) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[9] + mi := &file_websocket_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1554,7 +1687,7 @@ func (x *GetLogs) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogs.ProtoReflect.Descriptor instead. func (*GetLogs) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{9} + return file_websocket_proto_rawDescGZIP(), []int{10} } func (x *GetLogs) GetUrl() string { @@ -1573,7 +1706,7 @@ type GetStatus struct { func (x *GetStatus) Reset() { *x = GetStatus{} - mi := &file_pkg_client_websocket_proto_msgTypes[10] + mi := &file_websocket_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1585,7 +1718,7 @@ func (x *GetStatus) String() string { func (*GetStatus) ProtoMessage() {} func (x *GetStatus) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[10] + mi := &file_websocket_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1598,7 +1731,7 @@ func (x *GetStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use GetStatus.ProtoReflect.Descriptor instead. func (*GetStatus) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{10} + return file_websocket_proto_rawDescGZIP(), []int{11} } func (x *GetStatus) GetAll() bool { @@ -1628,7 +1761,7 @@ type Request struct { func (x *Request) Reset() { *x = Request{} - mi := &file_pkg_client_websocket_proto_msgTypes[11] + mi := &file_websocket_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1640,7 +1773,7 @@ func (x *Request) String() string { func (*Request) ProtoMessage() {} func (x *Request) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[11] + mi := &file_websocket_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1653,7 +1786,7 @@ func (x *Request) ProtoReflect() protoreflect.Message { // Deprecated: Use Request.ProtoReflect.Descriptor instead. func (*Request) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{11} + return file_websocket_proto_rawDescGZIP(), []int{12} } func (x *Request) GetId() int32 { @@ -1756,7 +1889,7 @@ type Response struct { func (x *Response) Reset() { *x = Response{} - mi := &file_pkg_client_websocket_proto_msgTypes[12] + mi := &file_websocket_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1768,7 +1901,7 @@ func (x *Response) String() string { func (*Response) ProtoMessage() {} func (x *Response) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[12] + mi := &file_websocket_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1781,7 +1914,7 @@ func (x *Response) ProtoReflect() protoreflect.Message { // Deprecated: Use Response.ProtoReflect.Descriptor instead. func (*Response) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{12} + return file_websocket_proto_rawDescGZIP(), []int{13} } func (x *Response) GetRequestId() int32 { @@ -1851,7 +1984,7 @@ type Message struct { func (x *Message) Reset() { *x = Message{} - mi := &file_pkg_client_websocket_proto_msgTypes[13] + mi := &file_websocket_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1863,7 +1996,7 @@ func (x *Message) String() string { func (*Message) ProtoMessage() {} func (x *Message) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[13] + mi := &file_websocket_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1876,7 +2009,7 @@ func (x *Message) ProtoReflect() protoreflect.Message { // Deprecated: Use Message.ProtoReflect.Descriptor instead. func (*Message) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{13} + return file_websocket_proto_rawDescGZIP(), []int{14} } func (x *Message) GetType() Message_Type { @@ -1914,7 +2047,7 @@ type Control_SensorDataTransfer struct { func (x *Control_SensorDataTransfer) Reset() { *x = Control_SensorDataTransfer{} - mi := &file_pkg_client_websocket_proto_msgTypes[14] + mi := &file_websocket_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1926,7 +2059,7 @@ func (x *Control_SensorDataTransfer) String() string { func (*Control_SensorDataTransfer) ProtoMessage() {} func (x *Control_SensorDataTransfer) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[14] + mi := &file_websocket_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1939,7 +2072,7 @@ func (x *Control_SensorDataTransfer) ProtoReflect() protoreflect.Message { // Deprecated: Use Control_SensorDataTransfer.ProtoReflect.Descriptor instead. func (*Control_SensorDataTransfer) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{3, 0} + return file_websocket_proto_rawDescGZIP(), []int{3, 0} } func (x *Control_SensorDataTransfer) GetSound() bool { @@ -2000,7 +2133,7 @@ type Settings_SensorSettings struct { func (x *Settings_SensorSettings) Reset() { *x = Settings_SensorSettings{} - mi := &file_pkg_client_websocket_proto_msgTypes[15] + mi := &file_websocket_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2012,7 +2145,7 @@ func (x *Settings_SensorSettings) String() string { func (*Settings_SensorSettings) ProtoMessage() {} func (x *Settings_SensorSettings) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[15] + mi := &file_websocket_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2025,7 +2158,7 @@ func (x *Settings_SensorSettings) ProtoReflect() protoreflect.Message { // Deprecated: Use Settings_SensorSettings.ProtoReflect.Descriptor instead. func (*Settings_SensorSettings) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{4, 0} + return file_websocket_proto_rawDescGZIP(), []int{4, 0} } func (x *Settings_SensorSettings) GetSensorType() SensorType { @@ -2098,7 +2231,7 @@ type Settings_StreamSettings struct { func (x *Settings_StreamSettings) Reset() { *x = Settings_StreamSettings{} - mi := &file_pkg_client_websocket_proto_msgTypes[16] + mi := &file_websocket_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2110,7 +2243,7 @@ func (x *Settings_StreamSettings) String() string { func (*Settings_StreamSettings) ProtoMessage() {} func (x *Settings_StreamSettings) ProtoReflect() protoreflect.Message { - mi := &file_pkg_client_websocket_proto_msgTypes[16] + mi := &file_websocket_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2123,7 +2256,7 @@ func (x *Settings_StreamSettings) ProtoReflect() protoreflect.Message { // Deprecated: Use Settings_StreamSettings.ProtoReflect.Descriptor instead. func (*Settings_StreamSettings) Descriptor() ([]byte, []int) { - return file_pkg_client_websocket_proto_rawDescGZIP(), []int{4, 1} + return file_websocket_proto_rawDescGZIP(), []int{4, 1} } func (x *Settings_StreamSettings) GetId() StreamIdentifier { @@ -2168,447 +2301,368 @@ func (x *Settings_StreamSettings) GetBestFps() int32 { return 0 } -var File_pkg_client_websocket_proto protoreflect.FileDescriptor - -var file_pkg_client_websocket_proto_rawDesc = string([]byte{ - 0x0a, 0x1a, 0x70, 0x6b, 0x67, 0x2f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2f, 0x77, 0x65, 0x62, - 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, 0x63, 0x6c, - 0x69, 0x65, 0x6e, 0x74, 0x22, 0xae, 0x01, 0x0a, 0x0a, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, - 0x61, 0x74, 0x61, 0x12, 0x32, 0x0a, 0x0a, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x54, 0x79, 0x70, - 0x65, 0x18, 0x01, 0x20, 0x02, 0x28, 0x0e, 0x32, 0x12, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x2e, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x52, 0x0a, 0x73, 0x65, 0x6e, - 0x73, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x73, 0x41, 0x6c, 0x65, - 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x41, 0x6c, 0x65, 0x72, - 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, - 0x1e, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x8b, 0x01, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x53, 0x65, 0x6e, - 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x6c, 0x6c, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x61, 0x6c, 0x6c, 0x12, 0x20, 0x0a, 0x0b, 0x74, 0x65, 0x6d, - 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, - 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x68, - 0x75, 0x6d, 0x69, 0x64, 0x69, 0x74, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x68, - 0x75, 0x6d, 0x69, 0x64, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x67, 0x68, 0x74, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x12, 0x14, 0x0a, - 0x05, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6e, 0x69, - 0x67, 0x68, 0x74, 0x22, 0xa0, 0x01, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x74, 0x7a, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x03, 0x70, 0x74, 0x7a, 0x12, 0x1e, 0x0a, 0x0a, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x4c, 0x69, 0x67, - 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x4c, - 0x69, 0x67, 0x68, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x4c, 0x69, 0x67, - 0x68, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x11, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x4c, 0x69, 0x67, 0x68, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x6f, - 0x75, 0x74, 0x12, 0x32, 0x0a, 0x14, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, - 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x45, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x14, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x54, 0x72, 0x61, 0x6e, - 0x73, 0x66, 0x65, 0x72, 0x45, 0x6e, 0x22, 0xd5, 0x03, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, - 0x63, 0x74, 0x54, 0x6f, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x14, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x54, 0x6f, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x2c, 0x0a, 0x11, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x4c, - 0x69, 0x67, 0x68, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x11, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x4c, 0x69, 0x67, 0x68, 0x74, 0x54, 0x69, 0x6d, - 0x65, 0x6f, 0x75, 0x74, 0x12, 0x3a, 0x0a, 0x0a, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x4c, 0x69, 0x67, - 0x68, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x2e, 0x4e, 0x69, 0x67, 0x68, 0x74, 0x4c, - 0x69, 0x67, 0x68, 0x74, 0x52, 0x0a, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x4c, 0x69, 0x67, 0x68, 0x74, - 0x12, 0x52, 0x0a, 0x12, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x54, 0x72, - 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x63, - 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x2e, 0x53, 0x65, - 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, - 0x52, 0x12, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x54, 0x72, 0x61, 0x6e, - 0x73, 0x66, 0x65, 0x72, 0x1a, 0xac, 0x01, 0x0a, 0x12, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, - 0x61, 0x74, 0x61, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x73, - 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x73, 0x6f, 0x75, 0x6e, - 0x64, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x06, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x74, 0x65, 0x6d, - 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, - 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x68, - 0x75, 0x6d, 0x69, 0x64, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x68, - 0x75, 0x6d, 0x69, 0x64, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x67, 0x68, 0x74, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x12, 0x14, 0x0a, - 0x05, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6e, 0x69, - 0x67, 0x68, 0x74, 0x22, 0x29, 0x0a, 0x0a, 0x4e, 0x69, 0x67, 0x68, 0x74, 0x4c, 0x69, 0x67, 0x68, - 0x74, 0x12, 0x0d, 0x0a, 0x09, 0x4c, 0x49, 0x47, 0x48, 0x54, 0x5f, 0x4f, 0x46, 0x46, 0x10, 0x00, - 0x12, 0x0c, 0x0a, 0x08, 0x4c, 0x49, 0x47, 0x48, 0x54, 0x5f, 0x4f, 0x4e, 0x10, 0x01, 0x22, 0xe5, - 0x08, 0x0a, 0x08, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x6e, - 0x69, 0x67, 0x68, 0x74, 0x56, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0b, 0x6e, 0x69, 0x67, 0x68, 0x74, 0x56, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, - 0x07, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, - 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x2e, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, - 0x07, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x73, 0x12, 0x39, 0x0a, 0x07, 0x73, 0x74, 0x72, 0x65, - 0x61, 0x6d, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, - 0x61, 0x6d, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x07, 0x73, 0x74, 0x72, 0x65, - 0x61, 0x6d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x3e, 0x0a, 0x0b, 0x61, - 0x6e, 0x74, 0x69, 0x46, 0x6c, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x1c, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x2e, 0x41, 0x6e, 0x74, 0x69, 0x46, 0x6c, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x52, 0x0b, - 0x61, 0x6e, 0x74, 0x69, 0x46, 0x6c, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x73, - 0x6c, 0x65, 0x65, 0x70, 0x4d, 0x6f, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, - 0x73, 0x6c, 0x65, 0x65, 0x70, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x4c, 0x69, 0x67, 0x68, 0x74, 0x4f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0d, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x4c, 0x69, 0x67, 0x68, 0x74, 0x4f, 0x6e, 0x12, - 0x22, 0x0a, 0x0c, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x69, 0x6e, 0x67, 0x4d, 0x6f, 0x64, 0x65, 0x18, - 0x0f, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x69, 0x6e, 0x67, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x77, 0x69, 0x66, 0x69, 0x42, 0x61, 0x6e, 0x64, 0x18, - 0x12, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, - 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x57, 0x69, 0x66, 0x69, 0x42, 0x61, 0x6e, 0x64, - 0x52, 0x08, 0x77, 0x69, 0x66, 0x69, 0x42, 0x61, 0x6e, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x6d, 0x69, - 0x63, 0x4d, 0x75, 0x74, 0x65, 0x4f, 0x6e, 0x18, 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6d, - 0x69, 0x63, 0x4d, 0x75, 0x74, 0x65, 0x4f, 0x6e, 0x1a, 0xf8, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x6e, - 0x73, 0x6f, 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x32, 0x0a, 0x0a, 0x73, - 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x02, 0x28, 0x0e, 0x32, - 0x12, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x54, - 0x79, 0x70, 0x65, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, - 0x28, 0x0a, 0x0f, 0x75, 0x73, 0x65, 0x4c, 0x6f, 0x77, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, - 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x75, 0x73, 0x65, 0x4c, 0x6f, 0x77, - 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x75, 0x73, 0x65, - 0x48, 0x69, 0x67, 0x68, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x10, 0x75, 0x73, 0x65, 0x48, 0x69, 0x67, 0x68, 0x54, 0x68, 0x72, 0x65, - 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x22, 0x0a, 0x0c, 0x6c, 0x6f, 0x77, 0x54, 0x68, 0x72, 0x65, - 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x6c, 0x6f, 0x77, - 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x68, 0x69, 0x67, - 0x68, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, - 0x52, 0x0d, 0x68, 0x69, 0x67, 0x68, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, - 0x2c, 0x0a, 0x11, 0x73, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, - 0x6c, 0x53, 0x65, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x11, 0x73, 0x61, 0x6d, 0x70, - 0x6c, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x53, 0x65, 0x63, 0x12, 0x2e, 0x0a, - 0x12, 0x74, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, - 0x53, 0x65, 0x63, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x12, 0x74, 0x72, 0x69, 0x67, 0x67, - 0x65, 0x72, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x53, 0x65, 0x63, 0x12, 0x34, 0x0a, - 0x15, 0x75, 0x73, 0x65, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x46, 0x6f, 0x72, 0x54, 0x68, 0x72, 0x65, - 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x75, 0x73, - 0x65, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x46, 0x6f, 0x72, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, - 0x6c, 0x64, 0x73, 0x1a, 0xd8, 0x01, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x28, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x02, - 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x72, 0x65, - 0x61, 0x6d, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x02, 0x69, 0x64, - 0x12, 0x18, 0x0a, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x12, 0x26, 0x0a, 0x0e, 0x65, 0x63, - 0x6f, 0x6e, 0x6f, 0x6d, 0x79, 0x42, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x0e, 0x65, 0x63, 0x6f, 0x6e, 0x6f, 0x6d, 0x79, 0x42, 0x69, 0x74, 0x72, 0x61, - 0x74, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x65, 0x63, 0x6f, 0x6e, 0x6f, 0x6d, 0x79, 0x46, 0x70, 0x73, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x65, 0x63, 0x6f, 0x6e, 0x6f, 0x6d, 0x79, 0x46, - 0x70, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x62, 0x65, 0x73, 0x74, 0x42, 0x69, 0x74, 0x72, 0x61, 0x74, - 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x62, 0x65, 0x73, 0x74, 0x42, 0x69, 0x74, - 0x72, 0x61, 0x74, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x65, 0x73, 0x74, 0x46, 0x70, 0x73, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x62, 0x65, 0x73, 0x74, 0x46, 0x70, 0x73, 0x22, 0x25, - 0x0a, 0x0b, 0x41, 0x6e, 0x74, 0x69, 0x46, 0x6c, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x0a, 0x0a, - 0x06, 0x46, 0x52, 0x35, 0x30, 0x48, 0x5a, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x52, 0x36, - 0x30, 0x48, 0x5a, 0x10, 0x01, 0x22, 0x2f, 0x0a, 0x08, 0x57, 0x69, 0x66, 0x69, 0x42, 0x61, 0x6e, - 0x64, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4e, 0x59, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x46, 0x52, - 0x32, 0x5f, 0x34, 0x47, 0x48, 0x5a, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x46, 0x52, 0x35, 0x5f, - 0x30, 0x47, 0x48, 0x5a, 0x10, 0x02, 0x22, 0x98, 0x03, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x12, 0x2c, 0x0a, 0x11, 0x75, 0x70, 0x67, 0x72, 0x61, 0x64, 0x65, 0x44, 0x6f, 0x77, 0x6e, - 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x75, 0x70, - 0x67, 0x72, 0x61, 0x64, 0x65, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x12, - 0x51, 0x0a, 0x12, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x21, 0x2e, 0x63, 0x6c, - 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x12, - 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x53, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x75, 0x72, 0x72, - 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x04, 0x6d, 0x6f, - 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x14, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x2e, 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x69, 0x6e, 0x67, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x04, - 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x2c, 0x0a, 0x11, 0x69, 0x73, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, - 0x74, 0x79, 0x55, 0x70, 0x67, 0x72, 0x61, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x11, 0x69, 0x73, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x55, 0x70, 0x67, 0x72, 0x61, - 0x64, 0x65, 0x12, 0x2c, 0x0a, 0x11, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x64, - 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x12, 0x28, 0x0a, 0x0f, 0x68, 0x61, 0x72, 0x64, 0x77, 0x61, 0x72, 0x65, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x68, 0x61, 0x72, 0x64, 0x77, - 0x61, 0x72, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x35, 0x0a, 0x12, 0x43, 0x6f, - 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x12, 0x10, 0x0a, 0x0c, 0x44, 0x49, 0x53, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54, 0x45, 0x44, - 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54, 0x45, 0x44, 0x10, - 0x01, 0x22, 0x5f, 0x0a, 0x08, 0x50, 0x6c, 0x61, 0x79, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x2f, 0x0a, - 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x02, 0x28, 0x0e, 0x32, 0x17, 0x2e, - 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6c, 0x61, 0x79, 0x62, 0x61, 0x63, 0x6b, 0x2e, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x22, - 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x41, 0x52, - 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, - 0x10, 0x01, 0x22, 0x87, 0x01, 0x0a, 0x06, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x27, 0x0a, - 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x02, 0x28, 0x0e, 0x32, 0x13, 0x2e, 0x63, 0x6c, - 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x2e, 0x54, 0x79, 0x70, 0x65, - 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x62, 0x70, 0x73, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x62, 0x70, 0x73, 0x22, 0x30, 0x0a, 0x04, 0x54, 0x79, - 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4c, 0x4f, 0x43, 0x41, 0x4c, 0x10, 0x00, 0x12, 0x0a, 0x0a, - 0x06, 0x52, 0x45, 0x4d, 0x4f, 0x54, 0x45, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x52, 0x54, 0x53, - 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, 0x50, 0x32, 0x50, 0x10, 0x03, 0x22, 0xcd, 0x01, 0x0a, - 0x09, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x69, 0x6e, 0x67, 0x12, 0x28, 0x0a, 0x02, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x02, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, - 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, - 0x52, 0x02, 0x69, 0x64, 0x12, 0x30, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, - 0x20, 0x02, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, - 0x72, 0x65, 0x61, 0x6d, 0x69, 0x6e, 0x67, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, - 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x72, 0x74, 0x6d, 0x70, 0x55, 0x72, - 0x6c, 0x18, 0x03, 0x20, 0x02, 0x28, 0x09, 0x52, 0x07, 0x72, 0x74, 0x6d, 0x70, 0x55, 0x72, 0x6c, - 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x08, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x73, 0x22, 0x2e, 0x0a, 0x06, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x41, 0x52, 0x54, 0x45, - 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, 0x10, 0x01, - 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x41, 0x55, 0x53, 0x45, 0x44, 0x10, 0x02, 0x22, 0x1b, 0x0a, 0x07, - 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, - 0x20, 0x02, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x1d, 0x0a, 0x09, 0x47, 0x65, 0x74, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x6c, 0x6c, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x03, 0x61, 0x6c, 0x6c, 0x22, 0xa3, 0x04, 0x0a, 0x07, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x02, 0x28, 0x05, - 0x52, 0x02, 0x69, 0x64, 0x12, 0x27, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x02, - 0x28, 0x0e, 0x32, 0x13, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x3b, 0x0a, - 0x0d, 0x67, 0x65, 0x74, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, - 0x74, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x52, 0x0d, 0x67, 0x65, 0x74, - 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x12, 0x32, 0x0a, 0x0a, 0x73, 0x65, - 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, - 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, - 0x74, 0x61, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x12, 0x2f, - 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x69, 0x6e, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x11, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, - 0x6d, 0x69, 0x6e, 0x67, 0x52, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x69, 0x6e, 0x67, 0x12, - 0x29, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x0f, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, - 0x6c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x12, 0x32, 0x0a, 0x0a, 0x67, 0x65, - 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, - 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x52, 0x0a, 0x67, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x12, 0x2c, - 0x0a, 0x08, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x10, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x08, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x26, 0x0a, 0x06, - 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x63, - 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x2f, 0x0a, 0x09, 0x67, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x2e, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x09, 0x67, 0x65, 0x74, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2c, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x79, 0x62, 0x61, 0x63, - 0x6b, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x2e, 0x50, 0x6c, 0x61, 0x79, 0x62, 0x61, 0x63, 0x6b, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x79, 0x62, - 0x61, 0x63, 0x6b, 0x12, 0x29, 0x0a, 0x07, 0x67, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x73, 0x18, 0x12, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, - 0x74, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x07, 0x67, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x73, 0x22, 0xda, - 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x02, 0x28, 0x05, 0x52, 0x09, - 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x35, 0x0a, 0x0b, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x02, 0x28, 0x0e, 0x32, 0x13, - 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, - 0x79, 0x70, 0x65, 0x52, 0x0b, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, - 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x03, - 0x20, 0x02, 0x28, 0x05, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x43, 0x6f, 0x64, 0x65, - 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x26, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x32, - 0x0a, 0x0a, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x18, 0x09, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x73, - 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, - 0x74, 0x61, 0x12, 0x2c, 0x0a, 0x08, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x08, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x12, 0x29, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x18, 0x0d, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x0f, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x22, 0xbe, 0x01, 0x0a, 0x07, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x28, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, - 0x01, 0x20, 0x02, 0x28, 0x0e, 0x32, 0x14, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, - 0x65, 0x12, 0x29, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x52, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x08, - 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, - 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x52, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x30, 0x0a, 0x04, 0x54, 0x79, - 0x70, 0x65, 0x12, 0x0d, 0x0a, 0x09, 0x4b, 0x45, 0x45, 0x50, 0x41, 0x4c, 0x49, 0x56, 0x45, 0x10, - 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x0c, - 0x0a, 0x08, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x2a, 0xe0, 0x06, 0x0a, - 0x0b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x11, 0x0a, 0x0d, - 0x47, 0x45, 0x54, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, - 0x11, 0x0a, 0x0d, 0x50, 0x55, 0x54, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x49, 0x4e, 0x47, - 0x10, 0x02, 0x12, 0x10, 0x0a, 0x0c, 0x47, 0x45, 0x54, 0x5f, 0x53, 0x45, 0x54, 0x54, 0x49, 0x4e, - 0x47, 0x53, 0x10, 0x04, 0x12, 0x10, 0x0a, 0x0c, 0x50, 0x55, 0x54, 0x5f, 0x53, 0x45, 0x54, 0x54, - 0x49, 0x4e, 0x47, 0x53, 0x10, 0x05, 0x12, 0x0f, 0x0a, 0x0b, 0x47, 0x45, 0x54, 0x5f, 0x43, 0x4f, - 0x4e, 0x54, 0x52, 0x4f, 0x4c, 0x10, 0x06, 0x12, 0x0f, 0x0a, 0x0b, 0x50, 0x55, 0x54, 0x5f, 0x43, - 0x4f, 0x4e, 0x54, 0x52, 0x4f, 0x4c, 0x10, 0x07, 0x12, 0x0e, 0x0a, 0x0a, 0x47, 0x45, 0x54, 0x5f, - 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x10, 0x08, 0x12, 0x0e, 0x0a, 0x0a, 0x50, 0x55, 0x54, 0x5f, - 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x10, 0x09, 0x12, 0x13, 0x0a, 0x0f, 0x47, 0x45, 0x54, 0x5f, - 0x53, 0x45, 0x4e, 0x53, 0x4f, 0x52, 0x5f, 0x44, 0x41, 0x54, 0x41, 0x10, 0x0c, 0x12, 0x13, 0x0a, - 0x0f, 0x50, 0x55, 0x54, 0x5f, 0x53, 0x45, 0x4e, 0x53, 0x4f, 0x52, 0x5f, 0x44, 0x41, 0x54, 0x41, - 0x10, 0x0b, 0x12, 0x10, 0x0a, 0x0c, 0x47, 0x45, 0x54, 0x5f, 0x55, 0x43, 0x54, 0x4f, 0x4b, 0x45, - 0x4e, 0x53, 0x10, 0x0d, 0x12, 0x10, 0x0a, 0x0c, 0x50, 0x55, 0x54, 0x5f, 0x55, 0x43, 0x54, 0x4f, - 0x4b, 0x45, 0x4e, 0x53, 0x10, 0x0e, 0x12, 0x15, 0x0a, 0x11, 0x50, 0x55, 0x54, 0x5f, 0x53, 0x45, - 0x54, 0x55, 0x50, 0x5f, 0x4e, 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x10, 0x0f, 0x12, 0x14, 0x0a, - 0x10, 0x50, 0x55, 0x54, 0x5f, 0x53, 0x45, 0x54, 0x55, 0x50, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x45, - 0x52, 0x10, 0x10, 0x12, 0x10, 0x0a, 0x0c, 0x47, 0x45, 0x54, 0x5f, 0x46, 0x49, 0x52, 0x4d, 0x57, - 0x41, 0x52, 0x45, 0x10, 0x11, 0x12, 0x10, 0x0a, 0x0c, 0x50, 0x55, 0x54, 0x5f, 0x46, 0x49, 0x52, - 0x4d, 0x57, 0x41, 0x52, 0x45, 0x10, 0x12, 0x12, 0x10, 0x0a, 0x0c, 0x47, 0x45, 0x54, 0x5f, 0x50, - 0x4c, 0x41, 0x59, 0x42, 0x41, 0x43, 0x4b, 0x10, 0x13, 0x12, 0x10, 0x0a, 0x0c, 0x50, 0x55, 0x54, - 0x5f, 0x50, 0x4c, 0x41, 0x59, 0x42, 0x41, 0x43, 0x4b, 0x10, 0x14, 0x12, 0x13, 0x0a, 0x0f, 0x47, - 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x55, 0x4e, 0x44, 0x54, 0x52, 0x41, 0x43, 0x4b, 0x53, 0x10, 0x15, - 0x12, 0x16, 0x0a, 0x12, 0x47, 0x45, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x4e, - 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x10, 0x16, 0x12, 0x15, 0x0a, 0x11, 0x47, 0x45, 0x54, 0x5f, - 0x4c, 0x49, 0x53, 0x54, 0x5f, 0x4e, 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x53, 0x10, 0x17, 0x12, - 0x0c, 0x0a, 0x08, 0x47, 0x45, 0x54, 0x5f, 0x4c, 0x4f, 0x47, 0x53, 0x10, 0x18, 0x12, 0x11, 0x0a, - 0x0d, 0x47, 0x45, 0x54, 0x5f, 0x42, 0x41, 0x4e, 0x44, 0x57, 0x49, 0x44, 0x54, 0x48, 0x10, 0x19, - 0x12, 0x17, 0x0a, 0x13, 0x47, 0x45, 0x54, 0x5f, 0x41, 0x55, 0x44, 0x49, 0x4f, 0x5f, 0x53, 0x54, - 0x52, 0x45, 0x41, 0x4d, 0x49, 0x4e, 0x47, 0x10, 0x1a, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x55, 0x54, - 0x5f, 0x41, 0x55, 0x44, 0x49, 0x4f, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x49, 0x4e, 0x47, - 0x10, 0x1b, 0x12, 0x12, 0x0a, 0x0e, 0x47, 0x45, 0x54, 0x5f, 0x57, 0x49, 0x46, 0x49, 0x5f, 0x53, - 0x45, 0x54, 0x55, 0x50, 0x10, 0x1c, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x55, 0x54, 0x5f, 0x57, 0x49, - 0x46, 0x49, 0x5f, 0x53, 0x45, 0x54, 0x55, 0x50, 0x10, 0x1d, 0x12, 0x13, 0x0a, 0x0f, 0x50, 0x55, - 0x54, 0x5f, 0x53, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x1e, 0x12, - 0x12, 0x0a, 0x0e, 0x50, 0x55, 0x54, 0x5f, 0x53, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x53, 0x54, 0x4f, - 0x50, 0x10, 0x1f, 0x12, 0x14, 0x0a, 0x10, 0x50, 0x55, 0x54, 0x5f, 0x53, 0x54, 0x49, 0x4e, 0x47, - 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x10, 0x20, 0x12, 0x13, 0x0a, 0x0f, 0x50, 0x55, 0x54, - 0x5f, 0x53, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x41, 0x4c, 0x45, 0x52, 0x54, 0x10, 0x22, 0x12, 0x12, - 0x0a, 0x0e, 0x50, 0x55, 0x54, 0x5f, 0x4b, 0x45, 0x45, 0x50, 0x5f, 0x41, 0x4c, 0x49, 0x56, 0x45, - 0x10, 0x23, 0x12, 0x14, 0x0a, 0x10, 0x47, 0x45, 0x54, 0x5f, 0x53, 0x54, 0x49, 0x4e, 0x47, 0x5f, - 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x10, 0x24, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x55, 0x54, 0x5f, - 0x53, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x54, 0x45, 0x53, 0x54, 0x10, 0x25, 0x12, 0x16, 0x0a, 0x12, - 0x50, 0x55, 0x54, 0x5f, 0x52, 0x54, 0x53, 0x50, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x49, - 0x4e, 0x47, 0x10, 0x26, 0x12, 0x0f, 0x0a, 0x0b, 0x47, 0x45, 0x54, 0x5f, 0x55, 0x4f, 0x4d, 0x5f, - 0x55, 0x52, 0x49, 0x10, 0x27, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x45, 0x54, 0x5f, 0x55, 0x4f, 0x4d, - 0x10, 0x28, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x55, 0x54, 0x5f, 0x55, 0x4f, 0x4d, 0x10, 0x29, 0x12, - 0x10, 0x0a, 0x0c, 0x47, 0x45, 0x54, 0x5f, 0x41, 0x55, 0x54, 0x48, 0x5f, 0x4b, 0x45, 0x59, 0x10, - 0x2a, 0x12, 0x10, 0x0a, 0x0c, 0x50, 0x55, 0x54, 0x5f, 0x41, 0x55, 0x54, 0x48, 0x5f, 0x4b, 0x45, - 0x59, 0x10, 0x2b, 0x12, 0x0e, 0x0a, 0x0a, 0x50, 0x55, 0x54, 0x5f, 0x48, 0x45, 0x41, 0x4c, 0x54, - 0x48, 0x10, 0x2c, 0x12, 0x13, 0x0a, 0x0f, 0x50, 0x55, 0x54, 0x5f, 0x54, 0x43, 0x50, 0x5f, 0x52, - 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x2d, 0x12, 0x13, 0x0a, 0x0f, 0x47, 0x45, 0x54, 0x5f, - 0x53, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x2e, 0x12, 0x10, 0x0a, - 0x0c, 0x47, 0x45, 0x54, 0x5f, 0x4c, 0x4f, 0x47, 0x53, 0x5f, 0x55, 0x52, 0x49, 0x10, 0x2f, 0x2a, - 0x58, 0x0a, 0x0a, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, - 0x05, 0x53, 0x4f, 0x55, 0x4e, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x4d, 0x4f, 0x54, 0x49, - 0x4f, 0x4e, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x54, 0x45, 0x4d, 0x50, 0x45, 0x52, 0x41, 0x54, - 0x55, 0x52, 0x45, 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x48, 0x55, 0x4d, 0x49, 0x44, 0x49, 0x54, - 0x59, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x4c, 0x49, 0x47, 0x48, 0x54, 0x10, 0x04, 0x12, 0x09, - 0x0a, 0x05, 0x4e, 0x49, 0x47, 0x48, 0x54, 0x10, 0x05, 0x2a, 0x36, 0x0a, 0x10, 0x53, 0x74, 0x72, - 0x65, 0x61, 0x6d, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x07, 0x0a, - 0x03, 0x44, 0x56, 0x52, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x41, 0x4e, 0x41, 0x4c, 0x59, 0x54, - 0x49, 0x43, 0x53, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x4d, 0x4f, 0x42, 0x49, 0x4c, 0x45, 0x10, - 0x02, 0x2a, 0x31, 0x0a, 0x0c, 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x69, 0x6e, 0x67, 0x4d, 0x6f, 0x64, - 0x65, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x41, 0x4e, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, - 0x54, 0x52, 0x41, 0x56, 0x45, 0x4c, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x57, 0x49, 0x54, - 0x43, 0x48, 0x10, 0x02, 0x42, 0x35, 0x5a, 0x33, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x69, 0x6e, 0x64, 0x69, 0x65, 0x66, 0x61, 0x6e, 0x2f, 0x68, 0x6f, 0x6d, 0x65, - 0x5f, 0x61, 0x73, 0x73, 0x69, 0x73, 0x74, 0x61, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6e, 0x69, 0x74, - 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, -}) +var File_websocket_proto protoreflect.FileDescriptor + +const file_websocket_proto_rawDesc = "" + + "\n" + + "\x0fwebsocket.proto\x12\x06client\"\xae\x01\n" + + "\n" + + "SensorData\x122\n" + + "\n" + + "sensorType\x18\x01 \x02(\x0e2\x12.client.SensorTypeR\n" + + "sensorType\x12\x18\n" + + "\aisAlert\x18\x04 \x01(\bR\aisAlert\x12\x1c\n" + + "\ttimestamp\x18\x05 \x01(\x05R\ttimestamp\x12\x1e\n" + + "\n" + + "valueMilli\x18\x06 \x01(\x05R\n" + + "valueMilli\x12\x14\n" + + "\x05value\x18\x03 \x01(\x05R\x05value\"\x8b\x01\n" + + "\rGetSensorData\x12\x10\n" + + "\x03all\x18\x01 \x01(\bR\x03all\x12 \n" + + "\vtemperature\x18\x04 \x01(\bR\vtemperature\x12\x1a\n" + + "\bhumidity\x18\x05 \x01(\bR\bhumidity\x12\x14\n" + + "\x05light\x18\x06 \x01(\bR\x05light\x12\x14\n" + + "\x05night\x18\a \x01(\bR\x05night\"\xa0\x01\n" + + "\n" + + "GetControl\x12\x10\n" + + "\x03ptz\x18\x01 \x01(\bR\x03ptz\x12\x1e\n" + + "\n" + + "nightLight\x18\x02 \x01(\bR\n" + + "nightLight\x12,\n" + + "\x11nightLightTimeout\x18\x03 \x01(\bR\x11nightLightTimeout\x122\n" + + "\x14sensorDataTransferEn\x18\x04 \x01(\bR\x14sensorDataTransferEn\"\xd5\x03\n" + + "\aControl\x122\n" + + "\x14forceConnectToServer\x18\x05 \x01(\bR\x14forceConnectToServer\x12,\n" + + "\x11nightLightTimeout\x18\x06 \x01(\x05R\x11nightLightTimeout\x12:\n" + + "\n" + + "nightLight\x18\x03 \x01(\x0e2\x1a.client.Control.NightLightR\n" + + "nightLight\x12R\n" + + "\x12sensorDataTransfer\x18\x04 \x01(\v2\".client.Control.SensorDataTransferR\x12sensorDataTransfer\x1a\xac\x01\n" + + "\x12SensorDataTransfer\x12\x14\n" + + "\x05sound\x18\x01 \x01(\bR\x05sound\x12\x16\n" + + "\x06motion\x18\x02 \x01(\bR\x06motion\x12 \n" + + "\vtemperature\x18\x03 \x01(\bR\vtemperature\x12\x1a\n" + + "\bhumidity\x18\x04 \x01(\bR\bhumidity\x12\x14\n" + + "\x05light\x18\x05 \x01(\bR\x05light\x12\x14\n" + + "\x05night\x18\x06 \x01(\bR\x05night\")\n" + + "\n" + + "NightLight\x12\r\n" + + "\tLIGHT_OFF\x10\x00\x12\f\n" + + "\bLIGHT_ON\x10\x01\"\x85\t\n" + + "\bSettings\x12 \n" + + "\vnightVision\x18\x02 \x01(\bR\vnightVision\x129\n" + + "\asensors\x18\a \x03(\v2\x1f.client.Settings.SensorSettingsR\asensors\x129\n" + + "\astreams\x18\b \x03(\v2\x1f.client.Settings.StreamSettingsR\astreams\x12\x16\n" + + "\x06volume\x18\t \x01(\x05R\x06volume\x12>\n" + + "\vantiFlicker\x18\n" + + " \x01(\x0e2\x1c.client.Settings.AntiFlickerR\vantiFlicker\x12\x1c\n" + + "\tsleepMode\x18\v \x01(\bR\tsleepMode\x12$\n" + + "\rstatusLightOn\x18\f \x01(\bR\rstatusLightOn\x12\"\n" + + "\fmountingMode\x18\x0f \x01(\x05R\fmountingMode\x125\n" + + "\bwifiBand\x18\x12 \x01(\x0e2\x19.client.Settings.WifiBandR\bwifiBand\x12\x1c\n" + + "\tmicMuteOn\x18\x14 \x01(\bR\tmicMuteOn\x12\x1e\n" + + "\n" + + "brightness\x18\x18 \x01(\x05R\n" + + "brightness\x1a\xf8\x02\n" + + "\x0eSensorSettings\x122\n" + + "\n" + + "sensorType\x18\x01 \x02(\x0e2\x12.client.SensorTypeR\n" + + "sensorType\x12(\n" + + "\x0fuseLowThreshold\x18\x02 \x01(\bR\x0fuseLowThreshold\x12*\n" + + "\x10useHighThreshold\x18\x03 \x01(\bR\x10useHighThreshold\x12\"\n" + + "\flowThreshold\x18\x04 \x01(\x05R\flowThreshold\x12$\n" + + "\rhighThreshold\x18\x05 \x01(\x05R\rhighThreshold\x12,\n" + + "\x11sampleIntervalSec\x18\x06 \x01(\x05R\x11sampleIntervalSec\x12.\n" + + "\x12triggerIntervalSec\x18\a \x01(\x05R\x12triggerIntervalSec\x124\n" + + "\x15useMilliForThresholds\x18\b \x01(\bR\x15useMilliForThresholds\x1a\xd8\x01\n" + + "\x0eStreamSettings\x12(\n" + + "\x02id\x18\x01 \x02(\x0e2\x18.client.StreamIdentifierR\x02id\x12\x18\n" + + "\abitrate\x18\x02 \x01(\x05R\abitrate\x12&\n" + + "\x0eeconomyBitrate\x18\x03 \x01(\x05R\x0eeconomyBitrate\x12\x1e\n" + + "\n" + + "economyFps\x18\x04 \x01(\x05R\n" + + "economyFps\x12 \n" + + "\vbestBitrate\x18\x05 \x01(\x05R\vbestBitrate\x12\x18\n" + + "\abestFps\x18\x06 \x01(\x05R\abestFps\"%\n" + + "\vAntiFlicker\x12\n" + + "\n" + + "\x06FR50HZ\x10\x00\x12\n" + + "\n" + + "\x06FR60HZ\x10\x01\"/\n" + + "\bWifiBand\x12\a\n" + + "\x03ANY\x10\x00\x12\f\n" + + "\bFR2_4GHZ\x10\x01\x12\f\n" + + "\bFR5_0GHZ\x10\x02\"\x98\x03\n" + + "\x06Status\x12,\n" + + "\x11upgradeDownloaded\x18\x01 \x01(\bR\x11upgradeDownloaded\x12Q\n" + + "\x12connectionToServer\x18\x02 \x01(\x0e2!.client.Status.ConnectionToServerR\x12connectionToServer\x12&\n" + + "\x0ecurrentVersion\x18\x03 \x01(\tR\x0ecurrentVersion\x12(\n" + + "\x04mode\x18\x04 \x01(\x0e2\x14.client.MountingModeR\x04mode\x12,\n" + + "\x11isSecurityUpgrade\x18\x05 \x01(\bR\x11isSecurityUpgrade\x12,\n" + + "\x11downloadedVersion\x18\x06 \x01(\tR\x11downloadedVersion\x12(\n" + + "\x0fhardwareVersion\x18\a \x01(\tR\x0fhardwareVersion\"5\n" + + "\x12ConnectionToServer\x12\x10\n" + + "\fDISCONNECTED\x10\x00\x12\r\n" + + "\tCONNECTED\x10\x01\"]\n" + + "\n" + + "Soundtrack\x12\x1a\n" + + "\bfilename\x18\x01 \x01(\tR\bfilename\x123\n" + + "\astorage\x18\x02 \x01(\x0e2\x19.client.SoundtrackStorageR\astorage\"\xaf\x01\n" + + "\bPlayback\x12/\n" + + "\x06status\x18\x01 \x02(\x0e2\x17.client.Playback.StatusR\x06status\x12\x1a\n" + + "\bduration\x18\x02 \x01(\x05R\bduration\x122\n" + + "\n" + + "soundtrack\x18\x03 \x01(\v2\x12.client.SoundtrackR\n" + + "soundtrack\"\"\n" + + "\x06Status\x12\v\n" + + "\aSTARTED\x10\x00\x12\v\n" + + "\aSTOPPED\x10\x01\"\x87\x01\n" + + "\x06Stream\x12'\n" + + "\x04type\x18\x01 \x02(\x0e2\x13.client.Stream.TypeR\x04type\x12\x10\n" + + "\x03url\x18\x02 \x01(\tR\x03url\x12\x10\n" + + "\x03bps\x18\x03 \x01(\x05R\x03bps\"0\n" + + "\x04Type\x12\t\n" + + "\x05LOCAL\x10\x00\x12\n" + + "\n" + + "\x06REMOTE\x10\x01\x12\b\n" + + "\x04RTSP\x10\x02\x12\a\n" + + "\x03P2P\x10\x03\"\xcd\x01\n" + + "\tStreaming\x12(\n" + + "\x02id\x18\x01 \x02(\x0e2\x18.client.StreamIdentifierR\x02id\x120\n" + + "\x06status\x18\x02 \x02(\x0e2\x18.client.Streaming.StatusR\x06status\x12\x18\n" + + "\artmpUrl\x18\x03 \x02(\tR\artmpUrl\x12\x1a\n" + + "\battempts\x18\x04 \x01(\x05R\battempts\".\n" + + "\x06Status\x12\v\n" + + "\aSTARTED\x10\x00\x12\v\n" + + "\aSTOPPED\x10\x01\x12\n" + + "\n" + + "\x06PAUSED\x10\x02\"\x1b\n" + + "\aGetLogs\x12\x10\n" + + "\x03url\x18\x01 \x02(\tR\x03url\"\x1d\n" + + "\tGetStatus\x12\x10\n" + + "\x03all\x18\x01 \x01(\bR\x03all\"\xa3\x04\n" + + "\aRequest\x12\x0e\n" + + "\x02id\x18\x01 \x02(\x05R\x02id\x12'\n" + + "\x04type\x18\x02 \x02(\x0e2\x13.client.RequestTypeR\x04type\x12;\n" + + "\rgetSensorData\x18\f \x01(\v2\x15.client.GetSensorDataR\rgetSensorData\x122\n" + + "\n" + + "sensorData\x18\r \x03(\v2\x12.client.SensorDataR\n" + + "sensorData\x12/\n" + + "\tstreaming\x18\x04 \x01(\v2\x11.client.StreamingR\tstreaming\x12)\n" + + "\acontrol\x18\x0f \x01(\v2\x0f.client.ControlR\acontrol\x122\n" + + "\n" + + "getControl\x18\x11 \x01(\v2\x12.client.GetControlR\n" + + "getControl\x12,\n" + + "\bsettings\x18\x05 \x01(\v2\x10.client.SettingsR\bsettings\x12&\n" + + "\x06status\x18\a \x01(\v2\x0e.client.StatusR\x06status\x12/\n" + + "\tgetStatus\x18\b \x01(\v2\x11.client.GetStatusR\tgetStatus\x12,\n" + + "\bplayback\x18\x10 \x01(\v2\x10.client.PlaybackR\bplayback\x12)\n" + + "\agetLogs\x18\x12 \x01(\v2\x0f.client.GetLogsR\agetLogs\"\xda\x02\n" + + "\bResponse\x12\x1c\n" + + "\trequestId\x18\x01 \x02(\x05R\trequestId\x125\n" + + "\vrequestType\x18\x02 \x02(\x0e2\x13.client.RequestTypeR\vrequestType\x12\x1e\n" + + "\n" + + "statusCode\x18\x03 \x02(\x05R\n" + + "statusCode\x12$\n" + + "\rstatusMessage\x18\x04 \x01(\tR\rstatusMessage\x12&\n" + + "\x06status\x18\x05 \x01(\v2\x0e.client.StatusR\x06status\x122\n" + + "\n" + + "sensorData\x18\t \x03(\v2\x12.client.SensorDataR\n" + + "sensorData\x12,\n" + + "\bsettings\x18\x06 \x01(\v2\x10.client.SettingsR\bsettings\x12)\n" + + "\acontrol\x18\r \x01(\v2\x0f.client.ControlR\acontrol\"\xbe\x01\n" + + "\aMessage\x12(\n" + + "\x04type\x18\x01 \x02(\x0e2\x14.client.Message.TypeR\x04type\x12)\n" + + "\arequest\x18\x02 \x01(\v2\x0f.client.RequestR\arequest\x12,\n" + + "\bresponse\x18\x03 \x01(\v2\x10.client.ResponseR\bresponse\"0\n" + + "\x04Type\x12\r\n" + + "\tKEEPALIVE\x10\x00\x12\v\n" + + "\aREQUEST\x10\x01\x12\f\n" + + "\bRESPONSE\x10\x02*\xe0\x06\n" + + "\vRequestType\x12\x11\n" + + "\rGET_STREAMING\x10\x03\x12\x11\n" + + "\rPUT_STREAMING\x10\x02\x12\x10\n" + + "\fGET_SETTINGS\x10\x04\x12\x10\n" + + "\fPUT_SETTINGS\x10\x05\x12\x0f\n" + + "\vGET_CONTROL\x10\x06\x12\x0f\n" + + "\vPUT_CONTROL\x10\a\x12\x0e\n" + + "\n" + + "GET_STATUS\x10\b\x12\x0e\n" + + "\n" + + "PUT_STATUS\x10\t\x12\x13\n" + + "\x0fGET_SENSOR_DATA\x10\f\x12\x13\n" + + "\x0fPUT_SENSOR_DATA\x10\v\x12\x10\n" + + "\fGET_UCTOKENS\x10\r\x12\x10\n" + + "\fPUT_UCTOKENS\x10\x0e\x12\x15\n" + + "\x11PUT_SETUP_NETWORK\x10\x0f\x12\x14\n" + + "\x10PUT_SETUP_SERVER\x10\x10\x12\x10\n" + + "\fGET_FIRMWARE\x10\x11\x12\x10\n" + + "\fPUT_FIRMWARE\x10\x12\x12\x10\n" + + "\fGET_PLAYBACK\x10\x13\x12\x10\n" + + "\fPUT_PLAYBACK\x10\x14\x12\x13\n" + + "\x0fGET_SOUNDTRACKS\x10\x15\x12\x16\n" + + "\x12GET_STATUS_NETWORK\x10\x16\x12\x15\n" + + "\x11GET_LIST_NETWORKS\x10\x17\x12\f\n" + + "\bGET_LOGS\x10\x18\x12\x11\n" + + "\rGET_BANDWIDTH\x10\x19\x12\x17\n" + + "\x13GET_AUDIO_STREAMING\x10\x1a\x12\x17\n" + + "\x13PUT_AUDIO_STREAMING\x10\x1b\x12\x12\n" + + "\x0eGET_WIFI_SETUP\x10\x1c\x12\x12\n" + + "\x0ePUT_WIFI_SETUP\x10\x1d\x12\x13\n" + + "\x0fPUT_STING_START\x10\x1e\x12\x12\n" + + "\x0ePUT_STING_STOP\x10\x1f\x12\x14\n" + + "\x10PUT_STING_STATUS\x10 \x12\x13\n" + + "\x0fPUT_STING_ALERT\x10\"\x12\x12\n" + + "\x0ePUT_KEEP_ALIVE\x10#\x12\x14\n" + + "\x10GET_STING_STATUS\x10$\x12\x12\n" + + "\x0ePUT_STING_TEST\x10%\x12\x16\n" + + "\x12PUT_RTSP_STREAMING\x10&\x12\x0f\n" + + "\vGET_UOM_URI\x10'\x12\v\n" + + "\aGET_UOM\x10(\x12\v\n" + + "\aPUT_UOM\x10)\x12\x10\n" + + "\fGET_AUTH_KEY\x10*\x12\x10\n" + + "\fPUT_AUTH_KEY\x10+\x12\x0e\n" + + "\n" + + "PUT_HEALTH\x10,\x12\x13\n" + + "\x0fPUT_TCP_REQUEST\x10-\x12\x13\n" + + "\x0fGET_STING_START\x10.\x12\x10\n" + + "\fGET_LOGS_URI\x10/*X\n" + + "\n" + + "SensorType\x12\t\n" + + "\x05SOUND\x10\x00\x12\n" + + "\n" + + "\x06MOTION\x10\x01\x12\x0f\n" + + "\vTEMPERATURE\x10\x02\x12\f\n" + + "\bHUMIDITY\x10\x03\x12\t\n" + + "\x05LIGHT\x10\x04\x12\t\n" + + "\x05NIGHT\x10\x05*6\n" + + "\x10StreamIdentifier\x12\a\n" + + "\x03DVR\x10\x00\x12\r\n" + + "\tANALYTICS\x10\x01\x12\n" + + "\n" + + "\x06MOBILE\x10\x02*1\n" + + "\fMountingMode\x12\t\n" + + "\x05STAND\x10\x00\x12\n" + + "\n" + + "\x06TRAVEL\x10\x01\x12\n" + + "\n" + + "\x06SWITCH\x10\x02**\n" + + "\x11SoundtrackStorage\x12\v\n" + + "\aFACTORY\x10\x00\x12\b\n" + + "\x04USER\x10\x01B5Z3github.com/indiefan/home_assistant_nanit/pkg/client" var ( - file_pkg_client_websocket_proto_rawDescOnce sync.Once - file_pkg_client_websocket_proto_rawDescData []byte + file_websocket_proto_rawDescOnce sync.Once + file_websocket_proto_rawDescData []byte ) -func file_pkg_client_websocket_proto_rawDescGZIP() []byte { - file_pkg_client_websocket_proto_rawDescOnce.Do(func() { - file_pkg_client_websocket_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pkg_client_websocket_proto_rawDesc), len(file_pkg_client_websocket_proto_rawDesc))) +func file_websocket_proto_rawDescGZIP() []byte { + file_websocket_proto_rawDescOnce.Do(func() { + file_websocket_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_websocket_proto_rawDesc), len(file_websocket_proto_rawDesc))) }) - return file_pkg_client_websocket_proto_rawDescData + return file_websocket_proto_rawDescData } -var file_pkg_client_websocket_proto_enumTypes = make([]protoimpl.EnumInfo, 12) -var file_pkg_client_websocket_proto_msgTypes = make([]protoimpl.MessageInfo, 17) -var file_pkg_client_websocket_proto_goTypes = []any{ +var file_websocket_proto_enumTypes = make([]protoimpl.EnumInfo, 13) +var file_websocket_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_websocket_proto_goTypes = []any{ (RequestType)(0), // 0: client.RequestType (SensorType)(0), // 1: client.SensorType (StreamIdentifier)(0), // 2: client.StreamIdentifier (MountingMode)(0), // 3: client.MountingMode - (Control_NightLight)(0), // 4: client.Control.NightLight - (Settings_AntiFlicker)(0), // 5: client.Settings.AntiFlicker - (Settings_WifiBand)(0), // 6: client.Settings.WifiBand - (Status_ConnectionToServer)(0), // 7: client.Status.ConnectionToServer - (Playback_Status)(0), // 8: client.Playback.Status - (Stream_Type)(0), // 9: client.Stream.Type - (Streaming_Status)(0), // 10: client.Streaming.Status - (Message_Type)(0), // 11: client.Message.Type - (*SensorData)(nil), // 12: client.SensorData - (*GetSensorData)(nil), // 13: client.GetSensorData - (*GetControl)(nil), // 14: client.GetControl - (*Control)(nil), // 15: client.Control - (*Settings)(nil), // 16: client.Settings - (*Status)(nil), // 17: client.Status - (*Playback)(nil), // 18: client.Playback - (*Stream)(nil), // 19: client.Stream - (*Streaming)(nil), // 20: client.Streaming - (*GetLogs)(nil), // 21: client.GetLogs - (*GetStatus)(nil), // 22: client.GetStatus - (*Request)(nil), // 23: client.Request - (*Response)(nil), // 24: client.Response - (*Message)(nil), // 25: client.Message - (*Control_SensorDataTransfer)(nil), // 26: client.Control.SensorDataTransfer - (*Settings_SensorSettings)(nil), // 27: client.Settings.SensorSettings - (*Settings_StreamSettings)(nil), // 28: client.Settings.StreamSettings -} -var file_pkg_client_websocket_proto_depIdxs = []int32{ + (SoundtrackStorage)(0), // 4: client.SoundtrackStorage + (Control_NightLight)(0), // 5: client.Control.NightLight + (Settings_AntiFlicker)(0), // 6: client.Settings.AntiFlicker + (Settings_WifiBand)(0), // 7: client.Settings.WifiBand + (Status_ConnectionToServer)(0), // 8: client.Status.ConnectionToServer + (Playback_Status)(0), // 9: client.Playback.Status + (Stream_Type)(0), // 10: client.Stream.Type + (Streaming_Status)(0), // 11: client.Streaming.Status + (Message_Type)(0), // 12: client.Message.Type + (*SensorData)(nil), // 13: client.SensorData + (*GetSensorData)(nil), // 14: client.GetSensorData + (*GetControl)(nil), // 15: client.GetControl + (*Control)(nil), // 16: client.Control + (*Settings)(nil), // 17: client.Settings + (*Status)(nil), // 18: client.Status + (*Soundtrack)(nil), // 19: client.Soundtrack + (*Playback)(nil), // 20: client.Playback + (*Stream)(nil), // 21: client.Stream + (*Streaming)(nil), // 22: client.Streaming + (*GetLogs)(nil), // 23: client.GetLogs + (*GetStatus)(nil), // 24: client.GetStatus + (*Request)(nil), // 25: client.Request + (*Response)(nil), // 26: client.Response + (*Message)(nil), // 27: client.Message + (*Control_SensorDataTransfer)(nil), // 28: client.Control.SensorDataTransfer + (*Settings_SensorSettings)(nil), // 29: client.Settings.SensorSettings + (*Settings_StreamSettings)(nil), // 30: client.Settings.StreamSettings +} +var file_websocket_proto_depIdxs = []int32{ 1, // 0: client.SensorData.sensorType:type_name -> client.SensorType - 4, // 1: client.Control.nightLight:type_name -> client.Control.NightLight - 26, // 2: client.Control.sensorDataTransfer:type_name -> client.Control.SensorDataTransfer - 27, // 3: client.Settings.sensors:type_name -> client.Settings.SensorSettings - 28, // 4: client.Settings.streams:type_name -> client.Settings.StreamSettings - 5, // 5: client.Settings.antiFlicker:type_name -> client.Settings.AntiFlicker - 6, // 6: client.Settings.wifiBand:type_name -> client.Settings.WifiBand - 7, // 7: client.Status.connectionToServer:type_name -> client.Status.ConnectionToServer + 5, // 1: client.Control.nightLight:type_name -> client.Control.NightLight + 28, // 2: client.Control.sensorDataTransfer:type_name -> client.Control.SensorDataTransfer + 29, // 3: client.Settings.sensors:type_name -> client.Settings.SensorSettings + 30, // 4: client.Settings.streams:type_name -> client.Settings.StreamSettings + 6, // 5: client.Settings.antiFlicker:type_name -> client.Settings.AntiFlicker + 7, // 6: client.Settings.wifiBand:type_name -> client.Settings.WifiBand + 8, // 7: client.Status.connectionToServer:type_name -> client.Status.ConnectionToServer 3, // 8: client.Status.mode:type_name -> client.MountingMode - 8, // 9: client.Playback.status:type_name -> client.Playback.Status - 9, // 10: client.Stream.type:type_name -> client.Stream.Type - 2, // 11: client.Streaming.id:type_name -> client.StreamIdentifier - 10, // 12: client.Streaming.status:type_name -> client.Streaming.Status - 0, // 13: client.Request.type:type_name -> client.RequestType - 13, // 14: client.Request.getSensorData:type_name -> client.GetSensorData - 12, // 15: client.Request.sensorData:type_name -> client.SensorData - 20, // 16: client.Request.streaming:type_name -> client.Streaming - 15, // 17: client.Request.control:type_name -> client.Control - 14, // 18: client.Request.getControl:type_name -> client.GetControl - 16, // 19: client.Request.settings:type_name -> client.Settings - 17, // 20: client.Request.status:type_name -> client.Status - 22, // 21: client.Request.getStatus:type_name -> client.GetStatus - 18, // 22: client.Request.playback:type_name -> client.Playback - 21, // 23: client.Request.getLogs:type_name -> client.GetLogs - 0, // 24: client.Response.requestType:type_name -> client.RequestType - 17, // 25: client.Response.status:type_name -> client.Status - 12, // 26: client.Response.sensorData:type_name -> client.SensorData - 16, // 27: client.Response.settings:type_name -> client.Settings - 15, // 28: client.Response.control:type_name -> client.Control - 11, // 29: client.Message.type:type_name -> client.Message.Type - 23, // 30: client.Message.request:type_name -> client.Request - 24, // 31: client.Message.response:type_name -> client.Response - 1, // 32: client.Settings.SensorSettings.sensorType:type_name -> client.SensorType - 2, // 33: client.Settings.StreamSettings.id:type_name -> client.StreamIdentifier - 34, // [34:34] is the sub-list for method output_type - 34, // [34:34] is the sub-list for method input_type - 34, // [34:34] is the sub-list for extension type_name - 34, // [34:34] is the sub-list for extension extendee - 0, // [0:34] is the sub-list for field type_name -} - -func init() { file_pkg_client_websocket_proto_init() } -func file_pkg_client_websocket_proto_init() { - if File_pkg_client_websocket_proto != nil { + 4, // 9: client.Soundtrack.storage:type_name -> client.SoundtrackStorage + 9, // 10: client.Playback.status:type_name -> client.Playback.Status + 19, // 11: client.Playback.soundtrack:type_name -> client.Soundtrack + 10, // 12: client.Stream.type:type_name -> client.Stream.Type + 2, // 13: client.Streaming.id:type_name -> client.StreamIdentifier + 11, // 14: client.Streaming.status:type_name -> client.Streaming.Status + 0, // 15: client.Request.type:type_name -> client.RequestType + 14, // 16: client.Request.getSensorData:type_name -> client.GetSensorData + 13, // 17: client.Request.sensorData:type_name -> client.SensorData + 22, // 18: client.Request.streaming:type_name -> client.Streaming + 16, // 19: client.Request.control:type_name -> client.Control + 15, // 20: client.Request.getControl:type_name -> client.GetControl + 17, // 21: client.Request.settings:type_name -> client.Settings + 18, // 22: client.Request.status:type_name -> client.Status + 24, // 23: client.Request.getStatus:type_name -> client.GetStatus + 20, // 24: client.Request.playback:type_name -> client.Playback + 23, // 25: client.Request.getLogs:type_name -> client.GetLogs + 0, // 26: client.Response.requestType:type_name -> client.RequestType + 18, // 27: client.Response.status:type_name -> client.Status + 13, // 28: client.Response.sensorData:type_name -> client.SensorData + 17, // 29: client.Response.settings:type_name -> client.Settings + 16, // 30: client.Response.control:type_name -> client.Control + 12, // 31: client.Message.type:type_name -> client.Message.Type + 25, // 32: client.Message.request:type_name -> client.Request + 26, // 33: client.Message.response:type_name -> client.Response + 1, // 34: client.Settings.SensorSettings.sensorType:type_name -> client.SensorType + 2, // 35: client.Settings.StreamSettings.id:type_name -> client.StreamIdentifier + 36, // [36:36] is the sub-list for method output_type + 36, // [36:36] is the sub-list for method input_type + 36, // [36:36] is the sub-list for extension type_name + 36, // [36:36] is the sub-list for extension extendee + 0, // [0:36] is the sub-list for field type_name +} + +func init() { file_websocket_proto_init() } +func file_websocket_proto_init() { + if File_websocket_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pkg_client_websocket_proto_rawDesc), len(file_pkg_client_websocket_proto_rawDesc)), - NumEnums: 12, - NumMessages: 17, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_websocket_proto_rawDesc), len(file_websocket_proto_rawDesc)), + NumEnums: 13, + NumMessages: 18, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pkg_client_websocket_proto_goTypes, - DependencyIndexes: file_pkg_client_websocket_proto_depIdxs, - EnumInfos: file_pkg_client_websocket_proto_enumTypes, - MessageInfos: file_pkg_client_websocket_proto_msgTypes, + GoTypes: file_websocket_proto_goTypes, + DependencyIndexes: file_websocket_proto_depIdxs, + EnumInfos: file_websocket_proto_enumTypes, + MessageInfos: file_websocket_proto_msgTypes, }.Build() - File_pkg_client_websocket_proto = out.File - file_pkg_client_websocket_proto_goTypes = nil - file_pkg_client_websocket_proto_depIdxs = nil + File_websocket_proto = out.File + file_websocket_proto_goTypes = nil + file_websocket_proto_depIdxs = nil } diff --git a/pkg/client/websocket.proto b/pkg/client/websocket.proto index 5796c24..7d95fa2 100644 --- a/pkg/client/websocket.proto +++ b/pkg/client/websocket.proto @@ -169,6 +169,7 @@ message Settings { optional WifiBand wifiBand = 18; optional bool micMuteOn = 20; + optional int32 brightness = 24; } message Status { @@ -187,6 +188,16 @@ message Status { optional string hardwareVersion = 7; } +enum SoundtrackStorage { + FACTORY = 0; + USER = 1; +} + +message Soundtrack { + optional string filename = 1; + optional SoundtrackStorage storage = 2; +} + message Playback { enum Status { STARTED = 0; @@ -194,6 +205,8 @@ message Playback { } required Status status = 1; + optional int32 duration = 2; + optional Soundtrack soundtrack = 3; } message Stream { diff --git a/pkg/mqtt/mqtt.go b/pkg/mqtt/mqtt.go index 4c9c6b3..d19fe6d 100644 --- a/pkg/mqtt/mqtt.go +++ b/pkg/mqtt/mqtt.go @@ -1,6 +1,7 @@ package mqtt import ( + "encoding/json" "fmt" "strings" "time" @@ -13,14 +14,20 @@ import ( type SendLightCommandHandler func(babyUID string, nightLightState bool) type SendStandbyCommandHandler func(babyUID string, standbyState bool) +type SendBrightnessCommandHandler func(babyUID string, brightness int32) +type SendPlaybackCommandHandler func(babyUID string, action string, soundtrack string, duration int32) +type SendVolumeCommandHandler func(babyUID string, volume int32) // Connection - MQTT context type Connection struct { - Opts Opts - StateManager *baby.StateManager - client MQTT.Client - sendLightCommandHandler SendLightCommandHandler - sendStandbyCommandHandler SendStandbyCommandHandler + Opts Opts + StateManager *baby.StateManager + client MQTT.Client + sendLightCommandHandler SendLightCommandHandler + sendStandbyCommandHandler SendStandbyCommandHandler + sendBrightnessCommandHandler SendBrightnessCommandHandler + sendPlaybackCommandHandler SendPlaybackCommandHandler + sendVolumeCommandHandler SendVolumeCommandHandler } // NewConnection - constructor @@ -60,82 +67,160 @@ func (conn *Connection) RegisterLightHandler(sendLightCommandHandler SendLightCo conn.sendLightCommandHandler = sendLightCommandHandler } +func (conn *Connection) RegisterStandbyHandler(sendStandbyCommandHandler SendStandbyCommandHandler) { + conn.sendStandbyCommandHandler = sendStandbyCommandHandler +} + +func (conn *Connection) RegisterBrightnessHandler(handler SendBrightnessCommandHandler) { + conn.sendBrightnessCommandHandler = handler +} + +func (conn *Connection) RegisterPlaybackHandler(handler SendPlaybackCommandHandler) { + conn.sendPlaybackCommandHandler = handler +} + +func (conn *Connection) RegisterVolumeHandler(handler SendVolumeCommandHandler) { + conn.sendVolumeCommandHandler = handler +} + func (conn *Connection) subscribeToLightCommand() { commandTopic := fmt.Sprintf("%v/babies/+/night_light/switch", conn.Opts.TopicPrefix) - log.Debug(). - Str("topic", commandTopic). - Msg("Subscribing to command topic") - - lightMessageHandler := func(mqttConn MQTT.Client, msg MQTT.Message) { - // Extract baby UID from topic - parts := strings.Split(msg.Topic(), "/") - if len(parts) < 4 { - log.Error().Str("topic", msg.Topic()).Msg("Invalid command topic format") + log.Debug().Str("topic", commandTopic).Msg("Subscribing to command topic") + + handler := func(_ MQTT.Client, msg MQTT.Message) { + babyUID := extractBabyUID(msg.Topic()) + if babyUID == "" { return } - babyUID := parts[2] - - // Validate baby UID - baby.EnsureValidBabyUID(babyUID) - enabled := string(msg.Payload()) == "true" - log.Debug(). - Str("baby", babyUID). - Bool("enabled", enabled). - Str("payload", string(msg.Payload())). - Msg("Received light command") + log.Debug().Str("baby", babyUID).Bool("enabled", enabled).Msg("Received light command") if conn.sendLightCommandHandler != nil { conn.sendLightCommandHandler(babyUID, enabled) } } - if token := conn.client.Subscribe(commandTopic, 0, lightMessageHandler); token.Wait() && token.Error() != nil { + if token := conn.client.Subscribe(commandTopic, 0, handler); token.Wait() && token.Error() != nil { log.Error().Err(token.Error()).Str("topic", commandTopic).Msg("Failed to subscribe to command topic") } } -func (conn *Connection) RegisterStandbyHandler(sendStandbyCommandHandler SendStandbyCommandHandler) { - conn.sendStandbyCommandHandler = sendStandbyCommandHandler -} - func (conn *Connection) subscribeToStandbyCommand() { commandTopic := fmt.Sprintf("%v/babies/+/standby/switch", conn.Opts.TopicPrefix) - log.Debug(). - Str("topic", commandTopic). - Msg("Subscribing to command topic") - - standbyMessageHandler := func(mqttConn MQTT.Client, msg MQTT.Message) { - // Extract baby UID from topic - parts := strings.Split(msg.Topic(), "/") - if len(parts) < 4 { - log.Error().Str("topic", msg.Topic()).Msg("Invalid command topic format") + log.Debug().Str("topic", commandTopic).Msg("Subscribing to command topic") + + handler := func(_ MQTT.Client, msg MQTT.Message) { + babyUID := extractBabyUID(msg.Topic()) + if babyUID == "" { return } - babyUID := parts[2] - - // Validate baby UID - baby.EnsureValidBabyUID(babyUID) - enabled := string(msg.Payload()) == "true" - log.Debug(). - Str("baby", babyUID). - Bool("enabled", enabled). - Str("payload", string(msg.Payload())). - Msg("Received standby command") + log.Debug().Str("baby", babyUID).Bool("enabled", enabled).Msg("Received standby command") if conn.sendStandbyCommandHandler != nil { conn.sendStandbyCommandHandler(babyUID, enabled) } } - if token := conn.client.Subscribe(commandTopic, 0, standbyMessageHandler); token.Wait() && token.Error() != nil { + if token := conn.client.Subscribe(commandTopic, 0, handler); token.Wait() && token.Error() != nil { log.Error().Err(token.Error()).Str("topic", commandTopic).Msg("Failed to subscribe to command topic") } } +func (conn *Connection) subscribeToBrightnessCommand() { + commandTopic := fmt.Sprintf("%v/babies/+/night_light/brightness", conn.Opts.TopicPrefix) + log.Debug().Str("topic", commandTopic).Msg("Subscribing to brightness command topic") + + handler := func(_ MQTT.Client, msg MQTT.Message) { + babyUID := extractBabyUID(msg.Topic()) + if babyUID == "" { + return + } + + var payload struct { + Brightness int32 `json:"brightness"` + } + if err := json.Unmarshal(msg.Payload(), &payload); err != nil { + log.Error().Err(err).Str("payload", string(msg.Payload())).Msg("Invalid brightness payload") + return + } + + log.Debug().Str("baby", babyUID).Int32("brightness", payload.Brightness).Msg("Received brightness command") + + if conn.sendBrightnessCommandHandler != nil { + conn.sendBrightnessCommandHandler(babyUID, payload.Brightness) + } + } + + if token := conn.client.Subscribe(commandTopic, 0, handler); token.Wait() && token.Error() != nil { + log.Error().Err(token.Error()).Str("topic", commandTopic).Msg("Failed to subscribe to brightness command topic") + } +} + +func (conn *Connection) subscribeToPlaybackCommand() { + commandTopic := fmt.Sprintf("%v/babies/+/playback", conn.Opts.TopicPrefix) + log.Debug().Str("topic", commandTopic).Msg("Subscribing to playback command topic") + + handler := func(_ MQTT.Client, msg MQTT.Message) { + babyUID := extractBabyUID(msg.Topic()) + if babyUID == "" { + return + } + + var payload struct { + Action string `json:"action"` + Soundtrack string `json:"soundtrack"` + Duration int32 `json:"duration"` + } + if err := json.Unmarshal(msg.Payload(), &payload); err != nil { + log.Error().Err(err).Str("payload", string(msg.Payload())).Msg("Invalid playback payload") + return + } + + log.Debug().Str("baby", babyUID).Str("action", payload.Action).Str("soundtrack", payload.Soundtrack).Msg("Received playback command") + + if conn.sendPlaybackCommandHandler != nil { + conn.sendPlaybackCommandHandler(babyUID, payload.Action, payload.Soundtrack, payload.Duration) + } + } + + if token := conn.client.Subscribe(commandTopic, 0, handler); token.Wait() && token.Error() != nil { + log.Error().Err(token.Error()).Str("topic", commandTopic).Msg("Failed to subscribe to playback command topic") + } +} + +func (conn *Connection) subscribeToVolumeCommand() { + commandTopic := fmt.Sprintf("%v/babies/+/volume", conn.Opts.TopicPrefix) + log.Debug().Str("topic", commandTopic).Msg("Subscribing to volume command topic") + + handler := func(_ MQTT.Client, msg MQTT.Message) { + babyUID := extractBabyUID(msg.Topic()) + if babyUID == "" { + return + } + + var payload struct { + Volume int32 `json:"volume"` + } + if err := json.Unmarshal(msg.Payload(), &payload); err != nil { + log.Error().Err(err).Str("payload", string(msg.Payload())).Msg("Invalid volume payload") + return + } + + log.Debug().Str("baby", babyUID).Int32("volume", payload.Volume).Msg("Received volume command") + + if conn.sendVolumeCommandHandler != nil { + conn.sendVolumeCommandHandler(babyUID, payload.Volume) + } + } + + if token := conn.client.Subscribe(commandTopic, 0, handler); token.Wait() && token.Error() != nil { + log.Error().Err(token.Error()).Str("topic", commandTopic).Msg("Failed to subscribe to volume command topic") + } +} + // Publish - publishes a message to an MQTT topic func (conn *Connection) Publish(topic string, payload interface{}) { token := conn.client.Publish(topic, 0, false, fmt.Sprintf("%v", payload)) @@ -149,6 +234,18 @@ func (conn *Connection) GetClient() MQTT.Client { return conn.client } +// extractBabyUID extracts the baby UID from an MQTT topic of the form prefix/babies/{uid}/... +func extractBabyUID(topic string) string { + parts := strings.Split(topic, "/") + if len(parts) < 3 { + log.Error().Str("topic", topic).Msg("Invalid command topic format") + return "" + } + babyUID := parts[2] + baby.EnsureValidBabyUID(babyUID) + return babyUID +} + func runMqtt(conn *Connection, attempt utils.AttemptContext) { if token := conn.client.Connect(); token.Wait() && token.Error() != nil { @@ -179,9 +276,12 @@ func runMqtt(conn *Connection, attempt utils.AttemptContext) { } }) - // Subscribe to accept light mqtt messages + // Subscribe to command topics conn.subscribeToLightCommand() conn.subscribeToStandbyCommand() + conn.subscribeToBrightnessCommand() + conn.subscribeToPlaybackCommand() + conn.subscribeToVolumeCommand() // Wait until interrupt signal is received <-attempt.Done() From 45390465720fa6c58dd60bb74e1426bd8ed5e04c Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 23:12:23 -0400 Subject: [PATCH 17/20] Add HA MQTT auto-discovery for sensors, switches, and binary sensors - Publish discovery configs on MQTT connect for each registered baby - Sensors: temperature, humidity, last motion/sound, stream URL - Binary sensors: night, stream active, camera online, crying, standing, left bed, alert zone, temp/humidity/breathing alerts, low battery, asleep, in bed - Switches: night light, standby mode - Gated behind NANIT_MQTT_DISCOVERY env var (default true when MQTT enabled) --- pkg/app/app.go | 9 ++ pkg/mqtt/discovery.go | 337 ++++++++++++++++++++++++++++++++++++++++++ pkg/mqtt/mqtt.go | 20 +++ 3 files changed, 366 insertions(+) create mode 100644 pkg/mqtt/discovery.go diff --git a/pkg/app/app.go b/pkg/app/app.go index 835c5d2..42d468a 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -83,6 +83,15 @@ func (app *App) Run(ctx utils.GracefulContext) { // MQTT if app.MQTTConnection != nil { + // Register babies for MQTT discovery + for _, babyInfo := range app.SessionStore.Session.Babies { + name := babyInfo.Name + if name == "" { + name = babyInfo.UID + } + app.MQTTConnection.RegisterBaby(babyInfo.UID, name) + } + // Register global MQTT command handlers (routes to correct baby via registry) app.MQTTConnection.RegisterLightHandler(func(babyUID string, enabled bool) { app.sendLightCommandWithReset(babyUID, enabled) diff --git a/pkg/mqtt/discovery.go b/pkg/mqtt/discovery.go new file mode 100644 index 0000000..6c81e8e --- /dev/null +++ b/pkg/mqtt/discovery.go @@ -0,0 +1,337 @@ +package mqtt + +import ( + "encoding/json" + "fmt" + + MQTT "github.com/eclipse/paho.mqtt.golang" + "github.com/rs/zerolog/log" +) + +// DeviceInfo represents a Home Assistant MQTT device +type DeviceInfo struct { + Identifiers []string `json:"identifiers"` + Name string `json:"name"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` +} + +// SensorConfig represents a Home Assistant MQTT sensor discovery config +type SensorConfig struct { + Name string `json:"name"` + UniqueID string `json:"unique_id"` + StateTopic string `json:"state_topic"` + Device *DeviceInfo `json:"device"` + DeviceClass string `json:"device_class,omitempty"` + UnitOfMeasurement string `json:"unit_of_measurement,omitempty"` + Icon string `json:"icon,omitempty"` +} + +// BinarySensorConfig represents a Home Assistant MQTT binary sensor discovery config +type BinarySensorConfig struct { + Name string `json:"name"` + UniqueID string `json:"unique_id"` + StateTopic string `json:"state_topic"` + PayloadOn string `json:"payload_on"` + PayloadOff string `json:"payload_off"` + Device *DeviceInfo `json:"device"` + DeviceClass string `json:"device_class,omitempty"` + Icon string `json:"icon,omitempty"` +} + +// SwitchConfig represents a Home Assistant MQTT switch discovery config +type SwitchConfig struct { + Name string `json:"name"` + UniqueID string `json:"unique_id"` + StateTopic string `json:"state_topic"` + CommandTopic string `json:"command_topic"` + PayloadOn string `json:"payload_on"` + PayloadOff string `json:"payload_off"` + Device *DeviceInfo `json:"device"` + Icon string `json:"icon,omitempty"` +} + +// DiscoveryPublisher publishes Home Assistant MQTT discovery configs +type DiscoveryPublisher struct { + client MQTT.Client + topicPrefix string + rtmpAddr string +} + +// NewDiscoveryPublisher creates a new discovery publisher +func NewDiscoveryPublisher(client MQTT.Client, topicPrefix string, rtmpAddr string) *DiscoveryPublisher { + return &DiscoveryPublisher{ + client: client, + topicPrefix: topicPrefix, + rtmpAddr: rtmpAddr, + } +} + +// PublishDiscovery publishes all discovery configs for a baby +func (dp *DiscoveryPublisher) PublishDiscovery(babyUID string, babyName string) { + device := &DeviceInfo{ + Identifiers: []string{fmt.Sprintf("nanit_%s", babyUID)}, + Name: fmt.Sprintf("Nanit %s", babyName), + Manufacturer: "Nanit", + Model: "Baby Monitor", + } + + dp.publishSensors(babyUID, device) + dp.publishBinarySensors(babyUID, device) + dp.publishSwitches(babyUID, device) + + // Publish initial states + dp.publishState(fmt.Sprintf("%s/babies/%s/camera_online", dp.topicPrefix, babyUID), "true") + + // Publish stream URL if RTMP address is configured + if dp.rtmpAddr != "" { + streamURL := fmt.Sprintf("rtmp://%s/local/%s", dp.rtmpAddr, babyUID) + dp.publishState(fmt.Sprintf("%s/babies/%s/stream_url", dp.topicPrefix, babyUID), streamURL) + } + + log.Info().Str("baby_uid", babyUID).Str("baby_name", babyName).Msg("Published MQTT discovery configs") +} + +func (dp *DiscoveryPublisher) publishSensors(babyUID string, device *DeviceInfo) { + prefix := dp.topicPrefix + + sensors := []SensorConfig{ + { + Name: "Temperature", + UniqueID: fmt.Sprintf("nanit_%s_temperature", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/temperature", prefix, babyUID), + Device: device, + DeviceClass: "temperature", + UnitOfMeasurement: "°C", + }, + { + Name: "Humidity", + UniqueID: fmt.Sprintf("nanit_%s_humidity", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/humidity", prefix, babyUID), + Device: device, + DeviceClass: "humidity", + UnitOfMeasurement: "%", + }, + { + Name: "Last Motion", + UniqueID: fmt.Sprintf("nanit_%s_motion_timestamp", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/motion_timestamp", prefix, babyUID), + Device: device, + DeviceClass: "timestamp", + Icon: "mdi:motion-sensor", + }, + { + Name: "Last Sound", + UniqueID: fmt.Sprintf("nanit_%s_sound_timestamp", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/sound_timestamp", prefix, babyUID), + Device: device, + DeviceClass: "timestamp", + Icon: "mdi:ear-hearing", + }, + { + Name: "Stream URL", + UniqueID: fmt.Sprintf("nanit_%s_stream_url", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/stream_url", prefix, babyUID), + Device: device, + Icon: "mdi:video", + }, + } + + for _, sensor := range sensors { + topic := fmt.Sprintf("homeassistant/sensor/nanit_%s_%s/config", babyUID, sensor.UniqueID) + dp.publishJSON(topic, sensor) + } +} + +func (dp *DiscoveryPublisher) publishBinarySensors(babyUID string, device *DeviceInfo) { + prefix := dp.topicPrefix + + binarySensors := []BinarySensorConfig{ + { + Name: "Night", + UniqueID: fmt.Sprintf("nanit_%s_is_night", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/is_night", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + Icon: "mdi:weather-night", + }, + { + Name: "Stream Active", + UniqueID: fmt.Sprintf("nanit_%s_is_stream_alive", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/is_stream_alive", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + DeviceClass: "connectivity", + }, + { + Name: "Camera Online", + UniqueID: fmt.Sprintf("nanit_%s_camera_online", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/camera_online", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + DeviceClass: "connectivity", + }, + { + Name: "Crying", + UniqueID: fmt.Sprintf("nanit_%s_crying", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/events/crying", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + DeviceClass: "sound", + Icon: "mdi:emoticon-cry-outline", + }, + { + Name: "Standing", + UniqueID: fmt.Sprintf("nanit_%s_standing", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/events/standing", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + Icon: "mdi:human-handsup", + }, + { + Name: "Left Bed", + UniqueID: fmt.Sprintf("nanit_%s_left_bed", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/events/left_bed", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + Icon: "mdi:bed-empty", + }, + { + Name: "Alert Zone", + UniqueID: fmt.Sprintf("nanit_%s_alert_zone", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/events/alert_zone", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + DeviceClass: "motion", + }, + { + Name: "Temperature Alert", + UniqueID: fmt.Sprintf("nanit_%s_temp_alert", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/events/temperature_alert", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + DeviceClass: "problem", + }, + { + Name: "Humidity Alert", + UniqueID: fmt.Sprintf("nanit_%s_humidity_alert", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/events/humidity_alert", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + DeviceClass: "problem", + }, + { + Name: "Breathing Alert", + UniqueID: fmt.Sprintf("nanit_%s_breathing_alert", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/events/breathing_alert", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + DeviceClass: "problem", + }, + { + Name: "Low Battery", + UniqueID: fmt.Sprintf("nanit_%s_low_battery", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/events/low_battery", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + DeviceClass: "battery", + }, + { + Name: "Asleep", + UniqueID: fmt.Sprintf("nanit_%s_is_asleep", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/is_asleep", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + Icon: "mdi:sleep", + }, + { + Name: "In Bed", + UniqueID: fmt.Sprintf("nanit_%s_in_bed", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/in_bed", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + Icon: "mdi:bed", + }, + } + + for _, sensor := range binarySensors { + topic := fmt.Sprintf("homeassistant/binary_sensor/nanit_%s_%s/config", babyUID, sensor.UniqueID) + dp.publishJSON(topic, sensor) + } +} + +func (dp *DiscoveryPublisher) publishSwitches(babyUID string, device *DeviceInfo) { + prefix := dp.topicPrefix + + switches := []SwitchConfig{ + { + Name: "Night Light", + UniqueID: fmt.Sprintf("nanit_%s_night_light", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/night_light", prefix, babyUID), + CommandTopic: fmt.Sprintf("%s/babies/%s/night_light/switch", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + Icon: "mdi:lightbulb-night", + }, + { + Name: "Standby", + UniqueID: fmt.Sprintf("nanit_%s_standby", babyUID), + StateTopic: fmt.Sprintf("%s/babies/%s/standby", prefix, babyUID), + CommandTopic: fmt.Sprintf("%s/babies/%s/standby/switch", prefix, babyUID), + PayloadOn: "true", + PayloadOff: "false", + Device: device, + Icon: "mdi:power-standby", + }, + } + + for _, sw := range switches { + topic := fmt.Sprintf("homeassistant/switch/nanit_%s_%s/config", babyUID, sw.UniqueID) + dp.publishJSON(topic, sw) + } +} + +// RemoveDiscovery removes all discovery configs for a baby by publishing empty payloads +func (dp *DiscoveryPublisher) RemoveDiscovery(babyUID string) { + // Publish empty payloads to remove entities + entityTypes := []string{"sensor", "binary_sensor", "switch"} + for _, entityType := range entityTypes { + // We don't know all entity IDs, so we publish empty to known ones + topic := fmt.Sprintf("homeassistant/%s/nanit_%s/config", entityType, babyUID) + dp.publishState(topic, "") + } +} + +func (dp *DiscoveryPublisher) publishJSON(topic string, payload interface{}) { + data, err := json.Marshal(payload) + if err != nil { + log.Error().Err(err).Str("topic", topic).Msg("Failed to marshal discovery config") + return + } + + token := dp.client.Publish(topic, 0, true, data) + if token.Wait(); token.Error() != nil { + log.Error().Err(token.Error()).Str("topic", topic).Msg("Failed to publish discovery config") + } +} + +func (dp *DiscoveryPublisher) publishState(topic string, value string) { + token := dp.client.Publish(topic, 0, true, value) + if token.Wait(); token.Error() != nil { + log.Error().Err(token.Error()).Str("topic", topic).Msg("Failed to publish state") + } +} diff --git a/pkg/mqtt/mqtt.go b/pkg/mqtt/mqtt.go index d19fe6d..244a873 100644 --- a/pkg/mqtt/mqtt.go +++ b/pkg/mqtt/mqtt.go @@ -18,6 +18,12 @@ type SendBrightnessCommandHandler func(babyUID string, brightness int32) type SendPlaybackCommandHandler func(babyUID string, action string, soundtrack string, duration int32) type SendVolumeCommandHandler func(babyUID string, volume int32) +// BabyInfo holds basic info for discovery registration +type BabyInfo struct { + UID string + Name string +} + // Connection - MQTT context type Connection struct { Opts Opts @@ -28,6 +34,7 @@ type Connection struct { sendBrightnessCommandHandler SendBrightnessCommandHandler sendPlaybackCommandHandler SendPlaybackCommandHandler sendVolumeCommandHandler SendVolumeCommandHandler + babies []BabyInfo } // NewConnection - constructor @@ -83,6 +90,11 @@ func (conn *Connection) RegisterVolumeHandler(handler SendVolumeCommandHandler) conn.sendVolumeCommandHandler = handler } +// RegisterBaby registers a baby for MQTT discovery +func (conn *Connection) RegisterBaby(uid string, name string) { + conn.babies = append(conn.babies, BabyInfo{UID: uid, Name: name}) +} + func (conn *Connection) subscribeToLightCommand() { commandTopic := fmt.Sprintf("%v/babies/+/night_light/switch", conn.Opts.TopicPrefix) log.Debug().Str("topic", commandTopic).Msg("Subscribing to command topic") @@ -276,6 +288,14 @@ func runMqtt(conn *Connection, attempt utils.AttemptContext) { } }) + // Publish MQTT discovery configs + if conn.Opts.DiscoveryEnabled { + discoveryPublisher := NewDiscoveryPublisher(conn.client, conn.Opts.TopicPrefix, conn.Opts.RTMPAddr) + for _, b := range conn.babies { + discoveryPublisher.PublishDiscovery(b.UID, b.Name) + } + } + // Subscribe to command topics conn.subscribeToLightCommand() conn.subscribeToStandbyCommand() From df8b2fbd660810a13cecafd39a50e420c85f397b Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 23:16:09 -0400 Subject: [PATCH 18/20] Add notification event polling: motion, sound, crying, standing, alerts - New notification package with event poller, deduplication, and publisher - Polls Nanit REST API for events with jitter and exponential backoff - Publishes events to MQTT: motion, sound, crying, standing, left bed, alert zone, temperature/humidity/breathing alerts, camera online/offline - New env vars: NANIT_NOTIFICATIONS_ENABLED, NANIT_NOTIFICATIONS_POLL_INTERVAL, NANIT_NOTIFICATIONS_MAX_BACKOFF --- cmd/nanit/main.go | 13 +++ pkg/app/app.go | 29 ++++++ pkg/app/opts.go | 12 +++ pkg/client/rest.go | 132 ++++++++++++++++++++++++ pkg/notification/dedup.go | 80 ++++++++++++++ pkg/notification/manager.go | 76 ++++++++++++++ pkg/notification/poller.go | 121 ++++++++++++++++++++++ pkg/notification/publisher.go | 72 +++++++++++++ pkg/notification/rest_adapter.go | 78 ++++++++++++++ pkg/notification/sleep_event.go | 42 ++++++++ pkg/notification/sleep_event_poller.go | 98 ++++++++++++++++++ pkg/notification/sleep_state_tracker.go | 66 ++++++++++++ pkg/notification/stats_poller.go | 102 ++++++++++++++++++ pkg/notification/types.go | 69 +++++++++++++ 14 files changed, 990 insertions(+) create mode 100644 pkg/notification/dedup.go create mode 100644 pkg/notification/manager.go create mode 100644 pkg/notification/poller.go create mode 100644 pkg/notification/publisher.go create mode 100644 pkg/notification/rest_adapter.go create mode 100644 pkg/notification/sleep_event.go create mode 100644 pkg/notification/sleep_event_poller.go create mode 100644 pkg/notification/sleep_state_tracker.go create mode 100644 pkg/notification/stats_poller.go create mode 100644 pkg/notification/types.go diff --git a/cmd/nanit/main.go b/cmd/nanit/main.go index d4e683c..3e70cb9 100644 --- a/cmd/nanit/main.go +++ b/cmd/nanit/main.go @@ -67,6 +67,19 @@ func main() { } } + if utils.EnvVarBool("NANIT_NOTIFICATIONS_ENABLED", false) { + opts.Notifications = app.NotificationOpts{ + Enabled: true, + PollInterval: utils.EnvVarSeconds("NANIT_NOTIFICATIONS_POLL_INTERVAL", 10*time.Second), + Jitter: 0.3, + MaxBackoff: utils.EnvVarSeconds("NANIT_NOTIFICATIONS_MAX_BACKOFF", 300*time.Second), + EnableSleepTracking: utils.EnvVarBool("NANIT_SLEEP_TRACKING_ENABLED", true), + SleepEventPollInterval: utils.EnvVarSeconds("NANIT_SLEEP_EVENT_POLL_INTERVAL", 30*time.Second), + StatsPollInterval: utils.EnvVarSeconds("NANIT_STATS_POLL_INTERVAL", 60*time.Second), + } + log.Info().Msgf("Notifications enabled with poll interval %v", opts.Notifications.PollInterval) + } + if opts.EventPolling.Enabled { log.Info().Msgf("Event polling enabled with an interval of %v", opts.EventPolling.PollingInterval) } diff --git a/pkg/app/app.go b/pkg/app/app.go index 42d468a..47cffb4 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -1,6 +1,7 @@ package app import ( + "context" "fmt" "strings" "sync" @@ -10,6 +11,7 @@ import ( "github.com/indiefan/home_assistant_nanit/pkg/client" "github.com/indiefan/home_assistant_nanit/pkg/message" "github.com/indiefan/home_assistant_nanit/pkg/mqtt" + "github.com/indiefan/home_assistant_nanit/pkg/notification" "github.com/indiefan/home_assistant_nanit/pkg/rtmpserver" "github.com/indiefan/home_assistant_nanit/pkg/session" "github.com/indiefan/home_assistant_nanit/pkg/utils" @@ -114,6 +116,33 @@ func (app *App) Run(ctx utils.GracefulContext) { }) } + // Notification polling + if app.Opts.Notifications.Enabled && app.MQTTConnection != nil { + restAdapter := notification.NewRestClientAdapter(app.RestClient) + publisher := notification.NewPublisher(app.MQTTConnection, app.MQTTConnection.Opts.TopicPrefix) + notifManager := notification.NewManager(restAdapter, publisher, notification.ManagerOpts{ + PollInterval: app.Opts.Notifications.PollInterval, + Jitter: app.Opts.Notifications.Jitter, + MaxBackoff: app.Opts.Notifications.MaxBackoff, + EnableSleepTracking: app.Opts.Notifications.EnableSleepTracking, + SleepEventPollInterval: app.Opts.Notifications.SleepEventPollInterval, + StatsPollInterval: app.Opts.Notifications.StatsPollInterval, + }) + + for _, babyInfo := range app.SessionStore.Session.Babies { + notifManager.RegisterBaby(babyInfo.UID) + } + + ctx.RunAsChild(func(childCtx utils.GracefulContext) { + notifCtx, cancel := context.WithCancel(context.Background()) + go func() { + <-childCtx.Done() + cancel() + }() + notifManager.Run(notifCtx) + }) + } + // Start reading the data from the stream for _, babyInfo := range app.SessionStore.Session.Babies { _babyInfo := babyInfo diff --git a/pkg/app/opts.go b/pkg/app/opts.go index b64f83c..627cf76 100644 --- a/pkg/app/opts.go +++ b/pkg/app/opts.go @@ -15,6 +15,7 @@ type Opts struct { RTMP *RTMPOpts EventPolling EventPollingOpts WebSocketReset WebSocketResetOpts + Notifications NotificationOpts } // NanitCredentials - user credentials for Nanit account @@ -51,3 +52,14 @@ type WebSocketResetOpts struct { Enabled bool CommandTimeout time.Duration } + +// NotificationOpts - options for the notification polling system +type NotificationOpts struct { + Enabled bool + PollInterval time.Duration + Jitter float64 + MaxBackoff time.Duration + EnableSleepTracking bool + SleepEventPollInterval time.Duration + StatsPollInterval time.Duration +} diff --git a/pkg/client/rest.go b/pkg/client/rest.go index 71451f2..6462bab 100644 --- a/pkg/client/rest.go +++ b/pkg/client/rest.go @@ -192,6 +192,41 @@ func (c *NanitClient) FetchAuthorized(req *http.Request, data interface{}) { log.Fatal().Msg("Unable to make request due failed authorization (2 attempts).") } +// TryFetchAuthorized - makes authorized http request, returns error instead of calling log.Fatal +func (c *NanitClient) TryFetchAuthorized(req *http.Request, data interface{}) error { + for i := 0; i < 2; i++ { + if c.SessionStore.Session.AuthToken != "" { + req.Header.Set("Authorization", c.SessionStore.Session.AuthToken) + + res, clientErr := myClient.Do(req) + if clientErr != nil { + return fmt.Errorf("HTTP request failed: %w", clientErr) + } + + defer res.Body.Close() + + if res.StatusCode != 401 { + if res.StatusCode != 200 { + return fmt.Errorf("server responded with status code %d", res.StatusCode) + } + + jsonErr := json.NewDecoder(res.Body).Decode(data) + if jsonErr != nil { + return fmt.Errorf("unable to decode response: %w", jsonErr) + } + + return nil + } + + log.Info().Msg("Token might be expired. Will try to re-authenticate.") + } + + c.Authorize() + } + + return fmt.Errorf("unable to make request due to failed authorization (2 attempts)") +} + // FetchBabies - fetches baby list func (c *NanitClient) FetchBabies() []baby.Baby { log.Info().Msg("Fetching babies list") @@ -274,3 +309,100 @@ func (c *NanitClient) FetchNewMessages(babyUID string, defaultMessageTimeout tim return filteredMessages } + +// -- Notification API methods -- + +// LastEventResponse holds the response from the last event API +type LastEventResponse struct { + Event *NotificationEventData `json:"event"` +} + +// NotificationEventData represents a notification event from the Nanit API +type NotificationEventData struct { + ID int `json:"id"` + BabyUID string `json:"baby_uid"` + Type string `json:"type"` + Time string `json:"time"` + CreatedAt string `json:"created_at"` + Data map[string]interface{} `json:"data"` +} + +// FetchLastEvent fetches the most recent notification event for a baby +func (c *NanitClient) FetchLastEvent(babyUID string) (*NotificationEventData, error) { + req, reqErr := http.NewRequest("GET", fmt.Sprintf("https://api.nanit.com/babies/%s/events/last", babyUID), nil) + if reqErr != nil { + return nil, fmt.Errorf("unable to create request: %w", reqErr) + } + + data := new(LastEventResponse) + err := c.TryFetchAuthorized(req, data) + if err != nil { + return nil, err + } + + return data.Event, nil +} + +// SleepEventsResponse holds the response from the sleep events API +type SleepEventsResponse struct { + Events []SleepEventData `json:"events"` +} + +// SleepEventData represents a sleep event from the Nanit API +type SleepEventData struct { + Key string `json:"key"` + Time string `json:"time"` + BeginTS string `json:"begin_ts"` + EndTS string `json:"end_ts"` + BabyUID string `json:"baby_uid"` + CameraUID string `json:"camera_uid"` + UID string `json:"uid"` +} + +// FetchSleepEvents fetches sleep events for a baby +func (c *NanitClient) FetchSleepEvents(babyUID string) ([]SleepEventData, error) { + req, reqErr := http.NewRequest("GET", fmt.Sprintf("https://api.nanit.com/babies/%s/events", babyUID), nil) + if reqErr != nil { + return nil, fmt.Errorf("unable to create request: %w", reqErr) + } + + data := new(SleepEventsResponse) + err := c.TryFetchAuthorized(req, data) + if err != nil { + return nil, err + } + + return data.Events, nil +} + +// SleepStatsResponse holds the response from the sleep stats API +type SleepStatsResponse struct { + States []SleepState `json:"states"` + SleepTime int `json:"sleep_time"` + AwakeTime int `json:"awake_time"` + TimesWokeUp int `json:"times_woke_up"` + Interventions int `json:"interventions"` +} + +// SleepState represents a single sleep state entry +type SleepState struct { + State string `json:"state"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` +} + +// FetchSleepStats fetches the latest sleep statistics for a baby +func (c *NanitClient) FetchSleepStats(babyUID string) (*SleepStatsResponse, error) { + req, reqErr := http.NewRequest("GET", fmt.Sprintf("https://api.nanit.com/babies/%s/stats/latest", babyUID), nil) + if reqErr != nil { + return nil, fmt.Errorf("unable to create request: %w", reqErr) + } + + data := new(SleepStatsResponse) + err := c.TryFetchAuthorized(req, data) + if err != nil { + return nil, err + } + + return data, nil +} diff --git a/pkg/notification/dedup.go b/pkg/notification/dedup.go new file mode 100644 index 0000000..1b238e8 --- /dev/null +++ b/pkg/notification/dedup.go @@ -0,0 +1,80 @@ +package notification + +import ( + "sync" +) + +// Deduplicator tracks seen message IDs to avoid double-publishing +type Deduplicator struct { + mu sync.Mutex + seen map[int]bool + order []int + maxSize int +} + +// NewDeduplicator creates a new deduplicator with the given max size +func NewDeduplicator(maxSize int) *Deduplicator { + return &Deduplicator{ + seen: make(map[int]bool), + maxSize: maxSize, + } +} + +// IsDuplicate returns true if the message ID has been seen before, and marks it as seen +func (d *Deduplicator) IsDuplicate(messageID int) bool { + d.mu.Lock() + defer d.mu.Unlock() + + if d.seen[messageID] { + return true + } + + d.seen[messageID] = true + d.order = append(d.order, messageID) + + // Evict oldest if over max size + for len(d.order) > d.maxSize { + oldest := d.order[0] + d.order = d.order[1:] + delete(d.seen, oldest) + } + + return false +} + +// UIDDeduplicator tracks seen UIDs as strings +type UIDDeduplicator struct { + mu sync.Mutex + seen map[string]bool + order []string + maxSize int +} + +// NewUIDDeduplicator creates a new UID deduplicator +func NewUIDDeduplicator(maxSize int) *UIDDeduplicator { + return &UIDDeduplicator{ + seen: make(map[string]bool), + maxSize: maxSize, + } +} + +// IsDuplicate returns true if the UID has been seen before, and marks it as seen +func (d *UIDDeduplicator) IsDuplicate(uid string) bool { + d.mu.Lock() + defer d.mu.Unlock() + + if d.seen[uid] { + return true + } + + d.seen[uid] = true + d.order = append(d.order, uid) + + for len(d.order) > d.maxSize { + oldest := d.order[0] + d.order = d.order[1:] + delete(d.seen, oldest) + } + + return false +} diff --git a/pkg/notification/manager.go b/pkg/notification/manager.go new file mode 100644 index 0000000..ed2942e --- /dev/null +++ b/pkg/notification/manager.go @@ -0,0 +1,76 @@ +package notification + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" +) + +// ManagerOpts holds configuration for the notification manager +type ManagerOpts struct { + PollInterval time.Duration + Jitter float64 + MaxBackoff time.Duration + EnableSleepTracking bool + SleepEventPollInterval time.Duration + StatsPollInterval time.Duration +} + +// Fetcher combines all API fetcher interfaces +type Fetcher interface { + EventFetcher + SleepEventFetcher + StatsFetcher +} + +// Manager coordinates all notification pollers for all babies +type Manager struct { + fetcher Fetcher + publisher *Publisher + opts ManagerOpts + babyUIDs []string +} + +// NewManager creates a new notification manager +func NewManager(fetcher Fetcher, publisher *Publisher, opts ManagerOpts) *Manager { + return &Manager{ + fetcher: fetcher, + publisher: publisher, + opts: opts, + } +} + +// RegisterBaby adds a baby to be polled +func (m *Manager) RegisterBaby(babyUID string) { + m.babyUIDs = append(m.babyUIDs, babyUID) +} + +// Run starts all pollers +func (m *Manager) Run(ctx context.Context) { + log.Info().Int("babies", len(m.babyUIDs)).Msg("Starting notification manager") + + for _, babyUID := range m.babyUIDs { + uid := babyUID + + // Start event poller + poller := NewPoller(uid, m.fetcher, m.publisher, PollerOpts{ + PollInterval: m.opts.PollInterval, + Jitter: m.opts.Jitter, + MaxBackoff: m.opts.MaxBackoff, + }) + go poller.Run(ctx) + + // Start sleep tracking pollers if enabled + if m.opts.EnableSleepTracking { + sleepPoller := NewSleepEventPoller(uid, m.fetcher, m.publisher, m.opts.SleepEventPollInterval, m.opts.MaxBackoff) + go sleepPoller.Run(ctx) + + statsPoller := NewStatsPoller(uid, m.fetcher, m.publisher, m.opts.StatsPollInterval, m.opts.MaxBackoff) + go statsPoller.Run(ctx) + } + } + + <-ctx.Done() + log.Info().Msg("Notification manager stopped") +} diff --git a/pkg/notification/poller.go b/pkg/notification/poller.go new file mode 100644 index 0000000..ebadd57 --- /dev/null +++ b/pkg/notification/poller.go @@ -0,0 +1,121 @@ +package notification + +import ( + "context" + "math" + "math/rand" + "time" + + "github.com/rs/zerolog/log" +) + +// EventFetcher is an interface for fetching events from the Nanit API +type EventFetcher interface { + FetchLastEvent(babyUID string) (*NotificationEvent, error) +} + +// NotificationEvent represents a notification from the Nanit API +type NotificationEvent struct { + ID int `json:"id"` + BabyUID string `json:"baby_uid"` + Type string `json:"type"` + Time time.Time `json:"time"` + CreatedAt string `json:"created_at"` + Data map[string]interface{} `json:"data"` +} + +// Poller polls the Nanit REST API for notification events +type Poller struct { + babyUID string + fetcher EventFetcher + publisher *Publisher + dedup *Deduplicator + pollInterval time.Duration + jitter float64 + maxBackoff time.Duration + consecutive int +} + +// NewPoller creates a new notification poller +func NewPoller(babyUID string, fetcher EventFetcher, publisher *Publisher, opts PollerOpts) *Poller { + return &Poller{ + babyUID: babyUID, + fetcher: fetcher, + publisher: publisher, + dedup: NewDeduplicator(1000), + pollInterval: opts.PollInterval, + jitter: opts.Jitter, + maxBackoff: opts.MaxBackoff, + } +} + +// PollerOpts holds configuration for the poller +type PollerOpts struct { + PollInterval time.Duration + Jitter float64 + MaxBackoff time.Duration +} + +// Run starts the polling loop +func (p *Poller) Run(ctx context.Context) { + log.Info().Str("baby_uid", p.babyUID).Dur("interval", p.pollInterval).Msg("Starting notification poller") + + for { + select { + case <-ctx.Done(): + log.Info().Str("baby_uid", p.babyUID).Msg("Notification poller stopped") + return + case <-time.After(p.nextInterval()): + p.poll() + } + } +} + +func (p *Poller) poll() { + event, err := p.fetcher.FetchLastEvent(p.babyUID) + if err != nil { + p.consecutive++ + log.Warn().Err(err).Str("baby_uid", p.babyUID).Int("consecutive_errors", p.consecutive).Msg("Failed to fetch notification events") + return + } + + p.consecutive = 0 + + if event == nil { + return + } + + if p.dedup.IsDuplicate(event.ID) { + return + } + + eventType := EventType(event.Type) + log.Debug(). + Str("baby_uid", p.babyUID). + Str("event_type", string(eventType)). + Int("event_id", event.ID). + Msg("New notification event") + + p.publisher.PublishEvent(p.babyUID, eventType, event.Time) +} + +func (p *Poller) nextInterval() time.Duration { + interval := p.pollInterval + + // Apply exponential backoff on consecutive errors + if p.consecutive > 0 { + backoff := time.Duration(math.Pow(2, float64(p.consecutive))) * time.Second + if backoff > p.maxBackoff { + backoff = p.maxBackoff + } + interval = backoff + } + + // Apply jitter + if p.jitter > 0 { + jitterRange := float64(interval) * p.jitter + interval += time.Duration(rand.Float64() * jitterRange) + } + + return interval +} diff --git a/pkg/notification/publisher.go b/pkg/notification/publisher.go new file mode 100644 index 0000000..bd7b32c --- /dev/null +++ b/pkg/notification/publisher.go @@ -0,0 +1,72 @@ +package notification + +import ( + "fmt" + "time" + + "github.com/rs/zerolog/log" +) + +// MQTTPublisher is an interface for publishing to MQTT +type MQTTPublisher interface { + Publish(topic string, payload interface{}) +} + +// Publisher publishes notification events to MQTT +type Publisher struct { + mqtt MQTTPublisher + topicPrefix string +} + +// NewPublisher creates a new notification publisher +func NewPublisher(mqtt MQTTPublisher, topicPrefix string) *Publisher { + return &Publisher{ + mqtt: mqtt, + topicPrefix: topicPrefix, + } +} + +// PublishEvent publishes a notification event to MQTT +func (p *Publisher) PublishEvent(babyUID string, eventType EventType, timestamp time.Time) { + mqttTopic, ok := MQTTTopic[eventType] + if !ok { + log.Debug().Str("event_type", string(eventType)).Msg("No MQTT topic mapping for event type") + return + } + + topic := fmt.Sprintf("%s/babies/%s/%s", p.topicPrefix, babyUID, mqttTopic) + + // Determine the value to publish + if boolState, hasBool := BooleanState[eventType]; hasBool { + p.mqtt.Publish(topic, boolState) + } else { + p.mqtt.Publish(topic, timestamp.Format(time.RFC3339)) + } + + log.Debug(). + Str("baby_uid", babyUID). + Str("event_type", string(eventType)). + Str("topic", topic). + Msg("Published notification event") +} + +// PublishSleepState publishes sleep state to MQTT +func (p *Publisher) PublishSleepState(babyUID string, isAsleep bool, inBed bool, lastEvent string) { + prefix := fmt.Sprintf("%s/babies/%s", p.topicPrefix, babyUID) + + p.mqtt.Publish(fmt.Sprintf("%s/is_asleep", prefix), isAsleep) + p.mqtt.Publish(fmt.Sprintf("%s/in_bed", prefix), inBed) + if lastEvent != "" { + p.mqtt.Publish(fmt.Sprintf("%s/last_sleep_event", prefix), lastEvent) + } +} + +// PublishSleepStats publishes sleep statistics to MQTT +func (p *Publisher) PublishSleepStats(babyUID string, timesWokeUp int, interventions int, awakeMinutes int, sleepMinutes int) { + prefix := fmt.Sprintf("%s/babies/%s", p.topicPrefix, babyUID) + + p.mqtt.Publish(fmt.Sprintf("%s/times_woke_up", prefix), timesWokeUp) + p.mqtt.Publish(fmt.Sprintf("%s/sleep_interventions", prefix), interventions) + p.mqtt.Publish(fmt.Sprintf("%s/awake_time_today", prefix), awakeMinutes) + p.mqtt.Publish(fmt.Sprintf("%s/sleep_time_today", prefix), sleepMinutes) +} diff --git a/pkg/notification/rest_adapter.go b/pkg/notification/rest_adapter.go new file mode 100644 index 0000000..9a963d1 --- /dev/null +++ b/pkg/notification/rest_adapter.go @@ -0,0 +1,78 @@ +package notification + +import ( + "time" + + "github.com/indiefan/home_assistant_nanit/pkg/client" +) + +// RestClientAdapter adapts the NanitClient to the EventFetcher interface +type RestClientAdapter struct { + Client *client.NanitClient +} + +// NewRestClientAdapter creates a new adapter +func NewRestClientAdapter(c *client.NanitClient) *RestClientAdapter { + return &RestClientAdapter{Client: c} +} + +// FetchLastEvent fetches the last notification event for a baby +func (a *RestClientAdapter) FetchLastEvent(babyUID string) (*NotificationEvent, error) { + data, err := a.Client.FetchLastEvent(babyUID) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + + eventTime, _ := time.Parse(time.RFC3339, data.Time) + + return &NotificationEvent{ + ID: data.ID, + BabyUID: data.BabyUID, + Type: data.Type, + Time: eventTime, + Data: data.Data, + }, nil +} + +// FetchSleepEvents fetches sleep events for a baby +func (a *RestClientAdapter) FetchSleepEvents(babyUID string) ([]SleepEvent, error) { + data, err := a.Client.FetchSleepEvents(babyUID) + if err != nil { + return nil, err + } + + events := make([]SleepEvent, len(data)) + for i, d := range data { + events[i] = SleepEvent{ + Key: d.Key, + Time: d.Time, + BeginTS: d.BeginTS, + EndTS: d.EndTS, + BabyUID: d.BabyUID, + CameraUID: d.CameraUID, + UID: d.UID, + } + } + return events, nil +} + +// FetchSleepStats fetches the latest sleep stats for a baby +func (a *RestClientAdapter) FetchSleepStats(babyUID string) (*SleepStats, error) { + data, err := a.Client.FetchSleepStats(babyUID) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + + return &SleepStats{ + SleepTime: data.SleepTime, + AwakeTime: data.AwakeTime, + TimesWokeUp: data.TimesWokeUp, + Interventions: data.Interventions, + }, nil +} diff --git a/pkg/notification/sleep_event.go b/pkg/notification/sleep_event.go new file mode 100644 index 0000000..08e00ba --- /dev/null +++ b/pkg/notification/sleep_event.go @@ -0,0 +1,42 @@ +package notification + +// SleepEvent represents a sleep event from the Nanit API +type SleepEvent struct { + Key string `json:"key"` + Time string `json:"time"` + BeginTS string `json:"begin_ts"` + EndTS string `json:"end_ts"` + BabyUID string `json:"baby_uid"` + CameraUID string `json:"camera_uid"` + UID string `json:"uid"` +} + +// GetEventType maps sleep event keys to EventType +func (e *SleepEvent) GetEventType() EventType { + switch e.Key { + case "fell_asleep": + return EventFellAsleep + case "woke_up": + return EventWokeUp + case "put_in_bed": + return EventPutInBed + case "put_to_sleep": + return EventPutToSleep + case "removed": + return EventRemoved + case "removed_asleep": + return EventRemovedAsleep + case "visit": + return EventVisit + default: + return EventType(e.Key) + } +} + +// SleepStats represents sleep statistics from the Nanit API +type SleepStats struct { + SleepTime int `json:"sleep_time"` + AwakeTime int `json:"awake_time"` + TimesWokeUp int `json:"times_woke_up"` + Interventions int `json:"interventions"` +} diff --git a/pkg/notification/sleep_event_poller.go b/pkg/notification/sleep_event_poller.go new file mode 100644 index 0000000..8ba46f6 --- /dev/null +++ b/pkg/notification/sleep_event_poller.go @@ -0,0 +1,98 @@ +package notification + +import ( + "context" + "math" + "time" + + "github.com/rs/zerolog/log" +) + +// SleepEventFetcher is an interface for fetching sleep events +type SleepEventFetcher interface { + FetchSleepEvents(babyUID string) ([]SleepEvent, error) +} + +// SleepEventPoller polls for sleep events and updates state +type SleepEventPoller struct { + babyUID string + fetcher SleepEventFetcher + publisher *Publisher + tracker *SleepStateTracker + dedup *UIDDeduplicator + pollInterval time.Duration + maxBackoff time.Duration + consecutive int +} + +// NewSleepEventPoller creates a new sleep event poller +func NewSleepEventPoller(babyUID string, fetcher SleepEventFetcher, publisher *Publisher, pollInterval time.Duration, maxBackoff time.Duration) *SleepEventPoller { + return &SleepEventPoller{ + babyUID: babyUID, + fetcher: fetcher, + publisher: publisher, + tracker: NewSleepStateTracker(), + dedup: NewUIDDeduplicator(500), + pollInterval: pollInterval, + maxBackoff: maxBackoff, + } +} + +// Run starts the sleep event polling loop +func (p *SleepEventPoller) Run(ctx context.Context) { + log.Info().Str("baby_uid", p.babyUID).Dur("interval", p.pollInterval).Msg("Starting sleep event poller") + + for { + select { + case <-ctx.Done(): + log.Info().Str("baby_uid", p.babyUID).Msg("Sleep event poller stopped") + return + case <-time.After(p.nextInterval()): + p.poll() + } + } +} + +func (p *SleepEventPoller) poll() { + events, err := p.fetcher.FetchSleepEvents(p.babyUID) + if err != nil { + p.consecutive++ + log.Warn().Err(err).Str("baby_uid", p.babyUID).Msg("Failed to fetch sleep events") + return + } + + p.consecutive = 0 + + for _, event := range events { + if event.UID == "" || p.dedup.IsDuplicate(event.UID) { + continue + } + + eventType := event.GetEventType() + if !IsSleepEvent(eventType) { + continue + } + + log.Debug(). + Str("baby_uid", p.babyUID). + Str("event_type", string(eventType)). + Str("uid", event.UID). + Msg("New sleep event") + + if p.tracker.ProcessEvent(event) { + isAsleep, inBed := p.tracker.GetState() + p.publisher.PublishSleepState(p.babyUID, isAsleep, inBed, string(eventType)) + } + } +} + +func (p *SleepEventPoller) nextInterval() time.Duration { + if p.consecutive > 0 { + backoff := time.Duration(math.Pow(2, float64(p.consecutive))) * time.Second + if backoff > p.maxBackoff { + backoff = p.maxBackoff + } + return backoff + } + return p.pollInterval +} diff --git a/pkg/notification/sleep_state_tracker.go b/pkg/notification/sleep_state_tracker.go new file mode 100644 index 0000000..c165828 --- /dev/null +++ b/pkg/notification/sleep_state_tracker.go @@ -0,0 +1,66 @@ +package notification + +import ( + "sync" + + "github.com/rs/zerolog/log" +) + +// SleepStateTracker maintains the sleep state for a baby +type SleepStateTracker struct { + mu sync.RWMutex + isAsleep bool + inBed bool +} + +// NewSleepStateTracker creates a new sleep state tracker +func NewSleepStateTracker() *SleepStateTracker { + return &SleepStateTracker{} +} + +// ProcessEvent updates the sleep state based on a sleep event +// Returns true if state changed +func (t *SleepStateTracker) ProcessEvent(event SleepEvent) bool { + t.mu.Lock() + defer t.mu.Unlock() + + prevAsleep := t.isAsleep + prevInBed := t.inBed + + eventType := event.GetEventType() + + switch eventType { + case EventFellAsleep: + t.isAsleep = true + t.inBed = true + case EventWokeUp: + t.isAsleep = false + // Still in bed + case EventPutInBed, EventPutToSleep: + t.inBed = true + case EventRemoved: + t.inBed = false + t.isAsleep = false + case EventRemovedAsleep: + t.inBed = false + // Still asleep (moved while sleeping) + } + + changed := prevAsleep != t.isAsleep || prevInBed != t.inBed + if changed { + log.Debug(). + Bool("is_asleep", t.isAsleep). + Bool("in_bed", t.inBed). + Str("event", string(eventType)). + Msg("Sleep state changed") + } + + return changed +} + +// GetState returns the current sleep state +func (t *SleepStateTracker) GetState() (isAsleep bool, inBed bool) { + t.mu.RLock() + defer t.mu.RUnlock() + return t.isAsleep, t.inBed +} diff --git a/pkg/notification/stats_poller.go b/pkg/notification/stats_poller.go new file mode 100644 index 0000000..5cfda17 --- /dev/null +++ b/pkg/notification/stats_poller.go @@ -0,0 +1,102 @@ +package notification + +import ( + "context" + "math" + "time" + + "github.com/rs/zerolog/log" +) + +// StatsFetcher is an interface for fetching sleep stats +type StatsFetcher interface { + FetchSleepStats(babyUID string) (*SleepStats, error) +} + +// StatsPoller polls for sleep statistics and publishes changes +type StatsPoller struct { + babyUID string + fetcher StatsFetcher + publisher *Publisher + pollInterval time.Duration + maxBackoff time.Duration + consecutive int + lastStats *SleepStats +} + +// NewStatsPoller creates a new stats poller +func NewStatsPoller(babyUID string, fetcher StatsFetcher, publisher *Publisher, pollInterval time.Duration, maxBackoff time.Duration) *StatsPoller { + return &StatsPoller{ + babyUID: babyUID, + fetcher: fetcher, + publisher: publisher, + pollInterval: pollInterval, + maxBackoff: maxBackoff, + } +} + +// Run starts the stats polling loop +func (p *StatsPoller) Run(ctx context.Context) { + log.Info().Str("baby_uid", p.babyUID).Dur("interval", p.pollInterval).Msg("Starting stats poller") + + for { + select { + case <-ctx.Done(): + log.Info().Str("baby_uid", p.babyUID).Msg("Stats poller stopped") + return + case <-time.After(p.nextInterval()): + p.poll() + } + } +} + +func (p *StatsPoller) poll() { + stats, err := p.fetcher.FetchSleepStats(p.babyUID) + if err != nil { + p.consecutive++ + log.Warn().Err(err).Str("baby_uid", p.babyUID).Msg("Failed to fetch sleep stats") + return + } + + p.consecutive = 0 + + if stats == nil { + return + } + + // Only publish if stats changed + if p.lastStats != nil && + p.lastStats.TimesWokeUp == stats.TimesWokeUp && + p.lastStats.Interventions == stats.Interventions && + p.lastStats.AwakeTime == stats.AwakeTime && + p.lastStats.SleepTime == stats.SleepTime { + return + } + + p.lastStats = stats + + // Convert seconds to minutes for display + awakeMinutes := stats.AwakeTime / 60 + sleepMinutes := stats.SleepTime / 60 + + p.publisher.PublishSleepStats(p.babyUID, stats.TimesWokeUp, stats.Interventions, awakeMinutes, sleepMinutes) + + log.Debug(). + Str("baby_uid", p.babyUID). + Int("times_woke_up", stats.TimesWokeUp). + Int("interventions", stats.Interventions). + Int("awake_minutes", awakeMinutes). + Int("sleep_minutes", sleepMinutes). + Msg("Published sleep stats") +} + +func (p *StatsPoller) nextInterval() time.Duration { + if p.consecutive > 0 { + backoff := time.Duration(math.Pow(2, float64(p.consecutive))) * time.Second + if backoff > p.maxBackoff { + backoff = p.maxBackoff + } + return backoff + } + return p.pollInterval +} diff --git a/pkg/notification/types.go b/pkg/notification/types.go new file mode 100644 index 0000000..e8c011b --- /dev/null +++ b/pkg/notification/types.go @@ -0,0 +1,69 @@ +package notification + +// EventType represents the type of notification event from Nanit +type EventType string + +const ( + EventMotion EventType = "MOTION" + EventSound EventType = "SOUND" + EventStanding EventType = "BABY_STANDING" + EventCrying EventType = "CRYING" + EventLeftBed EventType = "TODDLER_LEFT_THE_BED" + EventAlertZone EventType = "ALERT_ZONE" + EventTemperature EventType = "TEMPERATURE" + EventHumidity EventType = "HUMIDITY" + EventBreathingAlert EventType = "BREATHING_ALERT" + EventCameraOffline EventType = "CAMERA_OFFLINE" + EventCameraOnline EventType = "CAMERA_ONLINE" + EventLowBattery EventType = "LOW_BATTERY" + EventChangeState EventType = "CHANGE_STATE" + EventFellAsleep EventType = "FELL_ASLEEP" + EventWokeUp EventType = "WOKE_UP" + EventPutInBed EventType = "PUT_IN_BED" + EventPutToSleep EventType = "PUT_TO_SLEEP" + EventRemoved EventType = "REMOVED" + EventRemovedAsleep EventType = "REMOVED_ASLEEP" + EventVisit EventType = "VISIT" +) + +// MQTTTopic returns the MQTT topic suffix for an event type +var MQTTTopic = map[EventType]string{ + EventMotion: "events/motion", + EventSound: "events/sound", + EventStanding: "events/standing", + EventCrying: "events/crying", + EventLeftBed: "events/left_bed", + EventAlertZone: "events/alert_zone", + EventTemperature: "events/temperature_alert", + EventHumidity: "events/humidity_alert", + EventBreathingAlert: "events/breathing_alert", + EventCameraOffline: "camera_online", + EventCameraOnline: "camera_online", + EventLowBattery: "events/low_battery", +} + +// BooleanState returns true/false for events that map to binary sensor states +var BooleanState = map[EventType]bool{ + EventMotion: true, + EventSound: true, + EventStanding: true, + EventCrying: true, + EventLeftBed: true, + EventAlertZone: true, + EventTemperature: true, + EventHumidity: true, + EventBreathingAlert: true, + EventCameraOnline: true, + EventCameraOffline: false, + EventLowBattery: true, +} + +// IsSleepEvent returns true if the event type is sleep-related +func IsSleepEvent(eventType EventType) bool { + switch eventType { + case EventFellAsleep, EventWokeUp, EventPutInBed, EventPutToSleep, + EventRemoved, EventRemovedAsleep, EventVisit: + return true + } + return false +} From d28252130e7fe35e20f82b96be5a0971e3cd4fc7 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 23:16:56 -0400 Subject: [PATCH 19/20] Fix protobuf generated file: remove stray download log line --- pkg/client/websocket.pb.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/client/websocket.pb.go b/pkg/client/websocket.pb.go index 1d11afe..6deeb02 100644 --- a/pkg/client/websocket.pb.go +++ b/pkg/client/websocket.pb.go @@ -1,4 +1,3 @@ -go: downloading google.golang.org/protobuf v1.36.11 // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 From 37df47d3ef671d58eb90ee9f4d1a906a9f3a3ae2 Mon Sep 17 00:00:00 2001 From: Stuart Hall Date: Sat, 11 Apr 2026 23:17:41 -0400 Subject: [PATCH 20/20] Update README with MQTT features, env var table, and credits --- README.md | 137 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 99 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index ecfbac3..07b7b5f 100644 --- a/README.md +++ b/README.md @@ -5,63 +5,124 @@ This is a fork of [indiefan/home_assistant_nanit](https://github.com/indiefan/ho ## What this fork changes - **Automatic stream recovery on reconnection**: The upstream container maintains a websocket to Nanit's cloud and asks the camera to push its RTMP stream locally. After websocket disconnections, it would reconnect but never re-request the stream — leaving the RTMP server running with no publisher. This fork resets stream state on disconnect so the stream is always re-requested after reconnection. -- **Health endpoint with grace period**: Adds an HTTP health endpoint on port 8080 (`/health`) that reports whether the websocket is connected and the RTMP stream is alive. Includes a 3-minute grace period after stream loss to allow the reconnection fix to recover before triggering a restart. -- **Docker HEALTHCHECK**: The Dockerfile includes a built-in health check (every 3 minutes, 2 retries). If the stream doesn't recover within the grace period, the container is restarted automatically. -- **Updated base image**: Upgraded from `debian:buster` (EOL) to `debian:bookworm-slim`. -- **Bugfix**: Fixed a nil pointer dereference in `GetIsWebsocketAlive()` where it checked `StreamState` instead of `IsWebsocketAlive`. +- **Persistent broadcaster**: Subscribers (HA/Apple Home) stay connected across publisher reconnections. No more visible stream interruptions when the camera briefly disconnects. +- **go2rtc bundling**: Bundles go2rtc for RTSP output, enabling UniFi Protect and other RTSP-based consumers. +- **Multi-camera MQTT routing**: Global connection registry routes MQTT commands to the correct camera by baby UID (from tanvach PR #33). +- **MQTT device control**: Control night light brightness, sound playback, and volume via MQTT. +- **HA MQTT auto-discovery**: Automatically registers sensors, binary sensors, and switches in Home Assistant. +- **Notification event polling**: Polls Nanit REST API for events (motion, sound, crying, standing, alerts) and publishes to MQTT. +- **Sleep tracking**: Polls for sleep events and statistics, publishes sleep state and daily stats to MQTT. +- **Health endpoint with grace period**: HTTP health endpoint on port 8080 (`/health`) with 3-minute grace period for stream recovery. +- **Docker HEALTHCHECK**: Built-in health check (every 3 minutes, 2 retries) with automatic container restart. # Installation (Docker) ## Pull the Docker Image -While it is possible to build the image locally from the included Dockerfile, it is recommended to install and update by pulling the official image directly from Docker Hub. To pull the image manually without running the container, run: - -`docker pull ghcr.io/stuart22/home_assistant_nanit` +```bash +docker pull ghcr.io/stuart22/home_assistant_nanit +``` ## Authentication -Because Nanit requires 2FA authentication, before we can start we need to acquire a refresh token for your Nanit account, which can be done by running the included init-nanit.sh CLI tool, which will prompt you for required account information and the 2FA code which will be emailed during the process. The script will save this to a session.json file, where it will be updated automatically going forward. Note that the `/data` volume provided to the script command must be the same used when running the primary container image later. +Nanit requires 2FA authentication. Before starting, acquire a refresh token: -### Acquire the Refresh Token +```bash +docker run -it -v /path/to/data:/data --entrypoint=/app/scripts/init-nanit.sh ghcr.io/stuart22/home_assistant_nanit +``` -Run the bundled init-nanit.sh utility directly via the Docker command line to acquire the token (replace `/path/to/data` with the local path you'd like the container to use for storing session data): +**Security Note:** The refresh token provides complete access to your Nanit account. Protect your system accordingly. + +## Docker Compose + +```yaml +version: '3' +services: + nanit: + container_name: nanit + image: ghcr.io/stuart22/home_assistant_nanit:latest + volumes: + - /path/to/data:/data + - /etc/localtime:/etc/localtime:ro + environment: + - NANIT_RTMP_ADDR=192.168.1.x:1935 + - NANIT_LOG_LEVEL=info + - TZ=America/New_York + # MQTT (optional) + - NANIT_MQTT_ENABLED=true + - NANIT_MQTT_BROKER_URL=mqtt://192.168.1.x:1883 + # Notifications (optional, requires MQTT) + - NANIT_NOTIFICATIONS_ENABLED=true + ports: + - "1935:1935" # RTMP + - "8554:8554" # RTSP (go2rtc) + restart: unless-stopped +``` -`docker run -it -v /path/to/data:/data --entrypoint=/app/scripts/init-nanit.sh ghcr.io/stuart22/home_assistant_nanit` +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NANIT_EMAIL` | — | Nanit account email (for initial login) | +| `NANIT_PASSWORD` | — | Nanit account password (for initial login) | +| `NANIT_REFRESH_TOKEN` | — | Refresh token (alternative to email/password) | +| `NANIT_SESSION_FILE` | `/data/session.json` | Path to session file | +| `NANIT_RTMP_ENABLED` | `true` | Enable RTMP streaming | +| `NANIT_RTMP_ADDR` | — | Public IP:port for RTMP (e.g., `192.168.1.x:1935`) | +| `NANIT_RTSP_ENABLED` | `true` | Enable go2rtc RTSP output | +| `NANIT_LOG_LEVEL` | `info` | Log level (trace, debug, info, warn, error) | +| `NANIT_MQTT_ENABLED` | `false` | Enable MQTT integration | +| `NANIT_MQTT_BROKER_URL` | — | MQTT broker URL (e.g., `mqtt://192.168.1.x:1883`) | +| `NANIT_MQTT_CLIENT_ID` | `nanit` | MQTT client ID | +| `NANIT_MQTT_USERNAME` | — | MQTT username | +| `NANIT_MQTT_PASSWORD` | — | MQTT password | +| `NANIT_MQTT_PREFIX` | `nanit` | MQTT topic prefix | +| `NANIT_MQTT_DISCOVERY` | `true` | Enable HA MQTT auto-discovery | +| `NANIT_MQTT_RESET_WHEN_FAILED` | `false` | Auto-reconnect websocket on failed MQTT commands | +| `NANIT_WEBSOCKET_TIMEOUT` | `1` | Command response timeout in seconds | +| `NANIT_NOTIFICATIONS_ENABLED` | `false` | Enable notification event polling | +| `NANIT_NOTIFICATIONS_POLL_INTERVAL` | `10` | Event poll interval in seconds | +| `NANIT_NOTIFICATIONS_MAX_BACKOFF` | `300` | Max backoff on API errors in seconds | +| `NANIT_SLEEP_TRACKING_ENABLED` | `true` | Enable sleep tracking (requires notifications) | +| `NANIT_SLEEP_EVENT_POLL_INTERVAL` | `30` | Sleep event poll interval in seconds | +| `NANIT_STATS_POLL_INTERVAL` | `60` | Sleep stats poll interval in seconds | -** Important Note regarding Security** -The refresh token provides complete access to your Nanit account without requiring any additional account information, so be sure to protect your system from access by unauthorized parties, and proceed at your own risk. +## Home Assistant -## Docker Run +### Camera Entity (RTMP) -Now that the initial authentication has been done, and the refresh token has been generated, it's time to start the container: +```yaml +camera: + - name: Nanit + platform: ffmpeg + input: rtmp://192.168.1.x:1935/local/[baby_uid] +``` -```bash -# Note: use your local IP, reachable from Cam (not 127.0.0.1 nor localhost) - -docker run \ - -d \ - --name=nanit \ - --restart unless-stopped \ - -v /path/to/data:/data \ - -e NANIT_RTMP_ADDR=xxx.xxx.xxx.xxx:1935 \ - -e NANIT_LOG_LEVEL=trace \ - -p 1935:1935 \ - ghcr.io/stuart22/home_assistant_nanit +### Camera Entity (RTSP via go2rtc) + +```yaml +camera: + - name: Nanit + platform: ffmpeg + input: rtsp://192.168.1.x:8554/[baby_name] ``` -If this is your initial run, you may want to omit the `-d` flag so you can observe the output to find your `baby_uid` (which will be needed later if you plan on connecting anything to the feed, like Home Assistant). After getting the baby id (which won't change) you can stop the container and restart it with the `-d` flag. +### MQTT Auto-Discovery -As a note, the NANIT_RTMP_ADDR should be the local ip address of your docker environment, NOT the ip address of your nanit camera. +When MQTT is enabled with discovery, entities are automatically created in HA: -## Home Assistant +- **Sensors**: Temperature, Humidity, Last Motion, Last Sound, Stream URL +- **Binary Sensors**: Night, Stream Active, Camera Online, Crying, Standing, Left Bed, Alert Zone, Alerts (temp/humidity/breathing), Low Battery, Asleep, In Bed +- **Switches**: Night Light, Standby Mode -Once the server is running and mirroring the feed, you can then setup an entity in Home Assistant. Open your `configuration.yaml` file and add the following: +### UniFi Protect -``` -camera: -- name: Nanit - platform: ffmpeg - input: rtmp://xxx.xxx.xxx.xxx:1935/local/[your_baby_uid] -``` +Add cameras via RTSP: `rtsp://192.168.1.x:8554/[baby_name]` + +## Credits -Restart Home Assistant and you should now have a camera entity named Nanit for use in dashboards. +- [adam.stanek/nanit](https://gitlab.com/adam.stanek/nanit) — original project +- [indiefan/home_assistant_nanit](https://github.com/indiefan/home_assistant_nanit) — HA integration fork +- [tanvach](https://github.com/tanvach/home_assistant_nanit) — multi-camera MQTT routing (PR #33) +- [combmag](https://github.com/combmag/home_assistant_nanit) — device control (playback, volume, brightness) +- [scgreenhalgh](https://github.com/scgreenhalgh/home_assistant_nanit) — MQTT discovery, notifications, sleep tracking