Live view at head office, relayed through the agent's outbound connection
I got this wrong first time. "Head office cannot show live video cheaply" conflated TRUE VIDEO with SEEING THE CAMERA NOW, and only the first needs WebRTC and a TURN server. The shop PC is behind a router with no inbound route, so head office cannot pull the engine's MJPEG. It can answer the agent's outbound requests, which is the shape of everything else here: the server holds a poll open, the agent asks "is anyone watching?", and pushes JPEGs up for exactly as long as somebody is. Measured on the office camera: 98 KB full frame, 20.8 KB re-encoded at 640/q60, so one watcher costs ~83 KB/s. 47 frames arrived in 12 seconds - 4 fps, as configured. The UI says "about 4 frames a second" rather than letting anyone conclude the camera stutters. Nothing is uploaded when nobody is looking, which is the whole cost argument: Publish returns false once the last viewer goes, interest lapses on a timer each viewer refreshes as it reads (so a closed tab stops the upload within seconds), one push is capped at five minutes, and the UI streams one camera at a time. LiveHub is deliberately the opposite of the arrivals Hub. There a doorbell pushes nothing because nothing may be lost; here a dropped frame is the correct outcome, so each viewer has a one-slot buffer that is overwritten - the only frame worth having is the newest, and a queue would show an ever-growing delay behind the shop instead of dropping back to live. Ownership is proved once, before anything streams: the relay is keyed on a camera id, a hub does not know whose camera it holds, and a camera id is not a secret. Verified: another tenant gets 404, no session gets 401, and an agent cannot push into another site's camera. Also fixes a bug I introduced with it - the Live button was gated on `connected`, which is head office's last report and up to two minutes stale, so it hid itself during every reconnect. "Is that camera really down?" is exactly when somebody wants to look, and a hidden control says "you cannot" where the honest answer is "here is why". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
@@ -55,6 +55,9 @@ type Local struct {
|
||||
|
||||
// Desired is a camera as head office holds it.
|
||||
type Desired struct {
|
||||
// ID is head office's uuid for this camera; CameraID is the name the
|
||||
// engine on this PC knows it by. The live relay translates between them.
|
||||
ID string `json:"id"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Host string `json:"host"`
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -98,8 +99,29 @@ func (e *EngineClient) Remove(ctx context.Context, id string) error {
|
||||
// trip. A camera that has not produced a frame yet answers 503, which is a
|
||||
// normal state on a just-added camera and not an error worth logging loudly.
|
||||
func (e *EngineClient) Snapshot(ctx context.Context, id string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
e.Base+"/api/cameras/"+url.PathEscape(id)+"/frame.jpg", nil)
|
||||
return e.Frame(ctx, id, 0, 0)
|
||||
}
|
||||
|
||||
// Frame fetches the latest frame, optionally re-encoded smaller.
|
||||
//
|
||||
// The live relay asks for ~640 px at quality 60 - about a third the bytes of
|
||||
// the full frame - because it sends several a second up a shop's uplink, where
|
||||
// the snapshot sends one a minute and can afford the detail. The engine does
|
||||
// the re-encode: it already has OpenCV open and the frame in memory, and
|
||||
// shipping a scaler into the agent to redo that would be the same work twice.
|
||||
func (e *EngineClient) Frame(ctx context.Context, id string, width, quality int) ([]byte, error) {
|
||||
q := url.Values{}
|
||||
if width > 0 {
|
||||
q.Set("width", strconv.Itoa(width))
|
||||
}
|
||||
if quality > 0 {
|
||||
q.Set("quality", strconv.Itoa(quality))
|
||||
}
|
||||
target := e.Base + "/api/cameras/" + url.PathEscape(id) + "/frame.jpg"
|
||||
if len(q) > 0 {
|
||||
target += "?" + q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
225
agent/pkg/cameras/live.go
Normal file
225
agent/pkg/cameras/live.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Live relays camera frames to head office, but only while somebody is
|
||||
// watching.
|
||||
//
|
||||
// The engine serves MJPEG on this PC's loopback and this PC sits behind a
|
||||
// router with no inbound route, so head office cannot pull it. It can answer
|
||||
// our outbound requests, which is the shape of everything else here: we ask
|
||||
// "is anyone watching?", and push frames for as long as the answer is yes.
|
||||
//
|
||||
// It is a few frames a second of re-encoded JPEG, not 25 fps video. True video
|
||||
// needs WebRTC and a TURN server; this needs neither, and answers the question
|
||||
// somebody at head office is actually asking - what does that camera see right
|
||||
// now - at a cost a shop's uplink can carry.
|
||||
//
|
||||
// **Nothing is uploaded when nobody is looking.** That is the entire cost
|
||||
// argument, and it is why the wanted-check comes first and the push stops the
|
||||
// moment the server says the last viewer has gone.
|
||||
type Live struct {
|
||||
Engine *EngineClient
|
||||
Cloud *CloudClient
|
||||
Log *log.Logger
|
||||
FPS float64
|
||||
Width int
|
||||
Quality int
|
||||
pollDelay time.Duration
|
||||
}
|
||||
|
||||
// Defaults chosen to be honest about a shop's uplink rather than impressive:
|
||||
// 640 px at quality 60 is ~25-35 KB, so 4 fps is ~120 KB/s per watcher, and a
|
||||
// camera nobody is watching costs nothing at all.
|
||||
const (
|
||||
DefaultLiveFPS = 4.0
|
||||
DefaultLiveWidth = 640
|
||||
DefaultLiveQuality = 60
|
||||
)
|
||||
|
||||
func NewLive(eng *EngineClient, cloud *CloudClient, logger *log.Logger) *Live {
|
||||
return &Live{Engine: eng, Cloud: cloud, Log: logger,
|
||||
FPS: DefaultLiveFPS, Width: DefaultLiveWidth, Quality: DefaultLiveQuality}
|
||||
}
|
||||
|
||||
// Run waits for viewers and serves them until the context ends.
|
||||
func (l *Live) Run(ctx context.Context) {
|
||||
if l.Engine == nil || l.Cloud == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
wanted, err := l.Cloud.LiveWanted(ctx)
|
||||
if err != nil {
|
||||
// Unclaimed, offline, or head office is down. All three mean the
|
||||
// same thing here - nobody can be watching - so back off rather
|
||||
// than hammering, and keep the shop's own recognition untouched.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
l.sleep(ctx, 15*time.Second)
|
||||
continue
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
// The poll is held open by the server, so an empty answer already
|
||||
// means ~25 s passed. No extra delay.
|
||||
continue
|
||||
}
|
||||
for _, id := range wanted {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
l.serve(ctx, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serve pushes frames for one camera until the server says stop.
|
||||
func (l *Live) serve(ctx context.Context, cameraID string) {
|
||||
engineID, err := l.Cloud.LiveEngineID(ctx, cameraID)
|
||||
if err != nil {
|
||||
l.logf("live %s: %v", cameraID, err)
|
||||
l.sleep(ctx, 2*time.Second)
|
||||
return
|
||||
}
|
||||
interval := time.Duration(float64(time.Second) / l.fps())
|
||||
|
||||
// A pipe so frames can be written as they are grabbed while one request
|
||||
// carries all of them. A request per frame would spend more on handshakes
|
||||
// and headers than on pictures.
|
||||
pr, pw := io.Pipe()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- l.Cloud.PushLive(ctx, cameraID, pr) }()
|
||||
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = pw.CloseWithError(context.Canceled)
|
||||
<-done
|
||||
return
|
||||
case err := <-done:
|
||||
// The server closed the request: the last viewer went away, or the
|
||||
// session cap was reached. Either way stop grabbing frames.
|
||||
_ = pw.Close()
|
||||
if err != nil {
|
||||
l.logf("live %s ended: %v", cameraID, err)
|
||||
}
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
jpeg, err := l.Engine.Frame(ctx, engineID, l.Width, l.Quality)
|
||||
if err != nil || len(jpeg) == 0 {
|
||||
// A camera that is reconnecting has no frame. Keep the request
|
||||
// open - the viewer sees the last frame rather than a dropped
|
||||
// stream, and the next tick may well have one.
|
||||
continue
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(jpeg)))
|
||||
if _, err := pw.Write(hdr[:]); err != nil {
|
||||
<-done
|
||||
return
|
||||
}
|
||||
if _, err := pw.Write(jpeg); err != nil {
|
||||
<-done
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Live) fps() float64 {
|
||||
if l.FPS <= 0 || l.FPS > 15 {
|
||||
// Above this the relay stops being cheap and stops being honest about
|
||||
// what an outbound HTTP push can carry.
|
||||
return DefaultLiveFPS
|
||||
}
|
||||
return l.FPS
|
||||
}
|
||||
|
||||
func (l *Live) sleep(ctx context.Context, d time.Duration) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Live) logf(format string, args ...any) {
|
||||
if l.Log != nil {
|
||||
l.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ wire --
|
||||
|
||||
// LiveWanted asks head office which of this site's cameras are being watched.
|
||||
// The server holds the request open, so this returns promptly when somebody
|
||||
// presses Live and after ~25 s when nobody has.
|
||||
func (c *CloudClient) LiveWanted(ctx context.Context) ([]string, error) {
|
||||
var body struct {
|
||||
Cameras []string `json:"cameras"`
|
||||
}
|
||||
// Longer than the server's own wait, so a held request is not cut off by
|
||||
// our own client timeout and reported as a failure.
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
if err := c.do(ctx, http.MethodGet, "/api/agent/live", nil, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body.Cameras, nil
|
||||
}
|
||||
|
||||
// LiveEngineID maps head office's camera uuid to the name the engine knows,
|
||||
// which is the only name this PC can ask for a frame with.
|
||||
func (c *CloudClient) LiveEngineID(ctx context.Context, cameraID string) (string, error) {
|
||||
desired, err := c.Desired(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, d := range desired {
|
||||
if d.ID == cameraID {
|
||||
return d.CameraID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("camera %s is not one of this site's", cameraID)
|
||||
}
|
||||
|
||||
// PushLive streams frames until the server stops reading.
|
||||
func (c *CloudClient) PushLive(ctx context.Context, cameraID string, body io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.Base+"/api/agent/cameras/"+cameraID+"/live", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("head office: %s: %s", resp.Status, bytes.TrimSpace(blob))
|
||||
}
|
||||
var out struct {
|
||||
Frames int `json:"frames"`
|
||||
}
|
||||
_ = json.Unmarshal(blob, &out)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user