|
| 1 | +# Auto-Syncing Camera IPs to Frigate: A GitOps Approach |
| 2 | + |
| 3 | +**Date**: January 26, 2026 |
| 4 | +**Author**: Claude + Human collaboration |
| 5 | +**Tags**: frigate, home-assistant, gitops, kubernetes, automation, reolink, dhcp, camera |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## The Problem: Sunday Morning Feed Outages |
| 10 | + |
| 11 | +Every Sunday morning, my Reolink cameras reboot for scheduled maintenance. When they come back up, DHCP sometimes assigns them new IPs. Frigate keeps trying to connect to the old IPs, and I wake up to dead camera feeds. |
| 12 | + |
| 13 | +The "fix" was always the same: |
| 14 | +1. Open the Reolink app to find the new IPs |
| 15 | +2. SSH into the cluster |
| 16 | +3. Edit the Frigate ConfigMap |
| 17 | +4. Restart Frigate |
| 18 | +5. Wait for streams to reconnect |
| 19 | + |
| 20 | +Manual, annoying, and exactly the kind of thing that should be automated. |
| 21 | + |
| 22 | +## Why Not Just Use DHCP Reservations? |
| 23 | + |
| 24 | +Great question. My AT&T router has DHCP reservations, but they don't persist across router reboots. Every firmware update wipes them. I've re-added them at least 5 times. |
| 25 | + |
| 26 | +The cameras do support static IPs, but that creates a different problem: if I ever change my network configuration, I have to physically access each camera to reconfigure it. |
| 27 | + |
| 28 | +Dynamic IPs with automatic discovery is actually more resilient. |
| 29 | + |
| 30 | +## The Solution: Event-Driven IP Sync |
| 31 | + |
| 32 | +``` |
| 33 | +┌─────────────────────────────────────────────────────────────────────┐ |
| 34 | +│ Camera IP Auto-Sync Flow │ |
| 35 | +├─────────────────────────────────────────────────────────────────────┤ |
| 36 | +│ │ |
| 37 | +│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────────┐ │ |
| 38 | +│ │ Reolink │ │ Home │ │ K8s Cluster │ │ |
| 39 | +│ │ Cameras │ │ Assistant │ │ │ │ |
| 40 | +│ └──────┬───────┘ └───────┬───────┘ │ ┌────────────────┐ │ │ |
| 41 | +│ │ │ │ │ frigate-webhook│ │ │ |
| 42 | +│ │ DHCP assigns │ │ └────────┬───────┘ │ │ |
| 43 | +│ │ new IP │ │ │ │ │ |
| 44 | +│ ▼ │ │ ▼ │ │ |
| 45 | +│ ┌──────────────┐ │ │ ┌────────────────┐ │ │ |
| 46 | +│ │ 192.168.1.x │◄──nmap────┤ │ │ ConfigMap │ │ │ |
| 47 | +│ │ (by MAC) │ scan │ │ │ frigate-config │ │ │ |
| 48 | +│ └──────────────┘ │ │ └────────┬───────┘ │ │ |
| 49 | +│ │ │ │ │ │ |
| 50 | +│ ▼ │ ▼ │ │ |
| 51 | +│ ┌───────────────┐ │ ┌────────────────┐ │ │ |
| 52 | +│ │sensor.camera_ │ │ │ Reloader │ │ │ |
| 53 | +│ │ips (state │ │ │ (watches CM) │ │ │ |
| 54 | +│ │ change) │ │ └────────┬───────┘ │ │ |
| 55 | +│ └───────┬───────┘ │ │ │ │ |
| 56 | +│ │ │ ▼ │ │ |
| 57 | +│ │ │ ┌────────────────┐ │ │ |
| 58 | +│ ┌───────▼───────┐ │ │ Frigate │ │ │ |
| 59 | +│ │ Automation │──POST─┼─►│ (restarted) │ │ │ |
| 60 | +│ │ (REST cmd) │ │ └────────────────┘ │ │ |
| 61 | +│ └───────────────┘ │ │ │ |
| 62 | +│ └──────────────────────┘ │ |
| 63 | +└─────────────────────────────────────────────────────────────────────┘ |
| 64 | +``` |
| 65 | + |
| 66 | +### Component 1: Camera Discovery via MAC Address |
| 67 | + |
| 68 | +Reolink cameras don't support mDNS/Bonjour. They use a proprietary UDP discovery protocol and ONVIF WS-Discovery. Both are flaky for automated discovery. |
| 69 | + |
| 70 | +What does work reliably? Good old nmap. The cameras have fixed MAC addresses, and nmap can find them: |
| 71 | + |
| 72 | +```python |
| 73 | +# camera-ip-sync.py (runs in HA container) |
| 74 | +CAMERAS = { |
| 75 | + "hall": "0C:79:55:4B:D4:2A", |
| 76 | + "living_room": "14:EA:63:A9:04:08", |
| 77 | +} |
| 78 | + |
| 79 | +def scan_network(scan_range): |
| 80 | + result = subprocess.run( |
| 81 | + ["nmap", "-sn", scan_range], |
| 82 | + capture_output=True, text=True, timeout=120 |
| 83 | + ) |
| 84 | + # Parse MAC -> IP mapping from output |
| 85 | + ... |
| 86 | +``` |
| 87 | + |
| 88 | +This runs every 5 minutes via HA's `command_line` sensor. The output includes an `ip_state` string that changes when IPs change: |
| 89 | + |
| 90 | +```json |
| 91 | +{ |
| 92 | + "cameras": {"hall": "192.168.1.137", "living_room": "192.168.1.138"}, |
| 93 | + "ip_state": "hall:192.168.1.137,living_room:192.168.1.138", |
| 94 | + "all_found": true |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +### Component 2: State-Based Automation Trigger |
| 99 | + |
| 100 | +The key insight: use `ip_state` as the sensor's state value, not just an attribute. This means HA's state change trigger fires exactly when IPs change: |
| 101 | + |
| 102 | +```yaml |
| 103 | +# configuration.yaml |
| 104 | +command_line: |
| 105 | + - sensor: |
| 106 | + name: Camera IPs |
| 107 | + unique_id: camera_ips_sensor |
| 108 | + command: "cat /config/camera_ips.json" |
| 109 | + value_template: "{{ value_json.ip_state }}" # State = IP string |
| 110 | + json_attributes: |
| 111 | + - cameras |
| 112 | + - all_found |
| 113 | + scan_interval: 300 |
| 114 | +``` |
| 115 | +
|
| 116 | +The automation only fires on actual IP changes, not every scan: |
| 117 | +
|
| 118 | +```yaml |
| 119 | +# automations.yaml |
| 120 | +- id: camera_ip_sync_to_frigate |
| 121 | + alias: Camera IP Sync to Frigate |
| 122 | + trigger: |
| 123 | + - platform: state |
| 124 | + entity_id: sensor.camera_ips |
| 125 | + condition: |
| 126 | + # Ignore startup/unavailable states |
| 127 | + - condition: template |
| 128 | + value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown', ''] }}" |
| 129 | + - condition: template |
| 130 | + value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown', ''] }}" |
| 131 | + # Only if state actually changed |
| 132 | + - condition: template |
| 133 | + value_template: "{{ trigger.from_state.state != trigger.to_state.state }}" |
| 134 | + # Only if all cameras found |
| 135 | + - condition: template |
| 136 | + value_template: "{{ state_attr('sensor.camera_ips', 'all_found') == true }}" |
| 137 | + action: |
| 138 | + - service: rest_command.update_frigate_camera_ips |
| 139 | +``` |
| 140 | +
|
| 141 | +### Component 3: K8s Webhook for ConfigMap Updates |
| 142 | +
|
| 143 | +HA calls a webhook running in the K8s cluster. The webhook patches the Frigate ConfigMap: |
| 144 | +
|
| 145 | +```python |
| 146 | +# webhook.py (Flask app in K8s) |
| 147 | +@app.route("/update", methods=["POST"]) |
| 148 | +def update_ips(): |
| 149 | + camera_ips = request.get_json() |
| 150 | + # {"living_room": "192.168.1.138", "hall": "192.168.1.137"} |
| 151 | + |
| 152 | + # Patch ConfigMap with regex replacements |
| 153 | + # - go2rtc stream URLs: @OLD_IP:554 -> @NEW_IP:554 |
| 154 | + # - ONVIF hosts: host: OLD_IP -> host: NEW_IP |
| 155 | + |
| 156 | + # Trigger restart via deployment annotation |
| 157 | + patch = {"spec": {"template": {"metadata": {"annotations": { |
| 158 | + "camera-ips-updated": datetime.utcnow().isoformat() |
| 159 | + }}}}} |
| 160 | + apps_v1.patch_namespaced_deployment("frigate", "frigate", patch) |
| 161 | +``` |
| 162 | + |
| 163 | +The webhook has proper RBAC to patch ConfigMaps and Deployments in the frigate namespace. |
| 164 | + |
| 165 | +### Component 4: Reloader for Automatic Restarts |
| 166 | + |
| 167 | +Instead of the webhook triggering restarts, I use [Stakater Reloader](https://github.com/stakater/Reloader). It watches ConfigMaps and automatically restarts pods when they change: |
| 168 | + |
| 169 | +```yaml |
| 170 | +# deployment.yaml |
| 171 | +metadata: |
| 172 | + annotations: |
| 173 | + reloader.stakater.com/auto: "true" |
| 174 | +``` |
| 175 | +
|
| 176 | +This is cleaner than manual restart logic and works for any ConfigMap change, not just IP updates. |
| 177 | +
|
| 178 | +## The GitOps Angle |
| 179 | +
|
| 180 | +Everything is managed through GitOps: |
| 181 | +
|
| 182 | +1. **Webhook container** - Built via GitHub Actions, pushed to ghcr.io |
| 183 | +2. **K8s manifests** - Deployment, Service, IngressRoute in git |
| 184 | +3. **Flux reconciliation** - Changes auto-deploy on push |
| 185 | +
|
| 186 | +```yaml |
| 187 | +# .github/workflows/build-frigate-webhook.yaml |
| 188 | +on: |
| 189 | + push: |
| 190 | + paths: |
| 191 | + - 'gitops/clusters/homelab/apps/frigate-ip-webhook/**' |
| 192 | +``` |
| 193 | +
|
| 194 | +## What I Learned |
| 195 | +
|
| 196 | +### 1. State vs Attributes Matter |
| 197 | +
|
| 198 | +Initially I had `all_found` as the sensor state. This only triggered when cameras appeared/disappeared, not when IPs changed. Moving the IP string to state fixed it. |
| 199 | + |
| 200 | +### 2. nmap > Proprietary Discovery |
| 201 | + |
| 202 | +I spent hours trying to get Reolink's UDP discovery working from K8s. Broadcast packets don't cross subnets. nmap from the HA container (same L2 as cameras) just works. |
| 203 | + |
| 204 | +### 3. ConfigMap Changes Don't Restart Pods |
| 205 | + |
| 206 | +This surprised me. Kubernetes applies ConfigMap updates, but pods keep running with old values. You need either: |
| 207 | +- Reloader (recommended) |
| 208 | +- Checksum annotation in pod template |
| 209 | +- Manual restart logic |
| 210 | + |
| 211 | +### 4. Webhook > Direct K8s from HA |
| 212 | + |
| 213 | +I considered giving HA direct K8s access via a ServiceAccount token. But: |
| 214 | +- More attack surface |
| 215 | +- HA doesn't need K8s knowledge |
| 216 | +- Webhook is simpler to test and debug |
| 217 | + |
| 218 | +## Future Improvements |
| 219 | + |
| 220 | +1. **Notification on IP change** - Add a mobile push when automation fires |
| 221 | +2. **Frigate API validation** - Check streams are actually working after restart |
| 222 | +3. **Retry logic** - If webhook fails, retry with backoff |
| 223 | + |
| 224 | +## Files Reference |
| 225 | + |
| 226 | +| File | Purpose | |
| 227 | +|------|---------| |
| 228 | +| `scripts/haos/camera-ip-sync.py` | nmap scanner, writes JSON | |
| 229 | +| `scripts/haos/ha-config-camera-ip-sync.yaml` | HA config template | |
| 230 | +| `scripts/haos/automations/camera-ip-sync-to-frigate.yaml` | Automation | |
| 231 | +| `gitops/.../frigate-ip-webhook/webhook.py` | K8s webhook | |
| 232 | +| `gitops/.../frigate-ip-webhook/deployment.yaml` | RBAC + Deployment | |
| 233 | +| `.github/workflows/build-frigate-webhook.yaml` | Container build | |
| 234 | + |
| 235 | +--- |
| 236 | + |
| 237 | +*The real test comes next Sunday. I'll update this post with results.* |
0 commit comments