The agent read the engine's generated credential file once, at startup. On a brand new install that file does not exist yet: the agent starts the engine, and the engine writes its credential seconds later. So the agent held an empty credential for the life of the process and every call it makes - health, stats, camera sync, the embedding for a visit - came back 401, with a tray showing a red engine that was running perfectly. Measured on a fresh state directory today: three 401s, no camera ever reconciled, and the engine left running the YAML-seeded main stream instead of the sub-stream head office holds. The install script hid this on Windows because setup runs the engine once before the app starts. config.Creds resolves lazily and re-reads on a rejection; the camera client, the supervisor and the desktop app's engine client all retry once when it changes. A configured BEHAVISION_API_USER is never re-read - an operator who set one means it. Tests pin the actual first-run ordering. Also adds demo/, a one-screen live console for showing the whole chain: camera, the six steps with a measured camera-to-cloud latency, the customer editable in place, and the raw JSON a phone and a dashboard receive from production side by side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
150 lines
4.7 KiB
Go
150 lines
4.7 KiB
Go
// Package local talks to the Python recognition engine running on this PC.
|
|
//
|
|
// The desktop app is a CLIENT of the engine and never imports it. The engine
|
|
// owns the cameras, the models and the SQLite gallery; two processes touching
|
|
// one webcam or one WAL is the failure this separation exists to prevent.
|
|
package local
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
agentconfig "github.com/loyaly/behavision-agent/pkg/config"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Client struct {
|
|
Base string
|
|
User string
|
|
Password string
|
|
// Creds re-reads the engine's generated credential when one is rejected.
|
|
// On a first run the app starts the engine, and the engine writes that
|
|
// file seconds later - after the app has already looked for it.
|
|
Creds *agentconfig.Creds
|
|
http *http.Client
|
|
}
|
|
|
|
func New(base, user, password string) *Client {
|
|
return &Client{
|
|
Base: strings.TrimRight(base, "/"), User: user, Password: password,
|
|
// Generous: a camera Test opens an RTSP stream and can legitimately
|
|
// take ten seconds against a slow NVR.
|
|
http: &http.Client{Timeout: 45 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
user, pass := c.User, c.Password
|
|
if c.Creds != nil {
|
|
user, pass = c.Creds.Get()
|
|
}
|
|
if user != "" {
|
|
req.SetBasicAuth(user, pass)
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
// The single most common state on a fresh install: the engine has not
|
|
// been started yet. Say that, rather than surfacing a dial error the
|
|
// user cannot act on.
|
|
return fmt.Errorf("engine not reachable at %s (is it running?): %w", c.Base, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("engine %s %s: %s: %s", method, path, resp.Status,
|
|
strings.TrimSpace(string(msg)))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
|
|
}
|
|
|
|
func (c *Client) Health(ctx context.Context) (map[string]any, error) {
|
|
var out map[string]any
|
|
return out, c.do(ctx, http.MethodGet, "/api/health", nil, &out)
|
|
}
|
|
|
|
func (c *Client) Stats(ctx context.Context) (map[string]any, error) {
|
|
var out map[string]any
|
|
return out, c.do(ctx, http.MethodGet, "/api/stats", nil, &out)
|
|
}
|
|
|
|
func (c *Client) Events(ctx context.Context, limit int) ([]map[string]any, error) {
|
|
var out []map[string]any
|
|
return out, c.do(ctx, http.MethodGet,
|
|
fmt.Sprintf("/api/events?limit=%d", limit), nil, &out)
|
|
}
|
|
|
|
func (c *Client) Cameras(ctx context.Context) ([]map[string]any, error) {
|
|
var out []map[string]any
|
|
return out, c.do(ctx, http.MethodGet, "/api/cameras", nil, &out)
|
|
}
|
|
|
|
func (c *Client) AddCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
|
|
var out map[string]any
|
|
return out, c.do(ctx, http.MethodPost, "/api/cameras", cam, &out)
|
|
}
|
|
|
|
func (c *Client) UpdateCamera(ctx context.Context, id string, cam map[string]any) (map[string]any, error) {
|
|
var out map[string]any
|
|
return out, c.do(ctx, http.MethodPatch, "/api/cameras/"+id, cam, &out)
|
|
}
|
|
|
|
func (c *Client) DeleteCamera(ctx context.Context, id string) error {
|
|
return c.do(ctx, http.MethodDelete, "/api/cameras/"+id, nil, nil)
|
|
}
|
|
|
|
// DiscoverCameras asks the engine to scan the shop's network. A few seconds.
|
|
func (c *Client) DiscoverCameras(ctx context.Context) (map[string]any, error) {
|
|
var out map[string]any
|
|
return out, c.do(ctx, http.MethodGet, "/api/cameras/discover", nil, &out)
|
|
}
|
|
|
|
func (c *Client) TestCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
|
|
var out map[string]any
|
|
return out, c.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)
|
|
}
|
|
|
|
func (c *Client) StartPlacementCheck(ctx context.Context, id string, seconds float64) (map[string]any, error) {
|
|
var out map[string]any
|
|
return out, c.do(ctx, http.MethodPost, "/api/cameras/"+id+"/commission",
|
|
map[string]any{"seconds": seconds}, &out)
|
|
}
|
|
|
|
func (c *Client) PlacementResult(ctx context.Context, id string) (map[string]any, error) {
|
|
var out map[string]any
|
|
return out, c.do(ctx, http.MethodGet, "/api/cameras/"+id+"/commission", nil, &out)
|
|
}
|
|
|
|
func (c *Client) Identities(ctx context.Context, limit int) ([]map[string]any, error) {
|
|
var out []map[string]any
|
|
return out, c.do(ctx, http.MethodGet,
|
|
fmt.Sprintf("/api/identities?limit=%d", limit), nil, &out)
|
|
}
|
|
|
|
func (c *Client) Sightings(ctx context.Context, limit int) ([]map[string]any, error) {
|
|
var out []map[string]any
|
|
return out, c.do(ctx, http.MethodGet,
|
|
fmt.Sprintf("/api/sightings?limit=%d", limit), nil, &out)
|
|
}
|