package cameras import ( "bytes" "context" "crypto/sha256" "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, measured against the office camera rather than guessed. // // The engine produces ~12 distinct frames a second, so asking for more than // that only re-sends pictures the viewer already has - which is why the poll // runs slightly ahead of it and identical frames are dropped rather than sent. // 640 px at quality 60 is ~20 KB, so a watcher costs ~200 KB/s at the full // rate, and a camera nobody is watching costs nothing at all. const ( DefaultLiveFPS = 15.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() // The engine re-serves its latest frame until the pipeline produces a new // one, so polling faster than it encodes returns the SAME picture again. // Measured: 93 polls in 6 s yielded 72 distinct frames. Sending the // duplicates would cost a fifth of the bandwidth for nothing, so the poll // runs a little ahead of the engine and the repeats are dropped - which is // what lets the rate follow the camera instead of a guess. var lastSum [32]byte 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 } if sum := sha256.Sum256(jpeg); sum == lastSum { continue } else { lastSum = sum } 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 > 25 { // A ceiling rather than a target: duplicate frames are dropped, so // polling above what the engine encodes costs requests and no // bandwidth - but it is still work, on the PC doing the recognition. 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 }