Skip to content

Commit 3316521

Browse files
web-flowclaude
andcommitted
feat(frigate): add camera IP auto-discovery infrastructure
Problem: AT&T router DHCP reservations don't persist, causing Frigate to lose camera connectivity when IPs change. Solution components: - CronJob scans for ONVIF devices and updates ConfigMap - Webhook for HA integration (optional) - HAOS script for MAC-based discovery Limitation: Camera identification from K8s cluster requires reverse DNS or authenticated ONVIF. Full MAC-based identification requires running from HAOS. Camera MACs documented: - Hall: 0C:79:55:4B:D4:2A - Living Room: 14:EA:63:A9:04:08 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2f4a7c9 commit 3316521

7 files changed

Lines changed: 669 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Camera IP Auto-Sync
2+
3+
Automatically detects when Reolink camera IPs change and updates Frigate configuration.
4+
5+
## Problem
6+
7+
AT&T router DHCP reservations don't persist reliably. When cameras get new IPs via DHCP, Frigate loses connectivity and requires manual config updates.
8+
9+
## Solution
10+
11+
A CronJob in K8s that:
12+
1. Checks if cameras are responding at configured IPs (RTSP port 554)
13+
2. If not, scans 192.168.1.x for ONVIF devices (port 8000)
14+
3. Identifies cameras and updates Frigate ConfigMap
15+
4. Triggers Frigate pod restart
16+
17+
## Architecture
18+
19+
```
20+
┌─────────────────────────────────────────────────────────────────┐
21+
│ K8s (frigate namespace) │
22+
│ │
23+
│ ┌──────────────────────────────────────────────────────────┐ │
24+
│ │ CronJob: camera-ip-discovery (every 5 min) │ │
25+
│ │ 1. Check current IPs respond (RTSP 554) │ │
26+
│ │ 2. If down, scan 192.168.1.130-150 for ONVIF (8000) │ │
27+
│ │ 3. Update ConfigMap with new IPs │ │
28+
│ │ 4. Restart Frigate │ │
29+
│ └──────────────────────────────────────────────────────────┘ │
30+
│ │ │
31+
│ ▼ │
32+
│ ┌──────────────────────────────────────────────────────────┐ │
33+
│ │ ConfigMap: frigate-config │ │
34+
│ │ - go2rtc streams with camera IPs │ │
35+
│ │ - ONVIF host addresses │ │
36+
│ └──────────────────────────────────────────────────────────┘ │
37+
│ │ │
38+
│ ▼ │
39+
│ ┌──────────────────────────────────────────────────────────┐ │
40+
│ │ Deployment: frigate │ │
41+
│ │ - Restarts when ConfigMap changes │ │
42+
│ │ - Reconnects to cameras at new IPs │ │
43+
│ └──────────────────────────────────────────────────────────┘ │
44+
└─────────────────────────────────────────────────────────────────┘
45+
```
46+
47+
## Camera Identification
48+
49+
Since both cameras have identical credentials, identification is done by:
50+
- **Hostname**: Cameras register as `Hall.attlocal.net` and `Living.attlocal.net` via DHCP
51+
- **MAC Address**: `0C:79:55:4B:D4:2A` (Hall), `14:EA:63:A9:04:08` (Living Room)
52+
53+
Note: Hostname-based identification requires reverse DNS, which may not work from K8s. MAC-based identification requires nmap with privileges on the same L2 network.
54+
55+
## Current Limitation
56+
57+
The CronJob can detect when cameras are down and find new IPs, but **cannot definitively identify which camera is which** without:
58+
1. Reverse DNS working (AT&T router DNS)
59+
2. Running on HAOS (which can do nmap with MAC visibility)
60+
3. Using authenticated ONVIF queries
61+
62+
## Files
63+
64+
| Path | Description |
65+
|------|-------------|
66+
| `gitops/clusters/homelab/apps/frigate-ip-webhook/deployment.yaml` | ServiceAccount and RBAC for ConfigMap access |
67+
| `gitops/clusters/homelab/apps/frigate-ip-webhook/camera-discovery-cronjob.yaml` | CronJob that scans for cameras |
68+
| `gitops/clusters/homelab/apps/frigate-ip-webhook/webhook.py` | HTTP webhook for HA integration (optional) |
69+
| `scripts/haos/camera-ip-sync.sh` | HAOS script with MAC-based identification |
70+
71+
## Manual Recovery
72+
73+
If cameras change IPs and auto-sync doesn't identify them correctly:
74+
75+
```bash
76+
# From HAOS, find cameras by MAC
77+
docker exec homeassistant nmap -sn 192.168.1.0/24 | grep -B2 -E "0C:79:55|14:EA:63"
78+
79+
# Update Frigate config
80+
# Edit gitops/clusters/homelab/apps/frigate/configmap.yaml
81+
# Commit and push
82+
flux reconcile kustomization flux-system --with-source
83+
```
84+
85+
## Future Improvements
86+
87+
1. **Use DHCP reservations** on a proper router (Pi-hole, pfSense)
88+
2. **Store MAC→camera mapping** and run discovery from HAOS
89+
3. **Use Reolink P2P** protocol with camera UIDs (like the Reolink app does)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM python:3.12-slim
2+
3+
RUN pip install --no-cache-dir flask kubernetes
4+
5+
WORKDIR /app
6+
COPY webhook.py .
7+
8+
EXPOSE 8080
9+
CMD ["python", "-u", "webhook.py"]
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
---
2+
# CronJob to discover Reolink camera IPs and update Frigate ConfigMap
3+
# Runs every 5 minutes, scans for ONVIF devices, identifies cameras by DNS hostname
4+
apiVersion: batch/v1
5+
kind: CronJob
6+
metadata:
7+
name: camera-ip-discovery
8+
namespace: frigate
9+
spec:
10+
schedule: "*/5 * * * *" # Every 5 minutes
11+
concurrencyPolicy: Forbid
12+
successfulJobsHistoryLimit: 1
13+
failedJobsHistoryLimit: 3
14+
jobTemplate:
15+
spec:
16+
ttlSecondsAfterFinished: 300
17+
template:
18+
spec:
19+
serviceAccountName: frigate-ip-webhook
20+
restartPolicy: OnFailure
21+
containers:
22+
- name: discover
23+
image: python:3.12-slim
24+
command: ["sh", "-c"]
25+
args:
26+
- |
27+
pip install -q kubernetes
28+
python3 << 'PYEOF'
29+
import re
30+
import socket
31+
from datetime import datetime
32+
from kubernetes import client, config
33+
34+
NAMESPACE = "frigate"
35+
CONFIGMAP_NAME = "frigate-config"
36+
DEPLOYMENT_NAME = "frigate"
37+
38+
# Camera identification by reverse DNS hostname (set in Reolink app)
39+
# These are the hostnames registered via DHCP
40+
CAMERA_HOSTNAMES = {
41+
"living": "living_room", # Living.attlocal.net -> living_room
42+
"hall": "hall", # Hall.attlocal.net -> hall
43+
}
44+
45+
def find_onvif_devices(ip_range_start=130, ip_range_end=150):
46+
"""Scan for devices with ONVIF port 8000 open."""
47+
devices = []
48+
for i in range(ip_range_start, ip_range_end + 1):
49+
ip = f"192.168.1.{i}"
50+
try:
51+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
52+
s.settimeout(0.5)
53+
if s.connect_ex((ip, 8000)) == 0:
54+
devices.append(ip)
55+
s.close()
56+
except:
57+
pass
58+
return devices
59+
60+
def check_rtsp(ip):
61+
"""Check if RTSP port 554 is open."""
62+
try:
63+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
64+
s.settimeout(2)
65+
result = s.connect_ex((ip, 554))
66+
s.close()
67+
return result == 0
68+
except:
69+
return False
70+
71+
def get_hostname(ip):
72+
"""Get reverse DNS hostname for IP."""
73+
try:
74+
hostname, _, _ = socket.gethostbyaddr(ip)
75+
return hostname.lower()
76+
except:
77+
return None
78+
79+
def identify_camera(ip):
80+
"""Identify camera by its hostname."""
81+
hostname = get_hostname(ip)
82+
if hostname:
83+
for prefix, camera_name in CAMERA_HOSTNAMES.items():
84+
if hostname.startswith(prefix):
85+
return camera_name
86+
return None
87+
88+
def get_current_ips():
89+
"""Get current camera IPs from Frigate ConfigMap."""
90+
config.load_incluster_config()
91+
v1 = client.CoreV1Api()
92+
cm = v1.read_namespaced_config_map(name=CONFIGMAP_NAME, namespace=NAMESPACE)
93+
config_yaml = cm.data.get("config.yml", "")
94+
ips = {}
95+
match = re.search(r'living_room_main:.*?@(\d+\.\d+\.\d+\.\d+):', config_yaml)
96+
if match:
97+
ips["living_room"] = match.group(1)
98+
match = re.search(r'hall_main:.*?@(\d+\.\d+\.\d+\.\d+):', config_yaml)
99+
if match:
100+
ips["hall"] = match.group(1)
101+
return ips, config_yaml, cm
102+
103+
def update_configmap(cm, config_yaml, old_ip, new_ip, camera_name):
104+
"""Update ConfigMap with new IP and restart Frigate."""
105+
config.load_incluster_config()
106+
v1 = client.CoreV1Api()
107+
apps_v1 = client.AppsV1Api()
108+
109+
new_config = config_yaml.replace(old_ip, new_ip)
110+
cm.data["config.yml"] = new_config
111+
112+
v1.patch_namespaced_config_map(
113+
name=CONFIGMAP_NAME,
114+
namespace=NAMESPACE,
115+
body={"data": cm.data}
116+
)
117+
print(f"Updated ConfigMap: {camera_name} {old_ip} -> {new_ip}")
118+
119+
patch = {
120+
"spec": {
121+
"template": {
122+
"metadata": {
123+
"annotations": {
124+
"camera-ip-updated": datetime.utcnow().isoformat()
125+
}
126+
}
127+
}
128+
}
129+
}
130+
apps_v1.patch_namespaced_deployment(
131+
name=DEPLOYMENT_NAME,
132+
namespace=NAMESPACE,
133+
body=patch
134+
)
135+
print("Triggered Frigate restart")
136+
137+
# Main
138+
print(f"=== Camera IP Discovery [{datetime.now().isoformat()}] ===")
139+
140+
current_ips, config_yaml, cm = get_current_ips()
141+
print(f"Current IPs: {current_ips}")
142+
143+
# Check current IPs
144+
all_good = True
145+
for camera_name, ip in current_ips.items():
146+
if check_rtsp(ip):
147+
print(f" {camera_name} ({ip}): OK")
148+
else:
149+
print(f" {camera_name} ({ip}): DOWN")
150+
all_good = False
151+
152+
if all_good:
153+
print("All cameras OK.")
154+
else:
155+
print("\nScanning for cameras...")
156+
devices = find_onvif_devices()
157+
cameras_found = [(ip, identify_camera(ip)) for ip in devices if check_rtsp(ip)]
158+
print(f"Found: {cameras_found}")
159+
160+
# Build new IP mapping
161+
new_ips = {}
162+
for ip, name in cameras_found:
163+
if name:
164+
new_ips[name] = ip
165+
166+
# Update any changed IPs
167+
for camera_name, old_ip in current_ips.items():
168+
if camera_name in new_ips and new_ips[camera_name] != old_ip:
169+
update_configmap(cm, config_yaml, old_ip, new_ips[camera_name], camera_name)
170+
config_yaml = config_yaml.replace(old_ip, new_ips[camera_name])
171+
172+
print("Done.")
173+
PYEOF
174+
resources:
175+
requests:
176+
memory: "64Mi"
177+
cpu: "50m"
178+
limits:
179+
memory: "128Mi"
180+
cpu: "200m"
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
apiVersion: v1
3+
kind: ServiceAccount
4+
metadata:
5+
name: frigate-ip-webhook
6+
namespace: frigate
7+
---
8+
apiVersion: rbac.authorization.k8s.io/v1
9+
kind: Role
10+
metadata:
11+
name: frigate-ip-webhook
12+
namespace: frigate
13+
rules:
14+
- apiGroups: [""]
15+
resources: ["configmaps"]
16+
verbs: ["get", "patch", "update"]
17+
- apiGroups: ["apps"]
18+
resources: ["deployments"]
19+
verbs: ["get", "patch"]
20+
---
21+
apiVersion: rbac.authorization.k8s.io/v1
22+
kind: RoleBinding
23+
metadata:
24+
name: frigate-ip-webhook
25+
namespace: frigate
26+
subjects:
27+
- kind: ServiceAccount
28+
name: frigate-ip-webhook
29+
namespace: frigate
30+
roleRef:
31+
kind: Role
32+
name: frigate-ip-webhook
33+
apiGroup: rbac.authorization.k8s.io
34+
---
35+
apiVersion: apps/v1
36+
kind: Deployment
37+
metadata:
38+
name: frigate-ip-webhook
39+
namespace: frigate
40+
labels:
41+
app: frigate-ip-webhook
42+
spec:
43+
replicas: 1
44+
selector:
45+
matchLabels:
46+
app: frigate-ip-webhook
47+
template:
48+
metadata:
49+
labels:
50+
app: frigate-ip-webhook
51+
spec:
52+
serviceAccountName: frigate-ip-webhook
53+
containers:
54+
- name: webhook
55+
image: ghcr.io/homeiac/frigate-ip-webhook:latest
56+
imagePullPolicy: Always
57+
ports:
58+
- containerPort: 8080
59+
env:
60+
- name: FRIGATE_NAMESPACE
61+
value: "frigate"
62+
- name: FRIGATE_CONFIGMAP
63+
value: "frigate-config"
64+
- name: FRIGATE_DEPLOYMENT
65+
value: "frigate"
66+
resources:
67+
requests:
68+
memory: "32Mi"
69+
cpu: "10m"
70+
limits:
71+
memory: "64Mi"
72+
cpu: "100m"
73+
livenessProbe:
74+
httpGet:
75+
path: /health
76+
port: 8080
77+
initialDelaySeconds: 5
78+
periodSeconds: 30
79+
readinessProbe:
80+
httpGet:
81+
path: /health
82+
port: 8080
83+
initialDelaySeconds: 5
84+
periodSeconds: 10
85+
---
86+
apiVersion: v1
87+
kind: Service
88+
metadata:
89+
name: frigate-ip-webhook
90+
namespace: frigate
91+
spec:
92+
selector:
93+
app: frigate-ip-webhook
94+
ports:
95+
- port: 8080
96+
targetPort: 8080
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: kustomize.config.k8s.io/v1beta1
2+
kind: Kustomization
3+
namespace: frigate
4+
resources:
5+
- deployment.yaml

0 commit comments

Comments
 (0)