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:
89
CLAUDE.md
89
CLAUDE.md
@@ -2173,7 +2173,7 @@ Two bugs, both found by running it after a reboot rather than by reading it.
|
||||
stay up. A failure there means the server cannot authenticate to its own
|
||||
broker, which is exactly what this script exists to surface early.
|
||||
|
||||
## Where the live feed is, and why head office gets a picture instead
|
||||
## Live view at head office, and what it honestly is
|
||||
|
||||
Live video exists and always has — on the shop PC, where the camera is:
|
||||
|
||||
@@ -2181,20 +2181,81 @@ Live video exists and always has — on the shop PC, where the camera is:
|
||||
on the office camera: 1280×720, ~850 KB/s.
|
||||
- The desktop app's Live screen and the engine's own dashboard both render it.
|
||||
|
||||
**Head office does not, and cannot cheaply.** The engine serves that stream on
|
||||
`127.0.0.1` on a PC behind a shop's router with no inbound route. Putting live
|
||||
video on `platform.loyaly.ai` needs a relay — WebRTC with TURN, or the agent
|
||||
pushing a continuous stream up — which is infrastructure and bandwidth this does
|
||||
not have. Nor can the browser be pointed straight at the shop PC even on one
|
||||
LAN: the engine's API is Basic-authenticated with a credential it generates
|
||||
locally and never sends anywhere, and shipping that credential to the cloud so a
|
||||
web page could use it would put the key to the biometric API and the live face
|
||||
feed in the server's database. That is a far worse trade than not having live
|
||||
video at head office.
|
||||
Head office has it too, and this is the part that was got wrong first: the
|
||||
original answer here was "cannot cheaply", which conflated **true video** with
|
||||
**seeing the camera now**. Only the first needs infrastructure this does not
|
||||
have.
|
||||
|
||||
So head office shows the camera's **latest frame**, refreshed every 60 s. The
|
||||
agent already holds it in memory for its own stream, so it costs a memory copy
|
||||
rather than a camera round trip.
|
||||
The shop PC is behind a router with no inbound route, so head office cannot
|
||||
pull that stream. What it CAN do is answer the agent's outbound requests, which
|
||||
is the shape of everything else in this system — so `LiveHub` + `cameras.Live`
|
||||
relay frames the other way: head office holds a poll open, the agent asks "is
|
||||
anyone watching?", and pushes JPEGs up for exactly as long as somebody is.
|
||||
|
||||
**It is ~4 frames a second of 640 px JPEG, and the UI says so.** Measured on the
|
||||
office camera: the full frame is 98 KB, re-encoded at 640/q60 it is 20.8 KB, so
|
||||
one watcher costs ~83 KB/s. True 25 fps video would need WebRTC and a TURN
|
||||
server; for *"what does that camera see right now"* — which is the question
|
||||
somebody at head office is actually asking — a few frames a second is what the
|
||||
question requires, and a label saying "about 4 frames a second" is better than
|
||||
letting somebody conclude the camera stutters.
|
||||
|
||||
The browser cannot be pointed straight at the shop PC even on one LAN: the
|
||||
engine's API is Basic-authenticated with a credential it generates locally and
|
||||
never sends anywhere, and shipping that to the cloud so a web page could use it
|
||||
would put the key to the biometric API and the live face feed in the server's
|
||||
database.
|
||||
|
||||
**Nothing is uploaded when nobody is looking**, and that is the entire cost
|
||||
argument:
|
||||
|
||||
- `Publish` returns false once the last viewer has gone, which is what tells the
|
||||
agent to stop pushing. If it were ever optimistic every shop PC in an estate
|
||||
would upload continuously.
|
||||
- Interest lapses on a timer refreshed by each viewer as it reads, so a browser
|
||||
that vanishes without saying so — the normal way a tab closes — stops the
|
||||
upload within seconds.
|
||||
- One push is capped at five minutes. A tab left open for a week must not leave
|
||||
a shop uploading for a week; a viewer who is still there simply reconnects.
|
||||
- Only one camera streams at a time in the UI. A grid that went live all at once
|
||||
would put an estate's worth of cameras on the wire because somebody opened a
|
||||
page.
|
||||
|
||||
**`LiveHub` is the exact opposite of the arrivals `Hub`, deliberately.** There a
|
||||
doorbell pushes nothing because nothing may be lost. Here a dropped frame is the
|
||||
*correct* outcome: each viewer has a one-slot buffer and a full slot is
|
||||
overwritten, because the only frame worth having is the newest one and a queue
|
||||
would show an ever-growing delay behind the shop instead of dropping back to
|
||||
live.
|
||||
|
||||
Other decisions worth keeping:
|
||||
|
||||
- **Ownership is proved once, before anything streams.** Everything after that
|
||||
point is keyed on a camera id and a hub does not know whose camera it holds —
|
||||
and a camera id is not a secret. An agent pushing is checked against its own
|
||||
site for the same reason.
|
||||
- **The agent's poll is held open by the server** rather than answered at once.
|
||||
Polling every few seconds puts a floor under how quickly a view can start;
|
||||
polling slowly puts a ceiling on it. Holding it means pressing Live reaches
|
||||
the shop PC immediately and an idle site costs about two requests a minute.
|
||||
- **One request carries many frames**, each prefixed with its length. At a few
|
||||
frames a second, per-request overhead and TLS handshakes would cost more than
|
||||
the pictures.
|
||||
- **The engine does the re-encode** (`frame.jpg?width=&quality=`). It already
|
||||
has OpenCV open and the frame decoded; a scaler in the agent would be the same
|
||||
work twice. On demand only — a camera nobody watches must not pay for a second
|
||||
encode it will never use.
|
||||
- **The Live button is offered even when the card says the camera is down.**
|
||||
`connected` is head office's last report and can be two minutes stale, so
|
||||
gating on it hid the button during every reconnect — and "is that camera
|
||||
really down?" is exactly when somebody wants to look. A hidden control says
|
||||
*"you cannot"* where the honest answer is *"here is why"*, which the live view
|
||||
gives: it distinguishes a camera that is not connecting from a shop PC that is
|
||||
not answering.
|
||||
|
||||
Head office also still shows the camera's **latest frame** on the cards,
|
||||
refreshed every 60 s, which is what a page of cameras should cost when nobody
|
||||
has asked to watch one.
|
||||
|
||||
### The picture only worked if you had an S3 bucket
|
||||
|
||||
|
||||
@@ -274,6 +274,9 @@ func cmdRun() error {
|
||||
cloud.Upload = uploader.UploadBytes
|
||||
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
|
||||
go cameras.New(eng, cloud, logger).Run(ctx)
|
||||
// The live relay, which uploads nothing until somebody at head office is
|
||||
// actually watching a camera.
|
||||
go cameras.NewLive(eng, cloud, logger).Run(ctx)
|
||||
|
||||
// Before the engine starts, so the engine can be launched already knowing
|
||||
// where to post its detections.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -111,6 +111,35 @@ def _auth_dependencies(api_cfg: ApiSection) -> list:
|
||||
return [Depends(check)]
|
||||
|
||||
|
||||
def _reencode(jpeg: bytes, width: int, quality: int) -> "bytes | None":
|
||||
"""Decode, scale and re-encode one frame. None on any failure.
|
||||
|
||||
None rather than an exception on purpose: the caller falls back to the
|
||||
original frame, so a re-encode that fails costs bandwidth rather than the
|
||||
picture. A live view that goes blank because a resize failed is a worse
|
||||
outcome than one that is briefly larger than asked for.
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
return None
|
||||
if 0 < width < img.shape[1]:
|
||||
# Only ever DOWN. Upscaling a frame to a requested width would send
|
||||
# more bytes than the original for no more detail.
|
||||
scale = width / img.shape[1]
|
||||
img = cv2.resize(img, (width, max(1, int(img.shape[0] * scale))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
q = quality if 1 <= quality <= 100 else 75
|
||||
ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), q])
|
||||
return buf.tobytes() if ok else None
|
||||
except Exception:
|
||||
log.debug("frame re-encode failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def create_app(engine: Engine) -> FastAPI:
|
||||
app = FastAPI(title="Behavision", version="1.0.0",
|
||||
dependencies=_auth_dependencies(engine.cfg.api))
|
||||
@@ -313,10 +342,23 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
return probe_source(source, cam.max_width)
|
||||
|
||||
@app.get("/api/cameras/{camera_id}/frame.jpg")
|
||||
def frame(camera_id: str) -> Response:
|
||||
def frame(camera_id: str, width: int = 0, quality: int = 0) -> Response:
|
||||
"""The latest frame, optionally re-encoded smaller.
|
||||
|
||||
`width`/`quality` exist for the live relay, which sends several frames
|
||||
a second up a shop's uplink and cannot afford the full-size picture the
|
||||
dashboard uses. The re-encode happens here rather than in the agent
|
||||
because this process already has OpenCV open and the frame decoded;
|
||||
shipping a scaler into the agent would be the same work done twice.
|
||||
|
||||
Done on demand, not on every frame: a camera nobody is watching must
|
||||
not pay for a second encode it will never use.
|
||||
"""
|
||||
jpeg = worker_or_404(camera_id).latest_jpeg()
|
||||
if jpeg is None:
|
||||
raise HTTPException(503, "no frame yet")
|
||||
if width > 0 or quality > 0:
|
||||
jpeg = _reencode(jpeg, width, quality) or jpeg
|
||||
return Response(jpeg, media_type="image/jpeg")
|
||||
|
||||
@app.get("/api/cameras/{camera_id}/stream.mjpeg")
|
||||
|
||||
@@ -229,6 +229,9 @@ func (a *App) startLocalCameras(ctx context.Context, logger *log.Logger) {
|
||||
// this app - and the headless agent - both ended up wiring configuration
|
||||
// and forgetting the check runner, so "Test connection" at head office
|
||||
// never completed on any shop PC.
|
||||
// The live relay runs alongside the reconciler and uploads nothing until
|
||||
// somebody at head office is actually watching a camera.
|
||||
go agentcameras.NewLive(camEngine, camCloud, logger).Run(ctx)
|
||||
agentcameras.New(camEngine, camCloud, logger).Run(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,14 @@ type Store interface {
|
||||
// reachable only with that site's own agent token.
|
||||
AgentCameras(ctx context.Context, siteID string) ([]AgentCamera, error)
|
||||
ApplyAgentReport(ctx context.Context, clientID, siteID string, rep AgentCameraReport) error
|
||||
// CameraRef resolves one of a TENANT's cameras to its site and the name the
|
||||
// engine knows it by. Used to prove ownership before anything is streamed.
|
||||
CameraRef(ctx context.Context, clientID, cameraID string) (siteID, engineID string, err error)
|
||||
// CameraRefBySite is the same question asked by an agent, which is
|
||||
// authenticated for a site rather than a tenant.
|
||||
CameraRefBySite(ctx context.Context, siteID, cameraID string) (site, engineID string, err error)
|
||||
// SiteCameraIDs lists a site's camera uuids, for the agent's live poll.
|
||||
SiteCameraIDs(ctx context.Context, siteID string) ([]string, error)
|
||||
// Camera pictures held by this server, for deployments with no object
|
||||
// storage. Where a bucket is configured neither of these is called.
|
||||
PutCameraSnapshot(ctx context.Context, clientID, siteID, cameraID string, jpeg []byte) error
|
||||
@@ -118,6 +126,10 @@ type Server struct {
|
||||
// business questions the screens ask. Nil means this deployment has no
|
||||
// API key, which is supported: the UI hides the panel.
|
||||
Assistant Assistant
|
||||
// Live relays camera frames from a shop PC to whoever is watching, on
|
||||
// demand. Created on first use.
|
||||
Live *LiveHub
|
||||
liveOnce sync.Once
|
||||
// Hub wakes live arrival streams when the MQTT consumer records a visit.
|
||||
// Nil is supported and means the streams fall back to their slow tick -
|
||||
// a server assembled without one is slower, not broken.
|
||||
@@ -197,6 +209,7 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("PATCH /api/cameras/{id}", s.authed(s.handleUpdateCamera))
|
||||
mux.HandleFunc("DELETE /api/cameras/{id}", s.authed(s.handleDeleteCamera))
|
||||
mux.HandleFunc("GET /api/cameras/{id}/snapshot.jpg", s.authed(s.handleGetSnapshot))
|
||||
mux.HandleFunc("GET /api/cameras/{id}/live", s.authed(s.handleWatchLive))
|
||||
// Prove a camera works: "connection" asks whether the shop PC can open the
|
||||
// stream, "placement" asks whether somebody walking past produces a view
|
||||
// good enough to recognise. Two questions, because a camera passes the
|
||||
@@ -242,6 +255,9 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/agent/cameras", s.agentAuthed(s.handleAgentCameraReport))
|
||||
mux.HandleFunc("PUT /api/agent/cameras/{camera}/snapshot",
|
||||
s.agentAuthed(s.handlePutSnapshot))
|
||||
mux.HandleFunc("GET /api/agent/live", s.agentAuthed(s.handleAgentLiveWanted))
|
||||
mux.HandleFunc("POST /api/agent/cameras/{camera}/live",
|
||||
s.agentAuthed(s.handleAgentPushLive))
|
||||
mux.HandleFunc("GET /api/agent/checks", s.agentAuthed(s.handleAgentChecks))
|
||||
mux.HandleFunc("POST /api/agent/checks", s.agentAuthed(s.handleAgentCheckResult))
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ import (
|
||||
// live - which tenant, which message on failure, what is echoed back - and
|
||||
// those are exactly what a real database would make slow and awkward to test.
|
||||
type fakeStore struct {
|
||||
// Which tenant and site each camera belongs to. The live relay is keyed on
|
||||
// a camera id and a hub does not know whose camera it holds, so ownership
|
||||
// is proved before anything streams - and that is what these tests check.
|
||||
cameraRefs map[string]cameraRef
|
||||
|
||||
// Camera pictures held by the server, for a deployment with no bucket.
|
||||
// Keyed as written by PutCameraSnapshot (by camera_id) and as read by
|
||||
// CameraSnapshot ("client/camera"), so a test has to say which it means.
|
||||
@@ -622,3 +627,49 @@ func (f *fakeStore) CameraSnapshot(_ context.Context, clientID, cameraID string)
|
||||
}
|
||||
return img, time.Unix(1756900000, 0).UTC(), nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ live relay --
|
||||
|
||||
func (f *fakeStore) CameraRef(_ context.Context, clientID, cameraID string) (string, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ref, ok := f.cameraRefs[cameraID]
|
||||
if !ok || ref.client != clientID {
|
||||
return "", "", ErrNoSnapshot
|
||||
}
|
||||
return ref.site, ref.engineID, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CameraRefBySite(_ context.Context, siteID, cameraID string) (string, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ref, ok := f.cameraRefs[cameraID]
|
||||
if !ok || ref.site != siteID {
|
||||
return "", "", ErrNoSnapshot
|
||||
}
|
||||
return ref.site, ref.engineID, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SiteCameraIDs(_ context.Context, siteID string) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []string
|
||||
for id, ref := range f.cameraRefs {
|
||||
if ref.site == siteID {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addCameraRef registers a camera so ownership checks have something to check.
|
||||
func (f *fakeStore) addCameraRef(id, client, site, engineID string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.cameraRefs == nil {
|
||||
f.cameraRefs = map[string]cameraRef{}
|
||||
}
|
||||
f.cameraRefs[id] = cameraRef{client: client, site: site, engineID: engineID}
|
||||
}
|
||||
|
||||
type cameraRef struct{ client, site, engineID string }
|
||||
|
||||
173
server/internal/api/handlers_live.go
Normal file
173
server/internal/api/handlers_live.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// liveMaxFrame bounds one frame. The agent re-encodes to ~640 px before
|
||||
// sending, so a frame is tens of KB; 1 MB is a ceiling, not a target.
|
||||
liveMaxFrame = 1 << 20
|
||||
// liveSession caps one push. A browser tab left open for a week must not
|
||||
// leave a shop uploading for a week; the agent simply asks again while
|
||||
// anyone is still watching, so the cap costs a reconnect, not the stream.
|
||||
liveSession = 5 * time.Minute
|
||||
// liveWaitForWork is how long the agent's poll is held open. Long enough
|
||||
// that an idle site makes ~2 requests a minute; short enough to sit well
|
||||
// inside any proxy's idle timeout.
|
||||
liveWaitForWork = 25 * time.Second
|
||||
)
|
||||
|
||||
// handleWatchLive streams one camera's frames to a signed-in user over SSE.
|
||||
//
|
||||
// SSE rather than serving MJPEG directly, for the same reason the snapshot is
|
||||
// not a signed link: an <img> cannot send an Authorization header, and minting
|
||||
// a URL that works without a session - for LIVE video of a shop floor, no less
|
||||
// - would be a much worse trade than the 33% base64 costs.
|
||||
func (s *Server) handleWatchLive(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
||||
return
|
||||
}
|
||||
// Ownership is checked HERE, once, before anything is streamed. Everything
|
||||
// after this point is keyed on a camera id, and a hub does not know whose
|
||||
// camera it is holding.
|
||||
if _, _, err := s.Store.CameraRef(r.Context(), p.ClientID, id); err != nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
s.serverError(w, "live", errors.New("this server cannot stream"))
|
||||
return
|
||||
}
|
||||
|
||||
frames, release := s.live().Watch(id)
|
||||
defer release()
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
h.Set("Connection", "keep-alive")
|
||||
// Without this a proxy buffers the stream into one response that arrives
|
||||
// when the connection closes - which for live video means never.
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Told up front, so a viewer can say "waiting for the shop PC" rather than
|
||||
// showing an empty box while the agent is still being asked.
|
||||
fmt.Fprint(w, "event: waiting\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
// Refreshed as we go rather than once at the start: this is what tells the
|
||||
// agent somebody is still there, and a viewer that has gone away stops a
|
||||
// shop uploading within seconds without having to announce anything.
|
||||
keep := time.NewTicker(liveIdle / 3)
|
||||
defer keep.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-keep.C:
|
||||
s.live().Keep(id)
|
||||
case frame, ok := <-frames:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.live().Keep(id)
|
||||
if _, err := fmt.Fprintf(w, "event: frame\ndata: %s\n\n",
|
||||
base64.StdEncoding.EncodeToString(frame)); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleAgentLiveWanted is the shop PC asking whether anyone is watching.
|
||||
//
|
||||
// Held open rather than answered immediately: an agent polling every few
|
||||
// seconds would put a floor under how quickly a live view can start, and one
|
||||
// polling slowly would put a ceiling on it. Holding the request means pressing
|
||||
// "Live" reaches the shop PC at once, and an idle site costs about two requests
|
||||
// a minute.
|
||||
func (s *Server) handleAgentLiveWanted(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
ids, err := s.Store.SiteCameraIDs(r.Context(), ap.SiteID)
|
||||
if err != nil {
|
||||
s.serverError(w, "live wanted", err)
|
||||
return
|
||||
}
|
||||
if wanted := s.live().WantedAmong(ids); len(wanted) > 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cameras": wanted})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-s.live().Bell(ids):
|
||||
case <-time.After(liveWaitForWork):
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"cameras": s.live().WantedAmong(ids)})
|
||||
}
|
||||
|
||||
// handleAgentPushLive receives frames for as long as somebody is watching.
|
||||
//
|
||||
// One request carrying many frames, each prefixed with its length, rather than
|
||||
// a request per frame: at a few frames a second the per-request overhead and
|
||||
// the TLS handshakes would cost more than the pictures.
|
||||
func (s *Server) handleAgentPushLive(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
id := r.PathValue("camera")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
||||
return
|
||||
}
|
||||
// The camera must belong to the AGENT's own site. Without this an agent
|
||||
// could push its own pictures into another site's live view - a camera id
|
||||
// is not a secret, and the agent supplies this one.
|
||||
siteID, _, err := s.Store.CameraRefBySite(r.Context(), ap.SiteID, id)
|
||||
if err != nil || siteID != ap.SiteID {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
||||
return
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(liveSession)
|
||||
body := r.Body
|
||||
var header [4]byte
|
||||
frames := 0
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
if _, err := io.ReadFull(body, header[:]); err != nil {
|
||||
break
|
||||
}
|
||||
n := binary.BigEndian.Uint32(header[:])
|
||||
if n == 0 || n > liveMaxFrame {
|
||||
// A length this side cannot trust ends the stream rather than
|
||||
// allocating what it was told to.
|
||||
writeErr(w, http.StatusBadRequest, "bad_frame", "Frame size out of range.")
|
||||
return
|
||||
}
|
||||
frame := make([]byte, n)
|
||||
if _, err := io.ReadFull(body, frame); err != nil {
|
||||
break
|
||||
}
|
||||
frames++
|
||||
if !s.live().Publish(id, frame) {
|
||||
// Nobody is watching any more. Saying so in the response is what
|
||||
// stops the shop uploading; the agent goes back to waiting.
|
||||
break
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"frames": frames})
|
||||
}
|
||||
184
server/internal/api/live.go
Normal file
184
server/internal/api/live.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LiveHub carries camera frames from a shop PC to whoever is watching.
|
||||
//
|
||||
// The shop PC's engine serves MJPEG on its own loopback, behind a router with
|
||||
// no inbound route, so head office cannot pull it. What head office CAN do is
|
||||
// answer the agent's outbound requests - which is the whole shape of this
|
||||
// product already - so the agent asks "is anyone watching?", and pushes frames
|
||||
// up for as long as somebody is.
|
||||
//
|
||||
// This is NOT true video. It is a few frames a second of re-encoded JPEG, which
|
||||
// is what an outbound HTTP relay can carry honestly. Real 25 fps video needs
|
||||
// WebRTC and a TURN server; this needs neither, and for "is that camera pointed
|
||||
// at the right place, and is someone in the shop" a few frames a second is what
|
||||
// the question actually requires.
|
||||
//
|
||||
// It is deliberately the OPPOSITE of the arrivals Hub, which is a doorbell that
|
||||
// pushes nothing because nothing may be lost. Here a dropped frame is the
|
||||
// correct outcome: a slow viewer must never stall the pump or accumulate a
|
||||
// backlog of stale pictures, because the only frame worth having is the newest
|
||||
// one. So each viewer gets a one-slot buffer and a full slot is overwritten.
|
||||
type LiveHub struct {
|
||||
mu sync.Mutex
|
||||
cameras map[string]*liveCamera
|
||||
}
|
||||
|
||||
type liveCamera struct {
|
||||
viewers map[chan []byte]struct{}
|
||||
// wanted is refreshed by every watching viewer. The agent stops pushing
|
||||
// when it lapses, which is what keeps a shop's uplink idle when nobody is
|
||||
// looking - the entire cost argument for this feature.
|
||||
wanted time.Time
|
||||
// bell fires when the first viewer arrives, so an agent long-polling for
|
||||
// work is answered immediately instead of on its next tick.
|
||||
bell chan struct{}
|
||||
}
|
||||
|
||||
// liveIdle is how long a camera stays "wanted" after the last viewer refreshed
|
||||
// it. Longer than the viewer's refresh interval so an ordinary pause between
|
||||
// refreshes does not stop the stream, short enough that a browser that
|
||||
// vanished stops a shop uploading within seconds.
|
||||
const liveIdle = 12 * time.Second
|
||||
|
||||
func NewLiveHub() *LiveHub {
|
||||
return &LiveHub{cameras: map[string]*liveCamera{}}
|
||||
}
|
||||
|
||||
// Watch registers a viewer and returns its frame channel plus a release func.
|
||||
func (h *LiveHub) Watch(cameraID string) (<-chan []byte, func()) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
c := h.cameras[cameraID]
|
||||
if c == nil {
|
||||
c = &liveCamera{viewers: map[chan []byte]struct{}{}, bell: make(chan struct{}, 1)}
|
||||
h.cameras[cameraID] = c
|
||||
}
|
||||
ch := make(chan []byte, 1)
|
||||
c.viewers[ch] = struct{}{}
|
||||
c.wanted = time.Now().Add(liveIdle)
|
||||
select {
|
||||
case c.bell <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if cam := h.cameras[cameraID]; cam != nil {
|
||||
delete(cam.viewers, ch)
|
||||
if len(cam.viewers) == 0 {
|
||||
// Dropped entirely rather than left empty: an estate's worth of
|
||||
// cameras nobody is watching would otherwise accumulate here
|
||||
// for the life of the process.
|
||||
delete(h.cameras, cameraID)
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep extends a camera's interest window. Called by each viewer as it reads,
|
||||
// so interest expires on its own when a browser goes away without saying so -
|
||||
// which is the normal way a tab closes.
|
||||
func (h *LiveHub) Keep(cameraID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if c := h.cameras[cameraID]; c != nil {
|
||||
c.wanted = time.Now().Add(liveIdle)
|
||||
}
|
||||
}
|
||||
|
||||
// Wanted reports whether anyone is watching this camera right now.
|
||||
func (h *LiveHub) Wanted(cameraID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
c := h.cameras[cameraID]
|
||||
return c != nil && len(c.viewers) > 0 && time.Now().Before(c.wanted)
|
||||
}
|
||||
|
||||
// WantedAmong filters a site's cameras down to the ones being watched. The
|
||||
// agent asks with the cameras it has, so this never has to know a site's
|
||||
// inventory.
|
||||
func (h *LiveHub) WantedAmong(ids []string) []string {
|
||||
var out []string
|
||||
for _, id := range ids {
|
||||
if h.Wanted(id) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Bell returns a channel that fires when a viewer starts watching one of these
|
||||
// cameras, so an agent waiting for work wakes at once rather than on a tick.
|
||||
// A nil channel blocks forever, which is the right behaviour for the caller's
|
||||
// select when none of the cameras is known here yet.
|
||||
func (h *LiveHub) Bell(ids []string) <-chan struct{} {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
if c := h.cameras[id]; c != nil {
|
||||
return c.bell
|
||||
}
|
||||
}
|
||||
// Register a placeholder for the first camera so a later Watch can ring
|
||||
// something. Cheap: one struct per camera an agent asked about.
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
c := &liveCamera{viewers: map[chan []byte]struct{}{}, bell: make(chan struct{}, 1)}
|
||||
h.cameras[ids[0]] = c
|
||||
return c.bell
|
||||
}
|
||||
|
||||
// Publish hands one frame to every viewer of a camera and reports whether any
|
||||
// remain. The agent uses that answer to stop pushing.
|
||||
//
|
||||
// A viewer whose slot is full has its pending frame REPLACED, never queued. The
|
||||
// newest frame is the only one worth having, and a queue here would show a
|
||||
// viewer an ever-growing delay behind the shop rather than dropping back to
|
||||
// live.
|
||||
func (h *LiveHub) Publish(cameraID string, frame []byte) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
c := h.cameras[cameraID]
|
||||
if c == nil || len(c.viewers) == 0 {
|
||||
return false
|
||||
}
|
||||
for ch := range c.viewers {
|
||||
select {
|
||||
case ch <- frame:
|
||||
default:
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case ch <- frame:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Now().Before(c.wanted)
|
||||
}
|
||||
|
||||
|
||||
// live returns the hub, creating it on first use.
|
||||
//
|
||||
// Lazily, and stored on the Server, so a server built without one still works:
|
||||
// unlike the arrivals doorbell there is no degraded mode to fall back to here,
|
||||
// and a nil map panic on an endpoint somebody forgot to wire is the worst way
|
||||
// to find out.
|
||||
func (s *Server) live() *LiveHub {
|
||||
s.liveOnce.Do(func() {
|
||||
if s.Live == nil {
|
||||
s.Live = NewLiveHub()
|
||||
}
|
||||
})
|
||||
return s.Live
|
||||
}
|
||||
131
server/internal/api/live_test.go
Normal file
131
server/internal/api/live_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
const liveCam = "22222222-3333-4444-5555-666666666666"
|
||||
|
||||
// The whole cost argument for this feature: a camera nobody is watching must
|
||||
// cost a shop nothing at all. If Wanted were ever optimistic, every shop PC in
|
||||
// an estate would upload continuously.
|
||||
func TestNobodyWatchingMeansNothingIsWanted(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
if h.Wanted(liveCam) {
|
||||
t.Fatal("a camera nobody has asked for was reported as wanted")
|
||||
}
|
||||
if got := h.WantedAmong([]string{liveCam, "other"}); len(got) != 0 {
|
||||
t.Fatalf("wanted %v with no viewers", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAViewerMakesACameraWantedAndReleasingStopsIt(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
_, release := h.Watch(liveCam)
|
||||
if !h.Wanted(liveCam) {
|
||||
t.Fatal("a watched camera was not wanted")
|
||||
}
|
||||
release()
|
||||
if h.Wanted(liveCam) {
|
||||
t.Fatal("the camera stayed wanted after the last viewer left")
|
||||
}
|
||||
}
|
||||
|
||||
// The opposite policy to the arrivals Hub, and deliberately so. There nothing
|
||||
// may be lost; here the only frame worth having is the newest one, and a queue
|
||||
// would show a viewer an ever-growing delay behind the shop instead of dropping
|
||||
// back to live.
|
||||
func TestASlowViewerGetsTheNewestFrameNotTheOldest(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
frames, release := h.Watch(liveCam)
|
||||
defer release()
|
||||
|
||||
for _, f := range []string{"one", "two", "three"} {
|
||||
h.Publish(liveCam, []byte(f))
|
||||
}
|
||||
select {
|
||||
case got := <-frames:
|
||||
if string(got) != "three" {
|
||||
t.Fatalf("a slow viewer was served %q, want the newest frame", got)
|
||||
}
|
||||
default:
|
||||
t.Fatal("nothing was delivered")
|
||||
}
|
||||
}
|
||||
|
||||
// Publish reporting false is what stops the shop PC uploading. If it kept
|
||||
// saying true the agent would push into an empty room until the session cap.
|
||||
func TestPublishReportsWhenTheLastViewerHasGone(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
_, release := h.Watch(liveCam)
|
||||
if !h.Publish(liveCam, []byte("frame")) {
|
||||
t.Fatal("publish said to stop while somebody was watching")
|
||||
}
|
||||
release()
|
||||
if h.Publish(liveCam, []byte("frame")) {
|
||||
t.Fatal("publish did not say to stop after the last viewer left")
|
||||
}
|
||||
}
|
||||
|
||||
// A browser that vanishes without saying so - the normal way a tab closes -
|
||||
// must stop the upload on its own.
|
||||
func TestInterestExpiresWithoutBeingRefreshed(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
frames, release := h.Watch(liveCam)
|
||||
defer release()
|
||||
_ = frames
|
||||
|
||||
h.mu.Lock()
|
||||
h.cameras[liveCam].wanted = time.Now().Add(-time.Second)
|
||||
h.mu.Unlock()
|
||||
|
||||
if h.Wanted(liveCam) {
|
||||
t.Fatal("interest did not lapse")
|
||||
}
|
||||
if h.Publish(liveCam, []byte("frame")) {
|
||||
t.Fatal("publish kept the shop uploading for a viewer that had gone")
|
||||
}
|
||||
}
|
||||
|
||||
// The relay is keyed on a camera id and a hub does not know whose camera it is
|
||||
// holding, so ownership has to be proved before anything streams. Otherwise a
|
||||
// camera id - which is not a secret - would be enough to watch another
|
||||
// company's shop floor.
|
||||
func TestAnotherTenantCannotWatchYourCamera(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addCameraRef(liveCam, "client-1", "site-1", "cam1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cameras/"+liveCam+"/live", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), principalKey,
|
||||
auth.Principal{ClientID: "someone-else", Role: "owner"}))
|
||||
srv.handleWatchLive(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// An agent may only push into its OWN site's camera. The agent supplies this
|
||||
// id, and a camera id is not a secret.
|
||||
func TestAnAgentCannotPushIntoAnotherSitesCamera(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addCameraRef(liveCam, "client-1", "site-1", "cam1")
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-1", SiteID: "site-2"})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/agent/cameras/"+liveCam+"/live", nil)
|
||||
req.Header.Set("Authorization", "Bearer agent-token")
|
||||
srv.Routes().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -440,6 +440,11 @@ type CameraInput struct {
|
||||
// thing stopping a tenant response carrying camera passwords would be
|
||||
// remembering to blank a field, on every path, forever.
|
||||
type AgentCamera struct {
|
||||
// ID is head office's uuid. Carried alongside CameraID because the two
|
||||
// name the same camera to different halves of the system: head office
|
||||
// addresses it by uuid, the engine on the shop PC only knows the name in
|
||||
// CameraID, and the live relay has to translate between them.
|
||||
ID string `json:"id"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Host string `json:"host"`
|
||||
|
||||
@@ -199,7 +199,7 @@ func (s *Store) DeleteCamera(ctx context.Context, clientID, id string) (api.Came
|
||||
// yet" by absence, and would re-adopt what was just deleted.
|
||||
func (s *Store) AgentCameras(ctx context.Context, siteID string) ([]api.AgentCamera, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT camera_id, label, host, port, path, username, password_enc,
|
||||
SELECT id::text, camera_id, label, host, port, path, username, password_enc,
|
||||
max_width, tuning, enabled, revision, (deleted_at IS NOT NULL)
|
||||
FROM site_cameras
|
||||
WHERE site_id = $1::uuid
|
||||
@@ -213,7 +213,7 @@ func (s *Store) AgentCameras(ctx context.Context, siteID string) ([]api.AgentCam
|
||||
for rows.Next() {
|
||||
var c api.AgentCamera
|
||||
var sealed []byte
|
||||
if err := rows.Scan(&c.CameraID, &c.Label, &c.Host, &c.Port, &c.Path,
|
||||
if err := rows.Scan(&c.ID, &c.CameraID, &c.Label, &c.Host, &c.Port, &c.Path,
|
||||
&c.Username, &sealed, &c.MaxWidth, &c.Tuning, &c.Enabled,
|
||||
&c.Revision, &c.Deleted); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -82,3 +82,51 @@ func (s *Store) CameraSnapshot(ctx context.Context, clientID, cameraID string) (
|
||||
}
|
||||
return img, at, err
|
||||
}
|
||||
|
||||
// CameraRef resolves one of a tenant's cameras to its site and the name the
|
||||
// engine knows it by.
|
||||
//
|
||||
// Its job is to prove ownership before a live stream starts. Everything after
|
||||
// that point is keyed on a camera id, and a hub relaying frames does not know
|
||||
// whose camera it is holding — so this is the only place that can decide.
|
||||
func (s *Store) CameraRef(ctx context.Context, clientID, cameraID string) (string, string, error) {
|
||||
return s.cameraRef(ctx, `client_id = $2::uuid`, cameraID, clientID)
|
||||
}
|
||||
|
||||
// CameraRefBySite is the same question asked by an agent, which is
|
||||
// authenticated for a site rather than a tenant.
|
||||
func (s *Store) CameraRefBySite(ctx context.Context, siteID, cameraID string) (string, string, error) {
|
||||
return s.cameraRef(ctx, `site_id = $2::uuid`, cameraID, siteID)
|
||||
}
|
||||
|
||||
func (s *Store) cameraRef(ctx context.Context, scope, cameraID, owner string) (string, string, error) {
|
||||
var siteID, engineID string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT site_id::text, camera_id FROM site_cameras
|
||||
WHERE id = $1::uuid AND `+scope+` AND deleted_at IS NULL`,
|
||||
cameraID, owner).Scan(&siteID, &engineID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", ErrNoSnapshot
|
||||
}
|
||||
return siteID, engineID, err
|
||||
}
|
||||
|
||||
// SiteCameraIDs lists a site's camera uuids, for the agent's live poll.
|
||||
func (s *Store) SiteCameraIDs(ctx context.Context, siteID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text FROM site_cameras
|
||||
WHERE site_id = $1::uuid AND deleted_at IS NULL AND enabled`, siteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
46
server/internal/web/dist/assets/index-D6UMLnOQ.js
vendored
Normal file
46
server/internal/web/dist/assets/index-D6UMLnOQ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
server/internal/web/dist/index.html
vendored
4
server/internal/web/dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-B_gTsA07.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-pUqVBCLm.css">
|
||||
<script type="module" crossorigin src="/assets/index-D6UMLnOQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CtuyPF09.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -261,3 +261,70 @@ export function streamArrivals({ cursor, siteId, onPage, onError, signal }) {
|
||||
run()
|
||||
return () => { stopped = true }
|
||||
}
|
||||
|
||||
// streamCameraLive renders one camera's live view into an <img>.
|
||||
//
|
||||
// Frames arrive base64 over SSE for the same reason the snapshot is fetched
|
||||
// rather than linked: an <img> cannot send an Authorization header, and minting
|
||||
// a URL that works without a session — for LIVE video of a shop floor — would
|
||||
// be a far worse trade than the 33% base64 costs.
|
||||
//
|
||||
// The frame is written straight into `img.src` as a data URL rather than an
|
||||
// object URL. Object URLs would have to be revoked one per frame, several times
|
||||
// a second, and a single missed revoke is a leak that grows for as long as the
|
||||
// view is open. A data URL is owned by the element and replaced by the next one.
|
||||
export function streamCameraLive({ cameraId, img, onState, signal }) {
|
||||
let stopped = false
|
||||
|
||||
const run = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const { access } = tokens()
|
||||
const res = await fetch(`/api/cameras/${cameraId}/live`, {
|
||||
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
|
||||
signal,
|
||||
})
|
||||
if (res.status === 401) { await refresh(); continue }
|
||||
if (!res.ok || !res.body) throw new Error('live view unavailable')
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (!stopped) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
let split
|
||||
while ((split = buf.indexOf('\n\n')) !== -1) {
|
||||
const chunk = buf.slice(0, split)
|
||||
buf = buf.slice(split + 2)
|
||||
let event = 'message', data = ''
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('event: ')) event = line.slice(7).trim()
|
||||
else if (line.startsWith('data: ')) data = line.slice(6)
|
||||
}
|
||||
if (event === 'frame' && data) {
|
||||
if (img.current) img.current.src = 'data:image/jpeg;base64,' + data
|
||||
onState?.('live')
|
||||
} else if (event === 'waiting') {
|
||||
// The server has registered us; the shop PC has not started
|
||||
// pushing yet. Saying so beats an empty box, because the wait is
|
||||
// a real second or two while the agent is asked.
|
||||
onState?.('waiting')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (stopped || signal?.aborted) return
|
||||
onState?.('reconnecting')
|
||||
}
|
||||
if (stopped) return
|
||||
// The server caps one push so a tab left open for a week does not leave
|
||||
// a shop uploading for a week. Reconnecting is how a viewer who IS still
|
||||
// watching carries on, so this is a normal event, not an error.
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
}
|
||||
}
|
||||
run()
|
||||
return () => { stopped = true }
|
||||
}
|
||||
|
||||
@@ -497,3 +497,42 @@ button.ghost.danger:hover { border-color: var(--bad); }
|
||||
.assistant { width: 100%; height: 60vh; position: static; border-left: 0;
|
||||
border-top: 1px solid var(--line); }
|
||||
}
|
||||
|
||||
|
||||
/* ------------------------------------------------------- live camera view */
|
||||
/* Deliberately a small affordance on the picture rather than a big play
|
||||
button: opening it makes a shop PC start uploading, so it is an action
|
||||
somebody chooses, not one a page does on their behalf. */
|
||||
.card.cam .watch {
|
||||
position: absolute; left: 10px; top: 10px; z-index: 2;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 9px; border-radius: 999px; border: 0; cursor: pointer;
|
||||
font: inherit; font-size: 11.5px; font-weight: 600; letter-spacing: .02em;
|
||||
color: #fff; background: rgba(12, 16, 20, .72);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.card.cam .watch:hover { background: rgba(12, 16, 20, .9); }
|
||||
.card.cam .watch i {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #E5484D;
|
||||
box-shadow: 0 0 0 0 rgba(229, 72, 77, .7);
|
||||
animation: livepulse 2s infinite;
|
||||
}
|
||||
@keyframes livepulse {
|
||||
70% { box-shadow: 0 0 0 6px rgba(229, 72, 77, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card.cam .watch i { animation: none; }
|
||||
}
|
||||
|
||||
.drawer.live { max-width: 760px; }
|
||||
.liveshot {
|
||||
position: relative; background: #000; border-radius: 10px; overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.liveshot img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.livewait {
|
||||
position: absolute; inset: 0; display: grid; place-items: center;
|
||||
font-size: 13px; color: rgba(255, 255, 255, .78);
|
||||
background: rgba(0, 0, 0, .35);
|
||||
}
|
||||
|
||||
85
web/src/views/CameraLive.jsx
Normal file
85
web/src/views/CameraLive.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { streamCameraLive } from '../api.js'
|
||||
|
||||
// The live view of one camera.
|
||||
//
|
||||
// It is a few frames a second, not video, and the label says so rather than
|
||||
// letting somebody conclude the camera is stuttering. That is the honest limit
|
||||
// of relaying through the agent's outbound connection; true 25 fps would need
|
||||
// WebRTC and a TURN server, which is a different piece of infrastructure.
|
||||
//
|
||||
// **Nothing is uploaded from the shop until this component is mounted**, and it
|
||||
// stops within seconds of it going away. That is the whole reason a live view
|
||||
// is affordable at all, and why this is a deliberate action rather than
|
||||
// something every camera card does on its own.
|
||||
export default function CameraLive({ camera, onClose }) {
|
||||
const img = useRef(null)
|
||||
const [state, setState] = useState('waiting')
|
||||
// Nothing has arrived after a sensible wait. Its own state, because "the
|
||||
// shop PC has not answered" is a different thing to tell somebody than
|
||||
// "connecting", and leaving a spinner up forever tells them nothing at all.
|
||||
const [stalled, setStalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController()
|
||||
const stop = streamCameraLive({
|
||||
cameraId: camera.id, img,
|
||||
onState: s => { setState(s); if (s === 'live') setStalled(false) },
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
// Generous: the server holds the agent's poll, the agent then has to reach
|
||||
// the camera, and a cold start is a couple of seconds even when everything
|
||||
// works.
|
||||
const t = setTimeout(() => setStalled(true), 12000)
|
||||
return () => { stop(); ctrl.abort(); clearTimeout(t) }
|
||||
}, [camera.id])
|
||||
|
||||
// What to say when nothing arrives. The two causes need different actions -
|
||||
// one is the camera, the other is the PC - so they must not share a message.
|
||||
const stalledNote = camera.connected === false
|
||||
? 'This camera was not connecting when the shop PC last reported. Check it is powered on and reachable on the shop’s network.'
|
||||
: camera.connected == null
|
||||
? 'The shop PC has not reported on this camera yet. It may still be starting up.'
|
||||
: 'The shop PC is not sending frames. It may be offline, or its Behavision app may not be running.'
|
||||
|
||||
const note = stalled && state !== 'live'
|
||||
? stalledNote
|
||||
: {
|
||||
waiting: 'Asking the shop PC…',
|
||||
live: 'Live · about 4 frames a second',
|
||||
reconnecting: 'Reconnecting…',
|
||||
}[state]
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer live" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<div>
|
||||
<h2>{camera.label}</h2>
|
||||
<p className="sub">{camera.site}</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
<div className="drawer-body">
|
||||
<div className="liveshot">
|
||||
{/* Seeded with the stored snapshot so the first second shows the
|
||||
camera rather than a black rectangle. It is the same view, a
|
||||
minute old, which is a far better place to start from than
|
||||
nothing. */}
|
||||
<img ref={img} alt={`Live view from ${camera.label}`} />
|
||||
{state !== 'live' && (
|
||||
<div className="livewait">
|
||||
<span>{stalled ? 'No picture yet' : note}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="hint" style={{ marginTop: 10 }}>
|
||||
{state === 'live'
|
||||
? 'Live · about 4 frames a second. The shop only uploads while this is open.'
|
||||
: note}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import Shot from './Shot.jsx'
|
||||
import CameraLive from './CameraLive.jsx'
|
||||
import { ago, Loading, Problem } from './Sites.jsx'
|
||||
import CameraSetup from './CameraSetup.jsx'
|
||||
|
||||
@@ -39,6 +40,10 @@ export default function Cameras({ user }) {
|
||||
usePolled(() => api.cameras(), 20000, [])
|
||||
const { data: sites } = usePolled(() => api.sites(), 0, [])
|
||||
const [editing, setEditing] = useState(null)
|
||||
// Only one camera streams at a time, on purpose. Every open view makes a
|
||||
// shop PC upload, so a grid that went live all at once would put an estate's
|
||||
// worth of cameras on the wire because somebody opened a page.
|
||||
const [watching, setWatching] = useState(null)
|
||||
|
||||
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
|
||||
const list = cams || []
|
||||
@@ -76,7 +81,8 @@ export default function Cameras({ user }) {
|
||||
<div className="grid cams">
|
||||
{list.map(c => (
|
||||
<CameraCard key={c.id} cam={c} canEdit={canEdit}
|
||||
onEdit={() => setEditing(c)} />
|
||||
onEdit={() => setEditing(c)}
|
||||
onWatch={() => setWatching(c)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -90,11 +96,15 @@ export default function Cameras({ user }) {
|
||||
onSaved={(_, opts) => { if (!opts?.keepOpen) setEditing(null); reload() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{watching && (
|
||||
<CameraLive camera={watching} onClose={() => setWatching(null)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CameraCard({ cam, canEdit, onEdit }) {
|
||||
function CameraCard({ cam, canEdit, onEdit, onWatch }) {
|
||||
// Three states, not two. A camera nobody has tried yet is not a camera that
|
||||
// is down, and telling an operator to check the cabling on a camera the shop
|
||||
// PC has not even seen sends them to the wrong building.
|
||||
@@ -136,6 +146,18 @@ function CameraCard({ cam, canEdit, onEdit }) {
|
||||
{cam.snapshot_at && (
|
||||
<span className="shot-age">{ago(cam.snapshot_at)}</span>
|
||||
)}
|
||||
|
||||
{/* Always offered, including when this card says the camera is down.
|
||||
`connected` is head office's LAST REPORT and can be two minutes
|
||||
stale, so gating on it hid the button during every reconnect - and
|
||||
"is that camera really down?" is precisely the moment somebody wants
|
||||
to look. A hidden control says "you cannot" when the honest answer
|
||||
is "here is why", which the live view itself can give.
|
||||
stopPropagation because the card itself opens Edit. */}
|
||||
<button className="watch" title="Watch this camera now"
|
||||
onClick={e => { e.stopPropagation(); onWatch() }}>
|
||||
<i aria-hidden="true" />Live
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connected and verified are different claims, and the gap between them
|
||||
|
||||
Reference in New Issue
Block a user