I had an old iPhone in a drawer. It is stuck on iOS 12.5.8 and cannot install current apps. The camera still works.
I wanted to check on my pet while I am away. I tried the App Store first. I found no app that runs on iOS 12 and is free to use. Every one I tried had in-app purchases or severe limits, such as a single camera.
So I wrote a web page instead. Safari opens it, and it publishes the camera over WebRTC to Home Assistant. There is nothing to install on the phone, no subscription and no limits. It also sends a push with a picture when something moves.
Three things get in the way. iOS only gives camera access to HTTPS pages. Home Assistant's own HTTPS breaks plain-HTTP clients. And iOS 12 Safari would not open a websocket to my server at all.
Contents
Overview
old iPhone (Safari, iOS 12) Home Assistant OS
https://haos.home.arpa/local/cam.html
| +-----------------------------+
| 1. HTTPS POST (SDP offer) | NGINX SSL proxy app :443 |
+-----------------------------------> | /go2rtc/ -> go2rtc :1984 |
| | everything else -> HA :80 |
| 2. WebRTC video, UDP :8555 | |
+-----------------------------------> | go2rtc (WebRTC Camera) |
| |
motion on the phone | package pet_cam.yaml |
| 3. POST /api/webhook/<id> | webhook -> snapshot -> |
+-----------------------------------> | notify mobile app |
+-----------------------------+
- No app on the phone. Safari opens one web page.
- The phone publishes its camera to go2rtc over WebRTC.
- Signalling is one HTTPS POST, not a websocket.
- Movement is detected on the phone. The server does no video processing.
- Home Assistant sends a push with a snapshot to the Companion app on another phone.
- It survives an HA restart and a full VM reboot. I tested both.
- Watching while away needs a way into your home network. I use Tailscale with a subnet router and an app connector for
*.home.arpa, described in an earlier article. The viewing device must trust the CA from Step 5. Remote viewing is not part of the steps below and I did not test it for this article.
Before you start
What I ran this on:
- Home Assistant OS 2026.10 in a Proxmox VM, 2 cores, 4 GB RAM.
- HACS installed.
- A LAN DNS server I can add records to (Pi-hole). The router's DNS would also do.
- A second phone with the Home Assistant Companion app, for the push.
- A Linux or macOS machine with
openssl. - The Terminal & SSH app installed in HA. Every file write below uses it.
- Phone and HA on the same LAN. The phone must reach HA on TCP 443 and UDP 8555 (WebRTC media). The checks that use
http://haos.home.arpa:1984also need TCP 1984 from the laptop. If your network blocks it, usehttps://haos.home.arpa/go2rtc/api/streamswith--cacert ca.crtafter Step 6.
Each step says where to run things:
- laptop: your own machine.
- HA UI: the Home Assistant web interface.
-
HA shell: the Terminal & SSH app. It has the
hacommand and mounts/config,/ssland/share. Write files withcat > /pathand paste, or withnano. - Pi-hole host: wherever your DNS server runs.
Names used below: haos.home.arpa for the hostname, 192.168.1.50 for the HA address. Replace both with yours. home.arpa is the name reserved for home networks (RFC 8375).
Why a proxy and not HA's own TLS
HA can serve TLS itself with ssl_certificate in the http: config. I did not use it. Two reasons:
- It switches the one listener to HTTPS-only. Anything on your network that talks plain HTTP to HA breaks. In my case that was another service reading HA entities.
- On HA 2026.10 the
http:block inconfiguration.yamlis ignored. The http integration imports it once into.storage/http, setsyaml_migration_done, and from then on raises a repair issue if the YAML is still there.
So HTTP stays on port 80 and a proxy terminates TLS on 443.
If you only need live video and no motion alerts, you can skip the proxy. See Skip the proxy if you only need video.
Gotcha: on HA under Supervisor the web port is 80, not 8123. See Check the version facts first. Check .storage/http (server_port) or just curl http://<ha-ip>/ before you trust old tutorials.
Setup
Eight steps. Do them in order.
Step 1: Make a local CA and a server certificate
Alternative: use a certificate from a public CA and skip the CA, the phone trust step (Step 5) and manual renewal. I did not test it. See Use a real certificate.
One CA you install on the phones. One server certificate signed by it. The certificate must list every name and address you will use.
Run this on the laptop. The HAOS host shell has no openssl:
mkdir -p ~/homelab-certs/haos && cd ~/homelab-certs/haos
# CA, valid 10 years
openssl genrsa -out ca.key 4096
openssl req -x509 -new -key ca.key -sha256 -days 3650 \
-subj "/CN=Homelab Local CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-out ca.crt
# server key + request
openssl genrsa -out haos.key 2048
openssl req -new -key haos.key -subj "/CN=haos.home.arpa" -out haos.csr
# extensions: the SAN is what browsers check
cat > ext.cnf <<'EOF'
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:haos.home.arpa,IP:192.168.1.50
EOF
# sign it, 825 days
openssl x509 -req -in haos.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 825 -sha256 -extfile ext.cnf -out haos.crt
openssl verify -CAfile ca.crt haos.crt
chmod 600 ca.key haos.key
Verify it worked: the last openssl verify prints haos.crt: OK.
I used 825 days, the limit Apple documents for TLS server certificates, as a safe value. Private CAs may be exempt. I did not test a longer period.
Keep ca.key off the server. You need it again only to renew. If you lose it, every device has to trust a new CA.
Copy haos.crt and haos.key into HA's /ssl folder. The NGINX app in Step 2 reads certificates from there. Use the HA shell from the Terminal & SSH app: cat > /ssl/haos.crt, paste the contents of haos.crt from the laptop, end with Ctrl-D, and repeat for haos.key. Check each file: head -1 must print -----BEGIN ... and tail -1 must print -----END .... See Check files after you write them. On my install /ssl is /mnt/data/supervisor/ssl on the host.
The later curl --cacert ca.crt examples assume you run them on the laptop in ~/homelab-certs/haos.
Step 2: Install the NGINX proxy app and point it at the certificate
In the HA UI: Settings, Apps (called Add-ons before 2025), install NGINX Home Assistant SSL proxy from the official store. Open its Configuration tab and set:
domain: haos.home.arpa
certfile: haos.crt
keyfile: haos.key
hsts: ""
cloudflare: false
use_ssl_backend: false
client_max_body_size_megabytes: 1
customize:
active: false
default: nginx_proxy_default*.conf
servers: nginx_proxy/*.conf
real_ip_from: []
The last three options are the defaults. Enable "Start on boot", then start it.
Gotcha: leave hsts empty. HSTS is per host name, not per port. If you set it, browsers force http://haos.home.arpa to HTTPS and anything still on plain HTTP is locked out.
Gotcha: HTTPS works only with the host name, not the bare IP. A browser does not send a name (SNI) when you type an IP. The app's catch-all server rejects those connections (ssl_reject_handshake on in its nginx template). I tried adding a second server block for the IP. It is never chosen without SNI, so it does not help. Use the name.
Gotcha: if you edit app options through the Supervisor API instead of the UI, the call needs every option, not only the ones you change. A partial body fails with Missing option 'cloudflare'.
Step 3: Add the DNS record
Add a record haos.home.arpa -> 192.168.1.50 to your LAN DNS. On the Pi-hole host, for Pi-hole v6:
pihole-FTL --config dns.hosts '["192.168.1.50 haos.home.arpa"]'
That command replaces the whole list. Read it first with pihole-FTL --config dns.hosts and include the existing entries in the new array. Pi-hole applies the change without a restart.
The laptop and the old phone must use that DNS server. Mine get it from the router. If yours do not, set it by hand in the Wi-Fi settings.
Verify it worked: getent hosts haos.home.arpa (or nslookup haos.home.arpa) on the laptop returns 192.168.1.50.
Step 4: Tell HA to trust the proxy
Open https://haos.home.arpa/ now. It returns 400: Bad Request. The HA log says:
A request from a reverse proxy was received from 172.30.33.1, but your HTTP integration is not set-up for reverse proxies
HA must be told which proxy addresses to believe. On 2026.10 this lives in .storage/http, not YAML. Use the HA shell. The Terminal & SSH app has nano and vi.
- Stop core:
ha core stop. - Copy the file first:
cp /config/.storage/http /config/http.bak. Delete the copy when everything works. - Open
/config/.storage/http. Inside the existingdata->stableobject, add the two keysuse_x_forwarded_forandtrusted_proxies. Leave every other key alone. The file looks like this (yours may have more keys and other values):
{
"version": 2,
"minor_version": 2,
"key": "http",
"data": {
"stable": {
"server_port": 80,
"use_x_forwarded_for": true,
"trusted_proxies": ["172.30.33.0/24"],
"ip_ban_enabled": true
},
"pending": null,
"yaml_migration_done": true
}
}
Keep the JSON valid.
- While core is stopped, also create the folders later steps need:
mkdir -p /config/www /config/packages. HA only serves/local/for awwwfolder that existed when core started. - Start core:
ha core start.
On a running HA 2026.10 the file exists. If yours is missing or looks very different, you are on another version and this step may not apply. Use the address from your own log line (HA UI, Settings, System, Logs; the UI still works over plain HTTP at this point). 172.30.33.0/24 is the network of the proxy app on my install. The HAOS host itself is 172.30.32.1. They are different networks.
Gotcha: edit the file with core stopped. HA manages .storage files itself, and an edit made while it runs can be overwritten.
Verify it worked:
curl --cacert ca.crt https://haos.home.arpa/ -o /dev/null -w "%{http_code}\n" # 200
curl http://haos.home.arpa/ -o /dev/null -w "%{http_code}\n" # 200
Both must answer.
Step 5: Trust the CA on the phone
Skip this step if you use a real certificate.
Do this on the iOS 12 phone:
- Get
ca.crtonto the phone. In the HA shell:cpor paste it to/config/www/ca.crt. On the phone, openhttp://haos.home.arpa/local/ca.crtin Safari (plain HTTP on purpose, since HTTPS is not trusted yet). Install the profile (Settings, General, Profile). Delete/config/www/ca.crtafterwards. It is a public certificate, but there is no reason to leave it served. - Settings, General, About, Certificate Trust Settings. Switch the CA on. Installing alone is not enough.
- Open
https://haos.home.arpa/in Safari. No warning and a lock means it worked.
Gotcha: tapping through a certificate warning lets the page load. It does not make the phone trust the host. Install and enable the CA.
Step 6: go2rtc, the stream and the proxy path
This step uses the WebRTC Camera integration. A visible go2rtc app may be simpler. See Try the go2rtc app instead of the integration.
go2rtc is the part that receives the WebRTC video. HA has its own copy for its camera features. That one is internal. You want a separate one on port 1984 with a stream you define.
Install the WebRTC Camera integration:
-
HA UI, HACS, menu, Custom repositories. Add
https://github.com/AlexxIT/WebRTCas an Integration. - Download it from HACS and restart HA.
- Settings, Devices & services, Add integration, WebRTC Camera. It has no options. On my install no further restart was needed for the next check.
- In the HA shell:
ls /config/go2rtc*. A file likego2rtc-1.9.9should be there, andcurl http://haos.home.arpa:1984/apishould answer with JSON.
If the binary does not appear, nothing below will work. I have no tested fallback.
On my install a binary /config/go2rtc-1.9.9 appeared at that moment (its file time matches the time the config entry was created). It runs after every HA start and listens on 1984 (API), 8554 (RTSP) and 8555 (WebRTC). I did not read the integration's code to confirm that it launches the binary. The restart tests below are my evidence.
HA's built-in go2rtc is a different process on other ports. Ignore it.
Declare the stream in /config/go2rtc.yaml (the HA config folder):
streams:
old_phone_pet_cam:
The value is empty on purpose. It makes the stream exist so something can publish into it.
Restart core with ha core restart so go2rtc rereads the file.
Verify it worked:
curl http://haos.home.arpa:1984/api/streams
# {"old_phone_pet_cam":{"producers":null,"consumers":null}}
Gotcha: without the stream in go2rtc.yaml, publishing fails with stream not found. The viewer error streams: unknown error only means the stream exists and nobody is publishing.
Gotcha: do not start a second go2rtc, for example as a Docker container. It loses the race for ports 1984 and 8554 to the one HA starts and logs address already in use, while the first one keeps answering. The setup then looks fine and the second instance does nothing.
go2rtc speaks plain HTTP. The phone's page is HTTPS, so route go2rtc through the NGINX app. With customize.active on, the app includes /share/<customize.default> inside its HTTPS server block, before its own location /. I confirmed this in the app's nginx template.
Create /share/nginx_proxy_default.conf (the Samba share share; host path /mnt/data/supervisor/share/ on my VM):
# go2rtc (host process, plain HTTP/WS on :1984) behind the app's TLS.
# homeassistant.local.hass.io = the HAOS host (172.30.32.1), same name the app uses to reach HA.
location /go2rtc/ {
proxy_pass http://homeassistant.local.hass.io:1984/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_read_timeout 3600s;
}
Set customize.active: true in the app options (same options as Step 2) and restart the app. If /go2rtc/ then returns 502, the name does not resolve in your app version. Replace it in the file with the HAOS host address (172.30.32.1 on mine).
homeassistant.local.hass.io is the name the app itself uses to reach HA. Inside the app it resolves to the HAOS host (172.30.32.1 on mine), where go2rtc listens on all interfaces. $connection_upgrade is defined by the app's own nginx config, so the include can use it.
Verify it worked:
curl --cacert ca.crt https://haos.home.arpa/go2rtc/api/streams
Gotcha: go2rtc on 1984 and 8554 has no login. Anyone on your LAN can see the stream and the API. Do not forward those ports.
Gotcha: wss://haos.home.arpa:1984 will not work. go2rtc has no TLS on that port. Everything goes through /go2rtc/ on 443.
Step 7: The camera page
Generate one webhook id on the laptop. You use the same value three times: in cam.html, in pet_cam.yaml (Step 8) and in the test curl (Step 8):
python3 -c 'import secrets; print(secrets.token_hex(16))'
Put the file below at /config/www/cam.html (use the HA shell). The www folder was created in Step 4. HA then serves the file at https://haos.home.arpa/local/cam.html. Replace <WEBHOOK_ID> with your id:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iOS 12 Pet Cam Streamer</title>
<style>
body { font-family: -apple-system, sans-serif; background: #000; color: #fff; text-align: center; margin: 0; padding: 20px; }
video { width: 100%; max-width: 400px; background: #222; border-radius: 8px; transform: scaleX(-1); }
.status { margin: 15px; font-size: 14px; color: #aaa; font-weight: bold; }
</style>
</head>
<body>
<h3>iOS 12 Camera Broadcaster</h3>
<!-- These elements MUST exist before the script can find them -->
<video id="localVideo" autoplay playsinline muted></video>
<div id="status" class="status">Loading configuration...</div>
<div id="motion" class="status">Motion: waiting</div>
<script>
var STREAM_NAME = "old_phone_pet_cam";
// WHIP publish: SDP offer POSTed over plain HTTPS (iOS 12 Safari cannot open
// websockets to this server). Path is proxied to go2rtc by the HA nginx app.
var WHIP_URL = "/go2rtc/api/webrtc?dst=" + STREAM_NAME;
var pc = null;
function setStatus(text, color) {
var el = document.getElementById("status");
el.innerText = text;
if (color) { el.style.color = color; }
}
function waitForIce(peer, ms) {
return new Promise(function(resolve) {
if (peer.iceGatheringState === "complete") { resolve(); return; }
var done = function() { peer.onicegatheringstatechange = null; resolve(); };
peer.onicegatheringstatechange = function() {
if (peer.iceGatheringState === "complete") { done(); }
};
setTimeout(done, ms);
});
}
function post(url, body) {
return new Promise(function(resolve, reject) {
var x = new XMLHttpRequest();
x.open("POST", url);
x.setRequestHeader("Content-Type", "application/sdp");
x.onload = function() {
if (x.status >= 200 && x.status < 300) { resolve(x.responseText); }
else { reject(new Error("WHIP HTTP " + x.status)); }
};
x.onerror = function() { reject(new Error("WHIP network error")); };
x.send(body);
});
}
var localStream = null;
var retryTimer = null;
// Any failure (go2rtc/HA restarting, network drop) tears the peer down and retries.
function scheduleRetry(reason) {
setStatus(reason + " - retrying in 5s...", "#f44336");
if (pc) { try { pc.close(); } catch (e) {} pc = null; }
if (retryTimer) { return; }
retryTimer = setTimeout(function() { retryTimer = null; connect(); }, 5000);
}
function connect() {
setStatus("Connecting to go2rtc engine...");
var peer = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
pc = peer;
localStream.getTracks().forEach(function(t) { peer.addTrack(t, localStream); });
peer.oniceconnectionstatechange = function() {
if (pc !== peer) { return; }
var st = peer.iceConnectionState;
if (st === "connected" || st === "completed") {
setStatus("LIVE: BROADCASTING TO HOME ASSISTANT", "#4caf50");
} else if (st === "failed" || st === "disconnected" || st === "closed") {
scheduleRetry("ICE " + st);
}
};
peer.createOffer()
.then(function(offer) { return peer.setLocalDescription(offer); })
.then(function() { setStatus("Gathering ICE candidates..."); return waitForIce(peer, 3000); })
.then(function() { setStatus("Sending offer to go2rtc..."); return post(WHIP_URL, peer.localDescription.sdp); })
.then(function(answerSdp) {
setStatus("Got answer, connecting media...");
return peer.setRemoteDescription(new RTCSessionDescription({ type: "answer", sdp: answerSdp }));
})
.catch(function(err) {
if (pc === peer) { scheduleRetry("Error: " + (err && err.message ? err.message : err)); }
});
}
function startStream() {
var video = document.getElementById("localVideo");
setStatus("Requesting device camera access...");
var constraints = {
audio: false,
video: { facingMode: "environment", width: 640, height: 480, frameRate: 15 }
};
navigator.mediaDevices.getUserMedia(constraints)
.then(function(stream) {
localStream = stream;
video.srcObject = stream;
connect();
startMotion();
})
.catch(function(err) {
setStatus("Camera error: " + (err && err.message ? err.message : err), "#f44336");
});
}
// ---- Motion detection (runs on the phone, no server load) ----
// Frame-diff on a tiny grayscale canvas. On motion, POST the local-only HA
// webhook; the HA automation sends the push + snapshot.
var MOTION_WEBHOOK = "/api/webhook/<WEBHOOK_ID>";
var MOTION = { W: 64, H: 48, PIXEL: 25, FRACTION: 0.02, INTERVAL: 500, COOLDOWN: 60000, WARMUP: 8000 };
var motionPrev = null, motionLast = 0, motionStart = 0, motionRetryAt = 0;
function setMotion(text, color) {
var el = document.getElementById("motion");
el.innerText = text;
el.style.color = color || "#aaa";
}
function motionTick() {
var video = document.getElementById("localVideo");
if (video.readyState < 2) { return; }
var cv = document.getElementById("motionCanvas");
var ctx = cv.getContext("2d");
ctx.drawImage(video, 0, 0, MOTION.W, MOTION.H);
var d = ctx.getImageData(0, 0, MOTION.W, MOTION.H).data;
var n = MOTION.W * MOTION.H, gray = new Array(n), changed = 0, i;
for (i = 0; i < n; i++) {
gray[i] = (d[i * 4] + d[i * 4 + 1] + d[i * 4 + 2]) / 3;
if (motionPrev && Math.abs(gray[i] - motionPrev[i]) > MOTION.PIXEL) { changed++; }
}
var frac = changed / n;
motionPrev = gray;
var now = new Date().getTime();
if (now - motionStart < MOTION.WARMUP) { setMotion("Motion: warming up"); return; }
if (frac > MOTION.FRACTION) {
if (now - motionLast > MOTION.COOLDOWN && now > motionRetryAt) {
sendMotion(frac, now);
} else {
setMotion("Motion: detected (waiting)", "#ff9800");
}
} else if (now - motionLast > MOTION.COOLDOWN) {
setMotion("Motion: none");
}
}
// Cooldown starts only when HA accepted the webhook; on failure retry in 10 s.
function sendMotion(frac, now) {
var x = new XMLHttpRequest();
x.open("POST", MOTION_WEBHOOK);
x.setRequestHeader("Content-Type", "application/json");
x.onload = function() {
if (x.status >= 200 && x.status < 300) {
motionLast = new Date().getTime();
setMotion("Motion: ALERT SENT " + new Date().toLocaleTimeString(), "#ff9800");
} else {
motionRetryAt = new Date().getTime() + 10000;
setMotion("Motion: ALERT FAILED (HTTP " + x.status + ")", "#f44336");
}
};
x.onerror = function() {
motionRetryAt = new Date().getTime() + 10000;
setMotion("Motion: ALERT FAILED (network)", "#f44336");
};
motionRetryAt = now + 10000; // no duplicate send while this one is in flight
x.send(JSON.stringify({ changed_pct: Math.round(frac * 100) }));
}
function startMotion() {
var cv = document.createElement("canvas");
cv.id = "motionCanvas";
cv.width = MOTION.W;
cv.height = MOTION.H;
cv.style.display = "none";
document.body.appendChild(cv);
motionStart = new Date().getTime();
setInterval(motionTick, MOTION.INTERVAL);
}
setTimeout(startStream, 500);
</script>
</body>
</html>
What it does:
-
No framework,
varand XHR only. iOS 12 Safari does not run modern syntax or bundles. -
setTimeout(startStream, 500). An early version crashed withCannot set properties of nullon the phone. Adding the delay fixed it. I kept it. -
WHIP instead of a websocket. WHIP is a way to publish WebRTC with one HTTP POST. The page POSTs its SDP offer (the text that describes the media session) to
/go2rtc/api/webrtc?dst=old_phone_pet_camand reads the SDP answer from the response. - It retries every 5 seconds on any failure. After an HA restart it publishes again without anyone touching the phone.
Gotcha: my first version used go2rtc's websocket API (/api/ws). Start with WHIP instead. See Start with WHIP, not a websocket. On iOS 12.5.8 Safari it failed every time with close code 1006. It failed through nginx and against go2rtc's own TLS port. The page itself loaded with no certificate warning, so trust was not the cause. A websocket opened to Home Assistant's own /api/websocket failed the same way. I did not find the cause, so I used WHIP. A status line per stage found the failure quickly. See Build a diagnostic page first. Desktop Chrome worked with the websocket through nginx. I do not know why iOS 12 fails.
Gotcha: dst= publishes, src= views. With src= go2rtc treats your offer as a viewer and answers stream not found or unknown error.
Note: ICE candidates are the addresses the two sides can use to reach each other. The page sends them inside the offer (non-trickle) and waits up to 3 seconds for gathering to finish before it posts.
Note: the page uses Google's public STUN server (stun.l.google.com), a service that tells a client its public address. It is the only outside address involved. On one LAN the local candidates should be enough. I did not test removing it.
Gotcha: HA serves /local/ with long cache headers. After you edit cam.html, open it with a new query string (cam.html?v=2), or Safari keeps running the old copy.
Gotcha: cam.html contains the webhook id, and /local/ has no login. Anyone on your LAN who opens the page can read it. The webhook is local_only, so the exposure is limited to your LAN.
Open the page on the old phone and allow the camera. The status line goes through Connecting, Gathering ICE candidates, Sending offer, Got answer and ends on LIVE: BROADCASTING TO HOME ASSISTANT.
Verify it worked: the producers list now has an entry whose user_agent contains iPhone and iPhone OS 12:
curl http://haos.home.arpa:1984/api/streams
To watch the stream in a desktop browser:
https://haos.home.arpa/go2rtc/stream.html?src=old_phone_pet_cam
The same stream is available as RTSP at rtsp://192.168.1.50:8554/old_phone_pet_cam. You can add it to HA with the Generic Camera integration if you want an entity.
Step 8: Motion alerts (optional)
Detection runs in the page. Every 500 ms it draws the video into a 64 by 48 canvas and compares it with the previous frame. If more than 2 percent of the pixels changed by more than 25 gray levels, it POSTs a webhook to HA. The first 8 seconds are ignored. After an alert it waits 60 seconds. The cooldown starts only when HA answers with a 2xx. On a failed POST the page shows ALERT FAILED and tries again after 10 seconds.
The constants are at the top of the motion block in cam.html: PIXEL, FRACTION, COOLDOWN, WARMUP.
On the HA side you need three things.
First, packages must be enabled in configuration.yaml. Merge this into your existing homeassistant: key if you have one. A second homeassistant: key breaks the file:
homeassistant:
packages: !include_dir_named packages
Second, find your notify service name in Developer tools, Actions, by searching notify.mobile_app. It is notify.mobile_app_<device>, built from the device name in the Companion app. Put yours into the package below.
Third, create /config/packages/pet_cam.yaml (the folder was created in Step 4). Use your webhook id from Step 7. The package's automation: list merges with an existing automation: !include automations.yaml:
# Pet cam: motion webhook -> snapshot -> push
shell_command:
# Remove the old frame first so a failed fetch never sends a stale picture
pet_cam_snapshot_clear: "rm -f /config/www/pet_cam_last.jpg"
# Still frame from go2rtc (runs inside the HA container, host network)
pet_cam_snapshot: "curl -sf -o /config/www/pet_cam_last.jpg 'http://localhost:1984/api/frame.jpeg?src=old_phone_pet_cam'"
automation:
- alias: "Pet cam: motion notification"
id: pet_cam_motion_notify
mode: single
max_exceeded: silent
triggers:
- trigger: webhook
webhook_id: "<WEBHOOK_ID>"
allowed_methods: [POST]
local_only: true
actions:
- action: shell_command.pet_cam_snapshot_clear
- action: shell_command.pet_cam_snapshot
continue_on_error: true # no publisher -> push goes out without an image
- action: notify.mobile_app_<device>
data:
title: "Pet cam"
message: "Movement detected"
data:
tag: pet_cam_motion
image: "/local/pet_cam_last.jpg?t={{ now().timestamp() | int }}"
Replace <device> with your device name first. The configuration check does not validate a notify service. A wrong name fails when the automation runs (see Settings, System, Logs). Then run Developer tools, YAML, Check configuration, and restart HA core with ha core restart. I restarted. I did not test whether a reload is enough.
Verify it worked: send the webhook by hand from the laptop:
curl -X POST https://haos.home.arpa/api/webhook/<WEBHOOK_ID> --cacert ca.crt
The Companion app shows a push with a picture.
Gotcha: HA answers 200 to a webhook POST even when the id does not exist. A 200 proves nothing. Check the push, or check that /config/www/pet_cam_last.jpg has a new timestamp.
Gotcha: local_only: true is checked against the real client address. That only works because of Step 4 (use_x_forwarded_for). Without it every request looks like it comes from the proxy.
Gotcha: the snapshot is saved under /config/www/. /local/ serves that folder with no login to anyone on your LAN who knows the URL. The webhook id is a secret for the same reason. Keep it out of git.
Gotcha: a failed snapshot used to send the previous picture with a fresh timestamp. The package now deletes the old file first and uses continue_on_error. When there is no publisher, the push goes out without an image.
Gotcha: the snapshot step runs curl inside the HA container and asks go2rtc for a JPEG at /api/frame.jpeg. go2rtc needs ffmpeg for that. Both are in the HA container on HAOS. On a different install, check docker exec homeassistant which curl ffmpeg. If either is missing, the snapshot fails and the push goes out without a picture.
To tune it: too many false alerts means raise FRACTION or PIXEL. Missed movement means lower them. Expect lighting changes to trigger it.
Operate
Restarts
I tested two things.
-
ha core restart: go2rtc came back, and the phone page reconnected on its own. - Full VM reboot (
qm reboot): HA answered again after about 70 seconds. go2rtc was up. The iPhone was publishing again with no manual step.
Each was one run.
Keep the phone plugged in and the screen awake. The page does not prevent the phone from sleeping.
Renewing the certificate
Before the server certificate expires:
- Run the signing command from Step 1 again with the same
ext.cnfand a newhaos.csr. The CA stays the same, so no device needs to be touched. - Copy the new
haos.crtandhaos.keyinto/ssl. - Restart the NGINX app.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
https://haos.home.arpa/ returns 400: Bad Request
|
HA does not trust the proxy | Step 4: use_x_forwarded_for and trusted_proxies in .storage/http, core stopped |
http: block in configuration.yaml has no effect |
HA 2026.10 imports it once and ignores it after | Edit .storage/http with core stopped |
HTTPS by IP fails with unrecognized name
|
Browsers send no SNI for an IP; the catch-all server rejects it | Use the host name |
http://haos.home.arpa suddenly redirects to HTTPS |
HSTS set on the proxy app | Set hsts to an empty string, clear HSTS in the browser |
Apps API call fails with Missing option 'cloudflare'
|
The options call needs all fields | Send every option, or use the UI |
| Camera page does nothing on iOS | Page is not HTTPS, or CA not trusted | Steps 1 to 5 |
Status stays on Connecting to go2rtc engine
|
Websocket code on iOS 12 | Use the WHIP version of the page (Step 7) |
stream not found |
Stream is not in go2rtc.yaml, or src= used instead of dst=
|
Step 6, and use dst= to publish |
Viewer shows streams: unknown error
|
Nobody is publishing | Load the page on the phone |
go2rtc log says address already in use
|
A second go2rtc instance | Remove it; keep the one HA starts |
| Page edits have no effect on the phone |
/local/ is cached |
Open cam.html?v=N with a new N |
| Webhook returns 200 but no push | Wrong webhook id, or wrong notify service | Compare ids, check the service name in Developer tools |
| Push arrives without a picture | Snapshot fetch failed, usually no publisher | Check https://haos.home.arpa/go2rtc/api/streams for a producer |
| Too many motion alerts | Thresholds too low | Raise FRACTION or PIXEL
|
What I would change
These are the things I would do differently now. Items marked "not tested" are ideas, not results.
Use a real certificate
What I did: made a local CA, signed a server certificate, installed the CA on the phone, and put the expiry in a calendar.
What I would try: get a certificate from a public CA with a DNS challenge, for example Let's Encrypt through the DuckDNS app. The NGINX app reads fullchain.pem and privkey.pem from /ssl. The phone then needs no CA, and renewal is automatic. That removes the CA part of Step 1 and all of Step 5.
Cost: you need a public name (DuckDNS or your own domain) whose record points at your LAN address. That puts your LAN address in public DNS. iOS 12 should accept Let's Encrypt's current root. I did not check that on the phone.
Status: not tested.
Skip the proxy if you only need video
go2rtc can serve HTTPS itself (tls_listen, tls_cert and tls_key in its api section) and can serve static files (static_dir; check the docs for your version). If it serves cam.html too, the page and the WHIP POST share one origin. That removes the NGINX app, the .storage/http edit and the /go2rtc/ proxy.
The limit: the motion webhook cannot work. An HTTPS page cannot call a plain-HTTP Home Assistant, because the browser blocks it as mixed content. So this only fits live video without alerts.
Status: not tested. I enabled tls_listen once for a websocket test, which failed on iOS 12. I did not try WHIP against it.
Try the go2rtc app instead of the integration
I used the WebRTC Camera integration. It put a go2rtc binary in /config that I cannot see in the UI, and I could only confirm that it starts with HA by restarting. A go2rtc app from the community store (AlexxIT's hassio-addons repository) would run go2rtc as a visible app, with its config in the UI and a start-on-boot switch.
Status: not tested. I do not know its ports or config format.
Start with WHIP, not a websocket
go2rtc's examples use /api/ws. On iOS 12.5.8 it never connected, and most of my time went into that. A POST of the SDP offer to /api/webrtc?dst=<stream> worked on every device I tried. Use it from the first version. dst= publishes, src= views.
Build a diagnostic page first
A phone gives you no console. When the page did nothing, I guessed: certificate trust, then HTTP/2. Both guesses were wrong. What found the problem was a status line per stage, plus two probe websockets (one to Home Assistant, one to go2rtc) that printed OPEN or ERROR with the close code. Three page loads showed that websockets failed in general. Put a status line per stage into the first version.
Check the version facts first
I assumed port 8123 and that the http: YAML works. On HA 2026.10 the port was 80 and the YAML is ignored after the first import. Reading .storage/http and running curl against the port takes a minute. It would have saved two wrong turns.
Check files after you write them
A deploy command with an unset shell variable once wrote empty files over live ones. Print the size (wc -c) or the first line after every write, and keep a copy of the old file until the new one works.
Top comments (1)
The 'start with WHIP, not a websocket' is the most useful warning in this post. I've seen people spend hours on go2rtc's websocket examples without realizing that iOS 12 Safari just can't connect to them. The WHIP approach is simpler and works everywhere.