package cameras import ( "context" "encoding/base64" "fmt" "strings" "time" ) // Running the checks head office asks for. // // Both answers come from the engine, which already knows how to give them and // already phrases them for whoever is standing next to the camera. Nothing here // re-words a verdict; it carries one. // Job is one check the shop PC has been asked to run. type Job struct { CameraID string `json:"camera_id"` Kind string `json:"kind"` Seconds int `json:"seconds"` } // Result is what it found. type Result struct { CameraID string `json:"camera_id"` OK bool `json:"ok"` Verdict string `json:"verdict,omitempty"` Headline string `json:"headline,omitempty"` Advice []string `json:"advice,omitempty"` Detail map[string]any `json:"detail,omitempty"` ImageKey string `json:"image_key,omitempty"` } // Prober is the half of the engine that answers "does this camera work". type Prober interface { // Test opens the stream once and lets go, returning a frame. The engine's // probe checks TCP reachability first, so a wrong address answers in // milliseconds instead of the ~75 s an FFmpeg connect would take. Test(ctx context.Context, cam Local) (TestResult, error) // Placement watches for `seconds` and judges whether a person walking past // produced a view worth enrolling. Placement(ctx context.Context, cameraID string, seconds int) (map[string]any, error) } type TestResult struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` // Snapshot is base64 JPEG, the operator's proof that the camera is // pointing where they think it is. Snapshot string `json:"snapshot_jpeg_b64,omitempty"` } // Checks is the client for the job queue. type Checks interface { Pending(ctx context.Context) ([]Job, error) Submit(ctx context.Context, res Result) error } // runChecks picks up whatever head office has asked for and answers it. // // Called from the same sync loop as configuration, so a check requested at head // office is picked up on the next tick. Deliberately not its own faster poll: a // placement check needs a human to walk about anyway, so shaving a minute off // the request buys nothing an operator would notice. func (s *Syncer) runChecks(ctx context.Context, desired []Desired) { if s.Checks == nil || s.Prober == nil { return } jobs, err := s.Checks.Pending(ctx) if err != nil { s.logf("camera checks: %v", err) return } for _, job := range jobs { res := s.runOne(ctx, job, desired) if err := s.Checks.Submit(ctx, res); err != nil { // Nothing to retry against: the server released the claim on a // timeout, so the operator's next press starts a fresh one. Losing // a result is better than a queue of stale verdicts. s.logf("camera %s: could not report the check: %v", job.CameraID, err) } } } func (s *Syncer) runOne(ctx context.Context, job Job, desired []Desired) Result { res := Result{CameraID: job.CameraID} local, err := s.Engine.List(ctx) if err != nil { res.Headline = "the recognition software on this PC is not responding" res.Advice = []string{"Open Behavision on the shop's PC and make sure it is started."} return res } var cam Local var found bool for _, c := range local { if c.ID == job.CameraID { cam, found = c, true break } } if !found { // The camera exists at head office but the PC has not applied it yet. // Honest, and it tells the operator to wait rather than to go and look // at the cabling. res.Headline = "this PC has not set up that camera yet" res.Advice = []string{"It is applied within a couple of minutes of being added. Try again shortly."} return res } // The engine never returns a camera password - by design, it reports // `has_password` and nothing else - so probing with what it hands back // dials the camera with an empty credential. That failed, and reported // "could not open stream - check the host, port, path and credentials" // about a camera the same PC had been streaming for an hour, with advice // sending the installer to check the very credential that was never sent. // // Head office has the real one, and this sync already fetched it. if cam.Password == "" { for _, d := range desired { if d.CameraID == job.CameraID { cam.Password = d.Password break } } } switch job.Kind { case "placement": return s.runPlacement(ctx, job, cam) default: return s.runConnection(ctx, job, cam) } } func (s *Syncer) runConnection(ctx context.Context, job Job, cam Local) Result { res := Result{CameraID: job.CameraID} out, err := s.Prober.Test(ctx, cam) if err != nil { res.Headline = "could not test the camera: " + err.Error() return res } if !out.OK { res.Verdict = "unreachable" // The engine's own sentence. It distinguishes a refused connection from // a wrong path from a stream that opens and never sends a frame, and // those need three different things done about them. res.Headline = out.Error res.Advice = adviceFor(out.Error) return res } res.OK = true res.Verdict = "reachable" res.Headline = fmt.Sprintf("connected — %d×%d", out.Width, out.Height) res.Detail = map[string]any{"width": out.Width, "height": out.Height} res.Advice = []string{ "Check the picture below is the view you expect.", "Then run a walk-past check to prove faces here can actually be recognised.", } if out.Snapshot != "" { if jpeg, err := base64.StdEncoding.DecodeString(out.Snapshot); err == nil { if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil { res.ImageKey = key } else { // A missing picture does not invalidate the result: the camera // still connected, which is what was asked. s.logf("camera %s: check snapshot upload failed: %v", job.CameraID, err) } } } return res } func (s *Syncer) runPlacement(ctx context.Context, job Job, cam Local) Result { res := Result{CameraID: job.CameraID} seconds := job.Seconds if seconds <= 0 { seconds = 25 } // Room for the watch itself plus the engine's own overhead. Without the // margin the context dies at the exact moment the verdict is computed. ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds+30)*time.Second) defer cancel() report, err := s.Prober.Placement(ctx, job.CameraID, seconds) if err != nil { res.Headline = "the walk-past check could not be run: " + err.Error() return res } res.Detail = report res.Verdict, _ = report["verdict"].(string) res.Headline, _ = report["headline"].(string) if adv, ok := report["advice"].([]any); ok { for _, a := range adv { if str, ok := a.(string); ok { res.Advice = append(res.Advice, str) } } } // Only `good` is a pass. `marginal` means half the visitors are silently // discarded, which is not a working camera - calling it one is how a site // gets signed off and discovered three weeks later from a footfall report // that was always zero. res.OK = res.Verdict == "good" return res } // adviceFor turns the engine's diagnosis into the next thing to do. // // Matched on the engine's own wording rather than an error code, because the // engine returns prose - and prose that is already correct. This adds the // action, it does not restate the problem. func adviceFor(engineError string) []string { msg := strings.ToLower(engineError) switch { case strings.Contains(msg, "refused"): return []string{ "Something answered at that address but refused the connection.", "The port is usually 554 for an RTSP camera. Check the port first.", } case strings.Contains(msg, "unreachable"), strings.Contains(msg, "timed out"), strings.Contains(msg, "no route"): return []string{ "Nothing answered at that address from the shop's PC.", "Check the camera is powered on and plugged into the same network as the PC.", "Confirm the address in the camera's own app or on its label.", } case strings.Contains(msg, "could not open"): return []string{ "The address is reachable but the stream would not open.", "This is usually the stream path or the camera's username and password.", "Pick your camera's make above to fill in the usual path for it.", } case strings.Contains(msg, "no frame"): return []string{ "The camera accepted the connection but sent no picture.", "Some cameras only allow one viewer at a time — close any app watching it.", } } return []string{"Check the address, port, stream path, username and password."} }