Compare commits
64 Commits
v0.3.1
...
v0.5.5-dem
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cddd9c2e1 | |||
| 248025cdf9 | |||
| ff4f95c3b0 | |||
| 48a30d97db | |||
| ecc8bbba6f | |||
| 97a8ecc03a | |||
| 50d122e5f0 | |||
| dd3331ee9d | |||
| 68a50d10b1 | |||
| 8137480877 | |||
| f5eb2bb124 | |||
| 0b29dd4a50 | |||
| e33761d6d0 | |||
| 02b2c5bc39 | |||
| e5a63cc412 | |||
| e27eb8e927 | |||
| 7c564aca3c | |||
| 7dda6ab508 | |||
| e0bd764e44 | |||
| 6068b2c3c7 | |||
| dae18d651b | |||
| c7312d31b4 | |||
| f4102443ea | |||
| 359d48e1c4 | |||
| 830c1c1573 | |||
| 4db71e9381 | |||
| 6fafecd6e4 | |||
| 0e4cb1274e | |||
| abcf6aa012 | |||
| f61da2eeed | |||
| 2e60fbb57a | |||
| f97ffc913a | |||
| 62c2cc8a7b | |||
| 16f0e69cec | |||
| 9062d2fc51 | |||
| 177584e812 | |||
| 81e2c605b9 | |||
| 1607f4ce74 | |||
| f88d441bbf | |||
| 4ac08e5a85 | |||
| 7c74431fcf | |||
| 01f1c17c7f | |||
| 448fba8770 | |||
| 4bd1718491 | |||
| 51b9cb9743 | |||
| 2835252bb3 | |||
| 7021d5d2f5 | |||
| 4c62fc0ef3 | |||
| effa4f3d62 | |||
| 6c210f792f | |||
| 8786a5b0b4 | |||
| c93fbff31f | |||
| 4c750cb2ac | |||
| 5f83a1077d | |||
| a74cb899b4 | |||
| 8c88aad06e | |||
| 50a843ce46 | |||
| 979aa77cda | |||
| 3d3775c8be | |||
| a1fe0942e2 | |||
| e262fc8482 | |||
| b59e667a68 | |||
| 70c447873d | |||
| 719ba2c7f5 |
2
.gitignore
vendored
@@ -65,3 +65,5 @@ node_modules/
|
||||
|
||||
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
|
||||
/behavision.egg-info/
|
||||
/.prod/
|
||||
/.demo/
|
||||
|
||||
286
API.md
@@ -1,6 +1,6 @@
|
||||
# Behavision API — for the web console, a mobile app, and platform administration
|
||||
|
||||
Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`).
|
||||
Base URL: `https://mcp.loyaly.ai` — the API host. (`platform.loyaly.ai` serves the head-office web console, not the API.)
|
||||
Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says
|
||||
otherwise. Every shape below is taken from the server's own types, not written
|
||||
from memory — if the two ever disagree, the server is right and this file has a
|
||||
@@ -43,24 +43,36 @@ user; the tenant is always taken from the session and never from the request.
|
||||
|---|---|
|
||||
| `POST /api/auth/login` `refresh` · `GET /api/auth/invitation` · `POST /api/auth/register` | **no auth** |
|
||||
| `POST /api/auth/logout` · `GET /api/auth/me` · `/api/auth/sessions*` | authed |
|
||||
| `POST /api/auth/password` — change your OWN | authed (platform admins too) |
|
||||
| `GET /api/visits` · `GET /api/visits/stream` | authed |
|
||||
| `GET /api/visitors` · `GET /api/visitors/{id}/history` · `GET /api/visitors/{id}/image` · `GET /api/faces/{id}` | authed |
|
||||
| `PUT /api/visitors/{id}/profile` · `POST /api/purchases` | staff |
|
||||
| `POST /api/customers` | staff |
|
||||
| `POST /api/visitors/{id}/merge` — **irreversible** | manager |
|
||||
| `DELETE /api/visitors/{id}` — erasure | manager |
|
||||
| `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed |
|
||||
| `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager |
|
||||
| `POST /api/sites` — open a shop · `DELETE /api/sites/{site}` — remove an empty one | owner |
|
||||
| `POST /api/sites/{site}/enrolment-code` | manager |
|
||||
| `GET /api/team` | authed (tenant users only) |
|
||||
| `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager |
|
||||
| `GET /api/reports/footfall` · `GET /api/reports/conversion` | authed |
|
||||
| `GET /api/reports/footfall` · `GET /api/reports/conversion` · `GET /api/sales` · `GET /api/sales/{id}` · `GET /api/dashboard/summary` | authed |
|
||||
| `POST /api/assistant` | authed |
|
||||
| `GET` / `POST /api/admin/clients` | **platform admin** |
|
||||
| `GET` / `POST /api/admin/clients` · `PATCH /api/admin/clients/{id}` · `POST /api/admin/clients/{id}/owner-password` · `DELETE /api/admin/clients/{id}` | **platform admin** |
|
||||
| `GET /api/admin/clients/{id}` · `…/{id}/sites` · `…/sites/{site}` · `…/sites/{site}/cameras` · `…/cameras/{camera}` · `GET /api/admin/monitoring/summary` | **platform admin** |
|
||||
| `/api/agent/*` | **shop PC token** — never a user |
|
||||
|
||||
A role that may not call something gets **403 `forbidden`** with a message
|
||||
saying who can. Another tenant's data returns **404**, never 403: a tenant user
|
||||
has no business learning that a resource exists.
|
||||
|
||||
`authed` above means any signed-in user **of a company**. A platform admin has
|
||||
no company — that absence is what defines one — so a company's own routes
|
||||
answer them **403 `not_a_tenant_account`**, naming the `/api/admin/clients/{id}/…`
|
||||
route that reads the same data. Their own `/api/auth/*` keeps working: a session
|
||||
is not a company's data, and revoking a lost device must not depend on having a
|
||||
tenant.
|
||||
|
||||
---
|
||||
|
||||
## The onboarding chain — who creates whom
|
||||
@@ -101,6 +113,9 @@ travels over chat.
|
||||
|
||||
```
|
||||
GET /api/admin/clients → every merchant, with site and user counts
|
||||
PATCH /api/admin/clients/{id} {"active": false} → suspend (or true: reinstate)
|
||||
POST /api/admin/clients/{id}/owner-password → new owner password, shown once
|
||||
DELETE /api/admin/clients/{id} {"confirm": "<slug>"} → delete a SUSPENDED company
|
||||
```
|
||||
|
||||
### Tier 2 — the merchant owner registers sales staff
|
||||
@@ -164,9 +179,9 @@ PATCH /api/team/{id} { "role": "manager" } → promote
|
||||
PATCH /api/team/{id} { "active": false } → they have left; signs them out now
|
||||
```
|
||||
|
||||
The owner also sets the shop up from the same login — `POST
|
||||
/api/sites/{site}/enrolment-code` for the shop PC, `POST
|
||||
/api/sites/{site}/cameras` for cameras — see §8.
|
||||
The owner also opens shops and sets them up from the same login — `POST
|
||||
/api/sites` to open one, `POST /api/sites/{site}/enrolment-code` for its
|
||||
shop PC, `POST /api/sites/{site}/cameras` for cameras — see §8.
|
||||
|
||||
### Tier 3 — the salesperson gets their mobile login
|
||||
|
||||
@@ -217,9 +232,10 @@ somebody else's account.
|
||||
|
||||
- **There is no mobile app in this repository.** Tier 3 is a complete API with
|
||||
no client yet. Everything above is what that app will call.
|
||||
- **The admin cannot reset a merchant owner's password over HTTP**, nor suspend
|
||||
or delete a merchant. Today that is `behavision-server provision` on the
|
||||
server.
|
||||
- **A shop with visit history cannot be deleted**, only its cameras removed.
|
||||
`DELETE /api/sites/{site}` is for the shop opened by mistake (no visits, no
|
||||
cameras); taking away footfall and faces is an erasure decision, and there
|
||||
is no endpoint for it yet.
|
||||
|
||||
---
|
||||
|
||||
@@ -260,10 +276,13 @@ GET /api/reports/footfall?from=&to= → the numbers, with their confidenc
|
||||
POST /api/auth/login (an account with no company)
|
||||
GET /api/admin/clients → every company
|
||||
POST /api/admin/clients → create one, with its owner
|
||||
PATCH /api/admin/clients/{id} → suspend / reinstate
|
||||
POST /api/admin/clients/{id}/owner-password → reset the owner's password
|
||||
DELETE /api/admin/clients/{id} → delete, once suspended
|
||||
```
|
||||
|
||||
That is the whole admin surface today. Everything inside a company is the
|
||||
company's own business and is reached by signing in as one of its users.
|
||||
That is the whole admin surface. Everything inside a company is the company's
|
||||
own business and is reached by signing in as one of its users.
|
||||
|
||||
---
|
||||
|
||||
@@ -461,6 +480,34 @@ deactivate them (§4); that revokes every session they hold.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/password` — any signed-in account
|
||||
|
||||
Change your own password. Works for **every** account including a platform
|
||||
admin, who has no company and therefore cannot be reached by the team routes.
|
||||
|
||||
```json
|
||||
{ "current_password": "...", "new_password": "..." }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "changed": true, "sessions_revoked": 3 }
|
||||
```
|
||||
|
||||
- **The current password is required.** An access token lives twelve hours and
|
||||
travels on shop-floor PCs and staff phones; without this a stolen one would
|
||||
own the account permanently rather than until it expires. Wrong current
|
||||
password is **403 `wrong_password`** and changes nothing.
|
||||
- **Every other session is revoked; the caller's is kept.** Somebody changing
|
||||
their password because they think it is known must not wonder whether the
|
||||
device that already had it is still signed in — and must not be signed out of
|
||||
the one in their hand while dealing with it.
|
||||
- A new password under the floor is **400**, and so is reusing the current one.
|
||||
|
||||
This is the route to use rather than asking an administrator. `POST
|
||||
/api/team/{id}/password` remains what a manager uses on somebody *else*.
|
||||
|
||||
---
|
||||
|
||||
## 4. The team
|
||||
|
||||
### `GET /api/team` — anyone in the company
|
||||
@@ -679,6 +726,105 @@ record of what you were permitted to do is what an auditor asks for.
|
||||
Links a sale to a customer so the conversion report can say who bought. **One
|
||||
currency per report** — see §9.
|
||||
|
||||
### `GET /api/sales` — anyone in the company
|
||||
|
||||
The purchases behind the conversion report. That report has always summed this
|
||||
table; until 28 Sep nothing could read a row of it, so "revenue was 41,000"
|
||||
could not be checked against a till.
|
||||
|
||||
Takes the same window as a report: `from`, `to` (`YYYY-MM-DD`), `site` or
|
||||
`site_id` (slug or uuid), plus `customer` (`V-42` or a uuid) and `limit`
|
||||
(default 50, max 200). An unknown shop or customer is a **400**, not a silently
|
||||
ignored filter.
|
||||
|
||||
```json
|
||||
[{ "id": "8525ef18-…", "occurred_at": "2026-09-19T10:48:44Z",
|
||||
"site_id": "93d0565f-…", "site": "TeNext Coimbatore", "site_slug": "chennai",
|
||||
"amount": 1000, "currency": "INR",
|
||||
"visitor_id": "ba5e5d2e-…", "visitor_ref": "V-1", "visitor_label": "Visitor 1",
|
||||
"visit_id": "3bcd53ca-…", "items": ["Headphones"], "source": "manual" }]
|
||||
```
|
||||
|
||||
- **A sale with no `visitor_id` is listed**, not joined away. An unidentified
|
||||
walk-in is still revenue, and hiding it would make this disagree with the
|
||||
conversion report computed over the same rows.
|
||||
- `items` is always an array, never `null`.
|
||||
- **No cursor, deliberately.** A keyset cursor needs a monotonic
|
||||
server-assigned column and `purchases` has none; ordering by
|
||||
`(occurred_at, id)` with a random uuid tie-break is the shape that silently
|
||||
dropped visits from the arrivals feed before `visits.seq` existed. Narrow by
|
||||
date and `limit` instead.
|
||||
|
||||
### `GET /api/sales/{id}`
|
||||
|
||||
One sale, same shape. Another company's sale is **404**.
|
||||
|
||||
### `GET /api/dashboard/summary` — anyone in the company
|
||||
|
||||
The home screen in one call, so a client does not combine four.
|
||||
|
||||
Takes `site`/`site_id` and `tz` (IANA, default the company's). "Today" is cut
|
||||
in **that timezone** — a dashboard that says today and means UTC is five and a
|
||||
half hours wrong in India.
|
||||
|
||||
```json
|
||||
{ "date": "2026-09-28", "visitors": 0, "visits": 0,
|
||||
"sites_total": 4, "sites_online": 0, "cameras_total": 1, "cameras_up": 1,
|
||||
"fraction_below_gate": 0.59, "worst_site": "TeNext Coimbatore",
|
||||
"timezone": "Asia/Kolkata" }
|
||||
```
|
||||
|
||||
`visitors` is unique people and `visits` is arrivals — **do not add the daily
|
||||
bars of a footfall report to get either.** `fraction_below_gate` travels with
|
||||
them because it is what says whether the count is a number or a floor.
|
||||
|
||||
### `POST /api/customers` — staff and above
|
||||
|
||||
Register a customer before any camera has seen them — somebody standing at the
|
||||
counter. Body is a profile; **at least a name or a phone** is required, since a
|
||||
record with neither is a number nobody can search for.
|
||||
|
||||
```json
|
||||
{ "id": "…", "ref": "V-7", "label": "Asha Menon", "visit_count": 0, "has_profile": true }
|
||||
```
|
||||
|
||||
They get a `V-` reference from the same counter an enrolled customer does, so a
|
||||
hand-created record is indistinguishable from one the engine made.
|
||||
|
||||
**Know what this implies.** They have no face template, so when a camera sees
|
||||
that person later the matcher has nothing to compare against and enrols them
|
||||
again — by design, not by failure. Join the two with the merge below.
|
||||
|
||||
### `POST /api/visitors/{id}/merge` — manager and above
|
||||
|
||||
Fold the customer in the path **into** the one named in the body, and delete
|
||||
the source. `into` takes a uuid or a `V-` reference.
|
||||
|
||||
```json
|
||||
{ "into": "V-12" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "visitor_id": "…", "ref": "V-12", "label": "Asha Menon",
|
||||
"visits": 2, "purchases": 1, "embeddings": 1, "consents": 1,
|
||||
"retired_ref": "V-7",
|
||||
"discarded": ["phone: 9000000001"] }
|
||||
```
|
||||
|
||||
- **Irreversible**, which is why it is manager-and-above. Two different people
|
||||
welded together cannot be separated: nothing records which visit came from
|
||||
whom. It logs at WARNING and writes an audit row.
|
||||
- **`retired_ref` is the reference that has stopped resolving.** Staff write
|
||||
these on cards; asking for it afterwards returns 404.
|
||||
- **Nothing is discarded silently.** The survivor keeps its own profile values
|
||||
and its blanks are filled from the source; anything that loses is listed in
|
||||
`discarded` **and** appended to the survivor's notes — and `GET /api/visitors?q=`
|
||||
searches notes, so a customer reached by their old phone number is still found.
|
||||
- Visits, purchases, templates and consents all move. `visit_count` is
|
||||
recomputed by counting rows, and `first_seen_at` takes the earlier of the two.
|
||||
- Merging a customer into themselves is **400**; an unknown or another tenant's
|
||||
customer is **404**.
|
||||
|
||||
### `DELETE /api/visitors/{id}` — **erasure**, manager and above
|
||||
|
||||
Destroys the face template and the photo outright. Keeps the visit rows,
|
||||
@@ -718,6 +864,28 @@ unplugged PC.
|
||||
footfall lost because that queue overflowed. Non-zero `dropped` is a report
|
||||
that is wrong in a way the report itself cannot show.
|
||||
|
||||
### `POST /api/sites` — open a shop (owner)
|
||||
|
||||
```
|
||||
{ "name": "TeNext Bengaluru", "slug": "bengaluru", "timezone": "Asia/Kolkata" }
|
||||
→ 201 { "site_id": "…", "slug": "bengaluru", "name": "TeNext Bengaluru",
|
||||
"timezone": "Asia/Kolkata", "broker_username": "tenext-retail.bengaluru" }
|
||||
```
|
||||
|
||||
`slug` and `timezone` are optional: the slug is made from the name (lower-case,
|
||||
digits and dashes, 3–32 characters) and the timezone defaults to Asia/Kolkata.
|
||||
The slug is the shop PC's identity and an MQTT topic segment; it **cannot be
|
||||
changed afterwards**. The server registers the shop's broker login with
|
||||
Mosquitto in the same request, so the next step is simply
|
||||
`POST /api/sites/{slug}/enrolment-code` for the PC.
|
||||
|
||||
| status | code | meaning |
|
||||
|---|---|---|
|
||||
| 403 | `forbidden` | not the owner |
|
||||
| 409 | `conflict` | a shop with that slug exists |
|
||||
| 502 | `broker_unavailable` | the broker did not accept the login; **nothing was created** — try again |
|
||||
| 503 | `broker_unavailable` / `no_encryption_key` | this server cannot create shops; contact support |
|
||||
|
||||
### `GET /api/sites/{site}/check`
|
||||
|
||||
Five ordered steps that answer *is this shop working*, assembled from what head
|
||||
@@ -1022,6 +1190,96 @@ in as the company's owner, or by `behavision-server provision` on the server.
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/admin/clients/{id}` — suspend or reinstate
|
||||
|
||||
```
|
||||
{ "active": false }
|
||||
→ 200 { "client": { "id": "…", "slug": "acme", "active": false, "sites": 2, "users": 5, … },
|
||||
"sessions_revoked": 3 }
|
||||
```
|
||||
|
||||
Suspension is complete the moment it returns: the company's users cannot sign
|
||||
in, every session they hold is revoked in the same transaction (so a live
|
||||
access token stops working now, not at expiry), and visits from its shop PCs
|
||||
are dropped at ingest. `{"active": true}` reinstates; sessions are not
|
||||
restored — people sign in again.
|
||||
|
||||
### `POST /api/admin/clients/{id}/owner-password` — reset the owner's password
|
||||
|
||||
```
|
||||
{ "email": "owner@acme.com" } ← optional when the company has exactly one owner
|
||||
→ 200 { "email": "owner@acme.com", "password": "n7xw…" } ← shown ONCE
|
||||
```
|
||||
|
||||
For the owner who has locked themselves out with nobody above them. Generated,
|
||||
never chosen; every session that owner held is revoked. With several owners
|
||||
and no `email`, 400 listing them.
|
||||
|
||||
### `DELETE /api/admin/clients/{id}` — delete a company
|
||||
|
||||
```
|
||||
{ "confirm": "acme" }
|
||||
→ 200 { "deleted": "acme", "images_deleted": 12 }
|
||||
```
|
||||
|
||||
Irreversible, and the data is biometric, so it is a two-step decision: the
|
||||
company must already be **suspended** (`409 still_active` otherwise) and the
|
||||
body must repeat its slug. Stored face images are deleted from object storage
|
||||
first — a failure there is `502 storage_error` and nothing else is touched —
|
||||
then the shop PCs' broker logins, then every row (templates, visits, users,
|
||||
sessions, cameras) by cascade.
|
||||
|
||||
### The drill-down: `GET /api/admin/clients/{id}` and below
|
||||
|
||||
A platform admin has **no company**, so the tenant routes cannot serve this —
|
||||
they scope by the signed-in account's client, and an admin has none. These take
|
||||
the merchant in the path instead. `{site}` accepts a slug or a uuid; `{camera}`
|
||||
accepts the engine's camera id or a uuid.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/admin/clients/{id}` | the list row plus `owner_email`, `owner_name` |
|
||||
| `GET …/{id}/sites` | same shape as a tenant's `GET /api/sites` |
|
||||
| `GET …/{id}/sites/{site}` | one of them |
|
||||
| `GET …/{id}/sites/{site}/cameras` | **redacted** — see below |
|
||||
| `GET …/{id}/sites/{site}/cameras/{camera}` | one of them |
|
||||
| `GET /api/admin/monitoring/summary` | `cameras_total`, `cameras_online`, `merchants_active`, `sites_total`, `events_today`, `as_of` |
|
||||
|
||||
**Camera rows here are a different shape from `GET /api/cameras`** and carry no
|
||||
`host`, `port`, `path`, `username` or `has_password`. A company seeing those
|
||||
for its own camera is correct; a platform admin browsing somebody else's estate
|
||||
is a different question, and an RTSP host with a username beside it is most of
|
||||
a live path into that customer's camera.
|
||||
|
||||
```json
|
||||
[{ "id": "3a96a742-…", "site_id": "93d0565f-…", "site": "TeNext Coimbatore",
|
||||
"camera_id": "cam2", "label": "Open office", "enabled": true,
|
||||
"connected": true, "last_seen_at": "2026-09-24T08:33:10Z",
|
||||
"snapshot_at": "2026-09-24T08:33:10Z", "check": { … } }]
|
||||
```
|
||||
|
||||
`connected` is still three states: `null` = no shop PC has reported yet,
|
||||
`false` = not connecting, `true` = up.
|
||||
|
||||
A shop or camera belonging to a **different** merchant is **404**, never an
|
||||
empty list — `[]` would say "this shop has no cameras" when the truth is "not
|
||||
your shop". A suspended merchant stays readable; that is what an admin opens
|
||||
the console to see. Every read below the merchant list is written to
|
||||
`audit_log`; the counts-only summary is not.
|
||||
|
||||
**Not built, and each is a decision rather than a missing handler:**
|
||||
|
||||
- `…/events` and `…/alerts` — there is no events table and the shop PC
|
||||
deliberately does not send diagnostics (`camera.up`, `person.missed`) to the
|
||||
server. This needs that decision, a table and a retention policy first; an
|
||||
endpoint now would return `[]` forever.
|
||||
- `POST /api/admin/assistant` — the assistant's tools take no client id by
|
||||
design, which is what makes cross-tenant access impossible rather than merely
|
||||
disallowed. An admin one needs a principal scoped to the merchant being
|
||||
viewed, which weakens that. Deliberately not done quietly.
|
||||
|
||||
---
|
||||
|
||||
## 12. Errors
|
||||
|
||||
```json
|
||||
@@ -1036,7 +1294,7 @@ rewritten freely.
|
||||
|---|---|
|
||||
| 400 | the request was wrong; `message` says how — including an unknown reference in a query filter |
|
||||
| 401 | not signed in, or `token_expired` → refresh once and retry |
|
||||
| 403 | `forbidden` — signed in, but this role may not; `message` says who can |
|
||||
| 403 | `forbidden` — signed in, but this role may not; `message` says who can. Also `not_a_tenant_account`: a **platform admin** calling a company's own route, who reads that data through `/api/admin/clients/{id}/…` instead |
|
||||
| 404 | not found — **also** what another tenant's data returns, always; and what admin routes return to non-admins |
|
||||
| 409 | a conflict `message` explains (`last_owner`, duplicate address) |
|
||||
| 429 | `too_many_attempts` |
|
||||
@@ -1057,3 +1315,7 @@ a user session is refused.
|
||||
|
||||
A web or mobile client never calls them. They are listed here so nobody wonders
|
||||
what they are.
|
||||
|
||||
One field name, because it is the only one in the product that is easy to guess
|
||||
wrong: `POST /api/agent/enrol` takes **`site_token`** (the installation code),
|
||||
not `code`, plus an optional `device`. Verified against production.
|
||||
|
||||
841
CLAUDE.md
@@ -1696,20 +1696,41 @@ The enrol response gained `client_slug` and `topic_prefix`, both **derived from
|
||||
the broker username** rather than looked up separately, so the agent's topic
|
||||
prefix and the broker's ACL are equal by construction.
|
||||
|
||||
### Still a command: creating the shop itself
|
||||
### Opening a shop is an API call, and the broker learns of it in the same request
|
||||
|
||||
`provision site` prints a broker password that a human then has to add to
|
||||
Mosquitto. So a tenant cannot open their second shop without us, and that is the
|
||||
one remaining hole in self-service onboarding. Closing it needs a decision, not
|
||||
code:
|
||||
`POST /api/sites` (owner), and `provision site` behind the same code. This was
|
||||
the last piece of onboarding that needed a shell: `provision site` printed a
|
||||
broker password and a person typed it into Mosquitto's passwd file on the host
|
||||
— which turned out to be mounted read-only in the container, so the first
|
||||
attempt failed silently and the password had to be re-rolled. No tenant could
|
||||
open a second branch without us.
|
||||
|
||||
- **the server manages Mosquitto's `passwd`/`acl` and reloads it** — possible
|
||||
because they are co-located, and it couples the API to the broker's
|
||||
filesystem; or
|
||||
- **one broker user per CLIENT rather than per site** — then adding a shop needs
|
||||
no broker change at all. Cross-tenant isolation is unchanged; what is given up
|
||||
is that one of a customer's own PCs could publish as another of their sites.
|
||||
Every deployed site would need re-provisioning.
|
||||
Neither option recorded here before was taken. The server does not write the
|
||||
broker's files, and there is still one broker user per site. Mosquitto 2.0's
|
||||
**dynamic-security plugin** takes the same operations as commands on
|
||||
`$CONTROL/dynamic-security/v1`, from a client holding the `admin` role;
|
||||
`server/internal/broker` drives it over the server's own broker login.
|
||||
|
||||
- **A role per site, with literal topics.** The 2.0 plugin does **not**
|
||||
substitute `%u` in ACL topics (measured: the publish was denied), so
|
||||
`site.<client>.<site>` is created with the client and deleted with it.
|
||||
- **Idempotent.** Re-running `EnsureSite` on an existing login sets the password
|
||||
to the one the database holds and confirms the role. `addClientRole` on a
|
||||
client that already has the role answers "Internal error", so the role is
|
||||
checked with `getClient` rather than inferred from prose.
|
||||
- **The row and the login are created together, or not at all.** If the broker
|
||||
refuses, the just-created row is removed and the caller gets 502. A shop that
|
||||
exists in the database and not on the broker is one whose PC enrols fine and
|
||||
never delivers a visit — the silent-failure class this whole endpoint ends.
|
||||
- **Its own connection**, not the ingest client's: that one has
|
||||
`SetOrderMatters` and blocking handlers, and a provisioning call must neither
|
||||
wait behind a slow visit nor delay one.
|
||||
- **Cutover keeps every password.** `behavision-server broker-init` converts the
|
||||
passwd file into the plugin's store: `$7$` lines are PBKDF2-SHA512 with a
|
||||
salt and iteration count, which is exactly what the plugin stores, so no shop
|
||||
PC re-claims and no credential changes hands. Rehearsed locally against a
|
||||
file `mosquitto_passwd` wrote; `run-local.sh` now brings the broker up the
|
||||
same way as production.
|
||||
|
||||
## Running it against the real office camera: four dead wires
|
||||
|
||||
@@ -2130,6 +2151,510 @@ correctly calls them one person. Tests that need several different people use
|
||||
`distinctFace(i)`, which is orthogonal per index.
|
||||
|
||||
|
||||
## It recognises people, and that is now measured rather than assumed
|
||||
|
||||
Verified against **production** on 2026-09-24, reading what the live system had
|
||||
already recorded from the office cameras on 15-19 September. Until this, every
|
||||
claim about recognition rested on the August engine tests; the whole chain -
|
||||
camera to engine to agent to broker to server to a screen - had never once
|
||||
carried a real person.
|
||||
|
||||
```
|
||||
6 customers, 36 visits, 1,211 events accepted, 0 dropped, 0 duplicates
|
||||
Visitor 1 15 visits Visitor 3 8 visits Visitor 5 7 visits
|
||||
same-person similarity on returning matches: 0.437 - 0.716
|
||||
attributes travelling with each arrival: age 37 (spread 2), gender Male
|
||||
```
|
||||
|
||||
Those similarities are the point. The August measurement on this site said
|
||||
`match_threshold: 0.42` sat *inside* the same-person distribution for the
|
||||
overhead camera and no threshold could separate a person from themselves. On
|
||||
`cam2` the same code now returns 0.54, 0.64, 0.68, 0.72 for repeat sightings of
|
||||
the same people - a distribution the threshold sits clearly below. The camera
|
||||
that produced them is the open-office one at roughly head height, which is
|
||||
precisely the fix this file has recommended since August.
|
||||
|
||||
What is still true and unflattering: `fraction_below_gate` on that site is
|
||||
**0.59**, reported with `worst_site` beside it. Well over half the faces those
|
||||
two cameras see are still too poor to enrol. The footfall figure of 36 visits
|
||||
is therefore a floor, not a count, and the report says so in the same response -
|
||||
which is the entire reason that number travels with the one it qualifies.
|
||||
|
||||
### The image chain works; the engine is simply not capturing
|
||||
|
||||
Exercised on production exactly as a shop PC does it, end to end:
|
||||
|
||||
```
|
||||
enrolment code -> agent token 200
|
||||
POST /api/agent/upload-url 200 behavision/v2/<client>/<site>/2026/09/24/<random>.jpg
|
||||
PUT <presigned url> 200 JPEG in DigitalOcean Spaces
|
||||
anonymous GET of that same object 403 private, as the signature demands
|
||||
staff GET /api/visitors/{id}/image 404 "No photo was captured for this visit."
|
||||
```
|
||||
|
||||
Every server-side link holds: the key is minted by the server and scoped to one
|
||||
site, the ACL is inside the signature, and an unauthenticated read is refused.
|
||||
The 404 at the end is not a fault - it is `app.store_faces: false`, the shipped
|
||||
default, so the engine never wrote a crop for the agent to upload. Turning
|
||||
images on is one setting on the shop PC and a deliberate change to what the
|
||||
system is under GDPR and India's DPDP, which is why it is off until somebody
|
||||
decides otherwise rather than on until somebody notices.
|
||||
|
||||
### The mobile API, walked as a phone would
|
||||
|
||||
Sixteen checks against production as a **staff** account: login, arrivals feed
|
||||
with cursor, arrivals carrying `visitor_id` and `site_slug`, customer search,
|
||||
customer history, photo endpoint, profile write, purchase, device list, token
|
||||
rotation (the retired access token correctly 401s), team read allowed, invite
|
||||
refused 403, admin surface 404, SSE stream delivering rows, and Loya answering.
|
||||
All pass.
|
||||
|
||||
Three things that looked like product bugs and were not, recorded so the next
|
||||
person does not re-file them:
|
||||
|
||||
- `POST /api/purchases` takes `items` as a **list of strings**, not a count.
|
||||
API.md had it right; the test was wrong.
|
||||
- `new` and `returning` live **inside each bucket** of a footfall report, not at
|
||||
the top level. `total`, `visits`, `fraction_below_gate` and `worst_site` are
|
||||
the top-level fields.
|
||||
- `?range=7d` is not a parameter. Reports take `from` / `to` / `bucket` / `site`,
|
||||
and an unknown query parameter is silently ignored - so an invented one
|
||||
returns the default 30-day window rather than an error. That hazard is
|
||||
already recorded above for `site` vs `site_id`; it applies here too.
|
||||
|
||||
`POST /api/agent/enrol` takes **`site_token`**, not `code` - the only field name
|
||||
in the product a caller could reasonably guess wrong, and it is a route no
|
||||
client app should ever call.
|
||||
|
||||
## CPU: the engine was searching an empty room 15 times a second
|
||||
|
||||
Measured on this Mac against the office camera, because "it feels hot" is not
|
||||
a number. The first reading was **214% of a core**, and the first guess -
|
||||
H.265 decode - was wrong. Wall-clock time said decode cost 58 ms a frame, but
|
||||
`cap.read()` BLOCKS until the next frame arrives, so that number was the frame
|
||||
interval, not work. Measured as CPU time instead:
|
||||
|
||||
```
|
||||
wall/frame CPU/frame at 15 fps
|
||||
H.265 decode 58.8 ms 3.7 ms 5% of a core
|
||||
YuNet detect 8.9 ms 31.0 ms 47% of a core
|
||||
```
|
||||
|
||||
Detection costs three times its wall time because OpenCV spreads it over eight
|
||||
threads. Decode is nearly free. So the cost is detection, and it was running on
|
||||
**every frame whether or not anything was in front of the camera** - 6,649 of
|
||||
8,634 frames searched, with `faces_seen: 0` and `active_tracks: 0` throughout.
|
||||
|
||||
Two changes, both measured:
|
||||
|
||||
- **`app.detect_threads: 1`.** OpenCV sizes its pool for one big job on an idle
|
||||
machine; this is a small job repeated forever on a machine also running the
|
||||
recogniser, the tracker and possibly three other cameras. One thread costs
|
||||
15.3 ms of CPU against the default's 31.0 ms, for 6 ms more wall time against
|
||||
a 66 ms frame budget. Half the CPU, no latency that matters.
|
||||
- **`app.motion_gate`.** A 160x90 greyscale thumbnail and an `absdiff`: 0.1 ms
|
||||
against detection's 15 ms, ninety times cheaper. A shop is empty most of the
|
||||
day and an empty room costs exactly as much to search as a busy one.
|
||||
|
||||
Together: **80% -> 16% of a core**, with detection skipped on 92% of frames.
|
||||
Against the original main-stream reading that is 214% -> 16%.
|
||||
|
||||
### Why the gate cannot lose a face
|
||||
|
||||
Cheapness is easy; this is the part that makes it acceptable, and
|
||||
`tests/test_motion_gate.py` is the argument written down rather than asserted.
|
||||
|
||||
- It is only consulted while **no track is open**, so a person already being
|
||||
followed is never subject to it.
|
||||
- `motion_max_skip` forces a real detection about once a second whatever the
|
||||
thumbnail says. The test asserts the longest *run* of skips, not the total:
|
||||
what matters is the worst case a person can fall into, and counting the total
|
||||
would pass a gate that skipped forty frames and then looked forty times.
|
||||
- The comparison is against the last frame actually **searched**, not the last
|
||||
frame seen, so a slow drift accumulates and trips the gate instead of sliding
|
||||
under it one frame at a time. Someone easing into view slowly would otherwise
|
||||
be invisible indefinitely.
|
||||
- The threshold (1.0 mean absolute difference) sits above this camera's
|
||||
measured noise floor (~0.3) and far below a person. Anything ambiguous falls
|
||||
through to detection: when in doubt it looks.
|
||||
|
||||
Verified on the live camera after the change: `faces_seen: 0` - and the gate is
|
||||
not why. Running the detector directly over the same frames finds **0 faces at
|
||||
threshold 0.50**, let alone 0.82. The people in view are seated, side-on and
|
||||
far away, which is the same `fraction_below_gate: 0.59` this file already
|
||||
records. The placement is still the limit; the CPU was simply being spent to
|
||||
discover that 15 times a second.
|
||||
|
||||
## Three states that looked like health from outside
|
||||
|
||||
Found by auditing the engine for what it does when something goes wrong,
|
||||
rather than when it goes right. Each of these left the process healthy, the
|
||||
dashboard green and the product not working - the class of bug this file
|
||||
already calls a headcount wrong in a way nobody can detect.
|
||||
`tests/test_reliability.py` covers all three.
|
||||
|
||||
### A gallery the running encoder cannot read
|
||||
|
||||
The fallback chain exists so a memory-starved box still starts, and
|
||||
CLAUDE.md already warned that "on a memory-starved box the big model silently
|
||||
loses the chain". What it did not say is what that **costs**: embeddings are
|
||||
model-tagged, so every vector the previous encoder wrote becomes invisible.
|
||||
The shop keeps its whole customer list and recognises nobody on it. Every
|
||||
regular is greeted as a stranger and enrolled a second time. Footfall stays
|
||||
correct, which is precisely why nothing looks wrong.
|
||||
|
||||
The only evidence was an INFO line reading `gallery ready: 0 embeddings
|
||||
(model 'w600k_mbf') across 21 identities` - a sentence that says the disaster
|
||||
and calls it ready. Run against the real 87-embedding gallery with the
|
||||
fallback model forced, it now says:
|
||||
|
||||
```
|
||||
WARNING gallery: 87 of 87 stored embeddings were written by a DIFFERENT
|
||||
encoder (w600k_r50) and cannot be searched - 21 known people are
|
||||
unrecognisable under the running model 'w600k_mbf'.
|
||||
```
|
||||
|
||||
`Gallery.health` carries the same numbers to `/api/stats` and
|
||||
`gallery_unreadable_embeddings` to `/api/health`, because a log line on a shop
|
||||
PC is read by nobody. It travels for the same reason `fraction_below_gate`
|
||||
does: beside the number it qualifies. `identities_stranded` is the figure that
|
||||
matters - **people lost, not vectors** - and an empty gallery reports zero
|
||||
rather than raising an alarm on a fresh install.
|
||||
|
||||
### Connected, and sending nothing
|
||||
|
||||
`connected` meant *the socket opened*. A stream that opens and then goes quiet
|
||||
kept it `true` while `last_frame_age_s` climbed, so the heartbeat told head
|
||||
office the camera was up. OpenCV breaks a blocked read after 30s and we
|
||||
reconnect - but a camera trickling one frame every 20s never trips that at
|
||||
all, so it never reconnects and never recovers.
|
||||
|
||||
`stalled()` and `streaming` are reported beside `connected`, and the local
|
||||
dashboard now says **live / stalled / offline** rather than live / offline.
|
||||
Three states because two of them need opposite actions: offline sends you to
|
||||
the network, stalled says the camera is answering and sending nothing. Same
|
||||
rule as `artifact` vs `no_faces` in the commissioning verdicts.
|
||||
|
||||
`STALL_AFTER_S = 10` is not a preference. The tracker abandons a face after
|
||||
`max_misses` (25 frames, ~1.7s at 15 fps), so by 10s every track is long gone
|
||||
and 150 frames are missing: whatever this is, recognition cannot use it.
|
||||
|
||||
### The 5-second RTSP timeout that never existed
|
||||
|
||||
`capture.py` set `stimeout;5000000` with a comment claiming "a 5s socket
|
||||
timeout so a dead camera is noticed". Measured against this build (OpenCV
|
||||
4.11, FFmpeg 7.1) on a socket that accepts the connection and then says
|
||||
nothing:
|
||||
|
||||
```
|
||||
stimeout;5000000 -> 30.0s timeout;5000000 -> 30.0s
|
||||
stimeout;2000000 -> 30.5s timeout;2000000 -> 30.4s
|
||||
no timeout option at all -> 30.3s
|
||||
```
|
||||
|
||||
Identical with the option absent, under either name, so it was never honoured
|
||||
through this path - `stimeout` was renamed `timeout` in FFmpeg 5.0 and neither
|
||||
reaches the RTSP protocol here. The real bound is OpenCV's own interrupt
|
||||
callback, a compile-time constant we do not control. Both names are still set
|
||||
(harmless, and right on a build where they do work), but **nothing depends on
|
||||
them**.
|
||||
|
||||
What replaces it is `_tcp_reachable` in `_open()` - the pre-flight
|
||||
`probe_source` already used, in code we own. It matters beyond speed: the
|
||||
`cv2.VideoCapture` constructor is not interruptible, so `stop()` could not cut
|
||||
it short and a camera removed from head office left a daemon thread holding a
|
||||
socket for half a minute. Measured:
|
||||
|
||||
```
|
||||
unroutable address 30.3s -> 2.02s "no response from ... within 2s"
|
||||
host up, port closed 30.3s -> 0.00s "cannot reach ... Connection refused"
|
||||
wrong port, real cam 30.3s -> 1.01s "cannot reach ... Connection refused"
|
||||
```
|
||||
|
||||
`last_error` is reported with the camera, because `connected: false` alone
|
||||
cannot tell a wrong IP from a wrong password, and those are different jobs.
|
||||
|
||||
## The admin console could list merchants and see nothing inside them
|
||||
|
||||
`GET /api/admin/clients/{id}` and, under it, `/sites`, `/sites/{site}`,
|
||||
`/sites/{site}/cameras`, `/sites/{site}/cameras/{camera}`, plus
|
||||
`/api/admin/monitoring/summary`. The head-office console drills down
|
||||
merchant -> store -> camera and every level below the first showed *"Backend
|
||||
integration required"*.
|
||||
|
||||
They cannot be the tenant routes, and the reason is structural. Every tenant
|
||||
handler derives the client from the **session** - that is what makes
|
||||
cross-tenant access impossible rather than merely disallowed - and a platform
|
||||
admin has no client at all. The three workarounds each make it worse: passing
|
||||
a company id to a tenant route puts a caller-chosen tenant back in the one
|
||||
place this system refuses to take one, filtering the estate in the browser
|
||||
ships every merchant's data to render one, and signing in as the owner audits
|
||||
the wrong person. So the tenant STORE functions are reused with an explicit
|
||||
client id - they already take one - and the scoping the tenant handlers get
|
||||
from the session happens in the handler instead.
|
||||
|
||||
- **`AdminCamera` is a separate type from `Camera`**, for the same reason
|
||||
`AgentCamera` is: it cannot carry `host`, `port`, `path`, `username` or
|
||||
`has_password`. A tenant seeing those for their own camera is correct; a
|
||||
platform admin browsing another company's estate is a different question,
|
||||
and an RTSP host with a username beside it is most of a live path into a
|
||||
customer's camera. Blanking fields on a shared struct leaves "remember to
|
||||
redact, on every path, forever" as the only thing preventing a leak. The
|
||||
test asserts on the **raw JSON**, because decoding into the struct would
|
||||
discard exactly what it is looking for.
|
||||
- **An unowned site is 404, never an empty list.** `[]` says "this shop has no
|
||||
cameras" when the truth is "not your shop".
|
||||
- **Every read below the merchant list writes an audit row.** An admin is the
|
||||
one account for which nothing else here leaves a trace. The counts-only
|
||||
summary does not: a console refreshes it on a timer, and logging that buries
|
||||
the reads worth finding.
|
||||
- **A suspended merchant stays readable** - that is precisely what an admin
|
||||
opens the console to look at.
|
||||
|
||||
Alongside it, the two merchant-side reads that were only ever aggregates:
|
||||
`GET /api/sales` and `/api/sales/{id}` over the `purchases` table the
|
||||
conversion report has summed since it existed, and
|
||||
`GET /api/dashboard/summary`. No cursor on the sales list, deliberately: a
|
||||
keyset cursor needs a monotonic server-assigned column and `purchases` has
|
||||
none, so ordering by `(occurred_at, id)` with a random uuid tie-break is
|
||||
exactly the shape that dropped four of six simultaneous visits before
|
||||
`visits.seq` existed. Offering one would imply a delivery guarantee this table
|
||||
cannot make.
|
||||
|
||||
### What the fake could not catch, and the database did immediately
|
||||
|
||||
Both of these passed every in-memory test and failed on the first real call.
|
||||
|
||||
**A wrong URL answered 500.** `c.id = $1::uuid` makes Postgres cast the path
|
||||
segment, and casting a malformed string - or the empty one a shape check hands
|
||||
back in its place - is an **error**, not a miss. `c.id::text = $1` cannot fail.
|
||||
The two sibling resolvers were already written that way and correctly 404'd the
|
||||
same input: the rule was applied to two of three places, which is the shape of
|
||||
a rule that holds until somebody adds the next one. `api_admin_monitor_live_test.go`
|
||||
asserts it where the property actually lives.
|
||||
|
||||
**Five live endpoints answered 500 to a platform admin** - `/api/visits`,
|
||||
`/api/cameras`, `/api/sites`, `/api/visitors`, `/api/reports/footfall` - and had
|
||||
done since they shipped. Same cause one level up: a platform admin has no
|
||||
client, every tenant query scopes on `client_id = $1::uuid`, and `''::uuid` is
|
||||
a cast error. `tenantOnly` is the guard, beside `adminOnly` and for the
|
||||
opposite audience. **403, not 404**, because the two hide opposite things: a
|
||||
tenant must not learn a platform surface exists, while a platform admin already
|
||||
knows the tenant surface does - so the refusal names the route to use instead.
|
||||
`/api/auth/*` stays ungated: a session is not a company's data, and revoking a
|
||||
lost device must work for an account with no tenant.
|
||||
|
||||
Guarding at the chokepoint rather than per query is the point. A per-query cast
|
||||
is a fix the next query forgets, and the next query would 500 in production
|
||||
exactly as these did.
|
||||
|
||||
### Two bugs in the deploy script, both found by running it
|
||||
|
||||
- **`go: command not found` at step 1**, on the machine the script was written
|
||||
on. Go sits in a directory `.zprofile` adds and a script does not inherit. A
|
||||
deploy that needs the operator to fix their environment first is a deploy
|
||||
that gets skipped, which is the failure this script exists to end.
|
||||
- **Step 3 reported the wrong backup.** `ls | tail -1` sorts alphabetically, so
|
||||
`pre-...-demo-12` sorts before `pre-...-demo-6` and it printed a dump from
|
||||
four days earlier. A deploy that names the wrong safety net is worse than one
|
||||
that names none - that is the file somebody reaches for at the worst moment.
|
||||
- Step 7 verified five routes and none of them were the nine that had just
|
||||
shipped. It checks all of them now and treats **401 as a pass**: an
|
||||
unauthenticated call to a route that exists is refused, while one the binary
|
||||
never registered is a 404. That makes the step prove the *routing*, which is
|
||||
what a deploy gets wrong, and a missing route now fails the deploy loudly.
|
||||
|
||||
Verified live on 2026-09-28 against production (`v0.4.8-demo-14-g830c1c1`):
|
||||
merchant detail with its owner, the drill-down by slug and by uuid, camera rows
|
||||
carrying no host or username, another merchant's shop and camera both 404,
|
||||
malformed identifiers 404 rather than 500, one real sale (INR 1000, V-1) read
|
||||
back by id, and every tenant route still 200 for an ordinary tenant account.
|
||||
|
||||
## Nobody could change their own password
|
||||
|
||||
`POST /api/auth/password`. The cost of its absence was measured rather than
|
||||
argued. Rotating the three production accounts took a shell on the host, three
|
||||
round trips, and briefly left the **platform admin** — the account that reads
|
||||
every company on the estate — with the password `PASTE_IT_HERE`, because a
|
||||
placeholder in a pasted command was taken literally and there was no way to
|
||||
correct it from the product itself.
|
||||
|
||||
A manager could always reset somebody *else's* password. A platform admin could
|
||||
be reset by nobody: they have no client, so the team routes are not theirs, and
|
||||
`provision user` on the host was the only route. For software that puts
|
||||
accounts on shop-floor PCs and staff phones, this is not a feature — it is what
|
||||
makes every other credential decision recoverable.
|
||||
|
||||
- **`authed`, not `tenantOnly`.** A session is not a company's data, and the
|
||||
account with no company is precisely the one that had no route. Scoping it by
|
||||
client would have reproduced the hole it exists to close — which is also why
|
||||
`SetUserPassword` is not client-scoped the way `ResetMemberPassword` beside
|
||||
it is. The user id comes from the verified session, never the request.
|
||||
- **The current password is required**, or an access token alone takes an
|
||||
account over permanently instead of for the rest of the day.
|
||||
- **Every other session is revoked and the caller's is kept.** A failure there
|
||||
is logged, not returned: the password is already changed, and an error would
|
||||
send the user to retry with a current password that no longer exists.
|
||||
|
||||
Verified live against production: wrong current password 403 and nothing
|
||||
changed, a change revoking **45** stale sessions while the caller's own
|
||||
survived, the new password in and the old one out, then changed back.
|
||||
|
||||
### And a shell quoting trap worth not repeating
|
||||
|
||||
The first attempt to rotate the admin password from the operator's terminal
|
||||
ran with the literal string `PASTE_IT_HERE`. The second, reading the value out
|
||||
of a file, produced **no output at all** and changed nothing — `~` was not
|
||||
expanded in that eval context, `awk` could not open the file, returned
|
||||
non-zero, and `&&` short-circuited silently. Absolute paths and `;` instead of
|
||||
`&&` fixed it, and echoing the password *length* first is what proved the
|
||||
third attempt was about to set something real. A command handed to somebody to
|
||||
paste should contain nothing to edit and should fail loudly.
|
||||
|
||||
## A customer nobody has photographed, and the way back
|
||||
|
||||
`POST /api/customers` and `POST /api/visitors/{id}/merge`, shipped together
|
||||
because the first creates the need for the second. A customer typed in at a
|
||||
counter has **no face template**, so when a camera sees that person later the
|
||||
matcher has nothing to compare against and enrols them as somebody new. That is
|
||||
the design working, not failing — and it means every hand-created customer is a
|
||||
duplicate waiting to happen. Shipping the create alone would have manufactured
|
||||
duplicates into the state this file already flagged: *"there is no merge
|
||||
endpoint server-side, so its duplicates would be unrecoverable."*
|
||||
|
||||
The number comes from `clients.visitor_seq`, taken exactly as `RecordVisit`
|
||||
takes it. Two sources of visitor numbers that could disagree would be worse
|
||||
than none — `V-42` has to mean one person whichever way they arrived.
|
||||
|
||||
**The merge is one transaction over five tables, and the count is the point.**
|
||||
`visits`, `purchases`, `visitor_embeddings`, `consents` and `visitor_profiles`
|
||||
all reference visitors `ON DELETE CASCADE`, so a table it forgets to re-point
|
||||
is not an error: those rows are destroyed with the source and nobody finds out
|
||||
until a customer's history is short.
|
||||
|
||||
Policies carried from the edge gallery's merge, which settled them once
|
||||
already: a human name outranks an auto `Visitor N` whichever direction the
|
||||
operator merged; `visit_count` is recomputed with `COUNT(*)` and never summed;
|
||||
`first_seen_at` takes the earlier. Two that are this side's own: the source is
|
||||
**deleted for real** (a tombstone would leave its number resolving to a record
|
||||
holding nothing, which reads as *"exists and has never been here"*), and the
|
||||
response names the **retired reference**, because staff write `V-42` on cards.
|
||||
|
||||
### It lost a phone number on its first live run
|
||||
|
||||
Found by walking the scenario against production, not by a test. Two records
|
||||
each with a phone; the survivor kept its own and the source's stopped existing.
|
||||
|
||||
The first rule was *"fill the survivor's blanks, never overwrite"* — correct
|
||||
about which value **wins** and silent about the one that loses. One person can
|
||||
have two numbers. A merge that quietly deletes one is the same data loss this
|
||||
file already refuses: *"silently turning Alice back into Visitor 3 is data loss
|
||||
the operator cannot see happen."*
|
||||
|
||||
The profile is reconciled field by field in Go now, because the interesting
|
||||
case was never the winner. Every losing value is returned in `discarded` **and**
|
||||
appended to the survivor's notes — the response is read once, the record is read
|
||||
forever. Notes are additive rather than a winner: two people writing about one
|
||||
customer wrote two different true things.
|
||||
|
||||
And retained was not enough. `SearchVisitors` did not look at notes, so the
|
||||
number was kept and **unfindable** — the letter of "nothing is lost" without the
|
||||
point of it. Search covers notes now, which is what makes a customer reached by
|
||||
their old number the one who comes back.
|
||||
|
||||
One bug caught in that same patch and worth the warning: the new clause was
|
||||
written `ESCAPE` with two backslashes where the four beside it use one. In a Go
|
||||
raw string that is two literal characters and Postgres requires exactly one —
|
||||
it would have failed the **whole** customer search at runtime, on a query no
|
||||
in-memory test executes.
|
||||
|
||||
## The desktop app on two platforms, and three bugs found by launching it
|
||||
|
||||
All three were reported or found by *starting the app the way a person
|
||||
starts one*, and all three had survived every test.
|
||||
|
||||
### "Open dashboard" in the tray did nothing reliable
|
||||
|
||||
`runtime.Show` was wrong three times over and the first is why it failed
|
||||
rather than merely misbehaved. Wails implements `Show()` as a bare
|
||||
`mainWindow.Show()` while `WindowShow()` wraps the identical work in
|
||||
`runtime.LockOSThread`. Win32 window operations must run on the thread owning
|
||||
the window's message pump, and the tray handler runs on the **systray's**
|
||||
goroutine, which never is.
|
||||
|
||||
Two more, each sufficient alone: showing is not un-minimising (hidden and
|
||||
minimised are different states), and Windows refuses the foreground to a
|
||||
process that does not already hold it — so the window returned *behind*
|
||||
whatever was being looked at. A tray click is by definition a moment when the
|
||||
app is not in front, so that is every time, not an edge case. The
|
||||
always-on-top flip is the ordinary way to ask, and it is why this runs in a
|
||||
goroutine: a menu loop that sleeps is a tray that ignores the next click.
|
||||
|
||||
`OnSecondInstanceLaunch` had the same shape and is hit far more often —
|
||||
double-clicking the desktop icon while the app is already running.
|
||||
|
||||
### The engine inherited whatever directory launched the app
|
||||
|
||||
Nothing ever set `cmd.Dir`, so the child took the parent's — and an app
|
||||
started by double-clicking its bundle is handed `/`. On macOS the symptom was
|
||||
`python: No module named behavision` forever, because the dev engine runs as
|
||||
`-m behavision`, which resolves against the working directory.
|
||||
|
||||
**The same app launched from a terminal inside the repo worked perfectly**,
|
||||
which is the shape of a bug that survives every test a developer runs.
|
||||
`Config.EngineDir` (empty = install root) is set by both launchers, which had
|
||||
identical code and the identical omission.
|
||||
|
||||
### macOS is a supported DEVELOPER target, not a product
|
||||
|
||||
Indian retail counters are Windows. A Mac product means an Apple Developer
|
||||
account, notarisation, a second installer, and DPAPI having no macOS
|
||||
equivalent — a permanent second platform for customers who do not have Macs.
|
||||
What it *is* worth is demoing on the machine this is written on.
|
||||
|
||||
It cost one missing framework and then two crashes:
|
||||
|
||||
```
|
||||
link Undefined symbols: _OBJC_CLASS_$_UTType
|
||||
Wails' darwin frontend references it and does not link
|
||||
UniformTypeIdentifiers. Fails at the LINK step after compiling
|
||||
everything, so it reads like a broken toolchain.
|
||||
systray.Run SIGTRAP in cgo — nativeLoop takes the macOS main
|
||||
run loop and Wails already has it
|
||||
RunWithExternalLoop "NSWindow should only be instantiated on the main
|
||||
thread!" — still builds AppKit objects, and
|
||||
OnStartup is not the main thread
|
||||
```
|
||||
|
||||
So **there is no tray on macOS**, and the consequence is handled rather than
|
||||
left: with no tray there is no way back from a hidden window and no way to
|
||||
quit, so on macOS closing the window quits and stops the engine. Same rule the
|
||||
tray's Quit follows — never leave it watching with no visible control.
|
||||
|
||||
**The window could not be maximised**, and that was an omission with a precise
|
||||
consequence. Wails computes `zoomable` *inside* `if frontendOptions.Mac !=
|
||||
nil`; the variable defaults to 0, and the native side then does
|
||||
`if (!zoomable && resizable) [zoomButton setEnabled: NO]`. There was a
|
||||
`Windows` options block and no `Mac` one — so the platform that was configured
|
||||
behaved and the platform that was not looked broken.
|
||||
|
||||
### A macOS release costs nothing extra, and the reason is worth keeping
|
||||
|
||||
`release.sh` has never used PyInstaller. The Windows package is a **source
|
||||
install**: a pure-Python wheel plus `behavision-setup`, which builds a venv on
|
||||
the target machine, done that way because PyInstaller cannot cross-compile.
|
||||
macOS therefore needs nothing new — same wheel, same setup tool, a natively
|
||||
built `.app` instead of the `.exe`. `MAC=1 ./release.sh` opts in.
|
||||
|
||||
Not notarised, and that is stated in the notes rather than discovered: macOS
|
||||
*blocks* an unsigned download rather than warning like SmartScreen, so a first
|
||||
launch needs right-click → Open.
|
||||
|
||||
Verified by extracting the published zip to a clean directory: signature
|
||||
intact through the round trip, the app runs, and it reports *"engine not
|
||||
installed yet; run behavision-setup, then Start"* — the correct fresh-machine
|
||||
state rather than a crash.
|
||||
|
||||
## Setting up on a new machine
|
||||
|
||||
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds
|
||||
@@ -2782,3 +3307,295 @@ Against real Postgres, on the demo tenant:
|
||||
- HTML, PDF, GIF and empty bodies are all refused as face images: the check is
|
||||
on the magic bytes, never the `Content-Type` header, because this endpoint
|
||||
stores what it is handed and serves it back to a browser.
|
||||
|
||||
## Viewer mode: the app on a computer that is not watching anything
|
||||
|
||||
Signing in on a second Mac showed `engine not reachable at
|
||||
http://127.0.0.1:8010` and **0 of 0 cameras**, on an account whose shops were
|
||||
running and recognising people the whole time. Nothing was broken. `App.Live()`
|
||||
and `App.Cameras()` read **only** `a.local`, so the app answered as though the
|
||||
person had never signed in — and camera sync goes *through* the engine, which
|
||||
is why the count was zero rather than merely stale.
|
||||
|
||||
That is the wrong model of what this application is. A shop PC watches
|
||||
cameras; an owner's laptop, a manager's machine, a second till being set up do
|
||||
not, and all three are signed in to the same estate. **Having no engine is a
|
||||
normal state, not a failure**, and the app now says what it can see from where
|
||||
it is standing instead of reporting the absence of something it does not need.
|
||||
|
||||
Both methods try loopback first and fall back to head office when it fails and
|
||||
somebody is signed in. The order matters: a real shop PC must never be shown
|
||||
head office's minute-old summary when the engine two milliseconds away has the
|
||||
live one.
|
||||
|
||||
- **`Viewing` is on the snapshot, not inferred in the browser.** Three surfaces
|
||||
read it — the banner, the camera tally, the getting-started panel — and a
|
||||
screen that computed it separately is how the shops screen once came out
|
||||
labelled **Working**, in green, directly above *"2 of 3 cameras not
|
||||
connecting"*. One fact, one place, the same rule as the tray being a client
|
||||
of `EngineStatus()`.
|
||||
- **`fraction_below_gate` is the WORST shop, never an average.** 0.10 against
|
||||
0.73 averages to 0.42 and hides the only shop anyone needs to visit. Same
|
||||
rule the heartbeat already follows with `worst_site`.
|
||||
- **A remote camera is flagged `remote: true`, and the screen withholds Edit,
|
||||
Remove and Check placement.** Those talk to a camera on a LAN this computer
|
||||
cannot reach, and an Edit button that cannot work is worse than one that is
|
||||
absent. The tenant response structurally cannot carry `host`, `username` or
|
||||
`has_password`, so nothing here can invent them either — a test asserts that.
|
||||
- **`connected` is three states.** `null` is "no shop computer has reported on
|
||||
this yet" and reads as *waiting*; `false` is *"Not connecting"*. A bare false
|
||||
sends somebody to check cabling on a camera nobody has tried to reach.
|
||||
- **The picture is the last snapshot, and it says so.** There is no live video
|
||||
here: the engine's MJPEG stream is on the shop PC's loopback behind a router
|
||||
with no inbound route. Head office's `LiveHub` relay is the answer to that
|
||||
and is a further step for this client; the banner does not imply otherwise.
|
||||
- **Snapshots are fetched in Go and passed as `data:` URIs, cached by
|
||||
`snapshot_at`.** A webview `<img>` resolves a relative src against `wails://`
|
||||
and cannot send the session's bearer — the same problem `VisitorImage`
|
||||
already solved — and this screen polls every 8 seconds at ~90 KB a camera, so
|
||||
re-fetching an unchanged frame is megabytes an hour to redraw the same
|
||||
picture. Keyed on the server's `snapshot_at`, because a new timestamp is the
|
||||
only thing that means a new photograph.
|
||||
- **With no engine AND nobody signed in, the engine error is still the answer.**
|
||||
There is nothing else to show and the person is most likely setting this PC
|
||||
up; naming head office there points them at a step they have not reached.
|
||||
|
||||
## Watching a camera from the app, in another building
|
||||
|
||||
Snapshots answer *"is that camera working"*. They do not answer *"what is
|
||||
happening in my shop right now"*, which is what somebody who opens the app
|
||||
away from the counter is asking. Head office's browser already had the answer
|
||||
— `LiveHub` plus `cameras.Live`, where the shop PC asks outbound whether
|
||||
anybody is watching and pushes JPEG frames up for exactly as long as somebody
|
||||
is — and the app could not reach it.
|
||||
|
||||
`cloud.CameraLive` opens that feed and the app's own loopback relay re-emits
|
||||
it as **multipart MJPEG**, which is the whole trick: frames arrive base64 over
|
||||
SSE, an `<img>` cannot render that, and an `<img>` renders MJPEG natively. So a
|
||||
tile is an ordinary `<img>` pointed at loopback whether the camera is in this
|
||||
room or another city, and no screen has to know which.
|
||||
|
||||
- **Reconnecting happens in the relay, not the page.** The server caps one push
|
||||
at five minutes so a tab left open for a week cannot leave a shop uploading
|
||||
for a week. Doing it here means the `<img>` never sees the stream end.
|
||||
- **The headers are flushed before the first frame.** Go writes them on the
|
||||
first body write, so without that the whole response — status line included —
|
||||
waits for the shop PC to start pushing. Measured against production: thirty
|
||||
seconds and not even a `Content-Type`, which surfaces as the *request* timing
|
||||
out rather than a stream that has not painted yet.
|
||||
- **One camera at a time.** Watching makes a shop PC upload, so a grid that
|
||||
went live at once would put an estate's worth of cameras on the wire because
|
||||
somebody opened a page. `Watch live` is per tile and toggles the previous one
|
||||
off.
|
||||
- **`live.mjpeg` is behind the same per-run token as the engine routes**, and a
|
||||
wrong token is a 404 that never reaches head office at all. It is a live view
|
||||
of a shop floor; the relay being on loopback is not on its own a control.
|
||||
- **`CameraLive` uses its own HTTP client.** The shared one has a 30-second
|
||||
timeout that covers the whole response and would therefore sever a working
|
||||
live view every thirty seconds — the same trap that made the server set
|
||||
`WriteTimeout` to zero for its own SSE endpoint.
|
||||
|
||||
## A camera read "Connected" for 34 minutes after the shop PC went blind
|
||||
|
||||
Found while verifying the live view against production, and it is the reason
|
||||
that verification looked like a failure: head office registered the viewer and
|
||||
no frame ever came.
|
||||
|
||||
`reportWith` returns early when the engine is unreachable — correctly, because
|
||||
it has nothing to say — so the last state it sent **stays in the database
|
||||
looking current**. Measured on the live estate: `cam2` and `entrance` both
|
||||
reading **Connected**, in green, with `last_seen_at` thirty-four minutes old,
|
||||
while the heartbeat from the same PC said `cameras_up: 0, cameras_total: 0`.
|
||||
Two surfaces reading two stored fields and disagreeing about one fact.
|
||||
|
||||
`false` could not be the answer. It means *"this camera is not connecting"*,
|
||||
which sends an installer to check cabling on a camera that was working
|
||||
perfectly the last time anybody could ask it. So there are four states, not
|
||||
three, and `api.CameraState` is the one function that decides them:
|
||||
|
||||
| state | meaning | what to do |
|
||||
|---|---|---|
|
||||
| `connected` | reported within `CameraStaleAfter`, and working | — |
|
||||
| `not_connecting` | reported recently, and the stream will not open | check the address, password, cabling |
|
||||
| `waiting` | no shop PC has ever reported this camera | it has not reached the PC yet |
|
||||
| `stale` | reported once, and not lately | check the PC is on and Behavision is running |
|
||||
|
||||
- **`Connected` is CLEARED when the state is `stale` or `waiting`.** Leaving a
|
||||
stale `true` in place keeps the lie available to every client that reads the
|
||||
field directly — a mobile app, a script, an older desktop build — and leaves
|
||||
two fields on one object disagreeing, which is exactly how the shops screen
|
||||
once came out labelled **Working**, in green, above *"2 of 3 cameras not
|
||||
connecting"*.
|
||||
- **It is computed in `scanCamera`**, so every camera anybody reads passes
|
||||
through it. A state computed per handler is a state one handler forgets, and
|
||||
this one had already reached three screens.
|
||||
- **`CameraStaleAfter` is 5 minutes — five missed reports, not one.** The agent
|
||||
reports on a 60-second tick, so one miss is a dropped packet. Same reasoning
|
||||
as a site being offline after three missed heartbeats: an indicator that
|
||||
cries wolf is one people learn to ignore.
|
||||
- **An unparseable `last_seen_at` is stale**, not connected. It should be
|
||||
impossible, which is precisely why it must not fall through to the state that
|
||||
says everything is fine.
|
||||
|
||||
## A demo on somebody else's Mac found four things, all of them silent
|
||||
|
||||
Three failures in one afternoon on a colleague's machine, plus one the fixing
|
||||
uncovered. Every one produced a message that was true and useless.
|
||||
|
||||
### behavision-setup chose the Python least likely to work
|
||||
|
||||
`findPython` walked `3.14, 3.13, 3.12, 3.11, 3.10` and took the first hit — a
|
||||
floor with **no ceiling**, which is exactly backwards. The newest Python on a
|
||||
machine is the one least likely to have binary wheels for anything. It picked
|
||||
3.14, pip found no numpy wheel for cp314 (`numpy<2.0` caps the resolver at
|
||||
1.26.4, whose newest is cp312), fell back to building numpy from source and
|
||||
produced `ERROR: Unknown compiler(s)`; once the operator had installed Xcode's
|
||||
command line tools to get past that, ten minutes of compiling ended in
|
||||
`<arm_neon.h> is intended only for ARM and AArch64 targets`.
|
||||
|
||||
Two screens of C compiler output on a shop counter, for a version choice this
|
||||
program made silently. `maxMinor` refuses in one line before anything is
|
||||
downloaded, and **"too new" is a different message from "too old"** — telling
|
||||
somebody holding Python 3.14 that no Python was found sends them to install a
|
||||
newer one, which is the direction that just failed. It is a *wheel-availability*
|
||||
ceiling, not a language one: onnxruntime is the binding dependency today
|
||||
(cp314 is its newest), numpy publishes further ahead, and opencv ships a
|
||||
stable-ABI wheel that covers everything.
|
||||
|
||||
### `numpy<2.0` was the cap; OpenCV was the hazard
|
||||
|
||||
Widening to `<3.0` needed proof, and the proof found something else. Nine runs
|
||||
of the detector guard per combination, one machine, one sitting:
|
||||
|
||||
```
|
||||
numpy 1.26 / cv2 4.11 9 passed, 0 crashed
|
||||
numpy 2.0 / cv2 4.11 8 passed, 1 crashed
|
||||
numpy 1.26 / cv2 4.14 3 passed, 6 crashed
|
||||
numpy 2.0 / cv2 4.14 2 passed, 7 crashed
|
||||
```
|
||||
|
||||
**numpy is not the variable; OpenCV is** — the third row is numpy 1.26. The
|
||||
crash was `test_a_shared_detector_really_does_race`, which races a shared
|
||||
`cv2.FaceDetectorYN` on purpose to prove the per-camera rule. That is undefined
|
||||
behaviour in C++: 4.11 usually turned it into an exception, 4.14 usually turns
|
||||
it into a **segfault**, and 4.11 crashing once says the hazard was always there
|
||||
and 4.11 merely survived it.
|
||||
|
||||
It never reached the product — `Engine._build_worker` builds a detector per
|
||||
camera, which is the rule and is what the second test guards. What it reached
|
||||
was the suite: two runs in three died with **no failing assertion in them**,
|
||||
turning "we upgraded OpenCV" into the hardest kind of CI failure to read. The
|
||||
race now runs in a **subprocess**, so a segfault is an observed outcome rather
|
||||
than the end of the run, and one clean attempt proves nothing — the premise
|
||||
holds if *any* of several attempts misbehaves. With that fixed the suite is
|
||||
226 passed / 2 skipped on numpy 2.0.2, five runs out of five.
|
||||
|
||||
`opencv-python` stays capped below 5. Everything above was measured on 4.x, and
|
||||
an uncapped `>=4.8.1` means every NEW install silently gets a major release
|
||||
this project has never run a real camera through while every existing one keeps
|
||||
4.11.
|
||||
|
||||
### And the fix was defeated by the wreckage of the bug
|
||||
|
||||
`makeVenv` reused any environment already on disk, whatever Python built it.
|
||||
That machine had a runtime built by **3.14**, left behind by the run that
|
||||
failed — so with the ceiling in place setup would choose a good interpreter,
|
||||
reach `makeVenv`, find the 3.14 environment, keep it, and die in the same clang
|
||||
error as before. A fix a user cannot reach because the bug's own debris is in
|
||||
the way is not a fix, and it would have read as the release not working.
|
||||
|
||||
It now asks the interpreter inside an existing environment what it is and
|
||||
rebuilds when the answer is unsupported, saying so. Rebuilding costs a
|
||||
re-download of the libraries and nothing else — the models live in the state
|
||||
root, not in there. An environment that cannot be asked counts as unusable
|
||||
too: a half-created one answers nothing, and reusing it fails later in pip
|
||||
with an error about a package rather than about the environment.
|
||||
|
||||
### One MQTT client id for a whole shop, so two PCs fought over it
|
||||
|
||||
`behavision-<client>-<site>` is the same string on every computer claimed to
|
||||
one site. MQTT requires client ids to be unique and a broker enforces it by
|
||||
disconnecting the older session when a new one arrives with the same id, so the
|
||||
colleague's Mac and the shop's own till took turns kicking each other off:
|
||||
|
||||
```
|
||||
broker connected / broker connection lost: EOF / broker connected / EOF / ...
|
||||
```
|
||||
|
||||
**The damage is not confined to the new machine.** The shop's till is the other
|
||||
half of that loop, so somebody signing in on a laptop to look at the product
|
||||
stops a live shop delivering visits — and from each end it reads as an unstable
|
||||
network, because nothing says otherwise.
|
||||
|
||||
`Config.MQTTClientID()` appends a per-installation id, minted on first load and
|
||||
written back so an existing install gets one without anybody doing anything.
|
||||
The site stays in the name because that is what a broker log is read *by*. A
|
||||
config that could not be written falls back to a per-run id rather than a
|
||||
shared one: the right failure is a new name in the log after a restart, not the
|
||||
collision this exists to end.
|
||||
|
||||
### "no such file or directory" for an engine nobody had installed
|
||||
|
||||
Pressing Start with no engine went straight to the supervisor, which reported
|
||||
what `exec` reported:
|
||||
|
||||
```
|
||||
engine failed to start: fork/exec /private/var/folders/c2/.../AppTranslocation/
|
||||
500A5354-.../d/Behavision.app/Contents/MacOS/engine/behavision:
|
||||
no such file or directory
|
||||
```
|
||||
|
||||
Every word true, none of it saying *run the setup tool*. The startup path did
|
||||
have that sentence — in a log file nobody on a shop counter opens.
|
||||
`App.engineMissing()` is now the one function the startup path, the Start
|
||||
button and the status panel all consult, so three surfaces cannot give three
|
||||
accounts of one fact.
|
||||
|
||||
It also names **App Translocation**, which is in that path and is unguessable.
|
||||
macOS quarantines a downloaded app it cannot verify and runs it from a randomly
|
||||
named read-only copy, so every relative path resolves inside that copy — which
|
||||
is why the engine folder appears missing from a bundle that plainly contains
|
||||
one, and why installing into it would not survive a restart. Fixed by dragging
|
||||
the app to Applications; saying nothing leaves somebody re-running a setup tool
|
||||
that cannot win. The product is unsigned, so this is the *normal* first-run
|
||||
state on every Mac, not an edge case.
|
||||
|
||||
### And then it could not download a 230 KB file
|
||||
|
||||
With all of the above fixed the install succeeded on that Mac - Python 3.14
|
||||
chosen and accepted, numpy 2.5.3, onnxruntime 1.30, faiss 1.15.1, the engine
|
||||
itself - and setup died on the last step, fetching the YuNet model:
|
||||
|
||||
```
|
||||
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
|
||||
certificate verify failed: unable to get local issuer certificate
|
||||
```
|
||||
|
||||
A python.org macOS build ships its **own OpenSSL with no trust store**, and
|
||||
populates one only when somebody double-clicks `Install Certificates.command`
|
||||
in the Python folder. Nobody installing face-recognition software has any
|
||||
reason to know that exists, and the failure is forty lines of traceback about
|
||||
`_ssl.c` at the end of a ten-minute install.
|
||||
|
||||
`_urlopen` tries the default context first and retries with **certifi's**
|
||||
bundle on a verification failure. The order is the whole design:
|
||||
|
||||
- Default first, because on Windows and on a system or Homebrew Python the
|
||||
default context reads the machine's own certificate store - which is what
|
||||
makes a corporate proxy with its own root CA work. Replacing it
|
||||
unconditionally would break every site that has one in order to fix a
|
||||
different platform.
|
||||
- certifi second, because it is already installed: `requests` is a hard
|
||||
dependency and brings it.
|
||||
- A `URLError` is re-raised untouched. "No route to host" and "no trust store"
|
||||
are different problems, and retrying the first with a different CA list only
|
||||
delays the real message.
|
||||
|
||||
`urlretrieve` had to go, since it offers no way to pass a context - and that is
|
||||
exactly the kind of rewrite that silently drops something. The
|
||||
`download: <label> <n>%` lines are a **contract**: `supervisor.go`'s
|
||||
`progressRe` parses them to put first-run progress in the tray, because the API
|
||||
is not up yet and a shop PC showing a stopped engine for five minutes after
|
||||
install looks broken. `tests/test_model_download.py` asserts them, and the
|
||||
rewritten fetch was checked against the real URL: 232,589 bytes, sha256
|
||||
identical to the model already on disk.
|
||||
|
||||
93
agent/cmd/behavision-demo-pack/main.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Command behavision-demo-pack seals a camera list into demo-cameras.enc for a
|
||||
// demo release. It runs on the machine that builds the release and is never
|
||||
// shipped.
|
||||
//
|
||||
// behavision-demo-pack -cameras cameras.json -out demo-cameras.enc
|
||||
//
|
||||
// Prints the unlock code exactly once. It is not stored anywhere; a code you
|
||||
// can look up later is a code anyone with access to the build machine holds.
|
||||
// Lose it and seal again.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/demo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password) - the PC then runs on its own")
|
||||
enrolCode := flag.String("enrol-code", "", "an installation code from head office - the PC then claims that shop and gets its cameras from there")
|
||||
cloud := flag.String("cloud", "https://mcp.loyaly.ai", "head office, with -enrol-code")
|
||||
out := flag.String("out", "demo-cameras.enc", "sealed bundle to write")
|
||||
flag.Parse()
|
||||
if *in == "" && *enrolCode == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack (-cameras cameras.json | -enrol-code CODE [-cloud URL]) [-out demo-cameras.enc]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var payload demo.Payload
|
||||
if *in != "" {
|
||||
raw, err := os.ReadFile(*in)
|
||||
if err != nil {
|
||||
die("read cameras: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload.Cameras); err != nil {
|
||||
die("cameras.json: %v", err)
|
||||
}
|
||||
if len(payload.Cameras) == 0 {
|
||||
die("no cameras in %s", *in)
|
||||
}
|
||||
}
|
||||
payload.EnrolCode = *enrolCode
|
||||
if *enrolCode != "" {
|
||||
payload.CloudBase = *cloud
|
||||
}
|
||||
cams := payload.Cameras
|
||||
for i, c := range cams {
|
||||
switch {
|
||||
case c.ID == "":
|
||||
die("camera %d has no id", i)
|
||||
case c.Host == "":
|
||||
die("camera %q has no host", c.ID)
|
||||
case c.Path == "":
|
||||
die("camera %q has no path - the stream path is the field nobody can guess", c.ID)
|
||||
}
|
||||
}
|
||||
// Re-marshal so only the fields the engine accepts travel, in a stable
|
||||
// shape, whatever extra keys the input happened to carry.
|
||||
plain, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
die("marshal: %v", err)
|
||||
}
|
||||
|
||||
code, err := demo.NewCode()
|
||||
if err != nil {
|
||||
die("code: %v", err)
|
||||
}
|
||||
sealed, err := demo.Seal(code, plain)
|
||||
if err != nil {
|
||||
die("seal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*out, sealed, 0o644); err != nil {
|
||||
die("write: %v", err)
|
||||
}
|
||||
|
||||
if payload.EnrolCode != "" {
|
||||
fmt.Printf("\n sealed an installation code for %s into %s (%d bytes)\n\n", payload.CloudBase, *out, len(sealed))
|
||||
} else {
|
||||
fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed))
|
||||
}
|
||||
fmt.Printf(" unlock code: %s\n\n", code)
|
||||
fmt.Println(" Shown once. Give it to whoever runs behavision-setup, by voice")
|
||||
fmt.Println(" or message - not in the same place as the zip.")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, " "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -32,8 +32,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"github.com/loyaly/behavision-agent/pkg/demo"
|
||||
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/enrol"
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
@@ -41,6 +46,61 @@ import (
|
||||
// otherwise arrive as a syntax error deep inside a dependency.
|
||||
const minMinor = 10
|
||||
|
||||
// maxMinor is a WHEEL-availability ceiling, not a language one, and it is the
|
||||
// reason this constant exists at all.
|
||||
//
|
||||
// findPython used to take the newest interpreter it could find, with a floor
|
||||
// and no ceiling - which is precisely backwards, because the newest Python is
|
||||
// the one least likely to have binary wheels for anything. Measured on a
|
||||
// second Mac: it chose Python 3.14, pip found no numpy wheel for cp314, fell
|
||||
// back to building numpy from source, and produced
|
||||
//
|
||||
// ERROR: Unknown compiler(s): [['cc'], ['gcc'], ['clang'], ...]
|
||||
//
|
||||
// then, once the operator installed Xcode's command line tools to get past
|
||||
// that, ten minutes of compiling ending in
|
||||
//
|
||||
// arm_neon.h:28:2: error: "<arm_neon.h> is intended only for ARM and
|
||||
// AArch64 targets"
|
||||
//
|
||||
// Two screens of C compiler output, on a shop counter, for a version choice
|
||||
// made silently by this program. Refusing in one line, before anything is
|
||||
// downloaded, is the whole of the fix.
|
||||
//
|
||||
// Raise it when the dependency set has wheels for the next version. Today
|
||||
// onnxruntime is the binding one (cp314 is its newest); numpy publishes
|
||||
// further ahead, and opencv-python ships a stable-ABI wheel that covers
|
||||
// everything. `pip download --only-binary=:all: -r requirements.txt` against
|
||||
// a candidate interpreter is the check.
|
||||
const maxMinor = 14
|
||||
|
||||
// The three answers a candidate interpreter can get. Three, not two: a
|
||||
// version that is too new and one that is too old need opposite actions from
|
||||
// the operator, and collapsing them tells somebody holding Python 3.14 to go
|
||||
// and install a newer Python.
|
||||
const (
|
||||
verdictOK = "ok"
|
||||
verdictTooOld = "old"
|
||||
verdictTooNew = "new"
|
||||
verdictUnknown = "unparseable"
|
||||
)
|
||||
|
||||
func pythonVerdict(major, minor int, parsed bool) string {
|
||||
switch {
|
||||
case !parsed:
|
||||
return verdictUnknown
|
||||
case major != 3:
|
||||
// Python 4 is not a version this has been tried against, and 2 is
|
||||
// long gone. Neither is a thing to guess about.
|
||||
return verdictTooNew
|
||||
case minor < minMinor:
|
||||
return verdictTooOld
|
||||
case minor > maxMinor:
|
||||
return verdictTooNew
|
||||
}
|
||||
return verdictOK
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
|
||||
@@ -69,6 +129,36 @@ func run() error {
|
||||
return fmt.Errorf("could not create %s: %w", state, err)
|
||||
}
|
||||
|
||||
// A demo release ships its cameras sealed. Ask for the code NOW, before
|
||||
// the ten-minute download, so a mistyped one costs seconds; the cameras
|
||||
// are actually added at the end, through the running engine.
|
||||
bundle, err := unlockDemo(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var demoCams []demo.Camera
|
||||
if bundle != nil {
|
||||
demoCams = bundle.Cameras
|
||||
switch {
|
||||
case bundle.EnrolCode != "":
|
||||
step("Demo", "unlocked - this PC will join a shop at head office")
|
||||
default:
|
||||
step("Demo cameras", fmt.Sprintf("%d unlocked", len(demoCams)))
|
||||
}
|
||||
}
|
||||
|
||||
if running := behavisionRunning(); running != "" {
|
||||
// lint:ignore ST1005 — this is not a wrapped error, it is the whole
|
||||
// message an operator reads at a shop counter. ST1005 forbids
|
||||
// trailing punctuation because errors get concatenated mid-sentence;
|
||||
// nothing wraps this one, and stripping the full stops would make
|
||||
// three sentences run together.
|
||||
//lint:ignore ST1005 operator-facing prose, never wrapped
|
||||
return fmt.Errorf("%s is running. Quit Behavision from the tray icon first, then run setup again.\n\n"+
|
||||
"Setting up underneath a running copy starts a second engine on the same port and, in a demo,\n"+
|
||||
"re-claims the shop while the open app still holds the old credentials.", running)
|
||||
}
|
||||
|
||||
py, ver, err := findPython()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -113,14 +203,56 @@ func run() error {
|
||||
// Proving it starts is the point. An installer that reports success and
|
||||
// leaves a shop with an engine that will not run has done worse than
|
||||
// failing: the failure surfaces later, to someone who did not install it.
|
||||
if err := smokeTest(vpy); err != nil {
|
||||
// Joining a shop: head office supplies the cameras, so any left on this PC
|
||||
// from an earlier install go first. Otherwise the reconciler offers them UP
|
||||
// to head office - without their passwords, which the engine never returns
|
||||
// - and the shop ends up with the same lens listed twice, one copy of which
|
||||
// can never be pushed to another PC. Measured on the first claimed demo.
|
||||
if bundle != nil && bundle.EnrolCode != "" {
|
||||
if err := os.Remove(paths.CamerasFile()); err == nil {
|
||||
step("Earlier cameras", "removed - head office supplies them now")
|
||||
}
|
||||
}
|
||||
if err := smokeTest(vpy, demoCams); err != nil {
|
||||
return fmt.Errorf("the engine installed but would not start: %w", err)
|
||||
}
|
||||
step("Engine starts and answers", "verified")
|
||||
if len(demoCams) > 0 {
|
||||
step("Demo cameras", "added to the engine")
|
||||
}
|
||||
switch {
|
||||
case bundle != nil && bundle.EnrolCode != "":
|
||||
// The demo that IS the product: this PC claims a real shop, exactly
|
||||
// as a customer install does, and its cameras arrive from head office
|
||||
// on the first sync. The app then opens on Login.
|
||||
siteName, err := claimShop(bundle.EnrolCode, bundle.CloudBase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not join the shop at head office: %w", err)
|
||||
}
|
||||
step("Head office", "linked to "+siteName)
|
||||
case bundle != nil:
|
||||
// No head office in this demo. Without this the app opens on "type an
|
||||
// installation code" and sits there; with it, it opens on Live.
|
||||
if err := markStandalone(); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Head office", "none - running on this PC only")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
|
||||
fmt.Println(" It appears in the system tray; right-click there to stop it.")
|
||||
// The last thing setup says is the first thing the operator does, so it
|
||||
// has to describe THEIR machine. On macOS there is no Start menu and,
|
||||
// deliberately, no tray at all - telling somebody to right-click a tray
|
||||
// icon that does not exist is how software loses their trust on the step
|
||||
// where it was otherwise finished.
|
||||
if runtime.GOOS == "windows" {
|
||||
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
|
||||
fmt.Println(" It appears in the system tray; right-click there to stop it.")
|
||||
} else {
|
||||
fmt.Println(" Done. Open Behavision.app - right-click it and choose Open the")
|
||||
fmt.Println(" first time, because this build is not notarised.")
|
||||
fmt.Println(" There is no tray on macOS: closing the window stops recognition.")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
@@ -156,6 +288,22 @@ func engineSource() (string, error) {
|
||||
// `py -3` first on Windows: the launcher is what the official installer puts
|
||||
// on PATH, and `python` there is often the Microsoft Store stub that prints an
|
||||
// advert and exits 9009 instead of running anything.
|
||||
// behavisionRunning names a Behavision process if one is up. Windows only -
|
||||
// that is the platform setup ships on - and by image name via tasklist, which
|
||||
// needs no extra privilege.
|
||||
func behavisionRunning() string {
|
||||
if runtime.GOOS != "windows" {
|
||||
return ""
|
||||
}
|
||||
for _, name := range []string{"Behavision.exe", "behavision-agent.exe"} {
|
||||
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name, "/NH").Output()
|
||||
if err == nil && strings.Contains(strings.ToLower(string(out)), strings.ToLower(name)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findPython() (string, string, error) {
|
||||
type cand struct {
|
||||
exe string
|
||||
@@ -165,13 +313,59 @@ func findPython() (string, string, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
cands = append(cands, cand{"py", []string{"-3"}})
|
||||
}
|
||||
|
||||
// Versioned names FIRST, newest first, and this is not belt-and-braces on
|
||||
// macOS - it is the only thing that works. `/usr/bin/python3` there is
|
||||
// always the Command Line Tools build, 3.9 on current macOS, which is
|
||||
// below the 3.10 floor. Anything newer installs as `python3.12` or into a
|
||||
// directory that is not on a GUI application's PATH. Searching only
|
||||
// `python3` therefore told a Mac with Python 3.12 sitting on it to go and
|
||||
// install Python - measured on this machine, which has 3.12 under
|
||||
// ~/.local/opt and reported "Found, but too old: python3 3.9".
|
||||
// Newest first WITHIN the supported range. Newest overall is what broke
|
||||
// this; a version nobody has built wheels for is not a better choice than
|
||||
// one that works.
|
||||
var versions []string
|
||||
for v := maxMinor; v >= minMinor; v-- {
|
||||
versions = append(versions, fmt.Sprintf("3.%d", v))
|
||||
}
|
||||
for _, v := range versions {
|
||||
cands = append(cands, cand{"python" + v, nil})
|
||||
}
|
||||
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
|
||||
|
||||
var tried []string
|
||||
// And the places a Mac puts an interpreter that LookPath will not find,
|
||||
// because a double-clicked app inherits a minimal PATH rather than the
|
||||
// one a shell profile builds.
|
||||
if runtime.GOOS != "windows" {
|
||||
home, _ := os.UserHomeDir()
|
||||
for _, v := range versions {
|
||||
for _, dir := range []string{
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/Library/Frameworks/Python.framework/Versions/" + v + "/bin",
|
||||
filepath.Join(home, ".local", "opt", "python"+v, "bin"),
|
||||
} {
|
||||
cands = append(cands, cand{filepath.Join(dir, "python"+v), nil})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tried, tooNew []string
|
||||
for _, c := range cands {
|
||||
exe, err := exec.LookPath(c.exe)
|
||||
if err != nil {
|
||||
continue
|
||||
exe := c.exe
|
||||
if filepath.IsAbs(exe) {
|
||||
// An absolute candidate is a guess about where an interpreter
|
||||
// might be; most will not exist, and that is not an error.
|
||||
if fi, err := os.Stat(exe); err != nil || fi.IsDir() {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
found, err := exec.LookPath(exe)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
exe = found
|
||||
}
|
||||
args := append(append([]string{}, c.args...), "-c",
|
||||
"import sys;print('%d.%d'%sys.version_info[:2])")
|
||||
@@ -181,7 +375,18 @@ func findPython() (string, string, error) {
|
||||
}
|
||||
ver := strings.TrimSpace(string(out))
|
||||
tried = append(tried, c.exe+" "+ver)
|
||||
if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) {
|
||||
major, minor, parsed := parseVer(ver)
|
||||
switch verdict := pythonVerdict(major, minor, parsed); verdict {
|
||||
case verdictTooNew:
|
||||
// Recorded separately: "too new" and "too old" need opposite
|
||||
// actions, and a single "found, but unsuitable" list sends
|
||||
// somebody to upgrade a Python that is already past the problem.
|
||||
tooNew = append(tooNew, c.exe+" "+ver)
|
||||
continue
|
||||
case verdictTooOld, verdictUnknown:
|
||||
continue
|
||||
}
|
||||
{
|
||||
full := exe
|
||||
if len(c.args) > 0 {
|
||||
full = exe + " " + strings.Join(c.args, " ")
|
||||
@@ -190,13 +395,48 @@ func findPython() (string, string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
msg := "no Python 3.10 or newer was found on this PC.\n\n" +
|
||||
" Install it from https://www.python.org/downloads/windows/\n" +
|
||||
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
|
||||
" then run this again."
|
||||
// The advice has to match the machine. Telling a Mac user to tick "Add
|
||||
// python.exe to PATH" on a Windows installer page reads as software that
|
||||
// does not know where it is running, which is exactly the moment somebody
|
||||
// stops trusting the rest of what it says.
|
||||
// Only a too-new Python is a different problem with a different fix, and
|
||||
// saying "no Python was found" to somebody looking at Python 3.14 is the
|
||||
// kind of message that makes people stop believing the next one.
|
||||
if len(tooNew) > 0 && len(tried) == 0 {
|
||||
// Built as a value and wrapped, not written as an fmt.Errorf literal:
|
||||
// this is a paragraph shown to an operator, and a linter that wants
|
||||
// error strings to be lower-case fragments is right about errors
|
||||
// programs read and wrong about the ones people do.
|
||||
tooNewMsg := fmt.Sprintf(
|
||||
"this computer has %s, which is newer than Behavision supports.\n\n"+
|
||||
" Some of the libraries the engine needs have no build for it\n"+
|
||||
" yet, so installing would fail part-way through.\n\n"+
|
||||
" Install Python 3.%d and run this again:\n"+
|
||||
" macOS: brew install python@3.%d\n"+
|
||||
" or https://www.python.org/downloads/macos/\n"+
|
||||
" Windows: https://www.python.org/downloads/windows/\n\n"+
|
||||
" Both versions can sit on the machine together; this picks\n"+
|
||||
" the one it can use.",
|
||||
strings.Join(tooNew, ", "), maxMinor, maxMinor)
|
||||
return "", "", errors.New(tooNewMsg)
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("no Python between 3.%d and 3.%d was found on this computer.\n\n",
|
||||
minMinor, maxMinor)
|
||||
if runtime.GOOS == "windows" {
|
||||
msg += " Install it from https://www.python.org/downloads/windows/\n" +
|
||||
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
|
||||
" then run this again."
|
||||
} else {
|
||||
msg += " Install it with `brew install python@3.12`, or from\n" +
|
||||
" https://www.python.org/downloads/macos/, then run this again."
|
||||
}
|
||||
if len(tried) > 0 {
|
||||
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
|
||||
}
|
||||
if len(tooNew) > 0 {
|
||||
msg += "\n\n Found, but too new: " + strings.Join(tooNew, ", ")
|
||||
}
|
||||
return "", "", errors.New(msg)
|
||||
}
|
||||
|
||||
@@ -233,14 +473,52 @@ func venvPython(venv string) string {
|
||||
// engine requires - inside a shared interpreter is how you break the other
|
||||
// thing months later, silently.
|
||||
func makeVenv(py, venv string) error {
|
||||
// An existing environment is reused - but only if the Python inside it is
|
||||
// one this build supports.
|
||||
//
|
||||
// It used to be reused unconditionally, and that would have made the
|
||||
// version ceiling above look like it did not work. The machine this was
|
||||
// all found on already had a runtime built by Python 3.14, from the run
|
||||
// that failed: with the ceiling in place setup would choose a good
|
||||
// interpreter, reach here, find the 3.14 environment, keep it, and die in
|
||||
// the same clang error as before. A fix that is defeated by the wreckage
|
||||
// of the bug it fixes is not one.
|
||||
//
|
||||
// Rebuilding costs a re-download of the libraries and nothing else. The
|
||||
// models are in the state root, not in here, so they survive.
|
||||
if _, err := os.Stat(venvPython(venv)); err == nil {
|
||||
return nil // already built; pip below brings it up to date
|
||||
ok, ver := venvUsable(venv)
|
||||
if ok {
|
||||
return nil // pip below brings it up to date
|
||||
}
|
||||
fmt.Printf(" [..] %-24s %s\n", "Rebuilding environment",
|
||||
"the existing one uses "+ver+", which is not supported")
|
||||
if err := os.RemoveAll(venv); err != nil {
|
||||
return fmt.Errorf("removing the old environment at %s: %w", venv, err)
|
||||
}
|
||||
}
|
||||
exe, args := splitLauncher(py)
|
||||
args = append(args, "-m", "venv", venv)
|
||||
return stream(exec.Command(exe, args...), "creating the virtual environment")
|
||||
}
|
||||
|
||||
// venvUsable reports whether the interpreter already inside an environment is
|
||||
// one this build supports, and what it is when it is not.
|
||||
//
|
||||
// An environment that cannot be asked counts as unusable: a half-created or
|
||||
// truncated one answers nothing, and reusing it fails later in pip with an
|
||||
// error about a package rather than about the environment.
|
||||
func venvUsable(venv string) (bool, string) {
|
||||
out, err := exec.Command(venvPython(venv), "-c",
|
||||
"import sys;print('%d.%d'%sys.version_info[:2])").Output()
|
||||
if err != nil {
|
||||
return false, "an interpreter that will not run"
|
||||
}
|
||||
ver := strings.TrimSpace(string(out))
|
||||
major, minor, parsed := parseVer(ver)
|
||||
return pythonVerdict(major, minor, parsed) == verdictOK, "Python " + ver
|
||||
}
|
||||
|
||||
func pipInstall(vpy, src string) error {
|
||||
fmt.Println(" Installing the engine and its libraries. This downloads a few")
|
||||
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
|
||||
@@ -347,8 +625,8 @@ func writeConfig(vpy string) error {
|
||||
// smokeTest starts the engine exactly as the app will and waits for its API to
|
||||
// answer. Any reply counts, including 401: the engine invents its own
|
||||
// credential when none is configured, and a refusal proves it is serving.
|
||||
func smokeTest(vpy string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
func smokeTest(vpy string, demoCams []demo.Camera) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
||||
@@ -370,7 +648,15 @@ func smokeTest(vpy string) error {
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
if demoCams == nil {
|
||||
return nil
|
||||
}
|
||||
// Through the engine's own Add Camera, not written to its file:
|
||||
// the store is what applies DPAPI to the password on Windows, so
|
||||
// this is how the credential ends up encrypted on disk rather
|
||||
// than sitting in cameras.json for anyone who can read
|
||||
// ProgramData.
|
||||
return addCameras(demoCams)
|
||||
}
|
||||
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
||||
break
|
||||
@@ -410,3 +696,143 @@ func pause() {
|
||||
fmt.Print(" Press Enter to close. ")
|
||||
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
}
|
||||
|
||||
// unlockDemo returns the sealed cameras a demo release ships, or nil when this
|
||||
// is not a demo release. Asks for the unlock code on the console; three tries,
|
||||
// because a code is read down a phone and typed by hand.
|
||||
func unlockDemo(src string) (*demo.Payload, error) {
|
||||
sealed, err := os.ReadFile(filepath.Join(src, "demo-cameras.enc"))
|
||||
if err != nil {
|
||||
return nil, nil // not a demo release
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" This is a demo release with the cameras already set up.")
|
||||
fmt.Println(" It needs the unlock code you were given.")
|
||||
fmt.Println()
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
fmt.Print(" Unlock code: ")
|
||||
line, _ := in.ReadString('\n')
|
||||
plain, err := demo.Open(line, sealed)
|
||||
if err == nil {
|
||||
payload, err := demo.Decode(plain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the bundle unlocked but did not parse: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
return &payload, nil
|
||||
}
|
||||
fmt.Printf(" %v\n", err)
|
||||
}
|
||||
return nil, errors.New("no valid unlock code after three tries. Check it " +
|
||||
"with whoever gave you this release and run setup again")
|
||||
}
|
||||
|
||||
// claimShop redeems the installation code sealed in the bundle: the same call
|
||||
// the app's Setup screen and `behavision-agent claim` make, so the PC ends up
|
||||
// in exactly the state a customer's would - broker login, API token, the
|
||||
// broker's CA on disk - and head office pushes its cameras down on the first
|
||||
// sync.
|
||||
func claimShop(code, base string) (string, error) {
|
||||
if base == "" {
|
||||
base = "https://mcp.loyaly.ai"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
b, err := enrol.Claim(ctx, base, code)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := paths.AgentConfig()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg.ClientID = b.ClientSlug
|
||||
cfg.SiteID = b.SiteSlug
|
||||
cfg.SiteName = b.SiteName
|
||||
cfg.BrokerURL = b.MQTTURL
|
||||
cfg.BrokerUsername = b.MQTTUser
|
||||
cfg.BrokerPassword = b.MQTTPass
|
||||
cfg.AgentToken = b.AgentToken
|
||||
cfg.CloudBase = base
|
||||
cfg.Standalone = false
|
||||
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
|
||||
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg.BrokerCAFile = caPath
|
||||
if err := cfg.Save(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b.SiteName, nil
|
||||
}
|
||||
|
||||
// addCameras posts each demo camera to the running engine, with the credential
|
||||
// the engine generated for itself on first start.
|
||||
func addCameras(cams []demo.Camera) error {
|
||||
user, pass, err := engineCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
for _, c := range cams {
|
||||
if c.Port == 0 {
|
||||
c.Port = 554
|
||||
}
|
||||
body, _ := json.Marshal(c)
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8010/api/cameras",
|
||||
bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding camera %s: %w", c.ID, err)
|
||||
}
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
// 409 is "already there" - a re-run of setup, which is allowed.
|
||||
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusConflict {
|
||||
return fmt.Errorf("adding camera %s: %s: %s", c.ID, resp.Status,
|
||||
strings.TrimSpace(string(msg)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// engineCredential reads the Basic credential the engine wrote on its first
|
||||
// start. Empty when the engine is configured without one.
|
||||
func engineCredential() (string, string, error) {
|
||||
b, err := os.ReadFile(paths.APICredentials())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
var user, pass string
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "username="); ok {
|
||||
user = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "password="); ok {
|
||||
pass = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return user, pass, nil
|
||||
}
|
||||
|
||||
// markStandalone records that this PC runs on its own, through the same
|
||||
// config type the app reads.
|
||||
func markStandalone() error {
|
||||
path := paths.AgentConfig()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Standalone = true
|
||||
return cfg.Save(path)
|
||||
}
|
||||
|
||||
112
agent/cmd/behavision-setup/python_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The choice this program makes silently, and got wrong.
|
||||
//
|
||||
// findPython took the newest interpreter on the machine, with a floor and no
|
||||
// ceiling - backwards, because the newest Python is the one least likely to
|
||||
// have binary wheels. On a Mac holding Python 3.14 it chose 3.14, pip found
|
||||
// no numpy wheel for cp314, fell back to a source build and produced two
|
||||
// screens of clang errors ending in "<arm_neon.h> is intended only for ARM
|
||||
// and AArch64 targets". The operator's machine was fine; the version was not.
|
||||
func TestTooNewIsRefusedRatherThanCompiled(t *testing.T) {
|
||||
if got := pythonVerdict(3, maxMinor+1, true); got != verdictTooNew {
|
||||
t.Errorf("3.%d = %q, want %q - picking it means a source build",
|
||||
maxMinor+1, got, verdictTooNew)
|
||||
}
|
||||
if got := pythonVerdict(3, maxMinor, true); got != verdictOK {
|
||||
t.Errorf("3.%d = %q, want %q - the ceiling is inclusive", maxMinor, got, verdictOK)
|
||||
}
|
||||
}
|
||||
|
||||
// Too old and too new must stay different answers. Telling somebody holding
|
||||
// Python 3.14 that no Python was found, or that theirs is too old, sends them
|
||||
// to install a newer one - which is the direction that already failed.
|
||||
func TestOldAndNewAreDifferentAnswers(t *testing.T) {
|
||||
old := pythonVerdict(3, minMinor-1, true)
|
||||
fresh := pythonVerdict(3, maxMinor+1, true)
|
||||
if old == fresh {
|
||||
t.Fatalf("3.%d and 3.%d both reported %q", minMinor-1, maxMinor+1, old)
|
||||
}
|
||||
if old != verdictTooOld {
|
||||
t.Errorf("3.%d = %q, want %q", minMinor-1, old, verdictTooOld)
|
||||
}
|
||||
}
|
||||
|
||||
// Every version in the range is accepted, so the window this program claims
|
||||
// to support is the one it actually uses.
|
||||
func TestTheWholeSupportedRangeIsAccepted(t *testing.T) {
|
||||
for m := minMinor; m <= maxMinor; m++ {
|
||||
if got := pythonVerdict(3, m, true); got != verdictOK {
|
||||
t.Errorf("3.%d = %q, want %q", m, got, verdictOK)
|
||||
}
|
||||
}
|
||||
if minMinor > maxMinor {
|
||||
t.Fatal("the supported range is empty; nothing would ever be chosen")
|
||||
}
|
||||
}
|
||||
|
||||
// A major version nobody has tested against is not something to guess at, and
|
||||
// an unreadable version string is not a working interpreter.
|
||||
func TestUnknownVersionsAreNotAccepted(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
major, minor int
|
||||
parsed bool
|
||||
}{
|
||||
{"python 4", 4, 0, true},
|
||||
{"python 2", 2, 7, true},
|
||||
{"unparseable", 0, 0, false},
|
||||
} {
|
||||
if got := pythonVerdict(c.major, c.minor, c.parsed); got == verdictOK {
|
||||
t.Errorf("%s was accepted", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An environment already on disk is reused, and that is right until the Python
|
||||
// inside it is one this build cannot use.
|
||||
//
|
||||
// It was reused unconditionally, which would have defeated the ceiling above
|
||||
// on the exact machine that found the bug: that Mac already had a runtime
|
||||
// built by Python 3.14, left behind by the run that failed. Setup would pick a
|
||||
// good interpreter, find the 3.14 environment, keep it, and die in the same
|
||||
// clang error as before - a fix defeated by the wreckage of the bug it fixes.
|
||||
//
|
||||
// Real environments, not a fake: the thing under test is what an interpreter
|
||||
// on disk reports about itself.
|
||||
func TestAnUnsupportedEnvironmentIsNotReused(t *testing.T) {
|
||||
py, _, err := findPython()
|
||||
if err != nil {
|
||||
t.Skipf("no supported Python on this machine: %v", err)
|
||||
}
|
||||
venv := filepath.Join(t.TempDir(), "runtime")
|
||||
if err := makeVenv(py, venv); err != nil {
|
||||
t.Fatalf("makeVenv: %v", err)
|
||||
}
|
||||
if ok, ver := venvUsable(venv); !ok {
|
||||
t.Fatalf("an environment built from the interpreter setup just chose "+
|
||||
"reported itself unusable (%s)", ver)
|
||||
}
|
||||
|
||||
// The two states that must not be confused with a working one.
|
||||
empty := filepath.Join(t.TempDir(), "gone")
|
||||
if ok, _ := venvUsable(empty); ok {
|
||||
t.Error("a missing environment was reported usable")
|
||||
}
|
||||
broken := filepath.Join(t.TempDir(), "broken")
|
||||
if err := os.MkdirAll(filepath.Dir(venvPython(broken)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(venvPython(broken), []byte("not an interpreter"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, ver := venvUsable(broken); ok {
|
||||
t.Errorf("a half-created environment was reported usable (%s)", ver)
|
||||
}
|
||||
}
|
||||
BIN
agent/cmd/behavision-setup/rsrc_windows_amd64.syso
Normal file
@@ -3,7 +3,11 @@ module github.com/loyaly/behavision-agent
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3
|
||||
golang.org/x/sys v0.20.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
|
||||
@@ -6,3 +6,5 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
@@ -81,6 +81,7 @@ usage: %s <command>
|
||||
// agent.json - which is the state that screen was built to end.
|
||||
func cmdClaim(args []string) error {
|
||||
if len(args) == 0 {
|
||||
//lint:ignore ST1005 usage text read by a person, never wrapped
|
||||
return fmt.Errorf("usage: behavision-agent claim <installation code>\n" +
|
||||
"Ask whoever manages your shops for one - they can create it from\n" +
|
||||
"the Behavision platform, under the shop.")
|
||||
@@ -119,6 +120,12 @@ func cmdClaim(args []string) error {
|
||||
cfg.BrokerPassword = b.MQTTPass
|
||||
cfg.AgentToken = b.AgentToken
|
||||
cfg.CloudBase = base
|
||||
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
|
||||
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.BrokerCAFile = caPath
|
||||
// A PC that was running on its own and has now been linked is no longer
|
||||
// standalone.
|
||||
cfg.Standalone = false
|
||||
@@ -155,6 +162,7 @@ func cmdStatus() error {
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -163,7 +171,7 @@ func cmdStatus() error {
|
||||
Command: func(context.Context) *exec.Cmd { return nil },
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -198,6 +206,7 @@ func cmdRun() error {
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
// Opened before the engine starts: detections arriving in the first second
|
||||
// must have somewhere to land.
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
@@ -223,6 +232,14 @@ func cmdRun() error {
|
||||
sup := engine.New(engine.Options{
|
||||
Command: func(ctx context.Context) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, exe, cfg.EngineArgs...)
|
||||
// Run the engine FROM a known directory rather than from whatever
|
||||
// happened to launch us. A double-clicked bundle hands its child
|
||||
// "/", and an engine invoked as `-m behavision` then cannot find
|
||||
// itself - measured on macOS, where it retried forever.
|
||||
cmd.Dir = cfg.EngineDir
|
||||
if cmd.Dir == "" {
|
||||
cmd.Dir = paths.InstallRoot()
|
||||
}
|
||||
// How the engine learns where to send detections. The engine's
|
||||
// config already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`
|
||||
// and python-dotenv does not override a variable the process
|
||||
@@ -238,7 +255,7 @@ func cmdRun() error {
|
||||
LogWriter: logFile,
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
@@ -273,6 +290,7 @@ func cmdRun() error {
|
||||
cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken)
|
||||
cloud.Upload = uploader.UploadBytes
|
||||
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
|
||||
eng.Creds = creds
|
||||
go cameras.New(eng, cloud, logger).Run(ctx)
|
||||
// The live relay, which uploads nothing until somebody at head office is
|
||||
// actually watching a camera.
|
||||
@@ -307,7 +325,7 @@ func cmdRun() error {
|
||||
cfg.ClientID, cfg.SiteID, cfg.BrokerURL)
|
||||
client, err := mqtt.NewClient(mqtt.ClientOptions{
|
||||
BrokerURL: cfg.BrokerURL,
|
||||
ClientID: "behavision-" + cfg.ClientID + "-" + cfg.SiteID,
|
||||
ClientID: cfg.MQTTClientID(),
|
||||
Username: cfg.BrokerUsername, Password: cfg.BrokerPassword,
|
||||
CAFile: cfg.BrokerCAFile, Log: logger,
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/bridge"
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
)
|
||||
|
||||
// EngineClient talks to the recognition engine on this PC's loopback.
|
||||
@@ -21,7 +22,12 @@ type EngineClient struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
Client *http.Client
|
||||
// Creds re-reads the engine's generated credential when one is rejected.
|
||||
// Without it a fresh install is 401 for the life of the process: the agent
|
||||
// starts the engine, and the engine writes its credential file seconds
|
||||
// after the agent has already read (and failed to find) it.
|
||||
Creds *config.Creds
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewEngineClient(base, user, password string) *EngineClient {
|
||||
@@ -50,14 +56,24 @@ func (e *EngineClient) do(ctx context.Context, method, path string, body, out an
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if e.User != "" {
|
||||
req.SetBasicAuth(e.User, e.Password)
|
||||
user, pass := e.User, e.Password
|
||||
if e.Creds != nil {
|
||||
user, pass = e.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := e.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized && e.Creds != nil && e.Creds.Refresh() {
|
||||
// The engine generated its credential after we last looked. Read it
|
||||
// and try once more rather than failing for the life of the process.
|
||||
resp.Body.Close()
|
||||
return e.do(ctx, method, path, body, out)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The engine's message, not just a status. "camera stored but failed to
|
||||
// start: connection refused" is something an operator can act on;
|
||||
|
||||
@@ -30,13 +30,12 @@ import (
|
||||
// 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
|
||||
Engine *EngineClient
|
||||
Cloud *CloudClient
|
||||
Log *log.Logger
|
||||
FPS float64
|
||||
Width int
|
||||
Quality int
|
||||
}
|
||||
|
||||
// Defaults, measured against the office camera rather than guessed.
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -37,7 +39,18 @@ type Config struct {
|
||||
BrokerCAFile string `json:"broker_ca_file"`
|
||||
|
||||
// Engine process.
|
||||
EngineExe string `json:"engine_exe"`
|
||||
EngineExe string `json:"engine_exe"`
|
||||
// EngineDir is the working directory the engine is launched IN. Empty
|
||||
// means the install root.
|
||||
//
|
||||
// It exists because nothing set it, so the engine inherited whatever
|
||||
// launched the app - and for an app started by double-clicking its
|
||||
// bundle that is "/", not anywhere useful. The symptom on macOS was
|
||||
// `python: No module named behavision` repeating forever: the dev engine
|
||||
// is `-m behavision`, which resolves against the working directory. The
|
||||
// same app started from a terminal in the repo worked, which is exactly
|
||||
// the shape of a bug that survives every test run by a developer.
|
||||
EngineDir string `json:"engine_dir,omitempty"`
|
||||
EngineArgs []string `json:"engine_args"`
|
||||
APIBase string `json:"api_base"`
|
||||
APIUser string `json:"api_user"`
|
||||
@@ -72,6 +85,10 @@ type Config struct {
|
||||
// Queue.
|
||||
SpoolMax int `json:"spool_max"`
|
||||
|
||||
// InstallID distinguishes THIS installation from every other one claimed
|
||||
// to the same site. See MQTTClientID.
|
||||
InstallID string `json:"install_id,omitempty"`
|
||||
|
||||
path string
|
||||
}
|
||||
|
||||
@@ -113,6 +130,14 @@ func Load(path string) (Config, error) {
|
||||
return cfg, fmt.Errorf("config %s: %w", path, err)
|
||||
}
|
||||
cfg.path = path
|
||||
// Minted on first load and written back, so an installation that predates
|
||||
// this field gets one without anybody doing anything. Best effort: a
|
||||
// read-only config still yields a working id for this run, it is simply
|
||||
// not the same one next time.
|
||||
if cfg.InstallID == "" {
|
||||
cfg.InstallID = newInstallID()
|
||||
_ = cfg.Save(path)
|
||||
}
|
||||
for _, field := range []*string{&cfg.BrokerPassword, &cfg.APIPassword,
|
||||
&cfg.SessionToken, &cfg.SessionRefresh, &cfg.AgentToken} {
|
||||
plain, err := reveal(*field)
|
||||
@@ -199,3 +224,41 @@ func reveal(stored string) (string, error) {
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// MQTTClientID names this INSTALLATION, not this site.
|
||||
//
|
||||
// It was `behavision-<client>-<site>`, which is the same string on every
|
||||
// computer claimed to one shop. MQTT requires client ids to be unique and a
|
||||
// broker enforces it by disconnecting the older session when a new one
|
||||
// arrives with the same id - so two machines on one site take turns kicking
|
||||
// each other off, forever. Measured on a second Mac claimed to a live shop:
|
||||
//
|
||||
// broker connected / broker connection lost: EOF / broker connected / ...
|
||||
//
|
||||
// The damage is not confined to the new machine. The shop's own till is the
|
||||
// other half of that loop, so somebody signing in on a laptop to look at the
|
||||
// product stops the shop delivering visits - and nothing at either end says
|
||||
// why, because from each side it reads as an unstable network.
|
||||
//
|
||||
// The site stays in the id because it is what a broker log is read by, and
|
||||
// the random half is short for the same reason. `CleanSession(true)` means
|
||||
// there is no session state for a changed id to strand.
|
||||
func (c Config) MQTTClientID() string {
|
||||
id := c.InstallID
|
||||
if id == "" {
|
||||
// A config that could not be written still has to produce a UNIQUE
|
||||
// id, or this falls straight back into the collision it exists to
|
||||
// prevent. Per-run is the right failure: the connection works and the
|
||||
// only cost is a new name in the broker's log after a restart.
|
||||
id = newInstallID()
|
||||
}
|
||||
return "behavision-" + c.ClientID + "-" + c.SiteID + "-" + id
|
||||
}
|
||||
|
||||
func newInstallID() string {
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "x"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// EngineCredentials reads the Basic credentials the engine generated for
|
||||
@@ -60,3 +61,60 @@ func (c Config) WithEngineCredentials(path string) Config {
|
||||
c.APIUser, c.APIPassword = EngineCredentials(path)
|
||||
return c
|
||||
}
|
||||
|
||||
// Creds resolves the engine's Basic credentials, re-reading the file when it
|
||||
// has none.
|
||||
//
|
||||
// Reading once at startup is wrong on a fresh install, and that is the case
|
||||
// that matters: the agent starts the engine, the engine generates its
|
||||
// credential and writes the file a few seconds later, and an agent that read
|
||||
// the file before that holds "" forever. Every call it makes - health, stats,
|
||||
// camera sync, the embedding for a visit - then comes back 401 for the life of
|
||||
// the process, on a brand new shop PC, with the tray showing a red engine that
|
||||
// is running perfectly. Measured on a fresh state directory: three 401s and no
|
||||
// camera ever reconciled.
|
||||
//
|
||||
// A configured credential is never re-read: an operator who set
|
||||
// BEHAVISION_API_USER means it.
|
||||
type Creds struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
user string
|
||||
pass string
|
||||
fixed bool
|
||||
}
|
||||
|
||||
// NewCreds takes whatever the config already has. Non-empty means configured,
|
||||
// and is used unchanged.
|
||||
func NewCreds(path, user, password string) *Creds {
|
||||
c := &Creds{path: path, user: user, pass: password}
|
||||
c.fixed = user != "" || password != ""
|
||||
return c
|
||||
}
|
||||
|
||||
// Get returns the current pair, reading the file if it has nothing yet.
|
||||
func (c *Creds) Get() (string, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.user == "" && !c.fixed {
|
||||
c.user, c.pass = EngineCredentials(c.path)
|
||||
}
|
||||
return c.user, c.pass
|
||||
}
|
||||
|
||||
// Refresh re-reads the file after a rejection and reports whether the pair
|
||||
// changed. Callers retry once when it did - which covers both the fresh-install
|
||||
// race and a credential the engine regenerated under a running agent.
|
||||
func (c *Creds) Refresh() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.fixed {
|
||||
return false
|
||||
}
|
||||
u, p := EngineCredentials(c.path)
|
||||
if u == c.user && p == c.pass {
|
||||
return false
|
||||
}
|
||||
c.user, c.pass = u, p
|
||||
return u != ""
|
||||
}
|
||||
|
||||
77
agent/pkg/config/credentials_firstrun_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The sequence on a brand new shop PC, in order:
|
||||
//
|
||||
// agent starts -> file does not exist yet
|
||||
// agent starts the engine
|
||||
// engine generates its credential and writes the file
|
||||
// agent calls the engine -> must now succeed
|
||||
//
|
||||
// Read once at startup, the agent holds "" for the life of the process and
|
||||
// every engine call is 401: health, stats, camera sync, the embedding for a
|
||||
// visit. The tray shows a red engine that is running perfectly, and nothing
|
||||
// says why. Measured on a fresh state directory before this existed.
|
||||
func TestCredentialsArriveAfterTheAgentHasAlreadyLooked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
|
||||
creds := NewCreds(path, "", "") // nothing configured, file not there yet
|
||||
if u, _ := creds.Get(); u != "" {
|
||||
t.Fatalf("expected no credential before the engine has written one, got %q", u)
|
||||
}
|
||||
|
||||
// the engine starts and writes its credential
|
||||
if err := os.WriteFile(path, []byte("username=behavision\npassword=s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// a 401 makes the agent look again
|
||||
if !creds.Refresh() {
|
||||
t.Fatal("Refresh did not pick up the credential the engine just wrote")
|
||||
}
|
||||
u, p := creds.Get()
|
||||
if u != "behavision" || p != "s3cret" {
|
||||
t.Fatalf("got %q/%q", u, p)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator who set BEHAVISION_API_USER means it, and a file must never
|
||||
// override them.
|
||||
func TestAConfiguredCredentialIsNeverReplacedByTheFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
if err := os.WriteFile(path, []byte("username=generated\npassword=nope\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creds := NewCreds(path, "chosen", "byhand")
|
||||
if u, p := creds.Get(); u != "chosen" || p != "byhand" {
|
||||
t.Fatalf("configured credential was replaced: %q/%q", u, p)
|
||||
}
|
||||
if creds.Refresh() {
|
||||
t.Fatal("Refresh overrode a configured credential")
|
||||
}
|
||||
}
|
||||
|
||||
// A credential the engine regenerates under a running agent is picked up too -
|
||||
// the same mechanism, and the reason paths.APICredentials says the agent reads
|
||||
// the file "rather than storing a second copy".
|
||||
func TestARegeneratedCredentialIsPickedUp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
os.WriteFile(path, []byte("username=behavision\npassword=old\n"), 0o600)
|
||||
creds := NewCreds(path, "", "")
|
||||
creds.Get()
|
||||
os.WriteFile(path, []byte("username=behavision\npassword=new\n"), 0o600)
|
||||
if !creds.Refresh() {
|
||||
t.Fatal("a regenerated password was not picked up")
|
||||
}
|
||||
if _, p := creds.Get(); p != "new" {
|
||||
t.Fatalf("still holding %q", p)
|
||||
}
|
||||
}
|
||||
88
agent/pkg/config/installid_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The bug this exists to prevent, measured on a second Mac claimed to a live
|
||||
// shop: MQTT requires client ids to be unique, and a broker enforces it by
|
||||
// disconnecting the older session when a new one arrives with the same id. The
|
||||
// id was `behavision-<client>-<site>` - identical on every computer claimed to
|
||||
// one shop - so the two took turns kicking each other off:
|
||||
//
|
||||
// broker connected / broker connection lost: EOF / broker connected / ...
|
||||
//
|
||||
// The damage is not confined to the new machine. The shop's own till is the
|
||||
// other half of that loop, so somebody signing in on a laptop to look at the
|
||||
// product stops the shop delivering visits.
|
||||
func TestTwoInstallsOnOneSiteGetDifferentClientIDs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
one := writeClaimed(t, filepath.Join(dir, "a.json"))
|
||||
two := writeClaimed(t, filepath.Join(dir, "b.json"))
|
||||
|
||||
if one.MQTTClientID() == two.MQTTClientID() {
|
||||
t.Fatalf("both installs answered to %q; the broker will disconnect one "+
|
||||
"whenever the other connects", one.MQTTClientID())
|
||||
}
|
||||
}
|
||||
|
||||
// And the same install keeps its name across restarts, or a broker log is a
|
||||
// list of strangers and nobody can tell one till from a stream of new ones.
|
||||
func TestOneInstallKeepsItsClientIDAcrossRestarts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
first := writeClaimed(t, path)
|
||||
|
||||
again, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if got, want := again.MQTTClientID(), first.MQTTClientID(); got != want {
|
||||
t.Errorf("after a restart the id was %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The site stays in the id: it is what somebody reading a broker log is
|
||||
// reading FOR, and an opaque random string would make every connection
|
||||
// anonymous.
|
||||
func TestTheClientIDStillNamesTheShop(t *testing.T) {
|
||||
c := Config{ClientID: "tenext-retail", SiteID: "chennai", InstallID: "abcd1234"}
|
||||
id := c.MQTTClientID()
|
||||
for _, want := range []string{"tenext-retail", "chennai", "abcd1234"} {
|
||||
if !strings.Contains(id, want) {
|
||||
t.Errorf("client id %q does not contain %q", id, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A config that could not be written still has to produce a UNIQUE id, or a
|
||||
// read-only install falls straight back into the collision. Per-run is the
|
||||
// right failure: the connection works, and the only cost is a new name in the
|
||||
// broker's log after a restart.
|
||||
func TestAnUnsavedConfigStillGetsAUniqueID(t *testing.T) {
|
||||
a := Config{ClientID: "c", SiteID: "s"}
|
||||
b := Config{ClientID: "c", SiteID: "s"}
|
||||
if a.MQTTClientID() == b.MQTTClientID() {
|
||||
t.Fatal("two configs with no install id produced the same client id")
|
||||
}
|
||||
}
|
||||
|
||||
func writeClaimed(t *testing.T, path string) Config {
|
||||
t.Helper()
|
||||
cfg := Defaults()
|
||||
cfg.ClientID, cfg.SiteID = "tenext-retail", "chennai"
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
// Loading is what mints the id, so an installation that predates the
|
||||
// field gets one without anybody doing anything.
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got.InstallID == "" {
|
||||
t.Fatal("loading a config without an install id did not mint one")
|
||||
}
|
||||
return got
|
||||
}
|
||||
140
agent/pkg/demo/bundle.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Package demo seals a camera list so a release can carry it without carrying
|
||||
// the credentials in any usable form.
|
||||
//
|
||||
// The need: a demo build that installs with the office cameras already set up,
|
||||
// handed to people who should not be able to read the cameras' admin password
|
||||
// out of the zip. "Encode it" does not do that - anything the installer can
|
||||
// decode, anyone holding the installer can decode. So the bundle is encrypted
|
||||
// with a key that is NOT in the package: a short unlock code, generated when
|
||||
// the bundle is sealed, spoken or messaged to whoever runs setup, and typed
|
||||
// once. Without it the file is noise.
|
||||
//
|
||||
// The code is random, not chosen, so it is used as key material directly
|
||||
// (through SHA-256) rather than stretched with a KDF. A human-chosen
|
||||
// passphrase would need argon2 and a dependency; 120 random bits do not.
|
||||
package demo
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Magic identifies the file and the format version, so a future change can be
|
||||
// told apart from corruption instead of failing as "authentication failed".
|
||||
const magic = "BVDEMO1\n"
|
||||
|
||||
// Payload is what a sealed bundle carries. Two demo shapes exist:
|
||||
//
|
||||
// - cameras only: the PC runs on its own with these cameras (the first
|
||||
// demo build);
|
||||
// - an enrolment code: the PC claims a real shop at head office and gets
|
||||
// its cameras from there, exactly as a customer install would, so the
|
||||
// demo exercises the whole product rather than a local copy of it. The
|
||||
// code is single-use, so one bundle is one install.
|
||||
//
|
||||
// A bundle from the first build is a bare JSON array; Decode accepts both.
|
||||
type Payload struct {
|
||||
Cameras []Camera `json:"cameras,omitempty"`
|
||||
EnrolCode string `json:"enrol_code,omitempty"`
|
||||
CloudBase string `json:"cloud_base,omitempty"`
|
||||
}
|
||||
|
||||
// Decode reads either payload shape.
|
||||
func Decode(plain []byte) (Payload, error) {
|
||||
var p Payload
|
||||
if len(plain) > 0 && plain[0] == '[' {
|
||||
return p, json.Unmarshal(plain, &p.Cameras)
|
||||
}
|
||||
return p, json.Unmarshal(plain, &p)
|
||||
}
|
||||
|
||||
// Camera is one entry as the engine's Add Camera endpoint accepts it.
|
||||
type Camera struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MaxWidth int `json:"max_width,omitempty"`
|
||||
}
|
||||
|
||||
// NewCode mints an unlock code: 15 random bytes as 24 base32 characters in
|
||||
// four groups, the same shape as an installation code, for the same reason -
|
||||
// it gets read down a phone.
|
||||
func NewCode() (string, error) {
|
||||
raw := make([]byte, 15)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
|
||||
return fmt.Sprintf("%s-%s-%s-%s", s[0:6], s[6:12], s[12:18], s[18:24]), nil
|
||||
}
|
||||
|
||||
// NormalizeCode makes the typed and the printed form hash the same: case,
|
||||
// spaces and dashes are all noise a person adds or drops.
|
||||
func NormalizeCode(code string) string {
|
||||
code = strings.ToUpper(code)
|
||||
code = strings.NewReplacer("-", "", " ", "", "\t", "", "\r", "", "\n", "").Replace(code)
|
||||
return code
|
||||
}
|
||||
|
||||
func keyFor(code string) []byte {
|
||||
sum := sha256.Sum256([]byte("behavision-demo-bundle:" + NormalizeCode(code)))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Seal encrypts plaintext under the code. Output is magic || nonce || ciphertext.
|
||||
func Seal(code string, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := append([]byte(magic), nonce...)
|
||||
return gcm.Seal(out, nonce, plaintext, []byte(magic)), nil
|
||||
}
|
||||
|
||||
// ErrWrongCode is what a mistyped code looks like. GCM cannot tell a wrong key
|
||||
// from a corrupted file, and neither can we, so both read as this.
|
||||
var ErrWrongCode = errors.New("that unlock code does not open this bundle")
|
||||
|
||||
// Open decrypts a sealed bundle.
|
||||
func Open(code string, sealed []byte) ([]byte, error) {
|
||||
if !strings.HasPrefix(string(sealed), magic) {
|
||||
return nil, errors.New("not a Behavision demo bundle")
|
||||
}
|
||||
body := sealed[len(magic):]
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) < gcm.NonceSize() {
|
||||
return nil, errors.New("bundle is truncated")
|
||||
}
|
||||
nonce, ct := body[:gcm.NonceSize()], body[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ct, []byte(magic))
|
||||
if err != nil {
|
||||
return nil, ErrWrongCode
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
87
agent/pkg/demo/bundle_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealedBundleRoundTripsWithTheCodeAsTyped(t *testing.T) {
|
||||
code, err := NewCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(NormalizeCode(code)) != 24 {
|
||||
t.Fatalf("code should be 24 base32 chars, got %q", code)
|
||||
}
|
||||
secret := []byte(`[{"id":"cam1","password":"the-camera-admin-password"}]`)
|
||||
|
||||
sealed, err := Seal(code, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// People type codes in lower case, with the dashes dropped, with a space
|
||||
// where a dash was. All of those are the same code.
|
||||
for _, typed := range []string{
|
||||
code,
|
||||
strings.ToLower(code),
|
||||
strings.ReplaceAll(code, "-", ""),
|
||||
strings.ReplaceAll(code, "-", " "),
|
||||
" " + code + "\n",
|
||||
} {
|
||||
got, err := Open(typed, sealed)
|
||||
if err != nil {
|
||||
t.Fatalf("open with %q: %v", typed, err)
|
||||
}
|
||||
if !bytes.Equal(got, secret) {
|
||||
t.Fatalf("round trip changed the contents")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the file: the password is not in it.
|
||||
func TestTheSealedFileDoesNotContainTheSecret(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte(`{"password":"the-camera-admin-password","host":"192.168.1.121"}`))
|
||||
for _, leak := range []string{"the-camera-admin-password", "192.168.1.121", "password"} {
|
||||
if bytes.Contains(sealed, []byte(leak)) {
|
||||
t.Fatalf("sealed bundle contains %q in the clear", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongCodeIsRefusedNotMisread(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
other, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte("secret"))
|
||||
|
||||
if _, err := Open(other, sealed); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a different code should be ErrWrongCode, got %v", err)
|
||||
}
|
||||
// One flipped byte in the ciphertext is the same answer: GCM refuses
|
||||
// rather than returning garbage that then gets written into cameras.json.
|
||||
tampered := append([]byte{}, sealed...)
|
||||
tampered[len(tampered)-1] ^= 0x01
|
||||
if _, err := Open(code, tampered); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a tampered bundle should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotABundleSaysSo(t *testing.T) {
|
||||
if _, err := Open("ABCDEF-GHIJKL-MNOPQR-STUVWX", []byte("hello")); err == nil ||
|
||||
errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a non-bundle should be named as such, not blamed on the code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two seals of the same plaintext under the same code must differ: a fixed
|
||||
// nonce would let two releases' bundles be compared byte for byte.
|
||||
func TestEverySealIsDifferent(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
a, _ := Seal(code, []byte("same"))
|
||||
b, _ := Seal(code, []byte("same"))
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("nonce is not random")
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,12 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -56,6 +61,9 @@ type Options struct {
|
||||
// no captured output is undiagnosable, which on a customer site means a
|
||||
// site visit.
|
||||
LogWriter io.Writer
|
||||
// Creds re-reads the engine's generated credential when one is rejected,
|
||||
// which is the ordinary case on a first run.
|
||||
Creds *config.Creds
|
||||
// HealthURL, StatsURL, User, Password address the engine's own API.
|
||||
HealthURL string
|
||||
StatsURL string
|
||||
@@ -76,6 +84,7 @@ type Supervisor struct {
|
||||
restarts int
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
progress Progress
|
||||
}
|
||||
|
||||
func New(opts Options) *Supervisor {
|
||||
@@ -109,6 +118,37 @@ func (s *Supervisor) Start() {
|
||||
go s.supervise(ctx, done)
|
||||
}
|
||||
|
||||
// Progress is what the engine is busy with before it answers - on first run,
|
||||
// downloading ~275 MB of models. Empty once the engine is up.
|
||||
type Progress struct {
|
||||
What string `json:"what"`
|
||||
Percent int `json:"percent"`
|
||||
}
|
||||
|
||||
var progressRe = regexp.MustCompile(`download: (.+?) (\d{1,3})%`)
|
||||
|
||||
func (s *Supervisor) noteProgress(line string) {
|
||||
m := progressRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
pct, _ := strconv.Atoi(m[2])
|
||||
s.mu.Lock()
|
||||
if pct >= 100 {
|
||||
s.progress = Progress{}
|
||||
} else {
|
||||
s.progress = Progress{What: m[1], Percent: pct}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Progress reports the current first-run download, if any.
|
||||
func (s *Supervisor) Progress() Progress {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.progress
|
||||
}
|
||||
|
||||
// Stop asks the engine to exit and waits for it.
|
||||
func (s *Supervisor) Stop() {
|
||||
s.mu.Lock()
|
||||
@@ -194,22 +234,66 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
// Cancel ends the whole process tree, not just the process exec spawned.
|
||||
// `kill` is filled in after Start, once the tree is confined; until then
|
||||
// it is exec's own behaviour.
|
||||
var kill func() error
|
||||
cmd.Cancel = func() error {
|
||||
if kill == nil {
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
return kill()
|
||||
}
|
||||
// Cancel sends the kill; WaitDelay bounds how long Wait() then waits for
|
||||
// the output pipes to close. Without it Wait() blocks until every writer
|
||||
// is gone - and Stop() blocks on Wait() - so one grandchild still holding
|
||||
// the engine's stdout hangs Stop FOREVER, which on the desktop app means
|
||||
// the tray's Quit never returns. stopGrace was declared for exactly this
|
||||
// and never wired to anything; staticcheck found it as an unused const.
|
||||
cmd.WaitDelay = stopGrace
|
||||
prepare(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("engine failed to start: %w", err)
|
||||
}
|
||||
k, release, err := confine(cmd)
|
||||
if err != nil {
|
||||
// Not fatal: the engine runs, and stopping it falls back to killing
|
||||
// the one process. Logged because on Windows that fallback is the
|
||||
// bug this exists to fix.
|
||||
fmt.Fprintf(s.opts.LogWriter, "supervisor: could not confine engine process tree: %v\n", err)
|
||||
}
|
||||
kill = k
|
||||
defer release()
|
||||
|
||||
// The last few lines the engine printed travel with the failure, because
|
||||
// "engine exited: exit status 1" sends somebody to a log file on a shop
|
||||
// PC, and the one line that matters - "port 8010 is already in use" - was
|
||||
// right there.
|
||||
var tailMu sync.Mutex
|
||||
var tail []string
|
||||
pumped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pumped)
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
fmt.Fprintln(s.opts.LogWriter, sc.Text())
|
||||
line := sc.Text()
|
||||
fmt.Fprintln(s.opts.LogWriter, line)
|
||||
s.noteProgress(line)
|
||||
tailMu.Lock()
|
||||
tail = append(tail, line)
|
||||
if len(tail) > 12 {
|
||||
tail = tail[1:]
|
||||
}
|
||||
tailMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
s.setState(Running, nil)
|
||||
waitErr := cmd.Wait()
|
||||
s.mu.Lock()
|
||||
s.progress = Progress{}
|
||||
s.mu.Unlock()
|
||||
<-pumped
|
||||
|
||||
// A context cancel terminates the child through exec's own handling; the
|
||||
@@ -218,6 +302,12 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
if waitErr != nil {
|
||||
tailMu.Lock()
|
||||
reason := explain(tail)
|
||||
tailMu.Unlock()
|
||||
if reason != "" {
|
||||
return fmt.Errorf("%s (%v)", reason, waitErr)
|
||||
}
|
||||
return fmt.Errorf("engine exited: %w", waitErr)
|
||||
}
|
||||
return errors.New("engine exited unexpectedly with status 0")
|
||||
@@ -331,14 +421,24 @@ func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.opts.User != "" {
|
||||
req.SetBasicAuth(s.opts.User, s.opts.Password)
|
||||
user, pass := s.opts.User, s.opts.Password
|
||||
if s.opts.Creds != nil {
|
||||
user, pass = s.opts.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized && s.opts.Creds != nil && s.opts.Creds.Refresh() {
|
||||
// See cameras.EngineClient: on a fresh install the engine writes its
|
||||
// credential after the agent has already read for one.
|
||||
resp.Body.Close()
|
||||
return s.getJSON(ctx, url, out)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s returned %s", url, resp.Status)
|
||||
}
|
||||
@@ -353,3 +453,28 @@ func LogFile(path string) (*os.File, error) {
|
||||
}
|
||||
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
}
|
||||
|
||||
// explain turns the engine's last output into the sentence the tray shows.
|
||||
// The cases are the ones seen on real installs; anything else shows the last
|
||||
// non-empty line verbatim.
|
||||
func explain(tail []string) string {
|
||||
last := ""
|
||||
for _, l := range tail {
|
||||
low := strings.ToLower(l)
|
||||
switch {
|
||||
case strings.Contains(low, "address already in use") || strings.Contains(low, "only one usage of each socket address"):
|
||||
return "port 8010 is already in use - another Behavision or its engine is still running"
|
||||
case strings.Contains(low, "no module named behavision"):
|
||||
return "the engine is not installed in this Python - run behavision-setup again"
|
||||
case strings.Contains(low, "modulenotfounderror") || strings.Contains(low, "importerror"):
|
||||
return "the engine is missing a library - run behavision-setup again"
|
||||
}
|
||||
if strings.TrimSpace(l) != "" {
|
||||
last = strings.TrimSpace(l)
|
||||
}
|
||||
}
|
||||
if len(last) > 120 {
|
||||
last = last[:120] + "…"
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
13
agent/pkg/engine/tree_other.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package engine
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// On every other platform the engine is one process and exec's own kill is
|
||||
// enough. See tree_windows.go for why Windows is not.
|
||||
func prepare(*exec.Cmd) {}
|
||||
|
||||
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
|
||||
return cmd.Process.Kill, func() {}, nil
|
||||
}
|
||||
111
agent/pkg/engine/tree_windows.go
Normal file
@@ -0,0 +1,111 @@
|
||||
//go:build windows
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The engine is not one process on Windows, and stopping it used to leave
|
||||
// recognition running.
|
||||
//
|
||||
// The installer starts it as `<venv>\Scripts\python.exe -m behavision run`.
|
||||
// Since Python 3.7.2 that python.exe is a REDIRECTOR: a small launcher that
|
||||
// spawns the base interpreter as a child and waits for it. Stop() cancelled the
|
||||
// context, exec terminated the launcher, and the interpreter that actually
|
||||
// holds the cameras and the SQLite WAL carried on with no parent, no tray icon
|
||||
// and nothing left that could stop it. Seen on a Windows install: "Quit
|
||||
// Behavision" from the tray, and the engine still running.
|
||||
//
|
||||
// The fix is the primitive Windows has for exactly this: a job object with
|
||||
// KILL_ON_JOB_CLOSE. Every process the engine spawns inherits membership, and
|
||||
// the whole tree dies when the job is terminated or when this process's last
|
||||
// handle to it goes away - so "quitting the app stops recognition" holds even
|
||||
// if the app crashes, which no amount of careful Stop() code can promise.
|
||||
//
|
||||
// The child is started SUSPENDED and resumed only after it is in the job.
|
||||
// Assigning after the fact leaves a window in which the launcher has already
|
||||
// spawned the interpreter outside it, and that window is precisely the case
|
||||
// this file exists to close.
|
||||
|
||||
// prepare is applied to the command before it starts.
|
||||
func prepare(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
// CREATE_NO_WINDOW: python.exe is a console program and Behavision.exe is
|
||||
// not, so without this Windows opens a black console window for the
|
||||
// engine on a shop counter - the app looks like it has crashed into a
|
||||
// terminal. Output still arrives on the pipes.
|
||||
cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED | windows.CREATE_NO_WINDOW
|
||||
}
|
||||
|
||||
// confine is applied after Start. It puts the process in a kill-on-close job,
|
||||
// then resumes it. It returns a function that ends the whole tree, and one
|
||||
// that releases the job handle once the tree has exited.
|
||||
//
|
||||
// If the job cannot be set up the process is still resumed and the plain
|
||||
// terminate remains: a suspended engine that never runs is strictly worse
|
||||
// than one that may outlive its parent.
|
||||
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
|
||||
pid := uint32(cmd.Process.Pid)
|
||||
defer resumeProcess(pid)
|
||||
|
||||
kill = cmd.Process.Kill
|
||||
release = func() {}
|
||||
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return kill, release, fmt.Errorf("create job object: %w", err)
|
||||
}
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("configure job object: %w", err)
|
||||
}
|
||||
proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, pid)
|
||||
if err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("open engine process: %w", err)
|
||||
}
|
||||
defer windows.CloseHandle(proc)
|
||||
if err := windows.AssignProcessToJobObject(job, proc); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("assign engine to job: %w", err)
|
||||
}
|
||||
|
||||
kill = func() error { return windows.TerminateJobObject(job, 1) }
|
||||
release = func() { windows.CloseHandle(job) }
|
||||
return kill, release, nil
|
||||
}
|
||||
|
||||
// resumeProcess resumes every thread of a process started CREATE_SUSPENDED.
|
||||
// exec does not hand back the main thread handle, so it is found through the
|
||||
// toolhelp snapshot; a suspended new process has exactly one.
|
||||
func resumeProcess(pid uint32) {
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer windows.CloseHandle(snap)
|
||||
var te windows.ThreadEntry32
|
||||
te.Size = uint32(unsafe.Sizeof(te))
|
||||
for err = windows.Thread32First(snap, &te); err == nil; err = windows.Thread32Next(snap, &te) {
|
||||
if te.OwnerProcessID != pid {
|
||||
continue
|
||||
}
|
||||
h, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, te.ThreadID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
windows.ResumeThread(h)
|
||||
windows.CloseHandle(h)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -31,7 +32,7 @@ type Bootstrap struct {
|
||||
MQTTURL string `json:"mqtt_url"`
|
||||
MQTTUser string `json:"mqtt_username"`
|
||||
MQTTPass string `json:"mqtt_password"`
|
||||
CAPem string `json:"ca_pem,omitempty"`
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
@@ -90,3 +91,23 @@ func Claim(ctx context.Context, base, code string) (Bootstrap, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveCA writes the broker's CA beside the agent config and returns its path.
|
||||
//
|
||||
// The server hands the CA out at enrolment precisely so it never has to be
|
||||
// shipped in an installer - and for a while nothing on the receiving end
|
||||
// wrote it anywhere. Every claimed PC then dialled tls://mcp.loyaly.ai:8883
|
||||
// with the system trust store, the private CA failed verification, and the
|
||||
// agent reported "the broker did not accept this PC" (a TLS failure is
|
||||
// indistinguishable from a refusal at that layer). No real site could ever
|
||||
// publish a visit. An empty CA returns "" so a deployment on a public
|
||||
// certificate keeps working unchanged.
|
||||
func SaveCA(pem, path string) (string, error) {
|
||||
if strings.TrimSpace(pem) == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(pem), 0o600); err != nil {
|
||||
return "", fmt.Errorf("write broker CA: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -225,7 +224,7 @@ func checkTransport(raw string) error {
|
||||
// url.Parse, not hand-rolled splitting: an IPv6 literal is bracketed and
|
||||
// full of colons, so scanning for the first ":" turns "[::1]:1883" into
|
||||
// "[" and refuses a perfectly good loopback address.
|
||||
u, err := neturl.Parse(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mqtt: cannot parse broker url %q: %w", raw, err)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,11 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
|
||||
// The pump calls this on every tick; a nil-client panic would take the
|
||||
// whole agent down instead of backing off.
|
||||
c := &Client{}
|
||||
if err := c.Publish(nil, "t", []byte("{}")); err == nil { //nolint:staticcheck
|
||||
// The nil context is the POINT: the pump must not panic on a client that
|
||||
// never connected. //nolint is golangci-lint's directive and staticcheck
|
||||
// ignores it, which is why this kept being reported.
|
||||
//lint:ignore SA1012 passing nil is what is under test
|
||||
if err := c.Publish(nil, "t", []byte("{}")); err == nil {
|
||||
t.Fatal("publish on an unconnected client reported success")
|
||||
}
|
||||
if c.Connected() {
|
||||
|
||||
@@ -205,14 +205,3 @@ func (p *Pump) logf(format string, args ...any) {
|
||||
p.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,11 @@ func InstallRoot() string {
|
||||
}
|
||||
|
||||
func AgentConfig() string { return filepath.Join(StateRoot(), "agent.json") }
|
||||
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
|
||||
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
|
||||
|
||||
// BrokerCA is the broker's CA certificate, written at enrolment.
|
||||
func BrokerCA() string { return filepath.Join(StateRoot(), "broker-ca.crt") }
|
||||
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
|
||||
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
|
||||
|
||||
// APICredentials is the file the engine writes when it generates its own
|
||||
// Basic credentials. The agent reads it rather than storing a second copy,
|
||||
@@ -67,6 +70,12 @@ func APICredentials() string {
|
||||
return filepath.Join(StateRoot(), "data", "api_credentials.txt")
|
||||
}
|
||||
|
||||
// CamerasFile is the engine's own camera store. The agent never edits it -
|
||||
// cameras go through the engine's API so passwords are sealed - but setup
|
||||
// removes it when a PC joins a shop, because head office is the source of
|
||||
// truth from then on.
|
||||
func CamerasFile() string { return filepath.Join(StateRoot(), "data", "cameras.json") }
|
||||
|
||||
// EnsureState creates the writable tree. Called before anything opens a file
|
||||
// under it, so a first run on a fresh machine does not fail on a missing dir.
|
||||
func EnsureState() error {
|
||||
|
||||
BIN
agent/rsrc_windows_amd64.syso
Normal file
@@ -21,6 +21,17 @@ def cmd_run(args: argparse.Namespace) -> int:
|
||||
|
||||
cfg = load_config(args.config)
|
||||
setup_logging(cfg.app.log_level, cfg.app.data_dir)
|
||||
if cfg.app.detect_threads > 0:
|
||||
# OpenCV sizes its pool for one big job on an idle machine. This is a
|
||||
# small job repeated forever on a machine also running the recogniser,
|
||||
# the tracker and possibly three other cameras, so the default costs
|
||||
# twice the CPU for no useful latency. Measured: 31 ms CPU/frame at the
|
||||
# default against 15 ms at one thread, for 6 ms more wall time against
|
||||
# a 66 ms budget.
|
||||
import cv2
|
||||
cv2.setNumThreads(cfg.app.detect_threads)
|
||||
log.info("detection threads: %d (OpenCV default was %d)",
|
||||
cfg.app.detect_threads, cv2.getNumThreads())
|
||||
missing = setup_models(cfg.app.models_dir)
|
||||
if missing:
|
||||
log.error("required models missing: %s", ", ".join(missing))
|
||||
|
||||
@@ -6,6 +6,7 @@ models finish loading without a single unguarded None dereference.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
@@ -154,11 +155,25 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
def dashboard() -> str:
|
||||
return (_STATIC / "dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
@app.get("/static/favicon.png")
|
||||
def favicon() -> Response:
|
||||
# The one static asset besides the page itself. Served explicitly
|
||||
# rather than mounting the directory: nothing else in there is meant
|
||||
# to be reachable, and a mount would make that a matter of what lands
|
||||
# in the folder.
|
||||
return Response((_STATIC / "favicon.png").read_bytes(), media_type="image/png",
|
||||
headers={"cache-control": "public, max-age=86400"})
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
from .paths import describe
|
||||
# A gallery the running encoder cannot read is the failure most
|
||||
# worth catching from outside: the process is healthy, the cameras
|
||||
# are up, and the shop recognises nobody it already knows.
|
||||
stranded = engine.gallery.health["stranded"]
|
||||
return {"status": "ok" if engine.started_at else "starting",
|
||||
"recognition_model": engine.encoder.model_name,
|
||||
"gallery_unreadable_embeddings": stranded,
|
||||
# "where is my database" must be answerable from the API: the
|
||||
# tray, the installer and support all need it, and installed
|
||||
# it is not next to the code.
|
||||
@@ -322,6 +337,17 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
worker.commission.cancel()
|
||||
return {"cancelled": camera_id}
|
||||
|
||||
@app.get("/api/cameras/discover")
|
||||
def discover_cameras() -> dict:
|
||||
"""Cameras on this PC's network, for the add-camera form to pick from.
|
||||
|
||||
A sync def so FastAPI runs it in the threadpool: it holds a socket
|
||||
open for a couple of seconds and sweeps a /24, and the event loop
|
||||
must keep serving the live picture meanwhile.
|
||||
"""
|
||||
from .discover import discover
|
||||
return discover()
|
||||
|
||||
@app.post("/api/cameras/test")
|
||||
def test_camera(payload: CameraPayload) -> dict:
|
||||
"""Try a camera WITHOUT saving it - the UI's Test button.
|
||||
@@ -370,11 +396,23 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
# Stop when the camera is deleted or its worker dies - otherwise a
|
||||
# removed camera leaves this generator running for the life of the
|
||||
# process, holding a reference to a worker nothing else can see.
|
||||
# Driven by the camera, not a timer: a frame goes out when the
|
||||
# capture thread has one newer than the last one sent, so nothing
|
||||
# is sent twice and nothing waits on the recognition pipeline.
|
||||
# Capped at 15 fps - the office cameras' own rate - so a viewer
|
||||
# never costs more encodes than the camera produces pictures.
|
||||
last_ts, min_gap, sent_at = 0.0, 1.0 / 15, 0.0
|
||||
while engine.workers.get(camera_id) is worker and worker.is_alive():
|
||||
jpeg = worker.latest_jpeg()
|
||||
if jpeg is not None:
|
||||
yield boundary + jpeg + b"\r\n"
|
||||
await asyncio.sleep(0.1) # ~10 fps to the browser
|
||||
now = time.time()
|
||||
if now - sent_at < min_gap:
|
||||
await asyncio.sleep(min_gap - (now - sent_at))
|
||||
continue
|
||||
jpeg, ts = worker.latest_jpeg_since(last_ts)
|
||||
if jpeg is None:
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
last_ts, sent_at = ts, time.time()
|
||||
yield boundary + jpeg + b"\r\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
|
||||
@@ -17,13 +17,57 @@ import numpy as np
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Force TCP transport and a 5s socket timeout for RTSP before OpenCV loads
|
||||
# ffmpeg. UDP is the default and silently drops frames on lossy Wi-Fi.
|
||||
# Set before OpenCV loads ffmpeg, which reads this once.
|
||||
#
|
||||
# rtsp_transport=tcp: UDP is the default and silently drops frames on lossy
|
||||
# Wi-Fi.
|
||||
#
|
||||
# The timeout here is NOT what bounds a dead camera, and the comment that
|
||||
# once said it did was wrong. Measured against OpenCV 4.11 / FFmpeg 7.1 on a
|
||||
# socket that accepts the connection and then says nothing:
|
||||
#
|
||||
# stimeout;5000000 -> 30.0s timeout;5000000 -> 30.0s
|
||||
# stimeout;2000000 -> 30.5s timeout;2000000 -> 30.4s
|
||||
# no timeout option at all -> 30.3s
|
||||
#
|
||||
# Identical with the option absent, so it is not being honoured under either
|
||||
# name through this path. `stimeout` was renamed `timeout` in FFmpeg 5.0, and
|
||||
# neither reaches the RTSP protocol here. What actually bounds it is
|
||||
# OpenCV's own interrupt callback (30s for open, 30s for read), which is a
|
||||
# compile-time constant we do not control.
|
||||
#
|
||||
# Both names are still set, because on a build where they DO take effect the
|
||||
# shorter bound is what we want and an unrecognised option is ignored. But
|
||||
# nothing may depend on it: a wrong address is caught by _tcp_reachable
|
||||
# below, in code we own, in under a second.
|
||||
#
|
||||
# fflags=nobuffer and flags=low_delay: without them ffmpeg's RTSP demuxer
|
||||
# holds a comfortable queue of frames before handing over the first, which
|
||||
# on a live feed is half a second to two seconds of latency that no amount of
|
||||
# work downstream can recover - the frame is already old when we get it. A
|
||||
# recorder wants that buffer; a live view does not. max_delay caps the
|
||||
# reorder wait for the same reason.
|
||||
os.environ.setdefault(
|
||||
"OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp|stimeout;5000000"
|
||||
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
|
||||
"rtsp_transport;tcp|stimeout;5000000|timeout;5000000"
|
||||
"|fflags;nobuffer|flags;low_delay|max_delay;200000",
|
||||
)
|
||||
|
||||
|
||||
# A stream can stay open and stop delivering. OpenCV breaks a blocked read
|
||||
# after 30s and we reconnect, but for those 30s `connected` is True and the
|
||||
# camera is dead — and a stream that trickles a frame every 20s never trips
|
||||
# that timeout at all, so it never reconnects and never recovers either.
|
||||
#
|
||||
# 10s is not a preference. The tracker gives up on a face after `max_misses`
|
||||
# (25 frames, ~1.7s at 15 fps), so by 10s every track is long gone and 150
|
||||
# frames are missing: whatever this is, it is not something recognition can
|
||||
# work with. Reported separately from `connected` because the two need
|
||||
# opposite actions — one says check the network, the other says the camera
|
||||
# is answering but sending nothing.
|
||||
STALL_AFTER_S = 10.0
|
||||
|
||||
|
||||
def _tcp_reachable(source: "str | int", timeout: float
|
||||
) -> "tuple[bool, str]":
|
||||
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
|
||||
@@ -159,6 +203,10 @@ class VideoSource(threading.Thread):
|
||||
self.frames_total = 0
|
||||
self.reconnects = 0
|
||||
self._ever_connected = False
|
||||
# Why the last open failed, in the words an installer can act on.
|
||||
# Without it a camera that never connects reports only `connected:
|
||||
# false`, which cannot distinguish a wrong IP from a wrong password.
|
||||
self.last_error = ""
|
||||
|
||||
# -- public ---------------------------------------------------------
|
||||
def latest(self) -> "tuple[Optional[np.ndarray], float]":
|
||||
@@ -189,11 +237,23 @@ class VideoSource(threading.Thread):
|
||||
def stop(self) -> None:
|
||||
self._stopping.set()
|
||||
|
||||
def stalled(self) -> bool:
|
||||
"""Open, but not delivering. See STALL_AFTER_S."""
|
||||
if not self.connected or not self._frame_ts:
|
||||
return False
|
||||
return (time.time() - self._frame_ts) > STALL_AFTER_S
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"camera_id": self.camera_id,
|
||||
"url": self._display_url,
|
||||
"connected": self.connected,
|
||||
# Connected AND delivering. `connected` alone stays true through
|
||||
# a stall, so it is the wrong thing for a dashboard to colour a
|
||||
# camera green on.
|
||||
"streaming": self.connected and not self.stalled(),
|
||||
"stalled": self.stalled(),
|
||||
"last_error": self.last_error,
|
||||
"frames_total": self.frames_total,
|
||||
"reconnects": self.reconnects,
|
||||
"last_frame_age_s": round(time.time() - self._frame_ts, 1)
|
||||
@@ -250,6 +310,19 @@ class VideoSource(threading.Thread):
|
||||
log.info("[%s] capture stopped", self.camera_id)
|
||||
|
||||
def _open(self) -> Optional[cv2.VideoCapture]:
|
||||
# Pre-flight the socket, exactly as probe_source does. Without it a
|
||||
# camera that is off, moved or mistyped costs 30s per attempt inside
|
||||
# the VideoCapture constructor (measured; it is OpenCV's interrupt
|
||||
# timeout, not ours to shorten) — and the constructor is not
|
||||
# interruptible, so stop() cannot cut it short and a removed camera
|
||||
# leaves a daemon thread holding a socket for half a minute. A
|
||||
# refused or unroutable address answers in well under a second, which
|
||||
# is also what lets the backoff below mean what it says.
|
||||
reachable, why = _tcp_reachable(self._source, 2.0)
|
||||
if not reachable:
|
||||
log.debug("[%s] %s", self.camera_id, why)
|
||||
self.last_error = why
|
||||
return None
|
||||
try:
|
||||
if isinstance(self._source, int):
|
||||
cap = cv2.VideoCapture(self._source)
|
||||
@@ -258,8 +331,12 @@ class VideoSource(threading.Thread):
|
||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
self.last_error = ("reachable, but the stream would not open "
|
||||
"- check the path and credentials")
|
||||
return None
|
||||
self.last_error = ""
|
||||
return cap
|
||||
except cv2.error:
|
||||
log.exception("[%s] VideoCapture error", self.camera_id)
|
||||
self.last_error = "VideoCapture error - see the engine log"
|
||||
return None
|
||||
|
||||
@@ -132,6 +132,29 @@ class AppSection(BaseModel):
|
||||
# on changes what the system is under GDPR and India's DPDP, so it has to
|
||||
# be a decision somebody makes rather than one they inherit.
|
||||
store_faces: bool = False
|
||||
# How many threads OpenCV may use for detection. Measured on the office
|
||||
# camera (800x448 sub-stream): the default of 8 costs 31 ms of CPU per
|
||||
# frame for 8.9 ms of wall time, while ONE thread costs 15.3 ms of CPU for
|
||||
# 15.3 ms of wall - half the CPU for 6 ms more latency, against a 66 ms
|
||||
# frame budget at 15 fps. The default is wrong here because OpenCV sizes it
|
||||
# for one big job on an idle machine, and this is a small job repeated
|
||||
# forever on a machine also running the recogniser, the tracker and three
|
||||
# other cameras. 0 leaves OpenCV's own default alone.
|
||||
detect_threads: int = 1
|
||||
# Skip detection on frames where nothing has changed and nothing is being
|
||||
# tracked. A shop is empty most of the day and a frame of an empty room
|
||||
# costs exactly as much to search as a busy one. See CameraWorker.run for
|
||||
# why this cannot lose a face.
|
||||
motion_gate: bool = True
|
||||
# Mean absolute difference, 0-255, over a 160x90 greyscale thumbnail. 1.0
|
||||
# is well below the noise floor of a real camera - measured on this one,
|
||||
# an empty room varies by ~0.3 between frames - so it triggers on movement
|
||||
# rather than on sensor noise, and anything ambiguous detects.
|
||||
motion_threshold: float = 1.0
|
||||
# Detect at least this often regardless of the gate, so a change the
|
||||
# thumbnail cannot see - someone entering at the far edge, a slow lean into
|
||||
# frame - is still found within a second.
|
||||
motion_max_skip: int = 12
|
||||
|
||||
|
||||
class ApiSection(BaseModel):
|
||||
|
||||
206
behavision/discover.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Find the cameras on the shop's network, so nobody has to type an address.
|
||||
|
||||
The add-camera form asked for an IP address, and a shop owner does not know
|
||||
their camera's IP address. It is on a sticker under the camera, if at all, or
|
||||
inside the camera's own app under a menu called something different for every
|
||||
make. That one field is where onboarding stopped for anyone who was not an
|
||||
installer.
|
||||
|
||||
Two probes, merged:
|
||||
|
||||
- **WS-Discovery** (ONVIF's discovery protocol): one multicast to
|
||||
239.255.255.250:3702 and every ONVIF camera on the LAN answers with its
|
||||
address and, usually, its make and model. Cheap, fast, and names the device
|
||||
- but only cameras that speak ONVIF answer, and some cheap ones do not.
|
||||
- **A TCP sweep of port 554** across the local /24: anything listening on the
|
||||
RTSP port is very probably a camera or a recorder. Names nothing, misses
|
||||
nothing that streams.
|
||||
|
||||
A host found by either is a candidate; one found by both is a camera with a
|
||||
name. The result is a list to pick from, not a decision: the person still
|
||||
supplies the password, and Test still proves the stream opens.
|
||||
|
||||
Stdlib only. This runs inside the engine, which ships as a small wheel, and a
|
||||
network-scanning dependency would be a large thing to add for two sockets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Iterable
|
||||
|
||||
# ONVIF WS-Discovery probe. The MessageID must be unique per probe; devices
|
||||
# ignore a repeat.
|
||||
_PROBE = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" '
|
||||
'xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" '
|
||||
'xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" '
|
||||
'xmlns:dn="http://www.onvif.org/ver10/network/wsdl">'
|
||||
'<e:Header><w:MessageID>uuid:{mid}</w:MessageID>'
|
||||
'<w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To>'
|
||||
'<w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action>'
|
||||
'</e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body>'
|
||||
'</e:Envelope>'
|
||||
)
|
||||
_MCAST = ("239.255.255.250", 3702)
|
||||
|
||||
# Makes we can name from an ONVIF scope or hostname, mapped to the ids the
|
||||
# camera-make picker uses so the form can preselect the stream path.
|
||||
_MAKES = (
|
||||
("hikvision", "hikvision"), ("hik", "hikvision"), ("dahua", "dahua"),
|
||||
("cp plus", "cpplus"), ("cpplus", "cpplus"), ("cp-plus", "cpplus"),
|
||||
("uniview", "uniview"), ("unv", "uniview"), ("tapo", "tplink"),
|
||||
("tp-link", "tplink"), ("reolink", "reolink"), ("amcrest", "amcrest"),
|
||||
("axis", "axis"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Found:
|
||||
host: str
|
||||
rtsp: bool = False # port 554 answered
|
||||
onvif: bool = False # answered WS-Discovery
|
||||
name: str = "" # from ONVIF scopes, e.g. "Hikvision DS-2CD2043"
|
||||
make: str = "" # picker id, when it can be guessed
|
||||
onvif_url: str = ""
|
||||
sources: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def local_networks() -> list[ipaddress.IPv4Network]:
|
||||
"""The /24s this machine sits on, best effort and without dependencies.
|
||||
|
||||
Interface masks are not portable in the stdlib, so this assumes /24 - the
|
||||
shape of nearly every shop's router - for each local IPv4 address it can
|
||||
find. A bigger network would need a scan anyway that this should not run
|
||||
unasked.
|
||||
"""
|
||||
addrs: set[str] = set()
|
||||
try:
|
||||
# The address the OS would use to reach the internet: the LAN we care about.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(0.5)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
addrs.add(s.getsockname()[0])
|
||||
s.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
for a in socket.gethostbyname_ex(socket.gethostname())[2]:
|
||||
addrs.add(a)
|
||||
except OSError:
|
||||
pass
|
||||
nets = []
|
||||
for a in addrs:
|
||||
try:
|
||||
ip = ipaddress.IPv4Address(a)
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.is_loopback or ip.is_link_local:
|
||||
continue
|
||||
nets.append(ipaddress.IPv4Network(f"{a}/24", strict=False))
|
||||
return sorted(set(nets), key=str)
|
||||
|
||||
|
||||
def ws_discover(timeout: float = 2.5) -> list[Found]:
|
||||
"""One ONVIF probe, every answer within `timeout` seconds."""
|
||||
out: dict[str, Found] = {}
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
sock.settimeout(timeout)
|
||||
sock.sendto(_PROBE.format(mid=uuid.uuid4()).encode(), _MCAST)
|
||||
except OSError:
|
||||
return []
|
||||
import time
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
data, (host, _) = sock.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
f = parse_probe_match(data.decode("utf-8", "replace"), host)
|
||||
if f:
|
||||
out[f.host] = f
|
||||
sock.close()
|
||||
return list(out.values())
|
||||
|
||||
|
||||
_XADDR = re.compile(r"<[^>]*XAddrs[^>]*>([^<]+)<")
|
||||
_SCOPES = re.compile(r"<[^>]*Scopes[^>]*>([^<]+)<")
|
||||
|
||||
|
||||
def parse_probe_match(xml: str, host: str) -> Found | None:
|
||||
"""Pull the address and the human-readable scopes out of a ProbeMatch.
|
||||
|
||||
A regex rather than an XML parser on purpose: cameras emit every namespace
|
||||
prefix imaginable and some emit XML that is not quite well-formed, and the
|
||||
two fields wanted are flat text.
|
||||
"""
|
||||
xaddrs = _XADDR.search(xml)
|
||||
scopes = _SCOPES.search(xml)
|
||||
if not xaddrs and not scopes:
|
||||
return None
|
||||
url = xaddrs.group(1).split()[0] if xaddrs else ""
|
||||
# Prefer the host from the XAddrs URL: a device with several interfaces
|
||||
# answers from the one it heard us on, which is the one we can reach.
|
||||
m = re.match(r"https?://([^/:]+)", url)
|
||||
ip = m.group(1) if m else host
|
||||
f = Found(host=ip, onvif=True, onvif_url=url, sources=["onvif"])
|
||||
if scopes:
|
||||
words = []
|
||||
for s in scopes.group(1).split():
|
||||
if "/name/" in s or "/hardware/" in s:
|
||||
from urllib.parse import unquote
|
||||
words.append(unquote(s.rsplit("/", 1)[-1]))
|
||||
f.name = " ".join(dict.fromkeys(words)) # dedupe, keep order
|
||||
f.make = guess_make(f.name)
|
||||
return f
|
||||
|
||||
|
||||
def guess_make(text: str) -> str:
|
||||
low = text.lower()
|
||||
for needle, make in _MAKES:
|
||||
if needle in low:
|
||||
return make
|
||||
return ""
|
||||
|
||||
|
||||
def rtsp_sweep(nets: Iterable[ipaddress.IPv4Network], timeout: float = 0.5,
|
||||
workers: int = 128) -> list[str]:
|
||||
"""Every host in `nets` with port 554 open. ~254 hosts in about a second."""
|
||||
def probe(ip: str) -> str | None:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
return ip if s.connect_ex((ip, 554)) == 0 else None
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
s.close()
|
||||
hosts = [str(h) for n in nets for h in n.hosts()]
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
return [ip for ip in ex.map(probe, hosts) if ip]
|
||||
|
||||
|
||||
def discover(timeout: float = 2.5) -> dict:
|
||||
"""Both probes, merged, as the API returns it."""
|
||||
nets = local_networks()
|
||||
found: dict[str, Found] = {f.host: f for f in ws_discover(timeout)}
|
||||
for ip in rtsp_sweep(nets):
|
||||
f = found.setdefault(ip, Found(host=ip))
|
||||
f.rtsp = True
|
||||
f.sources.append("rtsp")
|
||||
cams = sorted(found.values(), key=lambda f: (not (f.rtsp and f.onvif), not f.rtsp,
|
||||
ipaddress.IPv4Address(f.host)))
|
||||
return {
|
||||
"networks": [str(n) for n in nets],
|
||||
"cameras": [asdict(c) for c in cams],
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import numpy as np
|
||||
|
||||
from .attributes import AttributeEstimator, aggregate as aggregate_attrs
|
||||
from .cameras import CameraStore
|
||||
from . import capture
|
||||
from .capture import VideoSource
|
||||
from .faces import FaceOutbox
|
||||
from .commission import CommissionRun
|
||||
@@ -162,10 +163,20 @@ class CameraWorker(threading.Thread):
|
||||
# every test using a stubbed worker passed.
|
||||
self._stopping = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._annotated_jpeg: Optional[bytes] = None
|
||||
# What the live view draws over the freshest frame: the boxes from
|
||||
# the most recent processed frame, and when they were computed. NOT a
|
||||
# pre-rendered JPEG - see latest_jpeg for why.
|
||||
self._overlay: "list[tuple[tuple[int, int, int, int], tuple[int, int, int], str]]" = []
|
||||
self._overlay_ts = 0.0
|
||||
self._last_frame_ts = 0.0
|
||||
self._was_connected = False
|
||||
self._was_stalled = False
|
||||
self.frames_processed = 0
|
||||
# Motion gate state: a 160x90 greyscale thumbnail of the last frame we
|
||||
# actually searched, and how many frames we have skipped since.
|
||||
self._motion_prev = None
|
||||
self._motion_skipped = 0
|
||||
self.frames_skipped = 0
|
||||
self.faces_seen = 0
|
||||
self.pipeline = PipelineStats()
|
||||
# One outbox per worker, all writing into the same directory. Files are
|
||||
@@ -187,13 +198,52 @@ class CameraWorker(threading.Thread):
|
||||
self.source.stop()
|
||||
|
||||
def latest_jpeg(self) -> Optional[bytes]:
|
||||
jpeg, _ = self.latest_jpeg_since(0.0)
|
||||
return jpeg
|
||||
|
||||
def latest_jpeg_since(self, known_ts: float) -> "tuple[Optional[bytes], float]":
|
||||
"""The freshest captured frame with the latest boxes drawn on it, or
|
||||
(None, known_ts) if the camera has produced nothing newer.
|
||||
|
||||
The live picture is deliberately NOT the frame the pipeline last
|
||||
finished with. That version advanced only when detection, tracking and
|
||||
identification had all completed on a frame - a few times a second on a
|
||||
modest shop PC - and every picture it showed was already as old as that
|
||||
processing. It looked like lag because it was lag. Here the picture runs
|
||||
at the camera's rate off the capture thread's latest frame, and the
|
||||
boxes - which genuinely can only update at pipeline rate - are drawn
|
||||
over it from the last processed frame. Boxes may trail a fast walker by
|
||||
one pipeline period; the picture never does.
|
||||
|
||||
Encoded on demand, per request, so a camera nobody is watching pays for
|
||||
no JPEG at all. The old path encoded every processed frame whether or
|
||||
not a viewer existed - CPU spent on precisely the machine short of it.
|
||||
"""
|
||||
frame, ts = self.source.latest_since(known_ts)
|
||||
if frame is None:
|
||||
return None, known_ts
|
||||
with self._lock:
|
||||
return self._annotated_jpeg
|
||||
overlay, overlay_ts = list(self._overlay), self._overlay_ts
|
||||
# A stalled pipeline must not leave a box floating over an empty spot.
|
||||
# Older than a second and the person has walked out from under it.
|
||||
draw = overlay if (time.time() - overlay_ts) < 1.0 else []
|
||||
if draw:
|
||||
frame = frame.copy()
|
||||
for (x1, y1, x2, y2), color, text in draw:
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
||||
if text:
|
||||
cv2.putText(frame, text, (x1, max(20, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
||||
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if not ok:
|
||||
return None, known_ts
|
||||
return buf.tobytes(), ts
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
**self.source.stats(),
|
||||
"frames_processed": self.frames_processed,
|
||||
"frames_skipped": self.frames_skipped,
|
||||
"faces_seen": self.faces_seen,
|
||||
"active_tracks": len(self.tracker.tracks),
|
||||
"pipeline": self.pipeline.snapshot(self.rcfg.min_enroll_quality),
|
||||
@@ -202,6 +252,43 @@ class CameraWorker(threading.Thread):
|
||||
"enroll_threshold": self.rcfg.enroll_threshold},
|
||||
}
|
||||
|
||||
def _nothing_moved(self, frame) -> bool:
|
||||
"""True when this frame is close enough to the last searched one that
|
||||
searching it again would find the same nothing.
|
||||
|
||||
It cannot lose a face, and that property is what makes it acceptable
|
||||
rather than merely cheap. Three guards, in order:
|
||||
|
||||
* the caller only asks while NO track is open, so a person already
|
||||
being followed is never affected by it;
|
||||
* `motion_max_skip` forces a real detection about once a second
|
||||
whatever the thumbnail says, which covers a change too small or too
|
||||
gradual for it - someone easing into frame at the far edge;
|
||||
* the threshold sits well above measured sensor noise and well below
|
||||
a person, and anything ambiguous falls through to detection. When
|
||||
in doubt it looks.
|
||||
|
||||
Cost is 0.1 ms against detection's 15 ms, so an empty shop stops paying
|
||||
for a search of an empty room ~90 times a second.
|
||||
"""
|
||||
import cv2 as _cv2
|
||||
small = _cv2.resize(_cv2.cvtColor(frame, _cv2.COLOR_BGR2GRAY), (160, 90),
|
||||
interpolation=_cv2.INTER_AREA)
|
||||
prev, self._motion_prev = self._motion_prev, small
|
||||
if prev is None:
|
||||
return False
|
||||
if self._motion_skipped >= self.cfg.app.motion_max_skip:
|
||||
self._motion_skipped = 0
|
||||
return False
|
||||
if float(_cv2.absdiff(small, prev).mean()) >= self.cfg.app.motion_threshold:
|
||||
self._motion_skipped = 0
|
||||
# Keep the thumbnail we just searched against, not this one, so a
|
||||
# slow drift cannot creep past the threshold one frame at a time.
|
||||
return False
|
||||
self._motion_prev = prev
|
||||
self._motion_skipped += 1
|
||||
return True
|
||||
|
||||
# -- thread ---------------------------------------------------------
|
||||
def run(self) -> None:
|
||||
tcfg = self.cfg.tracking
|
||||
@@ -214,6 +301,15 @@ class CameraWorker(threading.Thread):
|
||||
continue
|
||||
self._last_frame_ts = ts
|
||||
|
||||
# An empty room costs exactly as much to search as a busy one,
|
||||
# and a shop is empty most of the day. Only ever while nothing
|
||||
# is being tracked - see _nothing_moved.
|
||||
if (self.cfg.app.motion_gate and not self.tracker.tracks
|
||||
and self._nothing_moved(frame)):
|
||||
self.frames_skipped += 1
|
||||
self._remember_tracks([])
|
||||
continue
|
||||
|
||||
detections = self.detector.detect(frame)
|
||||
for det in detections:
|
||||
det.quality = face_quality(frame, det.box, det.kps)
|
||||
@@ -236,7 +332,7 @@ class CameraWorker(threading.Thread):
|
||||
for track in ended:
|
||||
self._finish_track(track, ts)
|
||||
|
||||
self._publish_annotated(frame, active)
|
||||
self._remember_tracks(active)
|
||||
self.frames_processed += 1
|
||||
except Exception:
|
||||
log.exception("[%s] frame processing failed", self.cam_cfg.id)
|
||||
@@ -252,6 +348,20 @@ class CameraWorker(threading.Thread):
|
||||
type="camera.up" if connected else "camera.down",
|
||||
camera_id=self.cam_cfg.id))
|
||||
|
||||
# A stall is not a disconnect and must not be reported as one: the
|
||||
# socket is fine, the camera is answering, and nothing is arriving.
|
||||
# Logged on the transition only — a per-frame warning would bury the
|
||||
# one line that matters under thousands of copies of itself.
|
||||
stalled = self.source.stalled()
|
||||
if stalled != self._was_stalled:
|
||||
self._was_stalled = stalled
|
||||
if stalled:
|
||||
log.warning("[%s] connected but no frame for over %.0fs - the "
|
||||
"camera is answering and sending nothing",
|
||||
self.cam_cfg.id, capture.STALL_AFTER_S)
|
||||
else:
|
||||
log.info("[%s] frames resumed", self.cam_cfg.id)
|
||||
|
||||
def _finish_track(self, track: Track, ts: float) -> None:
|
||||
"""Record what became of a track, once, as it ends.
|
||||
|
||||
@@ -426,12 +536,14 @@ class CameraWorker(threading.Thread):
|
||||
track.quality, rcfg=self.rcfg):
|
||||
track.reinforcements += 1
|
||||
|
||||
def _publish_annotated(self, frame: np.ndarray, tracks: "list[Track]") -> None:
|
||||
canvas = frame.copy()
|
||||
def _remember_tracks(self, tracks: "list[Track]") -> None:
|
||||
"""Record what to draw. Cheap: a handful of tuples under the lock,
|
||||
no frame copy and no encode. The encode happens in latest_jpeg_since,
|
||||
only when somebody is looking."""
|
||||
overlay = []
|
||||
for t in tracks:
|
||||
if t.misses > 0:
|
||||
continue # only draw tracks matched in this frame
|
||||
x1, y1, x2, y2 = t.box
|
||||
if t.state == "resolved":
|
||||
color = _COLORS["known"] if t.label and not str(t.label).startswith(
|
||||
"Visitor") else _COLORS["new"]
|
||||
@@ -440,15 +552,10 @@ class CameraWorker(threading.Thread):
|
||||
color, text = _COLORS["ambiguous"], "?"
|
||||
else:
|
||||
color, text = _COLORS["pending"], ""
|
||||
cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 2)
|
||||
if text:
|
||||
cv2.putText(canvas, text, (x1, max(20, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
||||
ok, buf = cv2.imencode(".jpg", canvas,
|
||||
[int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if ok:
|
||||
with self._lock:
|
||||
self._annotated_jpeg = buf.tobytes()
|
||||
overlay.append((tuple(t.box), color, text))
|
||||
with self._lock:
|
||||
self._overlay = overlay
|
||||
self._overlay_ts = time.time()
|
||||
|
||||
|
||||
class Engine:
|
||||
@@ -590,7 +697,10 @@ class Engine:
|
||||
"age_model": ("genderage" if self.attributes is not None
|
||||
and self.attributes.has_genderage else "caffe/none"),
|
||||
},
|
||||
"gallery": self.store.stats(),
|
||||
# Counts, plus whether the running encoder can actually SEARCH
|
||||
# them. A gallery of 21 identities that the loaded model cannot
|
||||
# read is the silent version of an empty one.
|
||||
"gallery": {**self.store.stats(), **self.gallery.health},
|
||||
"cameras": [w.stats() for w in self.snapshot_workers()],
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,56 @@ class Gallery:
|
||||
# vectors from a different model are numerically incompatible.
|
||||
ids, vecs = store.all_embeddings(index.dim, model=model_name)
|
||||
index.add(ids, vecs)
|
||||
self.health = self._assess(len(ids))
|
||||
if self.health["stranded"]:
|
||||
# Not an INFO line. The encoder fallback chain exists so a
|
||||
# memory-starved box still runs, and when it fires every vector
|
||||
# written by the previous encoder becomes invisible: the shop
|
||||
# keeps its customer list and recognises nobody on it, greeting
|
||||
# every regular as new and enrolling them a second time. Footfall
|
||||
# stays right, which is exactly why nothing looks wrong. The old
|
||||
# message for that state was "gallery ready: 0 embeddings".
|
||||
log.warning(
|
||||
"gallery: %d of %d stored embeddings were written by a "
|
||||
"DIFFERENT encoder (%s) and cannot be searched - %d known "
|
||||
"%s unrecognisable under the running model '%s'. Either "
|
||||
"restore that model or accept that these identities start "
|
||||
"over.",
|
||||
self.health["stranded"], self.health["stored"],
|
||||
", ".join(sorted(self.health["other_models"])),
|
||||
self.health["identities_stranded"],
|
||||
"person is" if self.health["identities_stranded"] == 1
|
||||
else "people are",
|
||||
model_name)
|
||||
log.info("gallery ready: %d embeddings (model '%s') across %d "
|
||||
"identities", len(ids), model_name,
|
||||
store.stats()["identities"])
|
||||
|
||||
def _assess(self, usable: int) -> dict:
|
||||
"""What share of the gallery the running encoder can actually reach.
|
||||
|
||||
Reported rather than merely logged, because a log line on a shop PC
|
||||
is read by nobody: this travels to head office the same way
|
||||
`fraction_below_gate` does, beside the number it qualifies.
|
||||
"""
|
||||
counts = self.store.model_counts()
|
||||
stored = sum(counts.values())
|
||||
others = {m: n for m, n in counts.items() if m != self.model_name}
|
||||
identities = self.store.stats()["identities"]
|
||||
return {
|
||||
"model": self.model_name,
|
||||
"stored": stored,
|
||||
"usable": usable,
|
||||
"stranded": sum(others.values()),
|
||||
"other_models": sorted(others),
|
||||
"identities": identities,
|
||||
"identities_usable": self.store.identities_with_model(
|
||||
self.model_name),
|
||||
"identities_stranded": max(
|
||||
0, identities - self.store.identities_with_model(
|
||||
self.model_name)),
|
||||
}
|
||||
|
||||
def resolve(self, embedding: np.ndarray, quality: float, camera_id: str,
|
||||
ts: "float | None" = None,
|
||||
attributes: "dict | None" = None,
|
||||
|
||||
@@ -281,6 +281,31 @@ class IdentityStore:
|
||||
return None
|
||||
return np.frombuffer(row["vector"], dtype=np.float32), float(row["quality"])
|
||||
|
||||
def model_counts(self) -> "dict[str, int]":
|
||||
"""How many stored embeddings each encoder produced.
|
||||
|
||||
The gallery only ever searches vectors tagged with the *running*
|
||||
encoder, so this is what says whether the rest of the gallery is
|
||||
reachable at all. See `Gallery.health` for why that matters.
|
||||
"""
|
||||
with self._lock:
|
||||
rows = self._db.execute(
|
||||
"SELECT model, COUNT(*) AS n FROM embeddings "
|
||||
"GROUP BY model").fetchall()
|
||||
return {str(r["model"]): int(r["n"]) for r in rows}
|
||||
|
||||
def identities_with_model(self, model: str) -> int:
|
||||
"""Identities holding at least one embedding from this encoder.
|
||||
|
||||
Not the same as the identity count: an identity whose only vectors
|
||||
came from a previous encoder still exists, and is unrecognisable.
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._db.execute(
|
||||
"SELECT COUNT(DISTINCT identity_id) AS n FROM embeddings "
|
||||
"WHERE model=?", (model,)).fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def embedding_owners(self, model: "str | None" = None) -> "dict[int, int]":
|
||||
"""embedding_id -> identity_id, for turning index hits into identity
|
||||
pairs without a round trip to SQLite per hit."""
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import ssl
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,6 +36,94 @@ _COPY_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _https_context() -> "ssl.SSLContext | None":
|
||||
"""The CA store to trust, or None to use whatever Python defaults to.
|
||||
|
||||
Returning None first is deliberate. On Windows and on a Homebrew or
|
||||
system Python, the default context reads the machine's own certificate
|
||||
store - which is what makes a corporate proxy with its own root CA work.
|
||||
Replacing that with certifi's bundle unconditionally would break every
|
||||
site that has one, in order to fix a different platform.
|
||||
|
||||
The platform this fixes is a python.org macOS build. It ships its own
|
||||
OpenSSL with NO trust store, and populates one only when somebody
|
||||
double-clicks `Install Certificates.command` in the Python folder -
|
||||
which nobody installing face-recognition software has any reason to know
|
||||
about. Every HTTPS request from that interpreter fails with:
|
||||
|
||||
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
|
||||
certificate verify failed: unable to get local issuer certificate
|
||||
|
||||
Measured on a colleague's Mac: the engine installed perfectly and then
|
||||
could not download a 230 KB model file, ending setup in forty lines of
|
||||
traceback about `_ssl.c`.
|
||||
"""
|
||||
try:
|
||||
import certifi
|
||||
except ImportError: # pragma: no cover - certifi ships with requests
|
||||
return None
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
|
||||
def _urlopen(url: str, timeout: float = 60.0):
|
||||
"""Open a URL, falling back to certifi's CA bundle on a verify failure.
|
||||
|
||||
Default first, certifi second, so the fix is additive: a machine whose
|
||||
own store works keeps using it, and one with no store at all gets a
|
||||
bundle rather than a traceback. certifi is already here - `requests` is a
|
||||
hard dependency and brings it.
|
||||
"""
|
||||
try:
|
||||
return urllib.request.urlopen(url, timeout=timeout)
|
||||
except ssl.SSLCertVerificationError:
|
||||
ctx = _https_context()
|
||||
if ctx is None:
|
||||
raise
|
||||
log.info("the system certificate store could not verify %s; "
|
||||
"using the bundled CA list", url.split("/")[2])
|
||||
return urllib.request.urlopen(url, timeout=timeout, context=ctx)
|
||||
|
||||
|
||||
def _fetch(url: str, dest: Path, label: str) -> None:
|
||||
"""Download with progress on stdout the supervisor can read.
|
||||
|
||||
On first run this is minutes of nothing: the API is not up yet, so the
|
||||
app cannot ask the engine what it is doing, and a shop PC that shows a
|
||||
stopped engine for five minutes after install looks broken. The
|
||||
supervisor watches for `download: <label> <n>%` and puts the number in
|
||||
the tray and the window. Logged every 5 points, not every chunk, so the
|
||||
log file does not fill with a progress bar.
|
||||
"""
|
||||
last = -5
|
||||
|
||||
def hook(blocks: int, block_size: int, total: int) -> None:
|
||||
nonlocal last
|
||||
if total <= 0:
|
||||
return
|
||||
pct = min(100, blocks * block_size * 100 // total)
|
||||
if pct >= last + 5:
|
||||
last = pct
|
||||
log.info("download: %s %d%%", label, pct)
|
||||
|
||||
# Streamed rather than urlretrieve, only because urlretrieve offers no way
|
||||
# to pass an SSL context and the whole point here is choosing one. The
|
||||
# `download: <label> <n>%` lines are a contract: the supervisor parses
|
||||
# them (`progressRe`) to put first-run progress in the tray, and without
|
||||
# them a shop PC shows a stopped engine for five minutes after install.
|
||||
with _urlopen(url) as resp:
|
||||
total = int(resp.headers.get("Content-Length") or 0)
|
||||
blocks, block_size = 0, 64 * 1024
|
||||
with open(dest, "wb") as out:
|
||||
while True:
|
||||
chunk = resp.read(block_size)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
blocks += 1
|
||||
hook(blocks, block_size, total)
|
||||
log.info("download: %s 100%%", label)
|
||||
|
||||
|
||||
def setup_models(models_dir: Path) -> "list[str]":
|
||||
"""Ensure all model files exist in models_dir. Returns missing ones."""
|
||||
models_dir = Path(models_dir)
|
||||
@@ -44,7 +133,7 @@ def setup_models(models_dir: Path) -> "list[str]":
|
||||
if not yunet.exists():
|
||||
log.info("downloading YuNet face detector (~230 KB)...")
|
||||
tmp = yunet.with_suffix(".part")
|
||||
urllib.request.urlretrieve(YUNET_URL, tmp)
|
||||
_fetch(YUNET_URL, tmp, "face detector")
|
||||
tmp.rename(yunet)
|
||||
log.info("YuNet saved to %s", yunet)
|
||||
|
||||
@@ -66,7 +155,7 @@ def setup_models(models_dir: Path) -> "list[str]":
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
with urllib.request.urlopen(BUFFALO_SC_URL) as resp:
|
||||
with _urlopen(BUFFALO_SC_URL) as resp:
|
||||
payload = io.BytesIO(resp.read())
|
||||
with zipfile.ZipFile(payload) as zf, \
|
||||
zf.open("w600k_mbf.onnx") as src, \
|
||||
@@ -88,7 +177,7 @@ def setup_models(models_dir: Path) -> "list[str]":
|
||||
import zipfile
|
||||
|
||||
tmp = models_dir / "buffalo_l.zip.part"
|
||||
urllib.request.urlretrieve(BUFFALO_L_URL, tmp)
|
||||
_fetch(BUFFALO_L_URL, tmp, "recognition models")
|
||||
with zipfile.ZipFile(tmp) as zf:
|
||||
for name, target in wanted.items():
|
||||
member = next((n for n in zf.namelist()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Behavision</title>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
@@ -513,11 +514,24 @@ async function refresh() {
|
||||
]);
|
||||
renderFeeds(camList);
|
||||
renderCameras(camList);
|
||||
// 'stalled' is its own word on purpose: connected and offline send you
|
||||
// to the network, a camera that is answering and sending nothing does
|
||||
// not. Three states, because two of them need opposite actions.
|
||||
const cams = stats.cameras.map(c =>
|
||||
`${c.camera_id}: ${c.connected ? 'live' : 'offline'}`).join(' · ');
|
||||
`${c.camera_id}: ${c.streaming ? 'live' : c.connected ? 'stalled' : 'offline'}`
|
||||
).join(' · ');
|
||||
// The one failure that otherwise looks like perfect health: the encoder
|
||||
// that loaded cannot read the embeddings already stored, so every known
|
||||
// customer is a stranger. Counts stay right, which is why it needs saying.
|
||||
const stranded = stats.gallery.stranded || 0;
|
||||
const warn = stranded
|
||||
? ` · ⚠ ${stats.gallery.identities_stranded} people unrecognisable `
|
||||
+ `(${stranded} embeddings from ${stats.gallery.other_models.join(', ')}, `
|
||||
+ `running ${stats.gallery.model})`
|
||||
: '';
|
||||
// textContent, not innerHTML — no escaping needed here.
|
||||
document.getElementById('status').textContent =
|
||||
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings`;
|
||||
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings${warn}`;
|
||||
|
||||
document.getElementById('events').innerHTML = events.map(e => {
|
||||
const cls = e.type === 'person.new' ? 'new'
|
||||
|
||||
BIN
behavision/static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
brand/loyaly-icon-128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
brand/loyaly-icon-16.png
Normal file
|
After Width: | Height: | Size: 732 B |
BIN
brand/loyaly-icon-256.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
brand/loyaly-icon-32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
brand/loyaly-icon-48.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
brand/loyaly-icon-512.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
brand/loyaly-icon-64.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
brand/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
brand/loyaly.ico
Normal file
|
After Width: | Height: | Size: 67 KiB |
258
demo/console.html
Normal file
@@ -0,0 +1,258 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Behavision — live demo</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0A0E12; --s1:#11171C; --s2:#161D24; --s3:#1D262E;
|
||||
--line:#24303A; --line2:#1B242C;
|
||||
--ink:#E8EEF3; --ink2:#9FB0BD; --ink3:#6B7E8C;
|
||||
--accent:#3DD0C4; --accent-dim:#123039;
|
||||
--ok:#3FBF7F; --warn:#E0A33A; --bad:#E15B4C;
|
||||
--mono:'SF Mono',ui-monospace,Menlo,monospace;
|
||||
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--ink);font-family:var(--font);font-size:14px;line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;padding:18px;min-height:100vh}
|
||||
.top{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
|
||||
.brand img{width:26px;height:26px;object-fit:contain}
|
||||
.brand b{font-size:16px;letter-spacing:-.01em}
|
||||
.brand span{color:var(--ink3);font-size:12px}
|
||||
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:99px;
|
||||
border:1px solid var(--line);background:var(--s1);font-size:12px;color:var(--ink2)}
|
||||
.dot{width:7px;height:7px;border-radius:99px;background:var(--ink3);flex:none}
|
||||
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,191,127,.16)}
|
||||
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px rgba(225,91,76,.16)}
|
||||
.dot.warn{background:var(--warn);box-shadow:0 0 0 3px rgba(224,163,58,.16)}
|
||||
|
||||
.grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start}
|
||||
@media(max-width:1100px){.grid{grid-template-columns:minmax(0,1fr)}}
|
||||
.card{background:var(--s1);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
||||
.card h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink3);
|
||||
padding:12px 16px;border-bottom:1px solid var(--line2);display:flex;align-items:center;gap:10px}
|
||||
.card h2 .grow{margin-left:auto;font-weight:500;letter-spacing:0;text-transform:none;font-size:12px;color:var(--ink3)}
|
||||
.pad{padding:16px}
|
||||
|
||||
.cam{position:relative;aspect-ratio:16/9;background:#05090C}
|
||||
.cam img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.cam .none{position:absolute;inset:0;display:grid;place-items:center;color:var(--ink3);font-size:13px;text-align:center;padding:20px}
|
||||
.cam .tag{position:absolute;top:10px;left:10px;background:rgba(10,14,18,.78);backdrop-filter:blur(8px);
|
||||
border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:11.5px;font-family:var(--mono)}
|
||||
|
||||
/* the chain */
|
||||
.chain{display:flex;flex-direction:column;gap:0}
|
||||
.step{display:grid;grid-template-columns:26px 1fr auto;gap:12px;align-items:start;padding:11px 16px;
|
||||
border-bottom:1px solid var(--line2);opacity:.38;transition:opacity .25s}
|
||||
.step:last-child{border-bottom:0}
|
||||
.step.on{opacity:1}
|
||||
.step .n{width:22px;height:22px;border-radius:99px;display:grid;place-items:center;font-size:11px;font-weight:600;
|
||||
background:var(--s3);color:var(--ink3);border:1px solid var(--line);margin-top:1px}
|
||||
.step.on .n{background:var(--accent);color:#04161B;border-color:transparent}
|
||||
.step b{font-size:13.5px;font-weight:550;display:block}
|
||||
.step small{color:var(--ink2);font-size:12px;display:block;margin-top:1px;font-family:var(--mono)}
|
||||
.step .ms{font-family:var(--mono);font-size:11.5px;color:var(--accent);white-space:nowrap;margin-top:2px}
|
||||
|
||||
.empty{padding:34px 16px;text-align:center;color:var(--ink3);font-size:13px;line-height:1.6}
|
||||
.empty b{display:block;color:var(--ink2);font-size:14px;margin-bottom:5px}
|
||||
|
||||
/* customer */
|
||||
.who{display:flex;gap:13px;align-items:center;padding:16px;border-bottom:1px solid var(--line2)}
|
||||
.av{width:50px;height:50px;border-radius:10px;background:var(--s3);border:1px solid var(--line);
|
||||
display:grid;place-items:center;font-weight:600;font-size:17px;color:var(--ink2);flex:none;overflow:hidden}
|
||||
.av img{width:100%;height:100%;object-fit:cover}
|
||||
.who .n{font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||||
.who .m{color:var(--ink3);font-size:12.5px;margin-top:2px}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10.5px;font-weight:600;
|
||||
letter-spacing:.04em;text-transform:uppercase}
|
||||
.badge.new{background:var(--accent-dim);color:var(--accent)}
|
||||
.badge.seen{background:rgba(63,191,127,.14);color:var(--ok)}
|
||||
label{display:block;font-size:11.5px;font-weight:550;color:var(--ink2);margin-bottom:5px}
|
||||
input{width:100%;background:var(--s2);border:1px solid var(--line);border-radius:7px;padding:9px 11px;
|
||||
color:var(--ink);font:inherit;font-size:13.5px}
|
||||
input:focus{outline:none;border-color:var(--accent)}
|
||||
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
|
||||
button{background:var(--accent);color:#04161B;border:0;border-radius:7px;padding:9px 16px;
|
||||
font:inherit;font-size:13px;font-weight:600;cursor:pointer}
|
||||
button:disabled{opacity:.45;cursor:default}
|
||||
button.sec{background:var(--s3);color:var(--ink);border:1px solid var(--line)}
|
||||
.saved{color:var(--ok);font-size:12.5px;margin-top:9px;display:flex;align-items:center;gap:6px}
|
||||
|
||||
/* raw json */
|
||||
pre{font-family:var(--mono);font-size:11px;line-height:1.55;color:var(--ink2);
|
||||
background:#080C10;border-top:1px solid var(--line2);padding:13px 16px;margin:0;
|
||||
max-height:230px;overflow:auto;white-space:pre-wrap;word-break:break-word}
|
||||
.req{font-family:var(--mono);font-size:11.5px;color:var(--accent);padding:10px 16px;background:var(--s2)}
|
||||
.req .st{float:right;color:var(--ink3)}
|
||||
.hint{color:var(--ink3);font-size:12px;padding:10px 16px 14px;line-height:1.55}
|
||||
.stack{display:flex;flex-direction:column;gap:14px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="brand">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDIxcy04LTQuNS04LTEwYTQuNSA0LjUgMCAwIDEgOC0yLjggNC41IDQuNSAwIDAgMSA4IDIuOGMwIDUuNS04IDEwLTggMTB6IiBmaWxsPSIjRjJDMTFGIi8+PC9zdmc+" alt="">
|
||||
<div><b>Behavision</b> <span id="site">— live demo</span></div>
|
||||
</div>
|
||||
<span class="pill"><i class="dot" id="d-eng"></i><span id="t-eng">engine…</span></span>
|
||||
<span class="pill"><i class="dot" id="d-cam"></i><span id="t-cam">camera…</span></span>
|
||||
<span class="pill"><i class="dot" id="d-cloud"></i><span id="t-cloud">cloud…</span></span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>The camera <span class="grow" id="camname"></span></h2>
|
||||
<div class="cam">
|
||||
<img id="feed" alt="" style="display:none">
|
||||
<div class="none" id="feednone">waiting for the camera…</div>
|
||||
<div class="tag" id="camtag" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>The customer <span class="grow">type a name, then walk past again</span></h2>
|
||||
<div id="cust">
|
||||
<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>What just happened <span class="grow" id="lat"></span></h2>
|
||||
<div id="chain"><div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>What the mobile app receives</h2>
|
||||
<div class="req" id="m-req">GET /api/visits<span class="st" id="m-st"></span></div>
|
||||
<pre id="m-body">…</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>What the dashboard receives</h2>
|
||||
<div class="req" id="d-req">GET /api/reports/footfall<span class="st" id="d-st"></span></div>
|
||||
<pre id="d-body">…</pre>
|
||||
<div class="hint">Both of these are the real production API at mcp.loyaly.ai, called from this
|
||||
machine with a staff login — not a mock, and not the local engine.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
let camStarted = null, current = null, savedFor = null;
|
||||
|
||||
function setPill(dot, text, tone, label){
|
||||
$(dot).className = 'dot' + (tone ? ' ' + tone : '');
|
||||
$(text).textContent = label;
|
||||
}
|
||||
|
||||
function initials(name, ref){
|
||||
const m = /^Visitor (\d+)$/.exec((name||'').trim());
|
||||
if (m) return m[1];
|
||||
const w = (name||'').trim().split(/\s+/).filter(Boolean);
|
||||
if (!w.length) return '?';
|
||||
return (w[0][0] + (w[1]?.[0] ?? '')).toUpperCase();
|
||||
}
|
||||
|
||||
function drawChain(e){
|
||||
if (!e){ $('#chain').innerHTML = '<div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div>'; $('#lat').textContent=''; return; }
|
||||
const g = e.engine, c = e.cloud;
|
||||
const steps = [
|
||||
[true, 'Camera saw a face', g.quality != null ? `quality ${(+g.quality).toFixed(2)} · camera ${g.camera}` : `camera ${g.camera}`, ''],
|
||||
[true, e.kind === 'new' ? 'Engine: nobody it knows → enrolled' : 'Engine: matched a returning customer',
|
||||
(g.label || '') + (g.similarity != null && g.similarity >= 0 ? ` · similarity ${(+g.similarity).toFixed(2)}` : '') , ''],
|
||||
[true, 'Agent queued the visit', 'durable on this disk until the broker confirms', ''],
|
||||
[!!c, 'Broker delivered it', 'MQTT over TLS to mcp.loyaly.ai', ''],
|
||||
[!!c, 'Server recorded it', c ? `${c.site} · ${c.is_new ? 'new customer' : 'returning'}` : 'waiting…', ''],
|
||||
[!!c, 'Mobile + dashboard can see it', c ? `visit ${String(c.visit_id).slice(0,8)}` : 'waiting…',
|
||||
e.latency != null ? `+${e.latency}s` : ''],
|
||||
];
|
||||
$('#chain').innerHTML = steps.map(([on,title,sub,ms],i)=>
|
||||
`<div class="step ${on?'on':''}"><div class="n">${i+1}</div><div><b>${title}</b><small>${sub}</small></div><div class="ms">${ms}</div></div>`
|
||||
).join('');
|
||||
$('#lat').textContent = e.latency != null ? `camera → cloud in ${e.latency}s` : '';
|
||||
}
|
||||
|
||||
function drawCustomer(e){
|
||||
const c = e && e.cloud;
|
||||
if (!c){ if(!current) $('#cust').innerHTML = '<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>'; return; }
|
||||
const changed = !current || current.visitor_id !== c.visitor_id || current.visit_id !== c.visit_id;
|
||||
if (!changed) return;
|
||||
current = c;
|
||||
const name = c.label || 'Unrecognised';
|
||||
const img = c.image && c.image.available && c.image.url;
|
||||
$('#cust').innerHTML = `
|
||||
<div class="who">
|
||||
<div class="av">${img ? `<img src="${img}">` : initials(name)}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="n">${name}</div>
|
||||
<div class="m">${c.ref ? c.ref + ' · ' : ''}${c.is_new ? 'first time here' : 'returning'}${c.similarity>0 ? ' · match ' + (+c.similarity).toFixed(2) : ''}</div>
|
||||
</div>
|
||||
<span class="badge ${c.is_new?'new':'seen'}">${c.is_new?'new':'returning'}</span>
|
||||
</div>
|
||||
<div class="pad">
|
||||
<div class="row">
|
||||
<div><label>Name</label><input id="f-name" placeholder="e.g. Suriya" value=""></div>
|
||||
<div><label>Phone</label><input id="f-phone" placeholder="+91…" value=""></div>
|
||||
</div>
|
||||
<button id="save">Save to the customer record</button>
|
||||
<div id="savedmsg"></div>
|
||||
<div class="hint" style="padding:12px 0 0">This writes to the production API. Walk past again and
|
||||
the name comes back through the cloud instead of “${name}”.</div>
|
||||
</div>`;
|
||||
$('#save').onclick = async () => {
|
||||
const b = $('#save'); b.disabled = true; b.textContent = 'Saving…';
|
||||
const r = await fetch('/api/profile', {method:'POST', headers:{'content-type':'application/json'},
|
||||
body: JSON.stringify({id: c.visitor_id, full_name: $('#f-name').value, phone: $('#f-phone').value})});
|
||||
const d = await r.json();
|
||||
b.disabled = false; b.textContent = 'Save to the customer record';
|
||||
$('#savedmsg').innerHTML = (d.status===200||d.status===204)
|
||||
? '<div class="saved">✓ Saved — PUT /api/visitors/'+String(c.visitor_id).slice(0,8)+'…/profile → '+d.status+'</div>'
|
||||
: '<div class="saved" style="color:var(--bad)">'+(d.body&&d.body.message||('HTTP '+d.status))+'</div>';
|
||||
savedFor = c.visitor_id;
|
||||
};
|
||||
}
|
||||
|
||||
async function tick(){
|
||||
let s;
|
||||
try { s = await (await fetch('/api/snapshot')).json(); } catch { return; }
|
||||
|
||||
const eng = s.engine || {};
|
||||
setPill('#d-eng','#t-eng', eng.up ? 'ok' : 'bad', eng.up ? ('engine · ' + (eng.model||'starting')) : 'engine starting…');
|
||||
const cam = (eng.cameras||[])[0];
|
||||
setPill('#d-cam','#t-cam', cam && cam.connected ? 'ok' : 'warn',
|
||||
cam ? (cam.connected ? `camera live · ${cam.frames||0} frames` : 'camera connecting…') : 'no camera yet');
|
||||
setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable');
|
||||
$('#camname').textContent = cam ? cam.id : '';
|
||||
|
||||
if (cam && cam.connected){
|
||||
if (camStarted !== cam.id){
|
||||
camStarted = cam.id;
|
||||
$('#feed').style.display = 'block'; $('#feednone').style.display = 'none';
|
||||
$('#camtag').style.display = 'block';
|
||||
// A polled still rather than an MJPEG stream: the engine re-serves its
|
||||
// latest frame anyway, and a multipart stream through a proxy is one
|
||||
// more thing to fail in front of an audience.
|
||||
setInterval(() => { $('#feed').src = '/camera.jpg?id=' +
|
||||
encodeURIComponent(camStarted) + '&t=' + Date.now(); }, 350);
|
||||
}
|
||||
$('#camtag').textContent = cam.id + (cam.tracks ? ` · ${cam.tracks} in frame` : '');
|
||||
}
|
||||
|
||||
const e = (s.chain||[])[0];
|
||||
drawChain(e); drawCustomer(e);
|
||||
|
||||
$('#m-st').textContent = s.mobile.status;
|
||||
$('#m-body').textContent = JSON.stringify(s.mobile.body, null, 1).slice(0, 2600);
|
||||
$('#d-st').textContent = s.dashboard.status;
|
||||
$('#d-body').textContent = JSON.stringify(s.dashboard.body, null, 1).slice(0, 1800);
|
||||
}
|
||||
tick(); setInterval(tick, 1500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
330
demo/console.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""A one-screen live demo of the whole Behavision chain, for showing someone.
|
||||
|
||||
Run it on the shop PC (here, this Mac) while the engine and agent are running.
|
||||
It holds every credential itself and the browser holds none, so the page can be
|
||||
put on a projector without putting a token on it.
|
||||
|
||||
What it shows, and why each part is there:
|
||||
|
||||
- the live camera, so the person walking past sees themselves;
|
||||
- the CHAIN, measured rather than described: the engine recognised a face at
|
||||
this instant, the same visit appeared in the cloud API this many seconds
|
||||
later. That number is the product's claim, and it is computed here from two
|
||||
independent sources rather than asserted;
|
||||
- the customer, editable - type a name, walk past again, watch the name come
|
||||
back through the cloud instead of "Visitor 5";
|
||||
- the raw JSON a phone and a dashboard receive, side by side, because a
|
||||
colleague's real question is "is this actually wired up or is it a mock".
|
||||
|
||||
.venv/bin/python demo/console.py # http://127.0.0.1:8099
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
STATE = Path(os.environ.get("BEHAVISION_DATA_DIR", ROOT / ".demo"))
|
||||
CLOUD = os.environ.get("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")
|
||||
ENGINE = "http://127.0.0.1:8010"
|
||||
PORT = int(os.environ.get("DEMO_PORT", "8099"))
|
||||
|
||||
# Whoever the demo signs in as. Staff on purpose: it is the weakest role that
|
||||
# can do everything the shop floor does, so nothing here is only possible
|
||||
# because we used an owner.
|
||||
EMAIL = os.environ.get("DEMO_EMAIL", "staff.demo@tenext.in")
|
||||
PASSWORD = os.environ.get("DEMO_PASSWORD", "admin@123")
|
||||
|
||||
|
||||
def engine_auth() -> str:
|
||||
"""The engine invents a Basic credential when none is configured, and
|
||||
writes it here. Read it rather than keeping a second copy."""
|
||||
f = STATE / "data" / "api_credentials.txt"
|
||||
if not f.exists():
|
||||
return ""
|
||||
user = pw = ""
|
||||
for line in f.read_text().splitlines():
|
||||
# `key=value`, and `key: value` too - the engine writes one and people
|
||||
# read the other, and which is which is not worth a support call.
|
||||
if "=" in line or ":" in line:
|
||||
k, v = line.split("=", 1) if "=" in line else line.split(":", 1)
|
||||
if k.strip().lower() == "username":
|
||||
user = v.strip()
|
||||
elif k.strip().lower() == "password":
|
||||
pw = v.strip()
|
||||
if not user:
|
||||
return ""
|
||||
return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||
|
||||
|
||||
def fetch(url: str, *, headers=None, body=None, method="GET", timeout=20):
|
||||
req = urllib.request.Request(url, method=method,
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={k: v for k, v in (headers or {}).items() if v})
|
||||
if body is not None:
|
||||
req.add_header("content-type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw and r.headers.get("content-type", "").startswith("application/json") else raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
return e.code, json.loads(raw or b"{}")
|
||||
except Exception:
|
||||
return e.code, raw[:400]
|
||||
except Exception as e:
|
||||
return 0, {"error": str(e)}
|
||||
|
||||
|
||||
class Cloud:
|
||||
"""The signed-in session, refreshed when it expires."""
|
||||
|
||||
def __init__(self):
|
||||
self.token = ""
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def sign_in(self) -> bool:
|
||||
st, d = fetch(f"{CLOUD}/api/auth/login", method="POST",
|
||||
body={"email": EMAIL, "password": PASSWORD, "device": "Demo console"})
|
||||
if st == 200 and isinstance(d, dict):
|
||||
self.token = d.get("access_token", "")
|
||||
return True
|
||||
return False
|
||||
|
||||
def call(self, path, method="GET", body=None, retry=True):
|
||||
with self.lock:
|
||||
if not self.token and not self.sign_in():
|
||||
return 0, {"error": "cannot sign in to the platform"}
|
||||
tok = self.token
|
||||
st, d = fetch(f"{CLOUD}{path}", method=method, body=body,
|
||||
headers={"authorization": f"Bearer {tok}"})
|
||||
if st == 401 and retry:
|
||||
with self.lock:
|
||||
self.sign_in()
|
||||
return self.call(path, method, body, retry=False)
|
||||
return st, d
|
||||
|
||||
|
||||
cloud = Cloud()
|
||||
|
||||
# The chain, as the watcher builds it. One dict per recognition, newest first.
|
||||
events: list[dict] = []
|
||||
events_lock = threading.Lock()
|
||||
|
||||
|
||||
def watch():
|
||||
"""Poll the engine's own event log and the cloud feed, and join them.
|
||||
|
||||
They are joined on the identity and the second, not on a shared id,
|
||||
because the engine numbers identities locally and the server numbers them
|
||||
per tenant - the two are deliberately different (see CLAUDE.md). What
|
||||
matters for the demo is the LATENCY between one seeing a person and the
|
||||
other, and that only needs the same person and the same moment.
|
||||
"""
|
||||
seen_local: set[str] = set()
|
||||
while True:
|
||||
try:
|
||||
auth = engine_auth()
|
||||
st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth})
|
||||
# The engine returns a bare list; a dict with "events" is accepted
|
||||
# too so this survives either shape.
|
||||
evs = d if isinstance(d, list) else (d or {}).get("events", []) if isinstance(d, dict) else []
|
||||
if st == 200:
|
||||
for e in evs:
|
||||
if e.get("type") not in ("person.new", "person.seen"):
|
||||
continue
|
||||
key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}"
|
||||
if key in seen_local:
|
||||
continue
|
||||
seen_local.add(key)
|
||||
data = e.get("data") or {}
|
||||
with events_lock:
|
||||
events.insert(0, {
|
||||
"key": key,
|
||||
"at": time.time(),
|
||||
"kind": "new" if e["type"] == "person.new" else "seen",
|
||||
"engine": {
|
||||
"label": data.get("label"),
|
||||
"identity_id": data.get("identity_id"),
|
||||
"similarity": data.get("similarity"),
|
||||
"quality": data.get("quality"),
|
||||
"gender": data.get("gender"),
|
||||
"age": data.get("age"),
|
||||
"camera": e.get("camera_id"),
|
||||
"ts": e.get("ts"),
|
||||
},
|
||||
"cloud": None,
|
||||
"latency": None,
|
||||
})
|
||||
del events[40:]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# the other half: has the cloud got it yet?
|
||||
try:
|
||||
with events_lock:
|
||||
pending = [e for e in events if e["cloud"] is None][:6]
|
||||
if pending:
|
||||
st, d = cloud.call("/api/visits?limit=12")
|
||||
arrivals = (d or {}).get("arrivals", []) if isinstance(d, dict) else []
|
||||
for e in pending:
|
||||
for a in arrivals:
|
||||
# same camera, and the cloud's visit is not older than
|
||||
# the engine's sighting
|
||||
if a.get("camera_id") != e["engine"]["camera"]:
|
||||
continue
|
||||
if a.get("visit_id") in [x["cloud"].get("visit_id") for x in events if x["cloud"]]:
|
||||
continue
|
||||
with events_lock:
|
||||
e["cloud"] = {
|
||||
"visit_id": a.get("visit_id"),
|
||||
"visitor_id": a.get("visitor_id"),
|
||||
"label": a.get("label"),
|
||||
"ref": a.get("customer_ref") or a.get("ref"),
|
||||
"is_new": a.get("is_new_visitor"),
|
||||
"similarity": a.get("similarity"),
|
||||
"site": a.get("site"),
|
||||
"occurred_at": a.get("occurred_at"),
|
||||
"image": a.get("image"),
|
||||
}
|
||||
e["latency"] = round(time.time() - e["at"], 1)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Everything the page draws, in one reply."""
|
||||
auth = engine_auth()
|
||||
_, health = fetch(f"{ENGINE}/api/health", headers={"authorization": auth})
|
||||
_, stats = fetch(f"{ENGINE}/api/stats", headers={"authorization": auth})
|
||||
st_v, visits = cloud.call("/api/visits?limit=3")
|
||||
st_f, foot = cloud.call("/api/reports/footfall?from=%s&to=%s"
|
||||
% (time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400)),
|
||||
time.strftime("%Y-%m-%d")))
|
||||
with events_lock:
|
||||
chain = json.loads(json.dumps(events[:8]))
|
||||
cams = (stats or {}).get("cameras", []) if isinstance(stats, dict) else []
|
||||
return {
|
||||
"engine": {
|
||||
"up": isinstance(health, dict) and bool(health.get("status")),
|
||||
"model": (health or {}).get("recognition_model") if isinstance(health, dict) else None,
|
||||
"cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"),
|
||||
"frames": c.get("frames_processed") or c.get("frames_total"),
|
||||
"faces": c.get("faces_seen"),
|
||||
"tracks": c.get("active_tracks")} for c in cams],
|
||||
},
|
||||
"cloud_ok": st_v == 200,
|
||||
"chain": chain,
|
||||
"mobile": {"request": "GET /api/visits?limit=3", "status": st_v, "body": visits},
|
||||
"dashboard": {"request": "GET /api/reports/footfall?from=…&to=…", "status": st_f, "body": foot},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
page = (Path(__file__).parent / "console.html").read_bytes()
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): # quiet
|
||||
pass
|
||||
|
||||
def _send(self, code, body, ctype="application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("content-type", ctype)
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
u = urlparse(self.path)
|
||||
if u.path == "/":
|
||||
return self._send(200, page, "text/html; charset=utf-8")
|
||||
if u.path == "/api/snapshot":
|
||||
return self._send(200, json.dumps(snapshot()).encode())
|
||||
if u.path == "/api/customer":
|
||||
vid = parse_qs(u.query).get("id", [""])[0]
|
||||
if not vid:
|
||||
return self._send(400, b'{"error":"no id"}')
|
||||
st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8")
|
||||
return self._send(200, json.dumps({"status": st, "history": d}).encode())
|
||||
if u.path == "/camera.jpg":
|
||||
# A polled still, not the MJPEG stream. The engine re-serves its
|
||||
# latest frame until the pipeline produces a new one, so polling
|
||||
# shows the same picture - and a multipart stream through a
|
||||
# proxy is one more thing to fail in front of an audience.
|
||||
cam = parse_qs(u.query).get("id", [""])[0]
|
||||
st, body = fetch(f"{ENGINE}/api/cameras/{cam}/frame.jpg",
|
||||
headers={"authorization": engine_auth()}, timeout=15)
|
||||
if st != 200 or not isinstance(body, (bytes, bytearray)):
|
||||
return self._send(502, b'{"error":"no frame"}')
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "image/jpeg")
|
||||
self.send_header("cache-control", "no-store")
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
self._send(404, b'{"error":"no"}')
|
||||
|
||||
def do_POST(self):
|
||||
u = urlparse(self.path)
|
||||
n = int(self.headers.get("content-length", 0))
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
if u.path == "/api/profile":
|
||||
vid = body.pop("id", "")
|
||||
st, d = cloud.call(f"/api/visitors/{vid}/profile", method="PUT", body=body)
|
||||
return self._send(200, json.dumps({"status": st, "body": d}).encode())
|
||||
self._send(404, b'{"error":"no"}')
|
||||
|
||||
def _proxy_stream(self, url):
|
||||
"""The engine's MJPEG, relayed so the browser needs no credential.
|
||||
|
||||
The engine's API is Basic-authenticated with a credential it
|
||||
generated locally; putting that in a page would hand the whole
|
||||
biometric API to anyone who opened it.
|
||||
"""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"authorization": engine_auth()})
|
||||
up = urllib.request.urlopen(req, timeout=20)
|
||||
except Exception:
|
||||
return self._send(502, b'{"error":"camera not available"}')
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", up.headers.get("content-type", "multipart/x-mixed-replace"))
|
||||
self.end_headers()
|
||||
try:
|
||||
while True:
|
||||
chunk = up.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
up.close()
|
||||
|
||||
threading.Thread(target=watch, daemon=True).start()
|
||||
print(f"\n Demo console → http://127.0.0.1:{PORT}\n")
|
||||
print(f" engine {ENGINE} · cloud {CLOUD} · signed in as {EMAIL}\n")
|
||||
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
324
desktop/app.go
@@ -23,6 +23,7 @@ import (
|
||||
agentcameras "github.com/loyaly/behavision-agent/pkg/cameras"
|
||||
agentcfg "github.com/loyaly/behavision-agent/pkg/config"
|
||||
agentengine "github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/enrol"
|
||||
agentmqtt "github.com/loyaly/behavision-agent/pkg/mqtt"
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
agentspool "github.com/loyaly/behavision-agent/pkg/spool"
|
||||
@@ -43,6 +44,9 @@ type App struct {
|
||||
broker *agentmqtt.Client
|
||||
stopBridge func()
|
||||
hookURL string
|
||||
// The resolved engine command, so engineMissing() and the supervisor are
|
||||
// never looking at two different paths.
|
||||
engineExe string
|
||||
// Relays camera feeds to the webview so the engine's credential never has
|
||||
// to travel in an <img> src, which a Chromium webview would strip anyway.
|
||||
proxy *streamProxy
|
||||
@@ -65,7 +69,7 @@ func NewApp() *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
||||
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
||||
local: localWithCreds(base, cfg),
|
||||
proxy: newStreamProxy(),
|
||||
}
|
||||
}
|
||||
@@ -81,6 +85,14 @@ func (a *App) startup(ctx context.Context) {
|
||||
if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil {
|
||||
log.Printf("camera relay unavailable, tiles will not load: %v", err)
|
||||
}
|
||||
// And the other direction: watching a camera in another building, through
|
||||
// head office's relay. Enabled unconditionally rather than only when a
|
||||
// session already exists, because signing in is a thing that happens
|
||||
// while the app is open - and CameraLive refuses without a session
|
||||
// anyway, so there is nothing to gate.
|
||||
if err := a.proxy.watchRemote(a.cloud.CameraLive); err != nil {
|
||||
log.Printf("remote camera view unavailable: %v", err)
|
||||
}
|
||||
|
||||
// A saved session means a shop PC that rebooted overnight comes back
|
||||
// working instead of waiting for someone to log in.
|
||||
@@ -100,10 +112,21 @@ func (a *App) startup(ctx context.Context) {
|
||||
if exe != "" && !filepath.IsAbs(exe) {
|
||||
exe = filepath.Join(agentpaths.InstallRoot(), exe)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.engineExe = exe
|
||||
a.mu.Unlock()
|
||||
logFile, _ := agentengine.LogFile(agentpaths.EngineLog())
|
||||
a.sup = agentengine.New(agentengine.Options{
|
||||
Command: func(c context.Context) *exec.Cmd {
|
||||
cmd := exec.CommandContext(c, exe, a.cfg.EngineArgs...)
|
||||
// Run the engine FROM a known directory rather than from whatever
|
||||
// happened to launch us. A double-clicked bundle hands its child
|
||||
// "/", and an engine invoked as `-m behavision` then cannot find
|
||||
// itself - measured on macOS, where it retried forever.
|
||||
cmd.Dir = a.cfg.EngineDir
|
||||
if cmd.Dir == "" {
|
||||
cmd.Dir = agentpaths.InstallRoot()
|
||||
}
|
||||
// How the engine learns where to post its detections. Its config
|
||||
// already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`,
|
||||
// and python-dotenv does not override a variable the process
|
||||
@@ -124,6 +147,66 @@ func (a *App) startup(ctx context.Context) {
|
||||
})
|
||||
|
||||
a.startPipeline(ctx)
|
||||
go a.watchConfig(ctx)
|
||||
|
||||
// Recognition starts with the app. Until this, the engine only ever
|
||||
// started when somebody pressed Start - which meant a till that rebooted
|
||||
// overnight came back with the window open, the tray icon showing, the
|
||||
// session restored, and recognition off until a shop assistant noticed.
|
||||
// That is the failure the tray colours exist to catch, and it should not
|
||||
// be the default state every morning.
|
||||
//
|
||||
// Guarded on the interpreter actually being there: on a PC where setup has
|
||||
// not run yet, starting the supervisor would loop on a missing executable
|
||||
// with nothing useful to say. The Start button still exists for the one
|
||||
// case where somebody has deliberately stopped it.
|
||||
if why := a.engineMissing(); why == "" {
|
||||
a.sup.Start()
|
||||
} else {
|
||||
log.Printf("%s (looked for %s)", why, exe)
|
||||
}
|
||||
}
|
||||
|
||||
// engineMissing says, in a sentence somebody can act on, why recognition
|
||||
// cannot start here - or "" when it can.
|
||||
//
|
||||
// It exists because the answer was only ever given at startup, to a log file
|
||||
// nobody on a shop counter opens. Pressing Start went straight to the
|
||||
// supervisor, which reported what exec reported:
|
||||
//
|
||||
// engine failed to start: fork/exec /private/var/folders/c2/.../
|
||||
// AppTranslocation/500A5354-.../d/Behavision.app/Contents/MacOS/engine/
|
||||
// behavision: no such file or directory
|
||||
//
|
||||
// Every word of that is true and none of it says "run the setup tool". One
|
||||
// function, consulted by the startup path, the Start button and the status
|
||||
// panel, so the three cannot give three different accounts of one fact.
|
||||
func (a *App) engineMissing() string {
|
||||
a.mu.RLock()
|
||||
exe := a.engineExe
|
||||
a.mu.RUnlock()
|
||||
if exe == "" {
|
||||
return "The recognition engine is not set up on this computer yet."
|
||||
}
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := "The recognition engine is not installed on this computer yet. " +
|
||||
"Run behavision-setup from the folder you unzipped, then press Start."
|
||||
// macOS quarantines a downloaded app it cannot verify and runs it from a
|
||||
// randomly named READ-ONLY copy - App Translocation. Every relative path
|
||||
// then resolves inside that copy, which is why the engine folder appears
|
||||
// to be missing from a bundle that plainly contains one, and why an
|
||||
// install into it would not survive a restart. Detectable, unguessable,
|
||||
// and fixed by one drag; saying nothing leaves somebody re-running a
|
||||
// setup tool that cannot win.
|
||||
if strings.Contains(exe, "/AppTranslocation/") {
|
||||
msg = "macOS is running Behavision from a temporary read-only copy, " +
|
||||
"because it was opened straight from Downloads. Move Behavision " +
|
||||
"to your Applications folder and open it from there, then run " +
|
||||
"behavision-setup."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// webhookURL is the loopback address the bridge is listening on, or empty
|
||||
@@ -207,7 +290,7 @@ func (a *App) startPipeline(ctx context.Context) {
|
||||
}
|
||||
client, err := agentmqtt.NewClient(agentmqtt.ClientOptions{
|
||||
BrokerURL: a.cfg.BrokerURL,
|
||||
ClientID: "behavision-" + a.cfg.ClientID + "-" + a.cfg.SiteID,
|
||||
ClientID: a.cfg.MQTTClientID(),
|
||||
Username: a.cfg.BrokerUsername, Password: a.cfg.BrokerPassword,
|
||||
CAFile: a.cfg.BrokerCAFile, Log: logger,
|
||||
})
|
||||
@@ -417,6 +500,14 @@ func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
a.cfg.BrokerPassword = b.MQTTPass
|
||||
a.cfg.AgentToken = b.AgentToken
|
||||
a.cfg.CloudBase = a.cloud.Base
|
||||
// A new head office: whoever was signed in was signed in somewhere else.
|
||||
a.cfg.SessionToken, a.cfg.SessionRefresh, a.cfg.SessionEmail = "", "", ""
|
||||
a.cloud.Clear()
|
||||
caPath, err := enrol.SaveCA(b.CACert, agentpaths.BrokerCA())
|
||||
if err != nil {
|
||||
return SessionInfo{}, err
|
||||
}
|
||||
a.cfg.BrokerCAFile = caPath
|
||||
// A PC that was running on its own and has now been linked is no longer
|
||||
// standalone. Leaving the flag set would keep the head-office screens
|
||||
// hidden on the one machine that just earned them.
|
||||
@@ -441,6 +532,62 @@ func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
// the current config. Only Claim needs it today; it exists as its own method
|
||||
// because "stop everything that reads the config, then start it" is the part
|
||||
// that is easy to get half right.
|
||||
// watchConfig reloads agent.json when something else writes it.
|
||||
//
|
||||
// behavision-setup re-run on a PC with the app open re-claims the shop and
|
||||
// rotates its API token; the running app kept the old one and every camera
|
||||
// sync was refused from then on - heartbeats still flowed, so head office
|
||||
// looked fine while the cameras went stale. A claim from `behavision-agent
|
||||
// claim` does the same. Rather than ask people to restart the app, the app
|
||||
// watches the file and picks the new credentials up itself.
|
||||
func (a *App) watchConfig(ctx context.Context) {
|
||||
path := agentpaths.AgentConfig()
|
||||
last := mtime(path)
|
||||
t := time.NewTicker(10 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
now := mtime(path)
|
||||
if now.IsZero() || now.Equal(last) {
|
||||
continue
|
||||
}
|
||||
last = now
|
||||
fresh, err := agentcfg.Load(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fresh = fresh.WithEngineCredentials(agentpaths.APICredentials())
|
||||
a.mu.Lock()
|
||||
changed := fresh.AgentToken != a.cfg.AgentToken || fresh.SiteID != a.cfg.SiteID ||
|
||||
fresh.BrokerPassword != a.cfg.BrokerPassword || fresh.CloudBase != a.cfg.CloudBase ||
|
||||
fresh.Standalone != a.cfg.Standalone
|
||||
if changed {
|
||||
// Keep this process's live session; a claim clears it in the file
|
||||
// deliberately, and that is honoured too.
|
||||
a.cfg = fresh
|
||||
if fresh.SessionToken == "" {
|
||||
a.cloud.Clear()
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if changed {
|
||||
a.restartPipeline()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mtime(path string) time.Time {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return st.ModTime()
|
||||
}
|
||||
|
||||
func (a *App) restartPipeline() {
|
||||
if a.stopBridge != nil {
|
||||
a.stopBridge()
|
||||
@@ -478,6 +625,8 @@ type EngineStatus struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
Model string `json:"recognition_model,omitempty"`
|
||||
Cameras map[string]bool `json:"cameras,omitempty"`
|
||||
// Progress is the first-run model download, when one is happening.
|
||||
Progress *agentengine.Progress `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) EngineStatus() EngineStatus {
|
||||
@@ -488,9 +637,17 @@ func (a *App) EngineStatus() EngineStatus {
|
||||
st, err := a.sup.State()
|
||||
out.State = string(st)
|
||||
out.Restarts = a.sup.Restarts()
|
||||
if p := a.sup.Progress(); p.What != "" {
|
||||
out.Progress = &p
|
||||
}
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
}
|
||||
// The supervisor's own error is an exec failure; this replaces it with
|
||||
// the reason, which is the part that tells somebody what to do.
|
||||
if why := a.engineMissing(); why != "" {
|
||||
out.Error = why
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
// A running process is not a working engine: on a memory-starved box the
|
||||
@@ -505,6 +662,13 @@ func (a *App) EngineStatus() EngineStatus {
|
||||
}
|
||||
|
||||
func (a *App) StartEngine() EngineStatus {
|
||||
// Refused rather than attempted. Handing a missing path to the supervisor
|
||||
// produces a retry loop and an exec error for a message.
|
||||
if why := a.engineMissing(); why != "" {
|
||||
st := a.EngineStatus()
|
||||
st.Error = why
|
||||
return st
|
||||
}
|
||||
if a.sup != nil {
|
||||
a.sup.Start()
|
||||
}
|
||||
@@ -520,10 +684,52 @@ func (a *App) StopEngine() EngineStatus {
|
||||
|
||||
// ---------------------------------------------------------------- cameras --
|
||||
|
||||
// Cameras lists this PC's cameras, or the company's if this PC has none of
|
||||
// its own.
|
||||
//
|
||||
// The distinction is load-bearing and the UI is told which it got. A camera
|
||||
// from the local engine is one THIS machine can reach, edit and stream. One
|
||||
// from head office is a camera at a shop somewhere else: it has a snapshot
|
||||
// and a connection state, and it cannot be edited from here because the shop
|
||||
// PC on that LAN is the only thing that can reach it. Offering an Edit button
|
||||
// that could not work would be worse than not showing the camera at all.
|
||||
func (a *App) Cameras() ([]map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return a.local.Cameras(ctx)
|
||||
|
||||
cams, err := a.local.Cameras(ctx)
|
||||
if err == nil {
|
||||
return cams, nil
|
||||
}
|
||||
if !a.cloud.LoggedIn() {
|
||||
return nil, err
|
||||
}
|
||||
remote, rerr := a.cloud.RemoteCameras(ctx)
|
||||
if rerr != nil {
|
||||
return nil, err // the local failure is the one worth reporting
|
||||
}
|
||||
out := make([]map[string]any, 0, len(remote))
|
||||
for _, c := range remote {
|
||||
out = append(out, map[string]any{
|
||||
"id": c.ID, "camera_id": c.CameraID, "label": c.Label,
|
||||
"site": c.Site, "enabled": c.Enabled,
|
||||
"connected": c.Connected, "last_seen_at": c.LastSeenAt,
|
||||
"state": c.State, "state_note": c.StateNote,
|
||||
"snapshot": c.Snapshot, "snapshot_at": c.SnapshotAt,
|
||||
// What the screen keys off to hide Edit, Test and Check: this
|
||||
// camera is on a network this PC cannot reach.
|
||||
"remote": true,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiscoverCameras lists the cameras on this PC's network, so the add-camera
|
||||
// form is a pick-list and not a request for an IP address nobody knows.
|
||||
func (a *App) DiscoverCameras() (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return a.local.DiscoverCameras(ctx)
|
||||
}
|
||||
|
||||
func (a *App) TestCamera(cam map[string]any) (map[string]any, error) {
|
||||
@@ -582,25 +788,115 @@ func (a *App) StreamURL(cameraID string) string {
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
}
|
||||
|
||||
// RemoteStreamURL is the live view of a camera in another building.
|
||||
//
|
||||
// The picture comes from head office's relay - the shop PC pushes frames
|
||||
// outbound because nothing can reach in - and this app re-emits them as MJPEG
|
||||
// on its own loopback, so a tile is an ordinary <img> either way. A screen
|
||||
// therefore never has to know which building it is looking at.
|
||||
//
|
||||
// Empty when the relay is not running, and the caller shows the last snapshot
|
||||
// instead. There is no useful fallback URL: the head-office endpoint needs
|
||||
// this session's bearer, which an <img> cannot send.
|
||||
func (a *App) RemoteStreamURL(cameraID string) string {
|
||||
if !a.cloud.LoggedIn() {
|
||||
return ""
|
||||
}
|
||||
return a.proxy.urlFor(cameraID, "live.mjpeg")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- live --
|
||||
|
||||
type LiveSnapshot struct {
|
||||
Stats map[string]any `json:"stats"`
|
||||
Events []map[string]any `json:"events"`
|
||||
// Viewing is true when none of this came from an engine on THIS PC. The
|
||||
// screen must say so: the numbers are the company's, not this machine's,
|
||||
// and a laptop in a hotel showing "2 cameras live" without that word
|
||||
// would be claiming to be watching a shop it cannot see.
|
||||
Viewing bool `json:"viewing"`
|
||||
}
|
||||
|
||||
// Live is what the shop PC sees, and falls back to what HEAD OFFICE sees.
|
||||
//
|
||||
// A PC with no engine is not necessarily broken - it is somebody signed in on
|
||||
// a laptop away from the shop, which is the ordinary way an owner looks at
|
||||
// their estate. Until now that produced "engine not reachable at
|
||||
// 127.0.0.1:8010", an accurate sentence and a useless one when the reader was
|
||||
// never expecting an engine on that machine.
|
||||
//
|
||||
// The local engine always wins when it is there: it is this shop's own
|
||||
// ground truth and it is live rather than a heartbeat old.
|
||||
func (a *App) Live() (LiveSnapshot, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats, err := a.local.Stats(ctx)
|
||||
if err == nil {
|
||||
events, eerr := a.local.Events(ctx, 40)
|
||||
if eerr == nil {
|
||||
return LiveSnapshot{Stats: stats, Events: events}, nil
|
||||
}
|
||||
}
|
||||
// No engine here. If nobody is signed in either, the honest answer is
|
||||
// still the local error - there is nothing else to show and the person
|
||||
// is most likely setting this PC up.
|
||||
if !a.cloud.LoggedIn() {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
return a.liveFromCloud(ctx)
|
||||
}
|
||||
|
||||
// liveFromCloud builds the same shape the Live screen already renders, out of
|
||||
// the estate's own feed, so the view needs no second code path.
|
||||
func (a *App) liveFromCloud(ctx context.Context) (LiveSnapshot, error) {
|
||||
sites, err := a.cloud.Sites(ctx)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
events, err := a.local.Events(ctx, 40)
|
||||
arrivals, err := a.cloud.Arrivals(ctx, 40)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
return LiveSnapshot{Stats: stats, Events: events}, nil
|
||||
|
||||
// The counters are summed across the estate, and fraction_below_gate
|
||||
// takes the WORST site rather than an average - one badly placed camera
|
||||
// is a hole in the numbers, and averaging it against three good ones
|
||||
// hides the only site anyone needs to visit. Same rule the heartbeat
|
||||
// already follows.
|
||||
var up, total int
|
||||
worst := 0.0
|
||||
people := map[string]struct{}{}
|
||||
for _, s := range sites {
|
||||
up, total = up+s.CamerasUp, total+s.CamerasTotal
|
||||
if s.FractionBelowGate > worst {
|
||||
worst = s.FractionBelowGate
|
||||
}
|
||||
}
|
||||
events := make([]map[string]any, 0, len(arrivals))
|
||||
for _, v := range arrivals {
|
||||
if v.VisitorID != "" {
|
||||
people[v.VisitorID] = struct{}{}
|
||||
}
|
||||
events = append(events, map[string]any{
|
||||
"type": map[bool]string{true: "person.new", false: "person.seen"}[v.IsNew],
|
||||
"ts": v.OccurredAt, "camera_id": v.CameraID,
|
||||
"data": map[string]any{
|
||||
"label": v.Label, "ref": v.Ref, "site": v.Site,
|
||||
"similarity": v.Similarity, "attributes": v.Attributes,
|
||||
},
|
||||
})
|
||||
}
|
||||
return LiveSnapshot{
|
||||
Viewing: true,
|
||||
Events: events,
|
||||
Stats: map[string]any{
|
||||
"cameras": []map[string]any{},
|
||||
"gallery": map[string]any{"identities": len(people), "sightings": len(arrivals)},
|
||||
"cameras_up": up, "cameras_total": total,
|
||||
"fraction_below_gate": worst,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reports --
|
||||
@@ -636,6 +932,15 @@ func (a *App) Sites() ([]cloud.SiteHealth, error) {
|
||||
}
|
||||
|
||||
// VisitorHistory is one customer's timeline, for the customer record screen.
|
||||
// Ask is the help panel. It needs head office: the assistant runs there,
|
||||
// against this company's own data, as this signed-in user. A PC running on
|
||||
// its own has nobody to ask, and the panel says so rather than erroring.
|
||||
func (a *App) Ask(history []cloud.AssistantTurn) (cloud.AssistantAnswer, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.Ask(ctx, history)
|
||||
}
|
||||
|
||||
func (a *App) VisitorHistory(id string, limit int) ([]cloud.Visit, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
@@ -710,3 +1015,12 @@ func envOr(key, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// localWithCreds builds the engine client with a credential resolver, so a
|
||||
// first run - where the engine writes its credential after the app has looked
|
||||
// for it - recovers by itself instead of 401ing for the life of the process.
|
||||
func localWithCreds(base string, cfg agentcfg.Config) *local.Client {
|
||||
c := local.New(base, cfg.APIUser, cfg.APIPassword)
|
||||
c.Creds = agentcfg.NewCreds(agentpaths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
return c
|
||||
}
|
||||
|
||||
BIN
desktop/build/appicon.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
68
desktop/build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{safeBundleID .Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
63
desktop/build/darwin/Info.plist
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{safeBundleID .Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
desktop/build/windows/icon.ico
Normal file
|
After Width: | Height: | Size: 67 KiB |
15
desktop/build/windows/info.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
desktop/build/windows/wails.exe.manifest
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
25
desktop/darwin_link.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build darwin
|
||||
|
||||
// Link the framework Wails' darwin frontend forgets.
|
||||
//
|
||||
// It references UTType (UniformTypeIdentifiers) without linking it, so a macOS
|
||||
// build fails at the LINK step with `Undefined symbols: _OBJC_CLASS_$_UTType`
|
||||
// - after compiling everything successfully, which makes it read like a broken
|
||||
// toolchain rather than one missing flag. That is why there was no Mac build:
|
||||
// not a design limit, a link error nobody had chased.
|
||||
//
|
||||
// Declared in the source rather than passed as CGO_LDFLAGS on the command
|
||||
// line, for the same reason deploy.sh now finds Go itself: a build that needs
|
||||
// the operator to know an incantation is a build that does not happen. Plain
|
||||
// `go build` and `wails build` both work on a Mac with this file present, and
|
||||
// the build tag makes it inert everywhere else.
|
||||
//
|
||||
// Note for anyone editing: the comment directly above `import "C"` is cgo's C
|
||||
// PREAMBLE, not documentation. This paragraph sits above `package main` on
|
||||
// purpose - put it there and the prose is compiled as C, which is how the
|
||||
// first attempt failed.
|
||||
|
||||
package main
|
||||
|
||||
// #cgo LDFLAGS: -framework UniformTypeIdentifiers
|
||||
import "C"
|
||||
40
desktop/frontend/dist/assets/index-6LYbNlbD.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-B3NH0cQK.js
vendored
1
desktop/frontend/dist/assets/index-DOJ2bRrM.css
vendored
Normal file
BIN
desktop/frontend/dist/assets/loyaly-mark-Ch-P3VSF.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
4
desktop/frontend/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="./assets/index-B3NH0cQK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
|
||||
<script type="module" crossorigin src="./assets/index-6LYbNlbD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, isDesktop, message } from './bridge.js'
|
||||
import { usePolled } from './hooks.js'
|
||||
import * as Icon from './ui/icons.jsx'
|
||||
import logo from './assets/loyaly-mark.png'
|
||||
import Login from './views/Login.jsx'
|
||||
import Setup from './views/Setup.jsx'
|
||||
import Live from './views/Live.jsx'
|
||||
import Customers from './views/Customers.jsx'
|
||||
import Cameras from './views/Cameras.jsx'
|
||||
import Assistant from './views/Assistant.jsx'
|
||||
|
||||
// Three screens, and the trim is by AUDIENCE rather than by taste.
|
||||
//
|
||||
@@ -22,9 +25,9 @@ import Cameras from './views/Cameras.jsx'
|
||||
// the customer record lives on the server, the cameras and what this PC is
|
||||
// seeing do not.
|
||||
const VIEWS = [
|
||||
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
|
||||
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
|
||||
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
|
||||
{ id: 'live', label: 'Live', Glyph: Icon.Live, View: Live },
|
||||
{ id: 'customers', label: 'Customers', Glyph: Icon.People, View: Customers, cloud: true },
|
||||
{ id: 'cameras', label: 'Cameras', Glyph: Icon.Camera, View: Cameras },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
@@ -34,12 +37,23 @@ export default function App() {
|
||||
// A standalone PC can join head office later. That is the same Setup screen,
|
||||
// reached deliberately rather than because the app will not open otherwise.
|
||||
const [linking, setLinking] = useState(false)
|
||||
const [helping, setHelping] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setSession(await api.session()) } catch { setSession(null) }
|
||||
setBooting(false)
|
||||
})()
|
||||
let alive = true
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await api.session()
|
||||
if (alive) setSession(prev => JSON.stringify(prev) === JSON.stringify(s) ? prev : s)
|
||||
} catch { if (alive) setSession(null) }
|
||||
if (alive) setBooting(false)
|
||||
}
|
||||
load()
|
||||
// Re-read every few seconds: a session the server has ended - or one
|
||||
// that never belonged to this head office - must put Login back on
|
||||
// screen, not leave "session expired" banners on every page.
|
||||
const id = setInterval(load, 8000)
|
||||
return () => { alive = false; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
if (!isDesktop()) {
|
||||
@@ -48,6 +62,7 @@ export default function App() {
|
||||
// error nobody will read.
|
||||
return (
|
||||
<div className="login"><div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">
|
||||
This is the Behavision window running outside the app, so it has no
|
||||
@@ -78,41 +93,56 @@ export default function App() {
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<div className="brand">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<div className="id">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'This shop'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{views.map(v => (
|
||||
<button key={v.id} onClick={() => setView(v.id)}
|
||||
aria-current={v.id === view ? 'page' : undefined}>
|
||||
<span className="glyph">{v.glyph}</span>{v.label}
|
||||
{views.map(({ id, label, Glyph }) => (
|
||||
<button key={id} onClick={() => setView(id)}
|
||||
aria-current={id === view ? 'page' : undefined}>
|
||||
<Glyph size={17} />{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<EngineBox />
|
||||
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div className="who">
|
||||
{session.standalone
|
||||
? <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
Running on its own
|
||||
<div className="id">
|
||||
<b>On its own</b>
|
||||
<span>No head office</span>
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
<button className="btn sm icon" title="Link to head office"
|
||||
onClick={() => setLinking(true)}>
|
||||
Link to head office
|
||||
<Icon.Link size={15} />
|
||||
</button>
|
||||
</>
|
||||
: <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
{session.user?.email}
|
||||
<div className="id">
|
||||
<b>Signed in</b>
|
||||
<span>{session.user?.email}</span>
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
<button className="btn sm icon" title="Sign out"
|
||||
onClick={async () => setSession(await api.logout())}>
|
||||
Sign out
|
||||
<Icon.Logout size={15} />
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main"><Current session={session} /></main>
|
||||
<main className="main">
|
||||
<Current session={session} onNavigate={setView} />
|
||||
{/* Loya's door, top right of every screen. A buddy you have to find in
|
||||
a sidebar is not around; one in the corner is. */}
|
||||
{!helping && (
|
||||
<button className="loya-fab" onClick={() => setHelping(true)} aria-label="Ask Loya" title="Ask Loya">
|
||||
<img src={logo} alt="" /><span>Loya</span>
|
||||
</button>
|
||||
)}
|
||||
</main>
|
||||
{helping && <Assistant session={session} onClose={() => setHelping(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -136,7 +166,13 @@ function EngineBox() {
|
||||
const up = cams.filter(Boolean).length
|
||||
|
||||
let tone = 'idle', text = 'Stopped'
|
||||
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
||||
// Reachable but not ours: somebody started the engine outside this app, or a
|
||||
// previous copy is still up. Saying "Stopped" beside live camera feeds is the
|
||||
// two-surfaces-disagreeing bug the tray exists to avoid - and it is exactly
|
||||
// what this panel showed while recognition was visibly running.
|
||||
if (!running && s.reachable) { tone = 'warn'; text = 'Running outside the app' }
|
||||
else if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
||||
else if (running && !s.reachable && s.progress) { tone = 'warn'; text = `Downloading ${s.progress.what}… ${s.progress.percent}%` }
|
||||
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
|
||||
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
|
||||
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
|
||||
@@ -145,16 +181,21 @@ function EngineBox() {
|
||||
|
||||
return (
|
||||
<div className="enginebox">
|
||||
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
|
||||
{s.recognition_model && (
|
||||
<span className="label">Model: {s.recognition_model}</span>
|
||||
)}
|
||||
<div className="row">
|
||||
<i className={`dot ${tone === 'ok' ? 'live' : tone}`} />
|
||||
<span className="state">{text}</span>
|
||||
</div>
|
||||
{s.recognition_model && <span className="label">{s.recognition_model}</span>}
|
||||
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
|
||||
<div className="actions">
|
||||
<button className="btn sm" disabled={busy || running}
|
||||
onClick={() => act(api.startEngine)}>Start</button>
|
||||
<button className="btn sm" disabled={busy || running || s.reachable}
|
||||
onClick={() => act(api.startEngine)}>
|
||||
<Icon.Play size={13} />Start
|
||||
</button>
|
||||
<button className="btn sm" disabled={busy || !running}
|
||||
onClick={() => act(api.stopEngine)}>Stop</button>
|
||||
onClick={() => act(api.stopEngine)}>
|
||||
<Icon.Stop size={13} />Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
BIN
desktop/frontend/src/assets/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
@@ -32,11 +32,15 @@ export const api = {
|
||||
|
||||
cameras: () => call('Cameras'),
|
||||
testCamera: (cam) => call('TestCamera', cam),
|
||||
discoverCameras: () => call('DiscoverCameras'),
|
||||
saveCamera: (id, cam) => call('SaveCamera', id, cam),
|
||||
deleteCamera: (id) => call('DeleteCamera', id),
|
||||
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
|
||||
placementResult: (id) => call('PlacementResult', id),
|
||||
streamURL: (id) => call('StreamURL', id),
|
||||
// The live view of a camera in another building, relayed through head
|
||||
// office. Empty when nobody is signed in.
|
||||
remoteStreamURL: (id) => call('RemoteStreamURL', id),
|
||||
|
||||
live: () => call('Live'),
|
||||
pipelineStatus: () => call('PipelineStatus'),
|
||||
@@ -51,6 +55,7 @@ export const api = {
|
||||
sales: (from, to) => call('Sales', from, to),
|
||||
customers: (q, limit) => call('Customers', q, limit),
|
||||
saveProfile: (p) => call('SaveProfile', p),
|
||||
ask: (history) => call('Ask', history),
|
||||
recordPurchase: (id, amount, items, notes) =>
|
||||
call('RecordPurchase', id, amount, items, notes),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
// Dev only: ?mock=<scenario> renders the app in a browser with fake bindings.
|
||||
if (import.meta.env.DEV) await import('./mock.js')
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode><App /></React.StrictMode>
|
||||
)
|
||||
|
||||
83
desktop/frontend/src/mock.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// A stand-in for the Go bindings, for looking at screens in a browser.
|
||||
//
|
||||
// The app only exists inside Wails, so until this there was no way to put a
|
||||
// screen in front of somebody without a Windows build - which is how the
|
||||
// first-run experience went unreviewed. Loaded ONLY by the dev server and only
|
||||
// with ?mock=<scenario>; a production bundle never contains it.
|
||||
//
|
||||
// ?mock=fresh first launch after install: unclaimed, engine starting
|
||||
// ?mock=standalone chose "run on this PC only", no cameras yet
|
||||
// ?mock=claimed claimed, signed in, one camera, arrivals flowing
|
||||
//
|
||||
// State is in memory and advances as you click, so the flow can be walked.
|
||||
const scenario = new URLSearchParams(location.search).get('mock')
|
||||
|
||||
if (scenario) {
|
||||
const st = {
|
||||
claimed: scenario === 'claimed',
|
||||
standalone: scenario === 'standalone',
|
||||
logged_in: scenario === 'claimed',
|
||||
cameras: scenario === 'claimed' ? [{id: 'entrance', label: 'Entrance', host: '192.168.1.122', port: 554, path: '/ch0_1.264', username: 'admin', has_password: true, enabled: true}] : [],
|
||||
engine: scenario === 'fresh' ? 'starting' : 'running',
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
const user = {id: 'u1', email: 'suriya@tenext.in', full_name: 'Suriya', role: 'owner', client_id: 'c1', client_name: 'TeNext Retail'}
|
||||
const session = () => ({logged_in: st.logged_in, user: st.logged_in ? user : {}, site_name: st.claimed ? 'TeNext Chennai' : '', claimed: st.claimed, standalone: st.standalone && !st.claimed})
|
||||
const now = () => new Date().toISOString()
|
||||
const arrivals = () => st.cameras.length === 0 ? [] : [
|
||||
{type: 'person.seen', ts: now(), camera_id: 'entrance', data: {label: 'Visitor 3', identity_id: 3, similarity: 0.61, gender: 'Male', age: 34, emotion: 'neutral'}},
|
||||
{type: 'person.new', ts: new Date(Date.now() - 95e3).toISOString(), camera_id: 'entrance', data: {label: 'Visitor 7', identity_id: 7, gender: 'Female', age: 28}},
|
||||
{type: 'person.seen', ts: new Date(Date.now() - 410e3).toISOString(), camera_id: 'entrance', data: {label: 'Priya', identity_id: 2, similarity: 0.72, gender: 'Female', age: 41}},
|
||||
]
|
||||
const delay = (v, ms = 120) => new Promise(r => setTimeout(() => r(v), ms))
|
||||
const App = {
|
||||
Session: () => delay(session()),
|
||||
Login: (email) => { st.logged_in = true; user.email = email || user.email; return delay(session()) },
|
||||
Logout: () => { st.logged_in = false; return delay(session()) },
|
||||
Claim: (code) => code.replace(/[^A-Z0-9]/gi, '').length >= 20
|
||||
? (st.claimed = true, st.standalone = false, delay(session(), 900))
|
||||
: Promise.reject(new Error('That installation code is not valid. Ask for a new one.')),
|
||||
RunStandalone: () => { st.standalone = true; return delay(session()) },
|
||||
EngineStatus: () => {
|
||||
// The engine takes a minute or two on first run (models download).
|
||||
if (st.engine === 'starting' && Date.now() - st.startedAt > 20000) st.engine = 'running'
|
||||
const cams = Object.fromEntries(st.cameras.map(c => [c.id, true]))
|
||||
return delay({state: st.engine === 'starting' ? 'running' : 'running', reachable: st.engine !== 'starting', recognition_model: st.engine === 'starting' ? '' : 'w600k_r50', cameras: cams, restarts: 0})
|
||||
},
|
||||
StartEngine: () => delay({state: 'running', reachable: true}),
|
||||
StopEngine: () => delay({state: 'stopped', reachable: false}),
|
||||
Cameras: () => delay(st.cameras.map(c => ({...c, connected: true, frames: 1200, faces: 9}))),
|
||||
DiscoverCameras: () => delay({networks: ['192.168.1.0/24'], cameras: [
|
||||
{host: '192.168.1.122', rtsp: true, onvif: true, name: 'HIKVISION DS-2CD2043G2', make: 'hikvision'},
|
||||
{host: '192.168.1.121', rtsp: true, onvif: true, name: 'IPC-model IPC', make: ''},
|
||||
{host: '192.168.1.40', rtsp: true, onvif: false, name: '', make: ''},
|
||||
]}, 2500),
|
||||
TestCamera: (cam) => delay({ok: Boolean(cam.host), width: 800, height: 448, codec: 'hevc', error: cam.host ? '' : 'no host'}, 1500),
|
||||
SaveCamera: (id, cam) => { const c = {id: id || cam.id || 'cam' + (st.cameras.length + 1), ...cam, has_password: Boolean(cam.password)}; delete c.password; st.cameras = [...st.cameras.filter(x => x.id !== c.id), c]; return delay(c) },
|
||||
DeleteCamera: (id) => { st.cameras = st.cameras.filter(c => c.id !== id); return delay(null) },
|
||||
StartPlacementCheck: () => delay({state: 'running'}),
|
||||
PlacementResult: () => delay({state: 'finished', verdict: 'good', headline: 'faces recognised on a walk-past', advice: []}),
|
||||
StreamURL: () => '',
|
||||
Live: () => delay({stats: {cameras: st.cameras.map(c => ({camera_id: c.id, connected: true, pipeline: {best_quality: {n: 40, fraction_below_gate: 0.18}}})), gallery: {identities: st.cameras.length ? 7 : 0, sightings: st.cameras.length ? 44 : 0}}, events: arrivals()}),
|
||||
PipelineStatus: () => delay({webhook_url: 'http://127.0.0.1:53658/events', queued: 0, dropped: 0, claimed: st.claimed, standalone: st.standalone && !st.claimed, broker_up: st.claimed, accepted: st.claimed ? 12 : 0}),
|
||||
LocalIdentities: () => delay([]), LocalSightings: () => delay([]),
|
||||
Footfall: () => delay({total: 0, buckets: []}), Sites: () => delay([]),
|
||||
VisitorHistory: () => delay([]), VisitorPhoto: () => delay({available: false, reason: 'This system is not storing images.'}),
|
||||
ForgetCustomer: () => delay(null), Sales: () => delay([]),
|
||||
Customers: () => delay([{id: 'v1', label: 'Priya', number: 2, first_seen_at: now(), last_seen_at: now(), visits: 6}]),
|
||||
SaveProfile: () => delay(null), RecordPurchase: () => delay(null),
|
||||
Ask: (history) => {
|
||||
const q = history[history.length - 1]?.text ?? ''
|
||||
if (!st.claimed) return Promise.reject(new Error('The assistant is not switched on for this server.'))
|
||||
const text = /camera/i.test(q)
|
||||
? 'Go to Cameras and press Add camera. The address is on a sticker on the camera itself; pick the make and I fill in the stream path. Test it, save it, then walk past it once so I can tell you whether the placement works.'
|
||||
: /code|install/i.test(q)
|
||||
? 'Whoever runs head office makes one: open the shop there, press Set up a shop PC, and read the code out. It works once.'
|
||||
: /who|morning|came/i.test(q)
|
||||
? 'Three people so far: Priya at 13:12 (her sixth visit), a new face at 13:18 I have called Visitor 7, and Visitor 3 just now.'
|
||||
: 'Chennai is online and the door camera is connected, but nobody has proved it yet. Walk past it once with Check placement running and I will tell you if it can actually see faces - until then a quiet screen might just be a badly aimed camera.'
|
||||
return delay({text, used: /camera|code/i.test(q) ? [] : ['site_status', 'cameras']}, 1400)
|
||||
},
|
||||
}
|
||||
window.go = {main: {App}}
|
||||
}
|
||||
@@ -1,252 +1,665 @@
|
||||
/* Behavision desktop — an instrument panel, not a website.
|
||||
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
|
||||
but not cramped, and state readable at a glance from across a counter. */
|
||||
/* Behavision desktop — a shop-floor instrument, not a website.
|
||||
===========================================================================
|
||||
Designed for one situation: a PC behind a counter, on a cheap monitor, in a
|
||||
room with daylight, glanced at by somebody who is mid-conversation with a
|
||||
customer. Everything below follows from that.
|
||||
|
||||
- Dark, because the screen sits in peripheral vision all day and a white
|
||||
field at 1000 lux is a lamp pointed at the operator.
|
||||
- State is carried by shape AND colour: a pill, a dot and an edge stripe,
|
||||
never colour alone. This gets read from two metres away, and some
|
||||
operators do not see red and green apart.
|
||||
- One spacing scale and one type scale. The previous version set margins
|
||||
inline, per screen, which is how a UI ends up looking assembled rather
|
||||
than designed.
|
||||
- Motion only where it carries meaning: a live camera, a fresh arrival.
|
||||
Nothing loops for decoration — this process shares a CPU with recognition.
|
||||
=========================================================================== */
|
||||
|
||||
:root {
|
||||
--ground: #0E1317;
|
||||
--surface: #161D23;
|
||||
--surface-2: #1D262D;
|
||||
--line: #27333B;
|
||||
--line-soft: #1F2A31;
|
||||
--ink: #E7EEF3;
|
||||
--ink-2: #B4C2CC;
|
||||
--muted: #7C8B97;
|
||||
--accent: #45B0C7;
|
||||
--accent-dim:#123039;
|
||||
--ok: #4FB37B;
|
||||
--warn: #E0A33A;
|
||||
--bad: #E0655A;
|
||||
--radius: 8px;
|
||||
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
/* ground → raised, four steps, blue-green biased: the product lives in the
|
||||
world of lenses and CCTV, and a neutral grey reads as unfinished. */
|
||||
--bg: #0A0F13;
|
||||
--s1: #111A20;
|
||||
--s2: #17232B;
|
||||
--s3: #1E2D37;
|
||||
--line: #223038;
|
||||
--line-2: #1A252C;
|
||||
|
||||
--ink: #ECF3F7;
|
||||
--ink-2: #A3B6C2;
|
||||
--ink-3: #6C808D;
|
||||
|
||||
/* Accent is for state and focus only, never decoration, so that when it does
|
||||
appear the eye goes to it. */
|
||||
--accent: #40C4DC;
|
||||
--accent-2: #0F3B47;
|
||||
--accent-3: #0B2A33;
|
||||
|
||||
--ok: #48C78E; --ok-2: #102E22;
|
||||
--warn: #EAAA3D; --warn-2: #31260F;
|
||||
--bad: #EC6A5C; --bad-2: #331815;
|
||||
|
||||
--r-sm: 6px; --r: 10px; --r-lg: 14px;
|
||||
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
|
||||
--sp-5: 20px; --sp-6: 24px; --sp-7: 32px; --sp-8: 40px;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / .4), 0 8px 24px -12px rgb(0 0 0 / .6);
|
||||
--shadow-lg: 0 2px 4px rgb(0 0 0 / .4), 0 24px 48px -16px rgb(0 0 0 / .7);
|
||||
|
||||
/* Segoe UI Variable first: it is on every Windows 11 shop PC, it has real
|
||||
optical sizes, and it is what makes this look like an application rather
|
||||
than a web page in a frame. No webfont — a shop PC has no internet at
|
||||
install time, and a font that fails to arrive is a layout that shifts
|
||||
under the operator. */
|
||||
--font: "Segoe UI Variable Text", "Segoe UI", Inter, -apple-system,
|
||||
BlinkMacSystemFont, system-ui, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: "Segoe UI Variable Display", var(--font);
|
||||
--mono: "Cascadia Mono", "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
|
||||
/* Kept as aliases so any screen not yet rewritten keeps its colours. */
|
||||
--ground: var(--bg); --surface: var(--s1); --surface-2: var(--s2);
|
||||
--line-soft: var(--line-2); --muted: var(--ink-3); --radius: var(--r);
|
||||
--accent-dim: var(--accent-3);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
background: var(--ground);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ---------------------------------------------------------------- shell -- */
|
||||
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
input, textarea { user-select: text; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
||||
::selection { background: var(--accent-2); color: var(--ink); }
|
||||
|
||||
/* Digits that line up wherever they are compared or refreshed in place. */
|
||||
.num, .value, .metric-v, .when, .mono, .code, td { font-variant-numeric: tabular-nums; }
|
||||
.mono, .code { font-family: var(--mono); }
|
||||
|
||||
/* The default light scrollbar on a dark panel is the most obvious "this is a
|
||||
web page" tell there is. */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--s3) transparent; }
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--s3); border-radius: 99px; border: 3px solid var(--bg); }
|
||||
*::-webkit-scrollbar-thumb:hover { background: #2A3D49; }
|
||||
|
||||
/* ================================================================ shell == */
|
||||
|
||||
.shell { display: grid; grid-template-columns: 232px 1fr auto; height: 100%; }
|
||||
|
||||
.side {
|
||||
background: var(--surface); border-right: 1px solid var(--line);
|
||||
background: var(--s1); border-right: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.side .brand {
|
||||
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
|
||||
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.side .brand { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); }
|
||||
.side .brand .mark { width: 30px; height: 30px; flex: none; display: grid; place-items: center; }
|
||||
.side .brand .mark img, .login .mark img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.side .brand .id { min-width: 0; }
|
||||
.side .brand h1 { font-family: var(--font-display); font-size: 15px; font-weight: 600; letter-spacing: -.012em; line-height: 1.2; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--ink-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.nav { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.nav button {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
|
||||
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
|
||||
position: relative; display: flex; align-items: center; gap: var(--sp-3); width: 100%;
|
||||
background: none; border: 0; border-radius: var(--r-sm); padding: 9px var(--sp-3);
|
||||
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px; font-weight: 450;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
}
|
||||
.nav button:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
|
||||
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
|
||||
|
||||
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
|
||||
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
|
||||
display: block; line-height: 1.4; }
|
||||
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
|
||||
.main { min-width: 0; min-height: 0; overflow-y: auto; }
|
||||
.page { padding: 22px 26px 40px; max-width: 1180px; }
|
||||
.page > header { margin-bottom: 18px; }
|
||||
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
/* --------------------------------------------------------------- pieces -- */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 16px;
|
||||
.nav button svg { flex: none; opacity: .9; }
|
||||
.nav button:hover { background: var(--s2); color: var(--ink); }
|
||||
.nav button[aria-current="page"] { background: var(--accent-3); color: var(--accent); font-weight: 550; }
|
||||
/* A rail, not a background wash: it survives being looked at sideways. */
|
||||
.nav button[aria-current="page"]::before {
|
||||
content: ""; position: absolute; left: -12px; top: 7px; bottom: 7px;
|
||||
width: 2.5px; border-radius: 0 2px 2px 0; background: var(--accent);
|
||||
}
|
||||
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
|
||||
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
|
||||
font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
|
||||
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
|
||||
/* The one control that starts and stops the product, so it gets its own block
|
||||
at the foot rather than a row in a list. */
|
||||
.enginebox {
|
||||
margin: var(--sp-3); padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border: 1px solid var(--line); border-radius: var(--r); background: var(--s2);
|
||||
}
|
||||
.enginebox .row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.enginebox .state { font-size: 12.5px; font-weight: 600; letter-spacing: -.005em; }
|
||||
.enginebox .label { display: block; color: var(--ink-3); font-size: 11px; line-height: 1.45; margin-top: 3px; font-variant-numeric: tabular-nums; }
|
||||
.enginebox .actions, .enginebox .controls { display: flex; gap: var(--sp-2); margin-top: var(--sp-3); }
|
||||
.enginebox .actions .btn, .enginebox .controls .btn { flex: 1; justify-content: center; padding: 6px 8px; font-size: 12px; }
|
||||
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.bad { background: var(--bad); }
|
||||
.dot.idle { background: var(--muted); }
|
||||
.side .who {
|
||||
padding: var(--sp-3) var(--sp-5) var(--sp-5); border-top: 1px solid var(--line-2);
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
}
|
||||
.side .who .id { min-width: 0; flex: 1; }
|
||||
.side .who .id b { display: block; font-size: 12.5px; font-weight: 550; }
|
||||
.side .who .id span { display: block; font-size: 11px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
|
||||
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
|
||||
color: var(--muted); white-space: nowrap; }
|
||||
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
|
||||
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
|
||||
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
|
||||
.main { min-width: 0; min-height: 0; overflow: auto; position: relative; }
|
||||
|
||||
/* ================================================================= page == */
|
||||
|
||||
.page { padding: var(--sp-6) var(--sp-7) var(--sp-8); max-width: 1500px; }
|
||||
.page > header { margin-bottom: var(--sp-5); }
|
||||
.page > header h2, .page h2 { font-family: var(--font-display); font-size: 22px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
|
||||
.page > header p, .page header p { color: var(--ink-3); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap; }
|
||||
|
||||
h3 { font-size: 11px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
|
||||
|
||||
/* ================================================================ cards == */
|
||||
|
||||
.card { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-4); }
|
||||
.card > h3 { margin-bottom: var(--sp-3); }
|
||||
.card.flush { padding: 0; overflow: hidden; }
|
||||
|
||||
.panel { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
|
||||
.panel > .panelhead {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--line-2);
|
||||
background: linear-gradient(var(--s2), var(--s1)); flex: none;
|
||||
}
|
||||
.panel > .panelhead h3 { margin: 0; }
|
||||
.panel > .panelbody { padding: var(--sp-4); min-height: 0; overflow: auto; }
|
||||
.panel > .panelbody.flush { padding: 0; }
|
||||
|
||||
.grid { display: grid; gap: var(--sp-4); }
|
||||
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
@media (max-width: 1180px) { .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
|
||||
@media (max-width: 980px) { .cols-2, .cols-3 { grid-template-columns: minmax(0,1fr); } }
|
||||
|
||||
/* Four equal boxes used to dominate this screen. The numbers matter, but they
|
||||
are not what anybody opens the app to see. */
|
||||
.metrics {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
|
||||
gap: 1px; background: var(--line); border: 1px solid var(--line);
|
||||
border-radius: var(--r); overflow: hidden;
|
||||
}
|
||||
.metric { background: var(--s1); padding: var(--sp-3) var(--sp-4) var(--sp-4); }
|
||||
.metric .metric-k { font-size: 10.5px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.metric .metric-v { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; margin-top: 5px; }
|
||||
.metric .metric-s { font-size: 11.5px; color: var(--ink-3); margin-top: 3px; line-height: 1.4; }
|
||||
.metric.ok .metric-v { color: var(--ok); }
|
||||
.metric.warn .metric-v { color: var(--warn); }
|
||||
.metric.bad .metric-v { color: var(--bad); }
|
||||
|
||||
/* legacy .stat, for screens not yet rewritten */
|
||||
.stat h3 { margin-bottom: var(--sp-2); }
|
||||
.stat .value { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; }
|
||||
.stat .unit { font-size: 15px; color: var(--ink-3); margin-left: 3px; }
|
||||
.stat .sub { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; line-height: 1.4; }
|
||||
|
||||
/* =============================================================== status == */
|
||||
|
||||
.dot { width: 7px; height: 7px; border-radius: 99px; flex: none; background: var(--ink-3); }
|
||||
.dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 18%, transparent); }
|
||||
.dot.warn { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 18%, transparent); }
|
||||
.dot.bad { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 18%, transparent); }
|
||||
.dot.idle { background: var(--ink-3); }
|
||||
|
||||
/* A live camera is the one thing that should breathe: it is how an operator
|
||||
knows the picture is not frozen. Everything else holds still. */
|
||||
.dot.live { background: var(--ok); animation: pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); }
|
||||
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ok) 0%, transparent); }
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px 3px 7px;
|
||||
border-radius: 99px; font-size: 11px; font-weight: 600; letter-spacing: .02em;
|
||||
background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); white-space: nowrap;
|
||||
}
|
||||
.pill.ok { background: var(--ok-2); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 28%, transparent); }
|
||||
.pill.warn { background: var(--warn-2); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 28%, transparent); }
|
||||
.pill.bad { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 28%, transparent); }
|
||||
.pill.accent { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--r-sm);
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
background: var(--s3); color: var(--ink-2);
|
||||
}
|
||||
.tag.new { background: var(--accent-3); color: var(--accent); }
|
||||
.tag.seen { background: var(--ok-2); color: var(--ok); }
|
||||
.tag.miss { background: var(--warn-2); color: var(--warn); }
|
||||
|
||||
/* One line that answers "is this shop working" above everything else. */
|
||||
.statusbar {
|
||||
display: flex; align-items: center; gap: var(--sp-5); flex-wrap: wrap;
|
||||
padding: var(--sp-3) var(--sp-4); background: var(--s1);
|
||||
border: 1px solid var(--line); border-radius: var(--r); margin-bottom: var(--sp-4);
|
||||
}
|
||||
.statusbar .item { display: flex; align-items: center; gap: var(--sp-2); font-size: 12.5px; }
|
||||
.statusbar .item b { font-weight: 600; letter-spacing: -.005em; }
|
||||
.statusbar .item svg { color: var(--ink-3); }
|
||||
.statusbar .sep { width: 1px; align-self: stretch; background: var(--line); }
|
||||
.statusbar .grow { flex: 1; }
|
||||
|
||||
/* ============================================================= arrivals == */
|
||||
|
||||
/* The reason the product exists, so it gets the width and the weight. */
|
||||
.arrivals { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); }
|
||||
|
||||
.arrival {
|
||||
display: grid; grid-template-columns: 46px 1fr auto; gap: var(--sp-3); align-items: center;
|
||||
padding: var(--sp-3); border-radius: var(--r);
|
||||
background: var(--s2); border: 1px solid var(--line-2);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.arrival::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2.5px; background: var(--ink-3); }
|
||||
.arrival.is-new::before { background: var(--accent); }
|
||||
.arrival.is-seen::before { background: var(--ok); }
|
||||
.arrival.is-miss::before { background: var(--warn); }
|
||||
|
||||
/* Only the newest row animates, and only once. */
|
||||
.arrival.fresh { animation: slidein .28s cubic-bezier(.2,.8,.3,1); }
|
||||
@keyframes slidein { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.arrival .avatar {
|
||||
width: 46px; height: 46px; border-radius: var(--r-sm); display: grid; place-items: center;
|
||||
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
|
||||
font-family: var(--font-display); font-size: 15px; font-weight: 600;
|
||||
color: var(--ink-2); letter-spacing: -.01em; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.arrival .avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.arrival.is-new .avatar { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
|
||||
|
||||
.arrival .who { min-width: 0; }
|
||||
.arrival .who .name { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.arrival .who .meta { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.arrival .right { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }
|
||||
.arrival .right .when { font-size: 11.5px; color: var(--ink-3); }
|
||||
|
||||
/* ================================================================ feeds == */
|
||||
|
||||
.feeds { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); padding: var(--sp-3); }
|
||||
|
||||
.feed { position: relative; border-radius: var(--r); overflow: hidden; background: #05090C; border: 1px solid var(--line); aspect-ratio: 16 / 9; }
|
||||
.feed img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.feed .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
|
||||
/* Caption over the picture, not beneath it: the tile stays a picture. */
|
||||
.feed .cap {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
||||
padding: var(--sp-5) var(--sp-3) var(--sp-3);
|
||||
background: linear-gradient(transparent, rgb(0 0 0 / .8));
|
||||
font-size: 12.5px; font-weight: 600; letter-spacing: -.005em;
|
||||
}
|
||||
|
||||
/* ================================================================ lists == */
|
||||
|
||||
.events, .timeline { list-style: none; padding: 0; display: flex; flex-direction: column; }
|
||||
.events li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-4); border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.timeline li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.events li:last-child, .timeline li:last-child { border-bottom: 0; }
|
||||
.events .when, .timeline .when { font-size: 11.5px; color: var(--ink-3); width: 46px; flex: none; }
|
||||
|
||||
.empty {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: var(--sp-3); padding: var(--sp-8) var(--sp-5); color: var(--ink-3); text-align: center; font-size: 12.5px;
|
||||
}
|
||||
.empty svg { opacity: .35; }
|
||||
.empty b { display: block; color: var(--ink-2); font-size: 13.5px; font-weight: 550; }
|
||||
.empty p { max-width: 34ch; line-height: 1.5; }
|
||||
|
||||
.tablewrap { overflow: auto; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th {
|
||||
text-align: left; padding: 9px var(--sp-4); font-size: 10.5px; font-weight: 600;
|
||||
letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3);
|
||||
background: var(--s2); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 1;
|
||||
}
|
||||
td { padding: 10px var(--sp-4); border-bottom: 1px solid var(--line-2); }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr[role="button"], tbody tr.clickable { cursor: pointer; }
|
||||
tbody tr[role="button"]:hover, tbody tr.clickable:hover { background: var(--s2); }
|
||||
|
||||
/* ============================================================= controls == */
|
||||
|
||||
.btn {
|
||||
background: var(--surface-2); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
|
||||
color: var(--ink); white-space: nowrap;
|
||||
display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
|
||||
border-radius: var(--r-sm); background: var(--s3); color: var(--ink);
|
||||
border: 1px solid var(--line); cursor: pointer;
|
||||
font-size: 13px; font-weight: 550; letter-spacing: -.005em; white-space: nowrap;
|
||||
transition: background .12s ease, border-color .12s ease, transform .06s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #26323a; }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
|
||||
font-weight: 600; }
|
||||
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
|
||||
.btn.danger { color: var(--bad); border-color: #4a2823; }
|
||||
.btn.sm { padding: 4px 9px; font-size: 12px; }
|
||||
.btn:hover:not(:disabled) { background: #253643; border-color: #2E414E; }
|
||||
.btn:active:not(:disabled) { transform: translateY(.5px); }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn svg { flex: none; }
|
||||
.btn.primary { background: var(--accent); color: #04171C; border-color: transparent; font-weight: 600; }
|
||||
.btn.primary:hover:not(:disabled) { background: #55D0E6; }
|
||||
.btn.danger { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 32%, transparent); }
|
||||
.btn.danger:hover:not(:disabled) { background: #43201C; }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.ghost:hover:not(:disabled) { background: var(--s2); }
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn.icon { padding: 7px; }
|
||||
|
||||
.field { display: block; margin-bottom: 12px; }
|
||||
.field span { display: block; font-size: 11.5px; color: var(--muted);
|
||||
margin-bottom: 4px; letter-spacing: .01em; }
|
||||
.linkbtn { background: none; border: 0; color: var(--accent); cursor: pointer; font-size: 12.5px; padding: 2px 0; text-align: left; }
|
||||
.linkbtn:hover { text-decoration: underline; }
|
||||
|
||||
.seg { display: inline-flex; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 2px; gap: 2px; }
|
||||
.seg button { background: none; border: 0; border-radius: 4px; padding: 5px 11px; color: var(--ink-3); cursor: pointer; font-size: 12.5px; font-weight: 500; }
|
||||
.seg button[aria-pressed="true"] { background: var(--s3); color: var(--ink); }
|
||||
|
||||
/* ================================================================ forms == */
|
||||
|
||||
.field { display: block; margin-bottom: var(--sp-4); }
|
||||
.field > span { display: block; font-size: 11.5px; font-weight: 550; color: var(--ink-2); margin-bottom: 6px; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%; background: var(--ground); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
|
||||
user-select: text;
|
||||
width: 100%; padding: 9px 11px; background: var(--s2); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: var(--r-sm);
|
||||
transition: border-color .12s ease, background .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.field input::placeholder { color: var(--ink-3); }
|
||||
.field input:hover, .field select:hover, .field textarea:hover { border-color: #2C3D49; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
border-color: var(--accent); outline: none;
|
||||
outline: none; border-color: var(--accent); background: var(--s1); box-shadow: 0 0 0 3px var(--accent-3);
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 66px; }
|
||||
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
|
||||
.field .hint { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 5px; line-height: 1.45; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: var(--muted); font-weight: 600;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr.click { cursor: pointer; }
|
||||
tbody tr.click:hover { background: var(--surface-2); }
|
||||
td.num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.fieldrow { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-3); }
|
||||
.fieldrow.three { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
|
||||
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
|
||||
.err {
|
||||
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
|
||||
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
|
||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
||||
background: var(--bad-2); color: var(--bad);
|
||||
border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent);
|
||||
border-radius: var(--r-sm); padding: 9px 11px; font-size: 12.5px; line-height: 1.45; margin-bottom: var(--sp-4);
|
||||
}
|
||||
.note { color: var(--muted); font-size: 12.5px; }
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
.err svg { flex: none; margin-top: 1px; }
|
||||
|
||||
/* --------------------------------------------------------------- login --- */
|
||||
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
|
||||
.login .box { width: 100%; max-width: 380px; }
|
||||
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
|
||||
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
|
||||
.login form { background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 20px; }
|
||||
.login .btn { width: 100%; margin-top: 6px; }
|
||||
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
|
||||
text-align: center; line-height: 1.5; }
|
||||
/* The second way out of the setup screen: a shop with no head office. Styled
|
||||
quieter than the form above it because linking is still the common case,
|
||||
but present, because for a single-till shop it is the only one that works. */
|
||||
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
|
||||
border-top: 1px solid var(--line-soft); }
|
||||
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
|
||||
.note { color: var(--ink-3); font-size: 12px; line-height: 1.5; }
|
||||
.note.warn { color: var(--warn); }
|
||||
.note.bad { color: var(--bad); }
|
||||
.lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; }
|
||||
.sm { font-size: 12px; }
|
||||
.lbl, .key { color: var(--ink-3); font-size: 11.5px; }
|
||||
.grow { flex: 1; }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
|
||||
/* ================================================================= gate == */
|
||||
|
||||
/* Login and Setup: the first thing anybody sees, and previously a grey box on
|
||||
a grey field. One soft light behind the card gives the window a centre and
|
||||
costs nothing — it is a static gradient, not an animation. */
|
||||
.login {
|
||||
height: 100%; display: grid; place-items: center; padding: var(--sp-6); overflow: auto;
|
||||
background: radial-gradient(900px 480px at 50% -10%, #10303A 0%, transparent 62%), var(--bg);
|
||||
}
|
||||
.login .box {
|
||||
width: 100%; max-width: 396px; background: var(--s1); border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg); padding: var(--sp-7); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.login .mark { width: 44px; height: 44px; margin-bottom: var(--sp-4); display: grid; place-items: center; }
|
||||
.login h1 { font-family: var(--font-display); font-size: 21px; font-weight: 600; letter-spacing: -.022em; }
|
||||
.login .lead { margin: 6px 0 var(--sp-5); }
|
||||
.login .btn { width: 100%; justify-content: center; margin-top: var(--sp-1); }
|
||||
.login .foot { font-size: 11.5px; color: var(--ink-3); line-height: 1.55; margin-top: var(--sp-5); padding-top: var(--sp-4); border-top: 1px solid var(--line-2); }
|
||||
.login .alt { margin-top: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.login .alt .btn { margin-top: 0; }
|
||||
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
|
||||
font: inherit; font-size: 12.5px; color: var(--accent);
|
||||
text-decoration: underline; text-underline-offset: 3px; }
|
||||
.linkbtn:hover { color: var(--ink); }
|
||||
.login .note { margin: 0; }
|
||||
|
||||
/* ---------------------------------------------------------------- live --- */
|
||||
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
||||
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
overflow: hidden; }
|
||||
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
|
||||
.feed .cap { display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
|
||||
/* =============================================================== drawer == */
|
||||
|
||||
.events { list-style: none; max-height: 420px; overflow-y: auto; }
|
||||
.events li { display: flex; gap: 9px; align-items: baseline;
|
||||
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.events li:last-child { border-bottom: 0; }
|
||||
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
|
||||
flex: none; }
|
||||
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
|
||||
background: var(--surface-2); color: var(--muted); }
|
||||
.tag.new { background: #17364f; color: #86c2ec; }
|
||||
.tag.seen { background: #14301f; color: #7fcb9c; }
|
||||
.tag.miss { background: #3a1c1a; color: #eb9a92; }
|
||||
.drawer { position: fixed; inset: 0; z-index: 40; background: rgb(4 8 11 / .6); display: flex; justify-content: flex-end; animation: fade .16s ease; }
|
||||
@keyframes fade { from { opacity: 0 } to { opacity: 1 } }
|
||||
.drawer .sheet {
|
||||
width: min(540px, 100%); height: 100%; overflow: auto;
|
||||
background: var(--s1); border-left: 1px solid var(--line); box-shadow: var(--shadow-lg);
|
||||
animation: slidein-r .2s cubic-bezier(.2,.8,.3,1);
|
||||
}
|
||||
@keyframes slidein-r { from { transform: translateX(16px); opacity: .6 } to { transform: none; opacity: 1 } }
|
||||
.drawer .sheethead {
|
||||
position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); background: var(--s1); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.drawer .sheethead h2 { font-family: var(--font-display); font-size: 17px; font-weight: 600; letter-spacing: -.015em; }
|
||||
.drawer .sheetbody { padding: var(--sp-5); }
|
||||
.drawer .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
|
||||
.drawer .close:hover { background: var(--s2); color: var(--ink); }
|
||||
|
||||
/* -------------------------------------------------------------- charts --- */
|
||||
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
|
||||
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
|
||||
gap: 2px; min-width: 0; }
|
||||
.bars .seg { border-radius: 2px 2px 0 0; }
|
||||
.bars .seg.ret { background: var(--accent); }
|
||||
.bars .seg.new { background: #2f6f81; }
|
||||
.axis { display: flex; justify-content: space-between; color: var(--muted);
|
||||
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
|
||||
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
|
||||
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
|
||||
margin-right: 5px; vertical-align: -1px; }
|
||||
.avatar {
|
||||
width: 44px; height: 44px; border-radius: var(--r-sm); flex: none; display: grid; place-items: center;
|
||||
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
|
||||
font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* --------------------------------------------------------------- drawer -- */
|
||||
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
|
||||
display: flex; justify-content: flex-end; z-index: 30; }
|
||||
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
|
||||
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
|
||||
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
|
||||
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
|
||||
/* Close lives in the sticky header (.who) now. Positioned against the fixed
|
||||
overlay it stayed put while the sheet scrolled underneath it, printing the
|
||||
button on top of whatever happened to be at the top of the viewport. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* -- customer record ---------------------------------------------------- */
|
||||
/* Full-bleed sticky header: a customer record is long enough to scroll, and
|
||||
both the name and the way out have to stay reachable. The negative margins
|
||||
cancel the panel's padding so the background covers the full width. */
|
||||
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
|
||||
align-items: flex-start; background: var(--surface);
|
||||
margin: -20px -22px 18px; padding: 20px 22px 14px;
|
||||
border-bottom: 1px solid var(--line-soft); }
|
||||
.who .grow { flex: 1; min-width: 0; }
|
||||
.who h3 { margin-bottom: 2px; }
|
||||
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
|
||||
object-fit: cover; background: var(--ground);
|
||||
border: 1px solid var(--line); }
|
||||
.avatar.none { display: grid; place-items: center; color: var(--muted);
|
||||
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
|
||||
/* Cameras and arrivals side by side, the same height, each scrolling its own
|
||||
content. Left to itself the arrivals panel shrank to fit two cards and left
|
||||
a hole beside a tall camera tile - the layout looked broken precisely when
|
||||
the shop was quiet, which is most of the time. */
|
||||
.live-split {
|
||||
display: grid; gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
min-height: 420px;
|
||||
}
|
||||
.live-split > .panel { max-height: 62vh; }
|
||||
@media (max-width: 1100px) {
|
||||
.live-split { grid-template-columns: minmax(0, 1fr); }
|
||||
.live-split > .panel { max-height: none; }
|
||||
}
|
||||
|
||||
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
|
||||
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
|
||||
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.timeline li:last-child { border-bottom: 0; }
|
||||
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
|
||||
flex: none; min-width: 108px; }
|
||||
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Arrivals alone on the Live screen: one column, capped so a long day scrolls
|
||||
inside the panel rather than pushing the metrics off the bottom. */
|
||||
.arrivals-panel { max-height: 64vh; margin-bottom: var(--sp-4); }
|
||||
.arrivals-panel .arrivals { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--sp-2); }
|
||||
|
||||
/* Visually separated from Save: this is the one control in the sheet that
|
||||
cannot be undone, and it must not read as just another button in a row. */
|
||||
.danger-zone { margin-top: 22px; border-color: #4a2823; }
|
||||
.danger-zone > h3 { color: var(--bad); }
|
||||
.danger-zone .note { margin-bottom: 10px; }
|
||||
/* =============================================================== cameras == */
|
||||
|
||||
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
|
||||
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
|
||||
margin-bottom: 12px; }
|
||||
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
|
||||
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); margin-bottom: 5px; }
|
||||
.confirm .lbl.bad { color: var(--bad); }
|
||||
.confirm ul { list-style: none; font-size: 12.5px; }
|
||||
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
|
||||
.confirm li::before { content: '·'; position: absolute; left: 2px;
|
||||
color: var(--muted); }
|
||||
.confirm .row { display: flex; gap: 8px; }
|
||||
.pagehead { display: flex; justify-content: space-between; align-items: flex-end; gap: var(--sp-4); }
|
||||
.page > header.pagehead { margin-bottom: var(--sp-5); }
|
||||
|
||||
.camgrid { display: grid; gap: var(--sp-4); grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); }
|
||||
.camcard { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; }
|
||||
.camview { position: relative; aspect-ratio: 16 / 9; background: #05090C; }
|
||||
.camview img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.camview .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
|
||||
.camview .pill.over { position: absolute; top: 10px; right: 10px; backdrop-filter: blur(6px); }
|
||||
.cambody { padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.camtitle { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--sp-3); }
|
||||
.camtitle h3 { font-family: var(--font-display); font-size: 16px; font-weight: 600; letter-spacing: -.012em; text-transform: none; color: var(--ink); margin: 0 0 2px; }
|
||||
.camtitle .note { font-size: 12px; }
|
||||
.camactions { display: flex; gap: 6px; flex: none; }
|
||||
.camproof { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-3); border-radius: var(--r); background: var(--s2); border: 1px solid var(--line-2); }
|
||||
.camproof .note { font-size: 12px; line-height: 1.45; }
|
||||
@media (max-width: 640px) { .camproof { grid-template-columns: 1fr; } }
|
||||
|
||||
.empty.tall { padding: var(--sp-8) var(--sp-6); }
|
||||
.empty.tall .btn { margin-top: var(--sp-3); }
|
||||
|
||||
/* The sheet is a form that reads top to bottom: a sentence saying what is
|
||||
needed, three short sections, the result of the test, the actions. */
|
||||
.drawer .sheet { width: min(600px, 100%); }
|
||||
.sheetbody .lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; margin-bottom: var(--sp-5); }
|
||||
.formsection { margin-bottom: var(--sp-5); }
|
||||
.formsection h4 { font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 var(--sp-3); padding-bottom: 6px; border-bottom: 1px solid var(--line-2); }
|
||||
.field .hint { font-style: normal; }
|
||||
.fieldrow { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.fieldrow .field.narrow { max-width: 140px; }
|
||||
.fieldrow:has(.field.narrow) { grid-template-columns: minmax(0, 1fr) 140px; }
|
||||
.field input.mono { font-family: var(--font-mono); font-size: 12.5px; }
|
||||
.sheetactions { display: flex; gap: var(--sp-2); justify-content: flex-end; padding-top: var(--sp-4); border-top: 1px solid var(--line-2); margin-top: var(--sp-2); }
|
||||
.testresult { display: flex; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border-radius: var(--r); margin-bottom: var(--sp-4); border: 1px solid; font-size: 13px; }
|
||||
.testresult.ok { background: var(--ok-2); border-color: color-mix(in srgb, var(--ok) 30%, transparent); color: var(--ok); }
|
||||
.testresult.bad { background: var(--bad-2); border-color: color-mix(in srgb, var(--bad) 30%, transparent); color: var(--bad); }
|
||||
.testresult b { display: block; }
|
||||
.testresult span { display: block; color: var(--ink-2); margin-top: 2px; }
|
||||
.testresult img { width: 100%; margin-top: var(--sp-3); border-radius: var(--r-sm); border: 1px solid var(--line); display: block; }
|
||||
|
||||
/* Placement check: two numbered steps, then a verdict box in the tone of the answer. */
|
||||
.steps { list-style: none; counter-reset: step; margin: 0 0 var(--sp-5); padding: 0; display: grid; gap: var(--sp-3); }
|
||||
.steps li { counter-increment: step; position: relative; padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; border-radius: var(--r); border: 1px solid var(--line-2); background: var(--s2); opacity: .55; }
|
||||
.steps li.now, .steps li.done { opacity: 1; }
|
||||
.steps li::before { content: counter(step); position: absolute; left: 16px; top: 14px; width: 24px; height: 24px; border-radius: 50%;
|
||||
display: grid; place-items: center; font-size: 12px; font-weight: 600; background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.steps li.now::before { background: var(--accent); color: #041014; border-color: transparent; }
|
||||
.steps li.done::before { content: '✓'; background: var(--ok-2); color: var(--ok); }
|
||||
.steps b { display: block; font-size: 14px; }
|
||||
.steps span { display: block; font-size: 12.5px; color: var(--ink-2); line-height: 1.5; margin-top: 2px; }
|
||||
.progress { height: 4px; background: var(--s3); border-radius: 2px; overflow: hidden; margin-top: var(--sp-3); }
|
||||
.progress > div { height: 100%; background: var(--accent); transition: width .4s linear; }
|
||||
.verdict { padding: var(--sp-4); border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); }
|
||||
.verdict.ok { border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-2); }
|
||||
.verdict.warn { border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: var(--warn-2); }
|
||||
.verdict.bad { border-color: color-mix(in srgb, var(--bad) 35%, transparent); background: var(--bad-2); }
|
||||
.verdict-head { display: flex; align-items: center; gap: var(--sp-2); font-size: 15px; }
|
||||
.verdict.ok .verdict-head { color: var(--ok); } .verdict.warn .verdict-head { color: var(--warn); } .verdict.bad .verdict-head { color: var(--bad); }
|
||||
.verdict ul { margin: var(--sp-3) 0 0 18px; font-size: 13px; color: var(--ink); line-height: 1.5; }
|
||||
.verdict ul li { margin-bottom: 5px; }
|
||||
.verdict .note { margin-top: var(--sp-3); }
|
||||
.spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite; display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg) } }
|
||||
|
||||
/* ============================================================== assistant == */
|
||||
|
||||
.helpbtn { display: flex; align-items: center; gap: 9px; margin: 0 var(--sp-3) var(--sp-3); padding: 9px 10px; border-radius: var(--r);
|
||||
border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; font-size: 12.5px; font-weight: 550; text-align: left; }
|
||||
.helpbtn img { width: 18px; height: 18px; object-fit: contain; }
|
||||
.helpbtn span { flex: 1; }
|
||||
.helpbtn:hover, .helpbtn[aria-pressed="true"] { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: var(--accent-3); }
|
||||
|
||||
.helper { width: 380px; height: 100%; display: flex; flex-direction: column; min-height: 0;
|
||||
background: var(--s1); border-left: 1px solid var(--line); animation: slidein-r .2s cubic-bezier(.2,.8,.3,1); }
|
||||
.helperhead { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4); border-bottom: 1px solid var(--line); }
|
||||
.helperhead img { width: 26px; height: 26px; object-fit: contain; }
|
||||
.helperhead div { flex: 1; min-width: 0; }
|
||||
.helperhead b { display: block; font-family: var(--font-display); font-size: 14.5px; }
|
||||
.helperhead span { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 1px; }
|
||||
.helperhead .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
|
||||
.helperhead .close:hover { background: var(--s2); color: var(--ink); }
|
||||
.helperbody { flex: 1; overflow: auto; padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.helperintro p { color: var(--ink-2); font-size: 13px; line-height: 1.55; margin-bottom: var(--sp-3); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { border: 1px solid var(--line); background: var(--s2); color: var(--ink); border-radius: 99px; padding: 6px 11px; font-size: 12px; cursor: pointer; text-align: left; }
|
||||
.chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.chip:disabled { opacity: .5; cursor: default; }
|
||||
.helpernote { display: flex; gap: var(--sp-3); padding: var(--sp-3); border-radius: var(--r); background: var(--warn-2); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); color: var(--warn); font-size: 12.5px; }
|
||||
.helpernote b { display: block; } .helpernote span { display: block; color: var(--ink-2); margin-top: 2px; line-height: 1.45; }
|
||||
.turn { display: flex; flex-direction: column; gap: 4px; }
|
||||
.turn.user { align-items: flex-end; }
|
||||
.bubble { max-width: 92%; padding: 10px 13px; border-radius: 14px; font-size: 13.5px; line-height: 1.5; white-space: pre-wrap; }
|
||||
.turn.user .bubble { background: var(--accent); color: #041014; border-bottom-right-radius: 4px; }
|
||||
.turn.assistant .bubble { background: var(--s2); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
|
||||
.turn .used { font-size: 11px; color: var(--ink-3); padding: 0 4px; }
|
||||
.bubble.thinking { display: flex; gap: 4px; padding: 12px 14px; }
|
||||
.bubble.thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); animation: blink 1.2s infinite ease-in-out; }
|
||||
.bubble.thinking span:nth-child(2) { animation-delay: .2s } .bubble.thinking span:nth-child(3) { animation-delay: .4s }
|
||||
@keyframes blink { 0%, 80%, 100% { opacity: .25 } 40% { opacity: 1 } }
|
||||
.helperask { display: flex; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--line); }
|
||||
.helperask input { flex: 1; min-width: 0; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r); padding: 10px 12px; font-size: 13.5px; color: var(--ink); }
|
||||
.helperask input:focus { outline: none; border-color: var(--accent); }
|
||||
.helperask .btn { padding: 0 12px; }
|
||||
.quickhelp { margin-top: var(--sp-5); border-top: 1px solid var(--line-2); padding-top: var(--sp-4); }
|
||||
.quickhelp dt { font-size: 12.5px; font-weight: 600; margin-top: var(--sp-3); }
|
||||
.quickhelp dd { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; margin: 3px 0 0; }
|
||||
@media (max-width: 1100px) { .helper { position: fixed; right: 0; top: 0; bottom: 0; z-index: 30; box-shadow: var(--shadow-lg); } }
|
||||
|
||||
.search { display: flex; align-items: center; gap: 8px; background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: 0 12px; height: 36px; width: min(340px, 100%); color: var(--ink-3); }
|
||||
.search input { flex: 1; min-width: 0; background: none; border: 0; outline: none; color: var(--ink); font-size: 13.5px; }
|
||||
.search:focus-within { border-color: var(--accent); }
|
||||
.sheethead.who { align-items: center; gap: var(--sp-3); }
|
||||
.sheethead.who .grow { flex: 1; min-width: 0; }
|
||||
.sheethead.who .note { margin-top: 2px; font-size: 12px; }
|
||||
.sheethead .close { font-size: 14px; line-height: 1; }
|
||||
.formsection .field:last-child { margin-bottom: 0; }
|
||||
.formsection.dangerzone { margin-top: var(--sp-6); padding: var(--sp-4); border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent); border-radius: var(--r); background: var(--bad-2); }
|
||||
.formsection.dangerzone h4 { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 30%, transparent); }
|
||||
tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
|
||||
|
||||
/* Loya's door: fixed to the top-right of the content area, on every screen. */
|
||||
.loya-fab { position: fixed; top: var(--sp-4); right: var(--sp-5); z-index: 20;
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 14px 8px 10px; border-radius: 99px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); background: var(--s1); color: var(--ink);
|
||||
box-shadow: var(--shadow-lg); cursor: pointer; font-size: 13px; font-weight: 600; }
|
||||
.loya-fab img { width: 20px; height: 20px; object-fit: contain; }
|
||||
.loya-fab:hover { background: var(--accent-3); border-color: var(--accent); }
|
||||
|
||||
/* Camera finder: the pick-list that replaces "type an IP address". */
|
||||
.finder { display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border: 1px dashed var(--line); border-radius: var(--r); background: var(--s2); }
|
||||
.finder p { font-size: 13px; color: var(--ink-2); line-height: 1.5; margin: 0; }
|
||||
.finder.busy { flex-direction: row; align-items: center; color: var(--ink-2); font-size: 13px; border-style: solid; }
|
||||
.foundlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.foundlist li button { width: 100%; display: flex; align-items: center; gap: var(--sp-3); padding: 10px 12px; border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; }
|
||||
.foundlist li button:hover { border-color: var(--accent); }
|
||||
.foundlist li.picked button { border-color: var(--accent); background: var(--accent-3); }
|
||||
.foundlist .mono { font-family: var(--font-mono); font-size: 12.5px; min-width: 120px; }
|
||||
.foundlist .what { flex: 1; color: var(--ink-2); }
|
||||
.foundlist li.rescan button { width: auto; border: 0; background: none; padding: 4px 0; color: var(--ink-3); }
|
||||
|
||||
/* Welcome: two paths, each a card. */
|
||||
.login .box.wide { max-width: 640px; }
|
||||
.choices { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); margin-top: var(--sp-2); }
|
||||
@media (max-width: 720px) { .choices { grid-template-columns: 1fr; } }
|
||||
.choice { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; text-align: left; padding: var(--sp-4);
|
||||
border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; }
|
||||
.choice:hover { border-color: var(--accent); background: var(--accent-3); }
|
||||
.choice b { font-size: 14px; }
|
||||
.choice span { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; }
|
||||
.choice em { font-family: var(--font-mono); font-style: normal; font-size: 11.5px; }
|
||||
.choice svg { color: var(--accent); }
|
||||
.login .foot em { font-style: normal; color: var(--ink-2); }
|
||||
.linkbtn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* Getting started */
|
||||
.starter { margin-bottom: var(--sp-4); }
|
||||
.starter .panelhead { padding: var(--sp-3) var(--sp-4); }
|
||||
.starter .steps { margin: 0; padding: var(--sp-3) var(--sp-4) 0; }
|
||||
.starter .steps.compact li { padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; }
|
||||
.starter .steps li .btn { margin-top: var(--sp-2); }
|
||||
.starter .note { padding: var(--sp-3) var(--sp-4); font-size: 12px; }
|
||||
.btn.ghost { background: none; border-color: transparent; color: var(--ink-3); }
|
||||
.btn.ghost:hover { color: var(--ink); }
|
||||
|
||||
/* Viewer mode: this PC has no engine, so the screens show the company's own
|
||||
data from head office. Informational, not an error - it is the ordinary
|
||||
state of a laptop away from a shop, and styling it red would train people
|
||||
to ignore the red that means something. */
|
||||
.viewing {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 12px 14px; margin-bottom: 14px;
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
color: var(--ink-2); font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
.viewing b { color: var(--ink); font-weight: 600; }
|
||||
.viewing svg { flex: none; margin-top: 2px; color: var(--accent); }
|
||||
|
||||
/* Watch live sits over the picture, opposite the connection pill. It is on
|
||||
the tile rather than in the button row because it is about the picture, and
|
||||
because the row it would otherwise join is hidden on a remote camera. */
|
||||
.camview .btn.watch {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
border-color: rgba(255, 255, 255, .25);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.camview .btn.watch:hover { background: rgba(0, 0, 0, .72); }
|
||||
.camview .btn.watch.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
71
desktop/frontend/src/ui/icons.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// One icon set, drawn rather than typed.
|
||||
//
|
||||
// The navigation used to be text characters — ◉ ☺ ▢ — which render in whatever
|
||||
// the system decides, sit on the text baseline instead of optical centre, and
|
||||
// cannot take a stroke weight. On a shop PC that is the difference between
|
||||
// software somebody trusts with their customers and something that looks
|
||||
// improvised.
|
||||
//
|
||||
// All of these are 24-unit grid, 1.6 stroke, currentColor, no fill. That means
|
||||
// one icon works on every surface and in every state without a second copy.
|
||||
|
||||
const base = {
|
||||
width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke: 'currentColor', strokeWidth: 1.6,
|
||||
strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true', focusable: 'false',
|
||||
}
|
||||
|
||||
function Svg({ size, children, ...rest }) {
|
||||
return <svg {...base} {...rest} width={size ?? base.width} height={size ?? base.height}>{children}</svg>
|
||||
}
|
||||
|
||||
export const Live = p => (
|
||||
<Svg {...p}><circle cx="12" cy="12" r="3.2" /><path d="M5.6 5.6a9 9 0 0 0 0 12.8M18.4 18.4a9 9 0 0 0 0-12.8" /></Svg>
|
||||
)
|
||||
export const People = p => (
|
||||
<Svg {...p}><circle cx="9" cy="8.5" r="3.2" /><path d="M2.8 19.5a6.4 6.4 0 0 1 12.4 0" /><path d="M16.5 6.2a3.2 3.2 0 0 1 0 6.1M18 19.5a6 6 0 0 0-1.6-4" /></Svg>
|
||||
)
|
||||
export const Camera = p => (
|
||||
<Svg {...p}><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /></Svg>
|
||||
)
|
||||
export const Search = p => (
|
||||
<Svg {...p}><circle cx="11" cy="11" r="6.4" /><path d="M15.8 15.8 20.5 20.5" /></Svg>
|
||||
)
|
||||
export const Plus = p => (<Svg {...p}><path d="M12 5.5v13M5.5 12h13" /></Svg>)
|
||||
export const Close = p => (<Svg {...p}><path d="M6.5 6.5l11 11M17.5 6.5l-11 11" /></Svg>)
|
||||
export const Check = p => (<Svg {...p}><path d="M5 12.8l4.4 4.2L19 7" /></Svg>)
|
||||
export const Play = p => (<Svg {...p}><path d="M8 5.6v12.8L18.5 12z" /></Svg>)
|
||||
export const Stop = p => (<Svg {...p}><rect x="7" y="7" width="10" height="10" rx="1.6" /></Svg>)
|
||||
export const Warning = p => (
|
||||
<Svg {...p}><path d="M12 4.6 21 19.4H3z" /><path d="M12 10v4.1" /><path d="M12 17.1v.01" /></Svg>
|
||||
)
|
||||
export const Signal = p => (
|
||||
<Svg {...p}><path d="M5 19.4v-4.2M10.3 19.4v-7.6M15.7 19.4v-11M21 19.4V4.6" /></Svg>
|
||||
)
|
||||
export const Cloud = p => (
|
||||
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 .3 9.4z" /></Svg>
|
||||
)
|
||||
export const CloudOff = p => (
|
||||
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35 6.2 6.2 0 0 1 2-3.2M10.6 5.2A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 1.9 7.6" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
|
||||
)
|
||||
export const Shield = p => (
|
||||
<Svg {...p}><path d="M12 3.8 19.4 6.6v5.2c0 4.2-3 7.4-7.4 8.4-4.4-1-7.4-4.2-7.4-8.4V6.6z" /></Svg>
|
||||
)
|
||||
export const Link = p => (
|
||||
<Svg {...p}><path d="M10.2 13.8a3.6 3.6 0 0 0 5.2 0l2.8-2.8a3.7 3.7 0 0 0-5.2-5.2l-1.3 1.3" /><path d="M13.8 10.2a3.6 3.6 0 0 0-5.2 0l-2.8 2.8a3.7 3.7 0 0 0 5.2 5.2l1.3-1.3" /></Svg>
|
||||
)
|
||||
export const Logout = p => (
|
||||
<Svg {...p}><path d="M14.4 7.6V5.4H4.6v13.2h9.8v-2.2" /><path d="M10 12h9.4M16.4 8.8 19.8 12l-3.4 3.2" /></Svg>
|
||||
)
|
||||
export const Back = p => (<Svg {...p}><path d="M14.6 5.6 8 12l6.6 6.4" /></Svg>)
|
||||
export const Chevron = p => (<Svg {...p}><path d="M9.4 5.6 16 12l-6.6 6.4" /></Svg>)
|
||||
export const Dot = p => (<Svg {...p}><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none" /></Svg>)
|
||||
|
||||
// Drawn for the empty states rather than an apologetic sentence in grey.
|
||||
export const NoCamera = p => (
|
||||
<Svg {...p} strokeWidth="1.2"><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
|
||||
)
|
||||
export const NoFaces = p => (
|
||||
<Svg {...p} strokeWidth="1.2"><circle cx="12" cy="9" r="3.4" /><path d="M5.4 20a6.8 6.8 0 0 1 13.2 0" /></Svg>
|
||||
)
|
||||
123
desktop/frontend/src/views/Assistant.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import logo from '../assets/loyaly-mark.png'
|
||||
|
||||
// The help. A conversation with the head-office assistant, which answers from
|
||||
// the shop's own data and knows how the product is set up - so "why is nobody
|
||||
// being recognised" and "how do I add my camera" are both answered here, by
|
||||
// the same thing, without leaving the app.
|
||||
//
|
||||
// The history lives in this component and is resent whole; nothing is stored
|
||||
// anywhere. A PC running on its own has no head office to ask and is told so.
|
||||
const SUGGESTED = [
|
||||
'Is my shop working right now?',
|
||||
'How do I add my camera?',
|
||||
'Why has nobody been recognised today?',
|
||||
'Who came in this morning?',
|
||||
]
|
||||
|
||||
export default function Assistant({ session, onClose }) {
|
||||
const [turns, setTurns] = useState([])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const scroller = useRef(null)
|
||||
const standalone = session?.standalone
|
||||
|
||||
useEffect(() => { scroller.current?.scrollTo({ top: 1e9, behavior: 'smooth' }) }, [turns, busy])
|
||||
|
||||
async function ask(text) {
|
||||
const q = (text ?? draft).trim()
|
||||
if (!q || busy) return
|
||||
const history = [...turns, { role: 'user', text: q }]
|
||||
setTurns(history); setDraft(''); setBusy(true); setError(null)
|
||||
try {
|
||||
const a = await api.ask(history.map(t => ({ role: t.role, text: t.text })))
|
||||
setTurns([...history, { role: 'assistant', text: a.text, used: a.used ?? [] }])
|
||||
} catch (e) {
|
||||
const m = message(e)
|
||||
// Her only failure that is not hers: the login is gone. Say what to do,
|
||||
// not "session expired" - the shell returns to Login within seconds.
|
||||
setError(/session expired|unauthori[sz]ed/i.test(m)
|
||||
? 'You’re signed out of head office, so I can’t look anything up. Sign in again and ask me once more.'
|
||||
: m)
|
||||
setTurns(turns) // the question stays in the box, not in the transcript
|
||||
setDraft(q)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="helper" role="dialog" aria-label="Loya">
|
||||
<header className="helperhead">
|
||||
<img src={logo} alt="" />
|
||||
<div>
|
||||
<b>Loya</b>
|
||||
<span>Your Behavision buddy — knows your cameras, your customers and your numbers.</span>
|
||||
</div>
|
||||
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
|
||||
</header>
|
||||
|
||||
<div className="helperbody" ref={scroller}>
|
||||
{standalone && (
|
||||
<div className="helpernote">
|
||||
<Icon.CloudOff size={16} />
|
||||
<div>
|
||||
<b>This PC runs on its own</b>
|
||||
<span>Loya lives at head office. Link this PC to a shop to talk to her; the basics are below.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{turns.length === 0 && (
|
||||
<div className="helperintro">
|
||||
<div className="turn assistant"><div className="bubble">
|
||||
{greeting(session)} Ask me anything about {session?.site_name || 'this shop'} — what’s happening now, or how to set something up.
|
||||
</div></div>
|
||||
<div className="chips">
|
||||
{SUGGESTED.map(s => <button key={s} className="chip" onClick={() => ask(s)} disabled={busy || standalone}>{s}</button>)}
|
||||
</div>
|
||||
{standalone && <QuickHelp />}
|
||||
</div>
|
||||
)}
|
||||
{turns.map((t, i) => (
|
||||
<div key={i} className={`turn ${t.role}`}>
|
||||
<div className="bubble">{t.text}</div>
|
||||
{t.used?.length > 0 && <span className="used">Looked at: {t.used.join(', ')}</span>}
|
||||
</div>
|
||||
))}
|
||||
{busy && <div className="turn assistant"><div className="bubble thinking"><span /><span /><span /></div></div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
</div>
|
||||
|
||||
<form className="helperask" onSubmit={e => { e.preventDefault(); ask() }}>
|
||||
<input value={draft} onChange={e => setDraft(e.target.value)} disabled={busy || standalone}
|
||||
placeholder={standalone ? 'Link this PC to head office to talk to Loya' : 'Ask Loya…'} />
|
||||
<button className="btn primary" disabled={busy || standalone || !draft.trim()} aria-label="Ask"><Icon.Chevron size={16} /></button>
|
||||
</form>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function greeting(session) {
|
||||
const h = new Date().getHours()
|
||||
const part = h < 12 ? 'Morning' : h < 17 ? 'Afternoon' : 'Evening'
|
||||
const name = session?.user?.full_name?.split(' ')[0]
|
||||
return name ? `${part}, ${name}.` : `${part}.`
|
||||
}
|
||||
|
||||
// What a PC with no head office can still be told. Static on purpose: there is
|
||||
// nobody to ask, and a chat box that always fails is worse than a short list.
|
||||
function QuickHelp() {
|
||||
return (
|
||||
<dl className="quickhelp">
|
||||
<dt>Adding a camera</dt>
|
||||
<dd>Cameras → Add camera. The address is on a label on the camera; pick the make and the stream path fills itself in. Test, then save.</dd>
|
||||
<dt>Proving it works</dt>
|
||||
<dd>Press Check placement and walk past the camera like a customer for 25 seconds. Only “good” means it can recognise faces — otherwise move it to head height, facing the way people approach.</dd>
|
||||
<dt>Nothing showing on Live</dt>
|
||||
<dd>Check the camera is Connected and Proven. A camera aimed from above or the side streams fine and recognises nobody.</dd>
|
||||
<dt>Linking to head office later</dt>
|
||||
<dd>Use the link button at the bottom of the sidebar and type an installation code from head office. Nothing recorded here is lost.</dd>
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +1,92 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
|
||||
|
||||
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '',
|
||||
max_width: 1280 }
|
||||
// The camera screen is a picture, not a settings table.
|
||||
//
|
||||
// The first version was a table of id / rtsp url / status with three buttons
|
||||
// per row - the view a developer wants. A camera is a thing you look at, so
|
||||
// the live picture is the card, the state sits over it, and the one line that
|
||||
// matters is under it: whether anyone has PROVED this camera can recognise a
|
||||
// face, which is a different claim from "connected" and is the gap a site gets
|
||||
// signed off through.
|
||||
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '', max_width: 1280 }
|
||||
|
||||
export default function Cameras() {
|
||||
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [check, setCheck] = useState(null)
|
||||
const cams = data ?? []
|
||||
// Every camera is remote or none is: this list comes from the engine on
|
||||
// loopback, and when that is unreachable the whole list comes from head
|
||||
// office instead. A remote camera is on a network this computer cannot
|
||||
// reach, so the picture is the shop PC's last snapshot and the buttons that
|
||||
// would talk to the camera are not offered - one that cannot work is worse
|
||||
// than one that is absent.
|
||||
const remote = cams.some(c => c.remote)
|
||||
const streams = useStreamURLs(remote ? [] : cams)
|
||||
// ONE camera at a time, and that is a cost decision rather than a layout
|
||||
// one. A remote view makes the shop computer upload frames for as long as
|
||||
// somebody is watching, so a grid that all went live at once would put an
|
||||
// estate's worth of cameras on the wire because somebody opened a page.
|
||||
const [watching, setWatching] = useState(null)
|
||||
const [watchURL, setWatchURL] = useState('')
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
if (!watching) { setWatchURL(''); return }
|
||||
api.remoteStreamURL(watching).then(u => { if (alive) setWatchURL(u || '') })
|
||||
.catch(() => { if (alive) setWatchURL('') })
|
||||
return () => { alive = false }
|
||||
}, [watching])
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm(`Remove camera "${id}"? Recognition from it stops immediately.`)) return
|
||||
try { await api.deleteCamera(id); reload() } catch (e) { alert(message(e)) }
|
||||
async function remove(cam) {
|
||||
if (!confirm(`Remove ${cam.id}? Recognition from it stops immediately.`)) return
|
||||
try { await api.deleteCamera(cam.id); reload() } catch (e) { alert(message(e)) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<header className="pagehead">
|
||||
<div>
|
||||
<h2>Cameras</h2>
|
||||
<p>Add a camera, check it can see faces properly, then it starts working.</p>
|
||||
<p>{remote
|
||||
? 'The cameras across your shops, as the shop computers last reported them.'
|
||||
: 'Add a camera, then prove it can see faces with a walk-past. Only then is it working.'}</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
Add camera
|
||||
</button>
|
||||
{!remote && <button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
<Icon.Plus size={15} />Add camera
|
||||
</button>}
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
{remote && <div className="viewing">
|
||||
<b>Viewing your shops from here.</b> These cameras are wired to the shop
|
||||
computers, so they are set up and checked there. Each tile shows that
|
||||
camera's most recent frame; <b>Watch live</b> asks the shop computer to
|
||||
send video for as long as you are looking.
|
||||
</div>}
|
||||
|
||||
<div className="card">
|
||||
{cams.length === 0
|
||||
? <div className="empty">No cameras yet.</div>
|
||||
: <div className="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Address</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{cams.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.id}</td>
|
||||
<td className="mono">{c.url}</td>
|
||||
<td>
|
||||
{c.connected === undefined
|
||||
? <span className="pill"><i className="dot idle" />stopped</span>
|
||||
: c.connected
|
||||
? <span className="pill ok"><i className="dot ok" />live</span>
|
||||
: <span className="pill bad"><i className="dot bad" />offline</span>}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn sm" onClick={() => setCheck(c.id)}>
|
||||
Check placement
|
||||
</button>{' '}
|
||||
<button className="btn sm" onClick={() => setEditing(c)}>Edit</button>{' '}
|
||||
<button className="btn sm danger" onClick={() => remove(c.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{cams.length === 0
|
||||
? <div className="panel">
|
||||
<div className="empty tall">
|
||||
<Icon.NoCamera size={40} />
|
||||
<b>No cameras yet</b>
|
||||
<p>Add the camera by its address. Most cameras print it on a label underneath, or show it in their own app.</p>
|
||||
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}><Icon.Plus size={15} />Add your first camera</button>
|
||||
</div>
|
||||
</div>
|
||||
: <div className="camgrid">
|
||||
{cams.map(c => (
|
||||
<CameraCard key={c.id} cam={c}
|
||||
stream={watching === c.id ? watchURL : streams[c.id]}
|
||||
watching={watching === c.id}
|
||||
onWatch={() => setWatching(watching === c.id ? null : c.id)}
|
||||
onEdit={() => setEditing(c)} onCheck={() => setCheck(c.id)} onRemove={() => remove(c)} />
|
||||
))}
|
||||
</div>}
|
||||
|
||||
{editing && <CameraSheet cam={editing} onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); reload() }} />}
|
||||
@@ -72,10 +95,101 @@ export default function Cameras() {
|
||||
)
|
||||
}
|
||||
|
||||
function CameraCard({ cam, stream, watching, onWatch, onEdit, onCheck, onRemove }) {
|
||||
// Three states, not two, and the third is why `connected` is a pointer on
|
||||
// the wire: null means no shop computer has reported on this camera yet,
|
||||
// which reads as waiting rather than as a fault to go and investigate.
|
||||
const conn = cam.remote
|
||||
// Four states, decided once by the server. `stale` is the one that was
|
||||
// missing: the shop computer reports nothing when it cannot reach its own
|
||||
// engine, so its last report used to sit there reading Connected -
|
||||
// measured at 34 minutes on the live estate.
|
||||
? ({ connected: { tone: 'ok', label: 'Connected' },
|
||||
not_connecting: { tone: 'bad', label: 'Not connecting' },
|
||||
stale: { tone: 'warn', label: 'Not reporting' } }[cam.state]
|
||||
|| { tone: 'idle', label: 'Waiting for the shop computer' })
|
||||
: cam.connected === undefined || cam.connected === null
|
||||
? { tone: 'idle', label: 'Engine stopped' }
|
||||
: cam.connected ? { tone: 'ok', label: 'Connected' }
|
||||
: { tone: 'bad', label: 'Not connecting' }
|
||||
// The last placement verdict, so "proven" survives closing the sheet. Only
|
||||
// `good` is a pass: marginal means half the visitors are silently discarded.
|
||||
// Never asked for a remote camera: that answer lives on the shop computer,
|
||||
// and polling loopback for it here only produces an error every 15 seconds.
|
||||
const { data: last } = usePolled(
|
||||
() => cam.remote ? Promise.resolve(null) : api.placementResult(cam.id), 15000, [cam.id])
|
||||
const proof = !last || last.running || !last.verdict || last.verdict === 'starting'
|
||||
? { tone: 'miss', label: 'Not yet proven', text: 'Walk past it once and Behavision will tell you if the placement works.' }
|
||||
: last.verdict === 'good'
|
||||
? { tone: 'seen', label: 'Proven', text: last.headline || 'Faces recognised on a walk-past.' }
|
||||
: { tone: 'miss', label: 'Not proven', text: last.headline || 'Move the camera and check again.' }
|
||||
const shot = cam.snapshot?.available ? cam.snapshot.url : ''
|
||||
return (
|
||||
<article className="camcard">
|
||||
<div className="camview">
|
||||
{stream || shot
|
||||
? <img src={stream || shot} alt={cam.id} />
|
||||
: <div className="placeholder"><Icon.NoCamera size={34} /></div>}
|
||||
<span className={`pill ${conn.tone === 'idle' ? '' : conn.tone} over`}><i className={`dot ${conn.tone}`} />{conn.label}</span>
|
||||
{cam.remote && <button className={`btn sm watch ${watching ? 'on' : ''}`} onClick={onWatch}>
|
||||
<Icon.Play size={13} />{watching ? 'Stop watching' : 'Watch live'}
|
||||
</button>}
|
||||
</div>
|
||||
<div className="cambody">
|
||||
<div className="camtitle">
|
||||
<div>
|
||||
<h3>{cam.label || cam.camera_id || cam.id}</h3>
|
||||
<span className="mono note">{cam.remote
|
||||
? cam.site || cam.site_slug || ''
|
||||
: `${cam.host || cam.url}${cam.path ? ` · ${cam.path}` : ''}`}</span>
|
||||
</div>
|
||||
{!cam.remote && <div className="camactions">
|
||||
<button className="btn sm" onClick={onEdit}>Edit</button>
|
||||
<button className="btn sm danger" onClick={onRemove}>Remove</button>
|
||||
</div>}
|
||||
</div>
|
||||
{cam.remote
|
||||
? <div className="camproof">
|
||||
<span className="note">{cam.state_note
|
||||
? cam.state_note
|
||||
: watching
|
||||
? 'Live from the shop computer. It uploads only while you watch.'
|
||||
: cam.snapshot?.available
|
||||
? 'Last picture from the shop computer. Watch live to see it now.'
|
||||
: cam.snapshot?.reason || 'No picture yet from the shop computer.'}</span>
|
||||
</div>
|
||||
: <div className="camproof">
|
||||
<span className={`tag ${proof.tone}`}>{proof.label}</span>
|
||||
<span className="note">{proof.text}</span>
|
||||
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
|
||||
</div>}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// Stream URLs are fetched once per camera and left alone: reassigning an
|
||||
// MJPEG <img> src restarts the stream, so rebuilding them on every poll makes
|
||||
// every feed flicker forever.
|
||||
function useStreamURLs(cams) {
|
||||
const [urls, setUrls] = useState({})
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const missing = cams.filter(c => !(c.id in urls))
|
||||
if (!missing.length) return
|
||||
;(async () => {
|
||||
const add = {}
|
||||
for (const c of missing) { try { add[c.id] = await api.streamURL(c.id) } catch { add[c.id] = '' } }
|
||||
if (alive) setUrls(u => ({ ...u, ...add }))
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [cams.map(c => c.id).join('|')]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return urls
|
||||
}
|
||||
|
||||
function CameraSheet({ cam, onClose, onSaved }) {
|
||||
const isNew = !cam.id
|
||||
const [f, setF] = useState({ ...BLANK, ...cam, password: '',
|
||||
path: cam.path || (isNew ? MAKES[0].path : '') })
|
||||
const [f, setF] = useState({ ...BLANK, ...cam, password: '', path: cam.path || (isNew ? MAKES[0].path : '') })
|
||||
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
|
||||
const [test, setTest] = useState(null)
|
||||
const [busy, setBusy] = useState(null)
|
||||
@@ -114,93 +228,137 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
catch (e) { setError(message(e)) } finally { setBusy(null) }
|
||||
}
|
||||
|
||||
const chosen = makeById(make)
|
||||
|
||||
// The camera is picked from a scan of the shop's network rather than typed.
|
||||
// Nobody knows their camera's address; the sticker is under the camera and
|
||||
// the menu is different in every make's app. The scan names ONVIF cameras
|
||||
// and lists anything with the RTSP port open; picking one fills the
|
||||
// address and, when the make is recognisable, the stream path too.
|
||||
const [scan, setScan] = useState(null) // null | 'busy' | {cameras, networks} | {error}
|
||||
async function findCameras() {
|
||||
setScan('busy')
|
||||
try { setScan(await api.discoverCameras()) } catch (e) { setScan({ error: message(e) }) }
|
||||
}
|
||||
function pick(c) {
|
||||
const m = c.make ? makeById(c.make) : null
|
||||
setF(prev => ({ ...prev, host: c.host, path: m?.path || prev.path,
|
||||
id: prev.id || (m ? '' : ''), }))
|
||||
if (m) setMake(m.id)
|
||||
setTest(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<button className="btn sm close" onClick={onClose}>Close</button>
|
||||
<h3>{isNew ? 'Add camera' : cam.id}</h3>
|
||||
<p className="note" style={{ marginBottom: 18 }}>
|
||||
Test the connection before saving — a wrong address is the most common mistake.
|
||||
</p>
|
||||
<form onSubmit={save}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input value={f.id} onChange={set('id')} disabled={!isNew}
|
||||
placeholder="entrance" required autoComplete="off" />
|
||||
</label>
|
||||
{/* The highest-value field on this form. The address and the
|
||||
password are on a label or in the installer's notes; the RTSP
|
||||
path is not written anywhere a shop owner would look, and getting
|
||||
it wrong produces "could not open stream", which reads like a
|
||||
password problem and is not. */}
|
||||
<label className="field"><span>Make of camera</span>
|
||||
<select value={make} onChange={chooseMake}>
|
||||
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{makeById(make).note && (
|
||||
<p className="note" style={{ marginTop: -8, marginBottom: 12 }}>
|
||||
{makeById(make).note}
|
||||
</p>
|
||||
)}
|
||||
<div className="fieldrow">
|
||||
<label className="field"><span>Camera address</span>
|
||||
<input value={f.host} onChange={set('host')} placeholder="192.168.0.138"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
<label className="field"><span>Port</span>
|
||||
<input value={f.port} onChange={set('port')} inputMode="numeric"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field"><span>Stream path</span>
|
||||
<input value={f.path} onChange={set('path')} placeholder="/ch0_0.264"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
<div className="fieldrow">
|
||||
{/* A text input next to a password input is a sign-in form as far
|
||||
as the webview is concerned, so without this the browser offers
|
||||
the operator's own Behavision email as the camera's username -
|
||||
which fails with a message about credentials that points at the
|
||||
camera. "off" alone is frequently ignored; a non-login name and
|
||||
new-password on the secret are what actually work. */}
|
||||
<label className="field"><span>Username</span>
|
||||
<input value={f.username} onChange={set('username')}
|
||||
name="camera-account" autoComplete="off" />
|
||||
</label>
|
||||
<label className="field"><span>Password</span>
|
||||
<input type="password" value={f.password} onChange={set('password')}
|
||||
name="camera-secret" autoComplete="new-password"
|
||||
placeholder={cam.has_password ? '(unchanged)' : ''} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="sheet">
|
||||
<div className="sheethead">
|
||||
<h2>{isNew ? 'Add a camera' : `Edit ${cam.id}`}</h2>
|
||||
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
|
||||
</div>
|
||||
<form className="sheetbody" onSubmit={save} autoComplete="off">
|
||||
<p className="lead">Three things from the camera: its address, its make, and its password. Test before you save — a wrong address is the most common mistake.</p>
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<button type="button" className="btn" onClick={runTest} disabled={!!busy}>
|
||||
{busy === 'test' ? 'Connecting…' : 'Test connection'}
|
||||
</button>
|
||||
<button className="btn primary" disabled={!!busy || !f.id}>
|
||||
{busy === 'save' ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{isNew && (
|
||||
<section className="formsection">
|
||||
<h4>Find it</h4>
|
||||
{scan === null && (
|
||||
<div className="finder">
|
||||
<p>Behavision can look for cameras on this shop’s network.</p>
|
||||
<button type="button" className="btn primary" onClick={findCameras}><Icon.Search size={14} />Find cameras on this network</button>
|
||||
</div>
|
||||
)}
|
||||
{scan === 'busy' && <div className="finder busy"><span className="spinner" />Looking on the network… a few seconds.</div>}
|
||||
{scan?.error && <div className="err"><Icon.Warning size={15} />{scan.error}</div>}
|
||||
{scan?.cameras && (
|
||||
scan.cameras.length === 0
|
||||
? <div className="finder">
|
||||
<p>Nothing answered on {scan.networks?.join(', ') || 'this network'}. The camera may be on a different network, switched off, or not yet connected — check its cable and power, then try again. You can still type its address below.</p>
|
||||
<button type="button" className="btn" onClick={findCameras}>Try again</button>
|
||||
</div>
|
||||
: <ul className="foundlist">
|
||||
{scan.cameras.map(c => (
|
||||
<li key={c.host} className={f.host === c.host ? 'picked' : ''}>
|
||||
<button type="button" onClick={() => pick(c)}>
|
||||
<span className="mono">{c.host}</span>
|
||||
<span className="what">{c.name || (c.rtsp ? 'Streams video (RTSP)' : 'Answers ONVIF')}</span>
|
||||
{c.make && <span className="tag seen">{makeById(c.make).label}</span>}
|
||||
{f.host === c.host && <Icon.Check size={16} />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="rescan"><button type="button" className="btn sm" onClick={findCameras}>Scan again</button></li>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="formsection">
|
||||
<h4>The camera</h4>
|
||||
{isNew && (
|
||||
<label className="field"><span>Name</span>
|
||||
<input value={f.id} onChange={set('id')} placeholder="entrance" required autoFocus />
|
||||
<em className="hint">Short, no spaces. It names this camera everywhere and cannot be changed later.</em>
|
||||
</label>
|
||||
)}
|
||||
<div className="fieldrow">
|
||||
<label className="field"><span>Address</span>
|
||||
<input value={f.host} onChange={set('host')} placeholder="192.168.1.20" inputMode="decimal" />
|
||||
<em className="hint">On a label on the camera, or in its own app under “network”.</em>
|
||||
</label>
|
||||
<label className="field narrow"><span>Port</span>
|
||||
<input value={f.port} onChange={set('port')} inputMode="numeric" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="formsection">
|
||||
<h4>The stream</h4>
|
||||
<label className="field"><span>Make of camera</span>
|
||||
<select value={make} onChange={chooseMake}>
|
||||
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
{chosen.note && <em className="hint">{chosen.note}</em>}
|
||||
</label>
|
||||
<label className="field"><span>Stream path</span>
|
||||
<input className="mono" value={f.path} onChange={set('path')} placeholder="/Streaming/Channels/101" />
|
||||
<em className="hint">Filled in from the make. Change it only if the camera’s own app says something else.</em>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="formsection">
|
||||
<h4>Sign-in to the camera</h4>
|
||||
<div className="fieldrow">
|
||||
<label className="field"><span>Username</span>
|
||||
<input name="rtsp-account" autoComplete="off" value={f.username} onChange={set('username')} placeholder="admin" />
|
||||
</label>
|
||||
<label className="field"><span>Password</span>
|
||||
<input type="password" name="rtsp-secret" autoComplete="new-password" value={f.password}
|
||||
onChange={set('password')} placeholder={cam.has_password ? '(unchanged)' : ''} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{test && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{test.ok
|
||||
? <>
|
||||
<p style={{ color: 'var(--ok)', fontSize: 13 }}>
|
||||
Connected — {test.width}×{test.height}
|
||||
</p>
|
||||
{test.snapshot && (
|
||||
<img alt="Camera preview" style={{ width: '100%', marginTop: 8,
|
||||
borderRadius: 6, border: '1px solid var(--line)' }}
|
||||
src={`data:image/jpeg;base64,${test.snapshot}`} />
|
||||
)}
|
||||
</>
|
||||
: <div className="err">{test.error}</div>}
|
||||
</div>
|
||||
test.ok
|
||||
? <div className="testresult ok">
|
||||
<Icon.Check size={16} />
|
||||
<div>
|
||||
<b>Connected — {test.width}×{test.height}{test.codec ? ` · ${String(test.codec).toUpperCase()}` : ''}</b>
|
||||
{test.snapshot && <img alt="Camera preview" src={`data:image/jpeg;base64,${test.snapshot}`} />}
|
||||
</div>
|
||||
</div>
|
||||
: <div className="testresult bad"><Icon.Warning size={16} /><div><b>Could not connect</b><span>{test.error}</span></div></div>
|
||||
)}
|
||||
|
||||
<div className="sheetactions">
|
||||
<button type="button" className="btn" onClick={runTest} disabled={!!busy || !f.host}>
|
||||
{busy === 'test' ? 'Connecting…' : 'Test connection'}
|
||||
</button>
|
||||
<button className="btn primary" disabled={!!busy || !f.id || !f.host}>
|
||||
{busy === 'save' ? 'Saving…' : isNew ? 'Add camera' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,7 +366,7 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
}
|
||||
|
||||
// The commissioning wizard. This is what stops a site being signed off with a
|
||||
// camera that recognises nobody — the failure that otherwise shows up weeks
|
||||
// camera that recognises nobody - the failure that otherwise shows up weeks
|
||||
// later as a footfall report that was always zero.
|
||||
function PlacementSheet({ id, onClose }) {
|
||||
const [state, setState] = useState({ verdict: 'starting', advice: [] })
|
||||
@@ -233,45 +391,54 @@ function PlacementSheet({ id, onClose }) {
|
||||
return () => { alive = false; clearInterval(timer.current) }
|
||||
}, [id])
|
||||
|
||||
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad',
|
||||
no_faces: 'warn', inconclusive: 'warn' }[state.verdict]
|
||||
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad', no_faces: 'warn',
|
||||
inconclusive: 'warn', no_completed_passes: 'warn' }[state.verdict]
|
||||
const pct = state.seconds ? Math.min(100, (state.elapsed / state.seconds) * 100) : 0
|
||||
const running = state.running || state.verdict === 'starting'
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<button className="btn sm close" onClick={onClose}>Close</button>
|
||||
<h3>Placement check — {id}</h3>
|
||||
<p className="note" style={{ marginBottom: 18 }}>
|
||||
Walk past the camera the way a customer would, a few times.
|
||||
</p>
|
||||
<div className="sheet">
|
||||
<div className="sheethead">
|
||||
<h2>Check placement · {id}</h2>
|
||||
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
|
||||
</div>
|
||||
<div className="sheetbody">
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
<ol className="steps">
|
||||
<li className={running ? 'now' : 'done'}>
|
||||
<b>Walk past the camera</b>
|
||||
<span>The way a customer would — in through the door, not looking at the lens. Two or three times, for about 25 seconds.</span>
|
||||
{running && <div className="progress"><div style={{ width: `${pct}%` }} /></div>}
|
||||
</li>
|
||||
<li className={running ? '' : 'now'}>
|
||||
<b>Behavision judges what it saw</b>
|
||||
<span>Not “were the frames sharp”, but “did a person walking past produce a face worth recognising”.</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ fontSize: 15, fontWeight: 600,
|
||||
color: tone ? `var(--${tone})` : 'var(--ink)' }}>
|
||||
{state.headline || 'Starting…'}
|
||||
</div>
|
||||
{state.running && (
|
||||
<div style={{ height: 5, background: 'var(--surface-2)', borderRadius: 3,
|
||||
overflow: 'hidden', margin: '12px 0' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)',
|
||||
transition: 'width .4s linear' }} />
|
||||
<div className={`verdict ${tone ?? ''}`}>
|
||||
<div className="verdict-head">
|
||||
{running ? <span className="spinner" /> : tone === 'ok' ? <Icon.Check size={18} /> : <Icon.Warning size={18} />}
|
||||
<b>{state.headline || 'Watching…'}</b>
|
||||
</div>
|
||||
{state.advice?.length > 0 && (
|
||||
<ul>{state.advice.map((a, i) => <li key={i}>{a}</li>)}</ul>
|
||||
)}
|
||||
{state.quality?.n > 0 && (
|
||||
<p className="note">
|
||||
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen · median quality {state.quality.p50} ·
|
||||
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% too poor to use
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!running && (
|
||||
<div className="sheetactions">
|
||||
<button className="btn" onClick={onClose}>Close</button>
|
||||
<button className="btn primary" onClick={() => { setState({ verdict: 'starting', advice: [] }); api.startPlacement(id, 25).then(setState).catch(e => setError(message(e))) }}>Run again</button>
|
||||
</div>
|
||||
)}
|
||||
{state.advice?.length > 0 && (
|
||||
<ul style={{ margin: '12px 0 0 18px', fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{state.advice.map((a, i) => <li key={i} style={{ marginBottom: 5 }}>{a}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{state.quality?.n > 0 && (
|
||||
<p className="note" style={{ marginTop: 12 }}>
|
||||
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen ·
|
||||
median quality {state.quality.p50} ·
|
||||
gate {state.gate} ·
|
||||
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% below it
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,29 +54,28 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<div className="who">
|
||||
<div className="sheet">
|
||||
<div className="sheethead who">
|
||||
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
|
||||
customerRef={customer.ref}
|
||||
onBroken={() => setPhoto({ available: false,
|
||||
reason: 'The photo could not be loaded.' })} />
|
||||
<div className="grow">
|
||||
<h3>{customer.full_name || customer.label}</h3>
|
||||
<h2>{customer.full_name || customer.label}</h2>
|
||||
<p className="note">
|
||||
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
|
||||
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
|
||||
{shown && !shown.available && shown.reason && ` · ${shown.reason}`}
|
||||
</p>
|
||||
{shown && !shown.available && shown.reason &&
|
||||
<p className="note">{shown.reason}</p>}
|
||||
</div>
|
||||
<button type="button" className="btn sm" onClick={onClose}>Close</button>
|
||||
<button type="button" className="close" onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save}>
|
||||
<form className="sheetbody" onSubmit={save}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Customer details</h3>
|
||||
<section className="formsection">
|
||||
<h4>Customer details</h4>
|
||||
<label className="field">
|
||||
<span>Full name</span>
|
||||
<input value={f.full_name} onChange={set('full_name')} autoFocus />
|
||||
@@ -109,10 +108,10 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
<textarea value={f.notes} onChange={set('notes')}
|
||||
placeholder="Preferences, sizes, anything worth remembering" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Purchase (optional)</h3>
|
||||
<section className="formsection">
|
||||
<h4>Purchase (optional)</h4>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Amount</span>
|
||||
@@ -127,10 +126,10 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
</div>
|
||||
<p className="note">Leave the amount blank if they did not buy anything —
|
||||
a visit without a sale is still worth recording.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<h3>Consent</h3>
|
||||
<section className="formsection">
|
||||
<h4>Consent</h4>
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
|
||||
@@ -142,23 +141,23 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
Recorded with the date and who collected it. They can withdraw it
|
||||
at any time, which erases their face data.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Visits</h3>
|
||||
<section className="formsection">
|
||||
<h4>Visits</h4>
|
||||
<VisitHistory customer={customer} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="sheetactions">
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn primary" disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
{canErase && (
|
||||
<div className="card danger-zone">
|
||||
<h3>At the customer's request</h3>
|
||||
<section className="formsection dangerzone">
|
||||
<h4>At the customer's request</h4>
|
||||
{erasing
|
||||
? <EraseCustomer customer={customer}
|
||||
onCancel={() => setErasing(false)}
|
||||
@@ -174,7 +173,7 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
Erase this customer…
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtDate } from '../hooks.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import CustomerForm from './CustomerForm.jsx'
|
||||
|
||||
// The customer database, and the form staff fill in when someone walks in.
|
||||
@@ -15,34 +16,32 @@ export default function Customers({ session }) {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Customers</h2>
|
||||
<p>Everyone this business has recognised. Fill in details once and they
|
||||
are known at every store.</p>
|
||||
<header className="pagehead">
|
||||
<div>
|
||||
<h2>Customers</h2>
|
||||
<p>Everyone this business has recognised. Fill in details once and they are known at every store.</p>
|
||||
</div>
|
||||
<label className="search">
|
||||
<Icon.Search size={15} />
|
||||
<input placeholder="Search by name or phone" value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</label>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="Known people" value={rows.length || '—'} />
|
||||
<Stat label="With details" value={named || '—'}
|
||||
sub={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
|
||||
<Stat label="Returning" value={rows.filter(r => r.visit_count > 1).length || '—'} />
|
||||
<Stat label="With consent" value={rows.filter(r => r.has_consent).length || '—'} />
|
||||
<div className="metrics" style={{ marginBottom: 'var(--sp-4)' }}>
|
||||
<Metric k="Known people" v={rows.length || '—'} />
|
||||
<Metric k="With details" v={named || '—'} s={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
|
||||
<Metric k="Returning" v={rows.filter(r => r.visit_count > 1).length || '—'} />
|
||||
<Metric k="With consent" v={rows.filter(r => r.has_consent).length || '—'} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 12 }}>
|
||||
<input className="field" style={{ flex: 1, margin: 0, background: 'var(--ground)',
|
||||
border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px' }}
|
||||
placeholder="Search by name or phone"
|
||||
value={query} onChange={e => setQuery(e.target.value)} />
|
||||
<button className="btn" onClick={reload}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
{rows.length === 0
|
||||
? <div className="empty">
|
||||
No customers yet. They appear here the first time a camera sees them.
|
||||
? <div className="empty tall">
|
||||
<Icon.NoFaces size={34} />
|
||||
<b>No customers yet</b>
|
||||
<p>They appear here the first time a camera recognises them. Click one to add a name and phone number.</p>
|
||||
</div>
|
||||
: <div className="tablewrap">
|
||||
<table>
|
||||
@@ -89,11 +88,12 @@ export default function Customers({ session }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub }) {
|
||||
function Metric({ k, v, s }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3><div className="value">{value}</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
<div className="metric">
|
||||
<div className="metric-k">{k}</div>
|
||||
<div className="metric-v">{v}</div>
|
||||
{s && <div className="metric-s">{s}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,192 +1,275 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtTime } from '../hooks.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
|
||||
// What is happening right now. The first screen a shop manager opens, so it
|
||||
// answers "is it working" before it answers anything else.
|
||||
export default function Live() {
|
||||
// The shop floor screen.
|
||||
//
|
||||
// Rebuilt around what somebody standing at the counter is actually here for:
|
||||
// WHO JUST WALKED IN. The previous version led with four large stat boxes and
|
||||
// left arrivals as a thin list of "person.seen" rows in the corner — the least
|
||||
// actionable content taking the most space, and the product's whole reason for
|
||||
// existing rendered as a log.
|
||||
//
|
||||
// Now: a status strip that answers "is this working" in one line, and arrivals
|
||||
// as cards big enough to recognise a customer from while looking up at them.
|
||||
// No camera picture here - the person at the counter is not watching CCTV,
|
||||
// and a live video tile costs CPU the recognition pipeline needs. The picture
|
||||
// lives on the Cameras screen, where it is a setup tool.
|
||||
export default function Live({ onNavigate }) {
|
||||
const { data, error } = usePolled(() => api.live(), 3000)
|
||||
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
|
||||
const cams = useCameraFeeds()
|
||||
|
||||
const cameras = data?.stats?.cameras ?? []
|
||||
const gallery = data?.stats?.gallery ?? {}
|
||||
const events = data?.events ?? []
|
||||
// No engine on THIS PC, so everything below came from head office. It has to
|
||||
// be said rather than implied: a laptop in a hotel showing "2 cameras live"
|
||||
// without this line is claiming to watch a shop it cannot see.
|
||||
const viewing = data?.viewing === true
|
||||
|
||||
// fraction_below_gate is the number that decides a site: what share of the
|
||||
// faces this camera saw were too poor to enrol. Surfaced here rather than
|
||||
// buried, because a high value looks exactly like "a quiet day".
|
||||
const worst = cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
// faces this camera saw were too poor to enrol. Surfaced rather than buried,
|
||||
// because a high value looks exactly like "a quiet day".
|
||||
const worst = viewing
|
||||
? (data?.stats?.fraction_below_gate ?? 0)
|
||||
: cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
// Viewing: the server already summed these across the estate.
|
||||
const up = viewing ? (data?.stats?.cameras_up ?? 0) : cameras.filter(c => c.connected).length
|
||||
|
||||
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
|
||||
const freshest = useFreshest(arrivals[0])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Live</h2>
|
||||
<p>Cameras, recent detections, and whether this site is recognising people.</p>
|
||||
<p>{viewing
|
||||
? 'Your shops, as head office sees them.'
|
||||
: 'Who is in the shop, and whether it is reaching head office.'}</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="People known" value={gallery.identities ?? '—'} />
|
||||
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
|
||||
<Stat label="Cameras live"
|
||||
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
|
||||
<Stat label="Below quality gate"
|
||||
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
||||
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
||||
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
|
||||
: 'Share of faces too poor to enrol'} />
|
||||
{viewing && (
|
||||
<div className="viewing">
|
||||
<Icon.Cloud size={15} />
|
||||
<span><b>Viewing your shops from here.</b> This computer is not watching
|
||||
any cameras itself — everything below is what your shop PCs reported.
|
||||
To recognise people on this machine, it has to be on the same network
|
||||
as a camera.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
|
||||
|
||||
{/* Getting Started walks somebody through setting up a camera on THIS
|
||||
PC — not what a viewer is doing, and not something they could finish
|
||||
from here. */}
|
||||
{!viewing && <GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />}
|
||||
|
||||
<div className="panel arrivals-panel">
|
||||
<div className="panelhead">
|
||||
<h3>Who just walked in</h3>
|
||||
{arrivals.length > 0 && <span className="note">{arrivals.length} today</span>}
|
||||
</div>
|
||||
<div className="panelbody flush">
|
||||
{arrivals.length === 0
|
||||
? <div className="empty">
|
||||
<Icon.NoFaces size={34} />
|
||||
<b>Nobody yet</b>
|
||||
<p>Customers appear here the moment a camera recognises a face.</p>
|
||||
</div>
|
||||
: <div className="arrivals">
|
||||
{arrivals.slice(0, 30).map((e, i) => (
|
||||
<Arrival key={`${e.ts}-${i}`} e={e} fresh={i === 0 && freshest} />
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pipeline pipe={pipe} />
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Cameras</h3>
|
||||
{cameras.length === 0
|
||||
? <div className="empty">No cameras yet. Add one in Cameras.</div>
|
||||
: <div className="feeds">
|
||||
{cameras.map(c => (
|
||||
<div className="feed" key={c.camera_id}>
|
||||
{cams[c.camera_id]
|
||||
? <img src={cams[c.camera_id]} alt={c.camera_id} />
|
||||
: <div style={{ aspectRatio: '16/9' }} />}
|
||||
<div className="cap">
|
||||
<span>{c.camera_id}</span>
|
||||
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
|
||||
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
|
||||
{c.connected ? 'live' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Recent detections</h3>
|
||||
{events.length === 0
|
||||
? <div className="empty">Nothing detected yet.</div>
|
||||
: <ul className="events">
|
||||
{events.map((e, i) => <EventRow key={i} e={e} />)}
|
||||
</ul>}
|
||||
</div>
|
||||
<div className="metrics" style={{ marginTop: 'var(--sp-4)' }}>
|
||||
<Metric k="People known" v={gallery.identities ?? '—'} />
|
||||
<Metric k="Sightings" v={gallery.sightings ?? '—'} />
|
||||
<Metric k="Cameras live" v={cameras.length ? `${up}/${cameras.length}` : '—'}
|
||||
tone={!cameras.length ? null : up === 0 ? 'bad' : up < cameras.length ? 'warn' : 'ok'} />
|
||||
<Metric k="Faces too poor to use"
|
||||
v={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
||||
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
||||
s={worst > 0.5 ? 'Most visitors are being missed — move the camera'
|
||||
: 'Share of faces below the enrolment gate'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Whether anything is actually reaching head office. Without this the app can
|
||||
// look perfectly healthy while every detection piles up on disk unsent — which
|
||||
// is exactly what it did before the bridge existed.
|
||||
function Pipeline({ pipe }) {
|
||||
// The first five minutes, as a checklist that ticks itself.
|
||||
//
|
||||
// Before this the Live screen after install was "Nobody yet" over a row of
|
||||
// dashes, with an amber "No cameras" in the far corner. Nothing said what to
|
||||
// do next. This says the three things, in order, and each step reads its own
|
||||
// state from the engine: recognition ready, a camera added, the camera
|
||||
// proven by a walk-past. It disappears on its own once someone has actually
|
||||
// been recognised, because at that point the product has explained itself.
|
||||
function GettingStarted({ cameras, arrivals, onNavigate }) {
|
||||
const { data: eng } = usePolled(() => api.engineStatus(), 4000)
|
||||
const [hidden, setHidden] = useState(() => { try { return localStorage.getItem('bv.gettingStarted') === 'done' } catch { return false } })
|
||||
const hasCamera = cameras.length > 0
|
||||
const anyUp = cameras.some(c => c.connected)
|
||||
const { data: checks } = usePolled(async () => {
|
||||
const out = {}
|
||||
for (const c of cameras) { try { out[c.camera_id] = await api.placementResult(c.camera_id) } catch { /* not yet */ } }
|
||||
return out
|
||||
}, 10000, [cameras.map(c => c.camera_id).join('|')])
|
||||
const proven = Object.values(checks ?? {}).some(r => r && !r.running && r.verdict === 'good')
|
||||
const recognised = arrivals.length > 0
|
||||
|
||||
if (hidden || recognised) return null
|
||||
const engineReady = Boolean(eng?.reachable && eng?.recognition_model)
|
||||
const progress = eng?.progress
|
||||
|
||||
const steps = [
|
||||
{ done: engineReady, now: !engineReady,
|
||||
title: engineReady ? `Recognition ready (${eng.recognition_model})` : progress ? `Downloading ${progress.what}… ${progress.percent}%` : 'Starting recognition…',
|
||||
text: engineReady ? null : 'First start downloads about 275 MB of recognition models. A few minutes on a normal connection; nothing to do meanwhile.' },
|
||||
{ done: hasCamera && anyUp, now: engineReady && !(hasCamera && anyUp),
|
||||
title: hasCamera ? (anyUp ? 'Camera connected' : 'Camera added — not connecting yet') : 'Add your camera',
|
||||
text: hasCamera ? (anyUp ? null : 'Check its password and stream path under Cameras → Edit.') : 'Behavision can find it on the network; you type only its password.',
|
||||
action: hasCamera ? null : { label: 'Add camera', go: 'cameras' } },
|
||||
{ done: proven, now: hasCamera && anyUp && !proven,
|
||||
title: proven ? 'Camera proven — it can recognise faces' : 'Walk past the camera',
|
||||
text: proven ? null : 'Run Check placement and walk past like a customer for 25 seconds. Only a “good” verdict means it will recognise people.',
|
||||
action: hasCamera && anyUp && !proven ? { label: 'Check placement', go: 'cameras' } : null },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="panel starter">
|
||||
<div className="panelhead">
|
||||
<h3>Getting started</h3>
|
||||
<button className="btn sm ghost" onClick={() => { try { localStorage.setItem('bv.gettingStarted', 'done') } catch {} ; setHidden(true) }}>Hide</button>
|
||||
</div>
|
||||
<ol className="steps compact">
|
||||
{steps.map((st, i) => (
|
||||
<li key={i} className={st.done ? 'done' : st.now ? 'now' : ''}>
|
||||
<b>{st.title}</b>
|
||||
{st.text && <span>{st.text}</span>}
|
||||
{st.action && <button className="btn sm primary" onClick={() => onNavigate?.(st.action.go)}>{st.action.label}</button>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="note">The moment a customer is recognised, this list goes away.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// One customer, big enough to match against the person in front of you.
|
||||
function Arrival({ e, fresh }) {
|
||||
const isNew = e.type === 'person.new'
|
||||
const name = e.data?.label || 'Unrecognised'
|
||||
const bits = [e.data?.gender, e.data?.age ?? e.data?.age_range, e.data?.emotion].filter(Boolean)
|
||||
const sim = typeof e.data?.similarity === 'number' ? e.data.similarity : null
|
||||
|
||||
return (
|
||||
<article className={`arrival ${isNew ? 'is-new' : 'is-seen'} ${fresh ? 'fresh' : ''}`}>
|
||||
<div className="avatar">{avatarText(name)}</div>
|
||||
<div className="who">
|
||||
<div className="name">{name}</div>
|
||||
<div className="meta">
|
||||
{e.camera_id}
|
||||
{bits.length > 0 && <> · {bits.join(', ')}</>}
|
||||
{sim !== null && !isNew && <> · match {sim.toFixed(2)}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="right">
|
||||
<span className={`tag ${isNew ? 'new' : 'seen'}`}>{isNew ? 'new' : 'returning'}</span>
|
||||
<span className="when">{fmtTime(e.ts)}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// "Visitor 13" must show 13, not V1 — initials() would give the same two
|
||||
// characters to Visitor 10, 13 and 15, and read as the reference V-1 for a
|
||||
// fourth person. Found by looking at the screen, not by a test.
|
||||
function avatarText(name) {
|
||||
const auto = /^Visitor (\d+)$/.exec(String(name).trim())
|
||||
if (auto) return auto[1]
|
||||
const words = String(name).trim().split(/\s+/).filter(Boolean)
|
||||
if (!words.length) return '?'
|
||||
return (words[0][0] + (words[1]?.[0] ?? '')).toUpperCase()
|
||||
}
|
||||
|
||||
// One line, above everything, answering the question every other screen is a
|
||||
// detail of: is this shop working, and is anything leaving it.
|
||||
function PipelineStrip({ pipe, cameras, up }) {
|
||||
if (!pipe) return null
|
||||
// A PC set up on its own is not "not linked yet" — nothing is coming, and
|
||||
// saying so with an idle dot beside a count of zero reads as a fault.
|
||||
|
||||
// A PC set up on its own is not "not linked yet" — nothing is coming, and an
|
||||
// idle dot beside a count of zero reads as a fault.
|
||||
if (pipe.standalone) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 10, alignItems: 'center' }}>
|
||||
<i className="dot ok" />
|
||||
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
|
||||
<span className="note">Recognition and customers stay here.</span>
|
||||
<div className="statusbar">
|
||||
<span className="item"><i className="dot ok" /><b>Running on this PC only</b></span>
|
||||
<span className="sep" />
|
||||
<span className="item note">Recognition and customers stay here.</span>
|
||||
<span className="grow" />
|
||||
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stuck = pipe.claimed && !pipe.broker_up
|
||||
const tone = !pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'
|
||||
const text = !pipe.claimed ? 'Not linked to head office'
|
||||
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
|
||||
<strong style={{ fontSize: 13 }}>
|
||||
{!pipe.claimed ? 'Not linked to head office'
|
||||
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
|
||||
</strong>
|
||||
<div className="statusbar">
|
||||
<span className="item">
|
||||
{pipe.broker_up ? <Icon.Cloud size={15} /> : <Icon.CloudOff size={15} />}
|
||||
<i className={`dot ${tone}`} /><b>{text}</b>
|
||||
</span>
|
||||
<span className="note">{pipe.accepted} recorded today</span>
|
||||
<span className="sep" />
|
||||
<span className="item note">{pipe.accepted} recorded today</span>
|
||||
{pipe.queued > 0 && (
|
||||
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
|
||||
{pipe.queued} waiting to send
|
||||
</span>
|
||||
<span className={`item note ${stuck ? 'warn' : ''}`}>{pipe.queued} waiting to send</span>
|
||||
)}
|
||||
{pipe.dropped > 0 && (
|
||||
<span className="note" style={{ color: 'var(--bad)' }}>
|
||||
{pipe.dropped} lost — this PC was offline too long
|
||||
</span>
|
||||
<span className="item note bad"><Icon.Warning size={14} />{pipe.dropped} lost — this PC was offline too long</span>
|
||||
)}
|
||||
<span className="grow" />
|
||||
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ e }) {
|
||||
const cls = e.type === 'person.new' ? 'new'
|
||||
: e.type === 'person.seen' ? 'seen'
|
||||
: e.type === 'person.missed' ? 'miss' : ''
|
||||
const age = e.data?.age ?? e.data?.age_range
|
||||
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
|
||||
function Metric({ k, v, s, tone }) {
|
||||
return (
|
||||
<li>
|
||||
<span className="when">{fmtTime(e.ts)}</span>
|
||||
<span className={`tag ${cls}`}>{label(e.type)}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
{e.data?.label || e.camera_id}
|
||||
{extra && <span className="note"> · {extra}</span>}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// The event names are internal; a shop manager should not have to learn them.
|
||||
function label(type) {
|
||||
return {
|
||||
'person.new': 'new',
|
||||
'person.seen': 'returning',
|
||||
'person.missed': 'missed',
|
||||
'camera.up': 'camera up',
|
||||
'camera.down': 'camera down',
|
||||
'identity.merged': 'merged',
|
||||
}[type] ?? type
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub, tone }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3>
|
||||
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
<div className={`metric ${tone ?? ''}`}>
|
||||
<div className="metric-k">{k}</div>
|
||||
<div className="metric-v">{v}</div>
|
||||
{s && <div className="metric-s">{s}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stream URLs are fetched once per camera and then left alone: reassigning an
|
||||
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
|
||||
// make every feed flicker permanently.
|
||||
function useCameraFeeds() {
|
||||
const [urls, setUrls] = useState({})
|
||||
const { data } = usePolled(() => api.cameras(), 10000)
|
||||
// True for a few seconds after a genuinely new arrival, so the top card can
|
||||
// announce itself once. Keyed on the timestamp rather than the array, which
|
||||
// changes identity on every poll.
|
||||
function useFreshest(top) {
|
||||
const [fresh, setFresh] = useState(false)
|
||||
const seen = useRef(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const next = {}
|
||||
for (const cam of data ?? []) {
|
||||
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
|
||||
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
|
||||
}
|
||||
const changed = Object.keys(next).length !== Object.keys(urls).length ||
|
||||
Object.keys(next).some(k => next[k] !== urls[k])
|
||||
if (!cancelled && changed) setUrls(next)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
return urls
|
||||
if (!top || top.ts === seen.current) return
|
||||
const first = seen.current === null
|
||||
seen.current = top.ts
|
||||
if (first) return // do not flash the whole list on mount
|
||||
setFresh(true)
|
||||
const id = setTimeout(() => setFresh(false), 1200)
|
||||
return () => clearTimeout(id)
|
||||
}, [top?.ts])
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import logo from '../assets/loyaly-mark.png'
|
||||
|
||||
// The gate. Nothing else in the app is reachable until this succeeds, because
|
||||
// the broker credentials and the customer database both live behind it.
|
||||
@@ -24,10 +26,11 @@ export default function Login({ onDone }) {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">Sign in to connect this PC to your store.</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input type="email" value={email} autoComplete="username" required
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import logo from '../assets/loyaly-mark.png'
|
||||
|
||||
// Linking this PC to a shop — the first thing that happens on a new install,
|
||||
// and until now the one thing the app could not do.
|
||||
@@ -13,7 +15,6 @@ export default function Setup({ onDone, onCancel }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [alone, setAlone] = useState(false)
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
@@ -38,15 +39,69 @@ export default function Setup({ onDone, onCancel }) {
|
||||
}
|
||||
}
|
||||
|
||||
// First launch: a choice, not a code box. Two thirds of the people who
|
||||
// open this have no idea what an installation code is; the other third has
|
||||
// one in their hand. Both must see their own path in the first second.
|
||||
const [path, setPath] = useState(onCancel ? 'code' : null)
|
||||
|
||||
if (path === null) {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box wide">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>Welcome to Behavision</h1>
|
||||
<p className="lead">
|
||||
This PC will watch your shop’s cameras and recognise returning customers.
|
||||
First, one question: is this shop managed from a head office?
|
||||
</p>
|
||||
<div className="choices">
|
||||
<button type="button" className="choice" onClick={() => setPath('code')}>
|
||||
<Icon.Cloud size={22} />
|
||||
<b>Yes — I have an installation code</b>
|
||||
<span>Head office gave you a code like <em>ABCDEF-123456-…</em>. This PC joins that shop and gets its cameras from there.</span>
|
||||
</button>
|
||||
<button type="button" className="choice" onClick={() => setPath('alone')}>
|
||||
<Icon.Shield size={22} />
|
||||
<b>No — set up on this PC only</b>
|
||||
<span>Cameras, customers and recognition stay on this PC. Nothing is sent anywhere. You can link to a head office later.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (path === 'alone') {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>On this PC only</h1>
|
||||
<p className="lead">
|
||||
Behavision will run entirely here. Next you’ll add your camera — it can find it on the network for you — and walk past it once so it can prove it works.
|
||||
</p>
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<button type="button" className="btn primary" disabled={!!busy} onClick={standalone}>
|
||||
{busy === 'alone' ? 'Setting up…' : 'Continue'}
|
||||
</button>
|
||||
<div className="alt">
|
||||
<button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>{onCancel ? 'Link to head office' : 'Join your shop'}</h1>
|
||||
<p className="lead">
|
||||
Type the installation code for this shop. You only do this once.
|
||||
Type the installation code head office gave you. It works once, and this PC becomes that shop.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<label className="field">
|
||||
<span>Installation code</span>
|
||||
{/* Uppercase and letter-spaced because the code arrives read aloud
|
||||
@@ -64,38 +119,12 @@ export default function Setup({ onDone, onCancel }) {
|
||||
</button>
|
||||
</form>
|
||||
<p className="foot">
|
||||
The code works once. Ask whoever manages your shops for it — they can
|
||||
create one from the Behavision platform, under the shop.
|
||||
Don’t have one? Whoever runs head office creates it under the shop: <em>Shops → the shop → Set up a shop PC</em>.
|
||||
</p>
|
||||
|
||||
{/* The second way out of this screen, and the reason it exists.
|
||||
Recognition, the cameras and this shop's own gallery all run on
|
||||
this PC and need no server, so a shop with one till and no head
|
||||
office was being blocked from adding a camera until somebody
|
||||
issued it a code — the software refusing to do the thing it is
|
||||
for. Linking later is still one click away, and it keeps the
|
||||
visits already recorded here. */}
|
||||
<div className="alt">
|
||||
{onCancel
|
||||
? <button type="button" className="linkbtn" onClick={onCancel}>
|
||||
Not now — go back
|
||||
</button>
|
||||
: !alone
|
||||
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
|
||||
No head office — set this PC up on its own
|
||||
</button>
|
||||
: <>
|
||||
<p className="note">
|
||||
This PC will watch its cameras and recognise returning
|
||||
customers on its own. Nothing is sent anywhere. You can link
|
||||
it to head office later without losing anything recorded
|
||||
here.
|
||||
</p>
|
||||
<button type="button" className="btn" disabled={!!busy}
|
||||
onClick={standalone}>
|
||||
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
|
||||
</button>
|
||||
</>}
|
||||
? <button type="button" className="linkbtn" onClick={onCancel}>Not now — go back</button>
|
||||
: <button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,28 +2,32 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// iconFor renders the tray icon at run time rather than embedding four PNGs.
|
||||
// iconFor renders the tray icon at run time: the Loyaly mark with a state
|
||||
// dot in the corner. Green, amber, red or grey is the only thing a taskbar
|
||||
// conveys at this size, and the mark is what makes it OURS among a row of
|
||||
// other icons - a plain coloured circle read as a generic status light.
|
||||
//
|
||||
// A 16x16 filled circle is all the taskbar shows at this size, and generating
|
||||
// it means the four states cannot drift apart visually or have one file go
|
||||
// missing from a build.
|
||||
// The mark is embedded once at 128px and scaled down here, so the four
|
||||
// states cannot drift apart and no file can go missing from a build.
|
||||
//
|
||||
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
|
||||
// to a temp file and, on Windows, hands the path to LoadImageW with
|
||||
// IMAGE_ICON|LR_LOADFROMFILE — which decodes .ico and nothing else. A PNG
|
||||
// IMAGE_ICON|LR_LOADFROMFILE - which decodes .ico and nothing else. A PNG
|
||||
// there returns 0, systray logs "unable to set icon", and the product ships
|
||||
// with no tray icon at all: the one control surface a shop manager has.
|
||||
func iconFor(state string) []byte {
|
||||
img := circle(colorFor(state))
|
||||
img := trayImage(colorFor(state))
|
||||
if runtime.GOOS == "windows" {
|
||||
return encodeICO(img)
|
||||
}
|
||||
@@ -46,30 +50,93 @@ func colorFor(state string) color.RGBA {
|
||||
}
|
||||
}
|
||||
|
||||
const iconSize = 16
|
||||
// 32px rather than 16: Windows shows 16 at 100% scaling and 24 at 150%, and
|
||||
// scaling a 32 down looks right at both, where a 16 scaled up looks like 2005.
|
||||
const iconSize = 32
|
||||
|
||||
func circle(c color.RGBA) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
const r = 6.5
|
||||
cx, cy := float64(iconSize)/2-0.5, float64(iconSize)/2-0.5
|
||||
//go:embed tray-logo.png
|
||||
var trayLogoPNG []byte
|
||||
|
||||
var (
|
||||
trayLogoOnce sync.Once
|
||||
trayLogo *image.RGBA
|
||||
)
|
||||
|
||||
// logo is the embedded mark, decoded once and box-filtered down to iconSize.
|
||||
// A box filter rather than nearest-neighbour: 128->32 is an exact 4x4 average
|
||||
// and nearest would drop three pixels in four, which shreds the thin outline.
|
||||
func logo() *image.RGBA {
|
||||
trayLogoOnce.Do(func() {
|
||||
src, err := png.Decode(bytes.NewReader(trayLogoPNG))
|
||||
if err != nil {
|
||||
trayLogo = image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
return
|
||||
}
|
||||
b := src.Bounds()
|
||||
f := b.Dx() / iconSize
|
||||
out := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
for y := 0; y < iconSize; y++ {
|
||||
for x := 0; x < iconSize; x++ {
|
||||
var r, g, bl, a uint64
|
||||
for dy := 0; dy < f; dy++ {
|
||||
for dx := 0; dx < f; dx++ {
|
||||
// Premultiplied so transparent pixels do not drag the
|
||||
// colour of the edge towards black.
|
||||
pr, pg, pb, pa := src.At(b.Min.X+x*f+dx, b.Min.Y+y*f+dy).RGBA()
|
||||
r += uint64(pr)
|
||||
g += uint64(pg)
|
||||
bl += uint64(pb)
|
||||
a += uint64(pa)
|
||||
}
|
||||
}
|
||||
n := uint64(f * f)
|
||||
out.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(r / n >> 8), G: uint8(g / n >> 8), B: uint8(bl / n >> 8), A: uint8(a / n >> 8),
|
||||
})
|
||||
}
|
||||
}
|
||||
trayLogo = out
|
||||
})
|
||||
return trayLogo
|
||||
}
|
||||
|
||||
// trayImage is the mark with a state dot over its bottom-right corner, ringed
|
||||
// so it reads against both the yellow of the mark and a dark taskbar.
|
||||
func trayImage(c color.RGBA) *image.RGBA {
|
||||
base := logo()
|
||||
img := image.NewRGBA(base.Bounds())
|
||||
copy(img.Pix, base.Pix)
|
||||
const r = 6.0
|
||||
cx, cy := float64(iconSize)-r-0.5, float64(iconSize)-r-0.5
|
||||
ring := color.RGBA{R: 0x11, G: 0x14, B: 0x18, A: 0xFF}
|
||||
for y := 0; y < iconSize; y++ {
|
||||
for x := 0; x < iconSize; x++ {
|
||||
dx, dy := float64(x)-cx, float64(y)-cy
|
||||
d := dx*dx + dy*dy
|
||||
switch {
|
||||
case d <= (r-1)*(r-1):
|
||||
case d <= (r-1.5)*(r-1.5):
|
||||
img.SetRGBA(x, y, c)
|
||||
case d <= r*r:
|
||||
img.SetRGBA(x, y, ring)
|
||||
case d <= (r+1)*(r+1):
|
||||
// One-pixel feathered edge; a hard-aliased circle looks broken
|
||||
// next to every other icon in the tray.
|
||||
a := uint8(float64(c.A) * (r*r - d) / (r*r - (r-1)*(r-1)))
|
||||
img.SetRGBA(x, y, color.RGBA{R: c.R, G: c.G, B: c.B, A: a})
|
||||
a := uint8(255 * ((r+1)*(r+1) - d) / ((r+1)*(r+1) - r*r))
|
||||
bg := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, blend(bg, ring, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func blend(under, over color.RGBA, a uint8) color.RGBA {
|
||||
fa := float64(a) / 255
|
||||
mix := func(u, o uint8) uint8 { return uint8(float64(u)*(1-fa) + float64(o)*fa) }
|
||||
ua := float64(under.A)/255*(1-fa) + fa
|
||||
return color.RGBA{R: mix(under.R, over.R), G: mix(under.G, over.G), B: mix(under.B, over.B), A: uint8(ua * 255)}
|
||||
}
|
||||
|
||||
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
|
||||
//
|
||||
// Vista and later also accept a PNG stored inside the .ico container, which
|
||||
@@ -94,8 +161,8 @@ func encodeICO(img *image.RGBA) []byte {
|
||||
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
|
||||
b.WriteByte(byte(w))
|
||||
b.WriteByte(byte(h))
|
||||
b.WriteByte(0) // palette size: none
|
||||
b.WriteByte(0) // reserved
|
||||
b.WriteByte(0) // palette size: none
|
||||
b.WriteByte(0) // reserved
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
|
||||
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
|
||||
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// nothing — and nothing on a Mac could notice. These tests are the substitute
|
||||
// for the Windows box we do not have.
|
||||
func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||
b := encodeICO(circle(colorFor("ok")))
|
||||
b := encodeICO(trayImage(colorFor("ok")))
|
||||
if len(b) < 22 {
|
||||
t.Fatalf("far too short: %d bytes", len(b))
|
||||
}
|
||||
@@ -48,12 +48,13 @@ func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||
|
||||
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
|
||||
want := colorFor("error") // red: distinguishable from B and G if swapped
|
||||
img := circle(want)
|
||||
img := trayImage(want)
|
||||
b := encodeICO(img)
|
||||
// Centre of the circle, which is solid fill. Bottom-up means image row
|
||||
// iconSize/2 lands at DIB row iconSize/2-1 counting from the start.
|
||||
row := iconSize - 1 - iconSize/2
|
||||
i := 22 + 40 + (row*iconSize+iconSize/2)*4
|
||||
// Centre of the state dot, which is solid fill. Bottom-up means image row
|
||||
// y lands at DIB row iconSize-1-y counting from the start.
|
||||
x, y := iconSize-6-1, iconSize-6-1
|
||||
row := iconSize - 1 - y
|
||||
i := 22 + 40 + (row*iconSize+x)*4
|
||||
got := b[i : i+4]
|
||||
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
|
||||
t.Errorf("centre pixel = % x, want % x (BGRA)",
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -39,6 +40,19 @@ type Client struct {
|
||||
// single-use refresh token.
|
||||
refreshMu sync.Mutex
|
||||
onRefresh func(Session)
|
||||
|
||||
// Camera snapshots already fetched, keyed by camera id. The Cameras screen
|
||||
// polls every 8 seconds and a snapshot is ~90 KB, so re-fetching one that
|
||||
// has not changed would put megabytes an hour on the wire to redraw the
|
||||
// same picture - the same trap the web app's useAuthedImage avoids by
|
||||
// keying on the url rather than the object around it.
|
||||
shotMu sync.Mutex
|
||||
shots map[string]cachedShot
|
||||
}
|
||||
|
||||
type cachedShot struct {
|
||||
at string // the server's snapshot_at; a new one is a new picture
|
||||
uri string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -108,8 +122,16 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
||||
}
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
// The refresh token is gone too, so this really is a sign-in, not a
|
||||
// transient failure. Report it as such so the UI shows the login sheet
|
||||
// rather than an error dialog.
|
||||
// transient failure. Forget the session - in memory AND on disk, through
|
||||
// the same callback that persists rotations - so the app goes back to
|
||||
// Login instead of showing "session expired" on every screen until
|
||||
// somebody finds Sign out. Seen on a PC that had been claimed against a
|
||||
// demo head office and then re-claimed against the real one: the old
|
||||
// login sat there, dead, for the whole session.
|
||||
c.Clear()
|
||||
if c.onRefresh != nil {
|
||||
c.onRefresh(Session{})
|
||||
}
|
||||
return ErrUnauthorized
|
||||
}
|
||||
return c.send(ctx, method, path, raw, out)
|
||||
@@ -180,9 +202,16 @@ func (c *Client) send(ctx context.Context, method, path string, raw []byte, out
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
|
||||
return errTokenExpired
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
case resp.StatusCode == http.StatusUnauthorized && tok != "":
|
||||
// A 401 on a call we sent a session with: the session is the problem.
|
||||
return ErrUnauthorized
|
||||
case resp.StatusCode >= 400:
|
||||
// Every other 4xx/5xx - including a 401 on a call that carried NO
|
||||
// session, such as redeeming an installation code - is about the
|
||||
// request, and the server wrote its message for exactly this moment.
|
||||
// Mapping those to "session expired" told an installer their session
|
||||
// had lapsed on a screen where they had never signed in, and hid
|
||||
// "That installation code is not valid" behind it.
|
||||
msg := e.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
|
||||
@@ -515,6 +544,27 @@ func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
|
||||
"/api/visitors/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
// AssistantTurn is one message in the help conversation. The browser holds
|
||||
// the history and resends it; nothing is stored server-side.
|
||||
type AssistantTurn struct {
|
||||
Role string `json:"role"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// AssistantAnswer is the reply, and the names of what it looked at - shown to
|
||||
// the user, because an assistant that silently ran a camera check would be
|
||||
// alarming and naming what it consulted makes a wrong answer traceable.
|
||||
type AssistantAnswer struct {
|
||||
Text string `json:"text"`
|
||||
Used []string `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
// Ask puts a question to the head-office assistant as this signed-in user.
|
||||
func (c *Client) Ask(ctx context.Context, history []AssistantTurn) (AssistantAnswer, error) {
|
||||
var out AssistantAnswer
|
||||
return out, c.do(ctx, http.MethodPost, "/api/assistant", map[string]any{"history": history}, &out)
|
||||
}
|
||||
|
||||
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
|
||||
var out []Visit
|
||||
return out, c.do(ctx, http.MethodGet,
|
||||
@@ -593,3 +643,182 @@ func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
|
||||
"items": items, "source": "manual", "notes": notes,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- viewing --
|
||||
//
|
||||
// A PC with no engine of its own is not broken, it is a VIEWER: somebody
|
||||
// signed in on a laptop away from the shop. Everything below reads head
|
||||
// office so those screens have something true to show instead of "engine not
|
||||
// reachable", which is an accurate sentence and a useless one when the reader
|
||||
// was never expecting an engine on that machine.
|
||||
|
||||
// Arrival is one visit as the estate's feed reports it, across every shop -
|
||||
// not just this PC's. `GET /api/visits`.
|
||||
type Arrival struct {
|
||||
VisitID string `json:"visit_id"`
|
||||
VisitRef string `json:"visit_ref"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Site string `json:"site"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
CameraID string `json:"camera_id"`
|
||||
VisitorID string `json:"visitor_id"`
|
||||
Ref string `json:"ref"`
|
||||
Label string `json:"label"`
|
||||
IsNew bool `json:"is_new_visitor"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
Image Photo `json:"image"`
|
||||
}
|
||||
|
||||
// RemoteCamera is a camera as HEAD OFFICE knows it. Deliberately not the same
|
||||
// type the local engine returns: this one can never be edited from here (the
|
||||
// shop PC on that LAN is the only thing that can reach it) and it carries a
|
||||
// snapshot rather than a stream.
|
||||
type RemoteCamera struct {
|
||||
ID string `json:"id"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Site string `json:"site"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected *bool `json:"connected"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
// State is the server's single answer - connected / not_connecting /
|
||||
// waiting / stale - and the screen renders that rather than deciding
|
||||
// again from Connected. Two places deciding one fact is how a shop came
|
||||
// out labelled Working, in green, above "2 of 3 cameras not connecting".
|
||||
State string `json:"state"`
|
||||
StateNote string `json:"state_note"`
|
||||
Snapshot Photo `json:"snapshot"`
|
||||
SnapshotAt string `json:"snapshot_at"`
|
||||
}
|
||||
|
||||
// Arrivals reads the estate's recent visits, newest last.
|
||||
func (c *Client) Arrivals(ctx context.Context, limit int) ([]Arrival, error) {
|
||||
var out struct {
|
||||
Arrivals []Arrival `json:"arrivals"`
|
||||
}
|
||||
if err := c.send(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/visits?limit=%d", limit), nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Arrivals, nil
|
||||
}
|
||||
|
||||
// RemoteCameras lists every camera head office knows about for this company.
|
||||
func (c *Client) RemoteCameras(ctx context.Context) ([]RemoteCamera, error) {
|
||||
var out []RemoteCamera
|
||||
if err := c.send(ctx, http.MethodGet, "/api/cameras", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Snapshot = c.resolveShot(ctx, out[i].ID, out[i].SnapshotAt, out[i].Snapshot)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveShot turns a camera snapshot into something the window can render.
|
||||
//
|
||||
// Same problem VisitorImage has and the same answer: a deployment with no
|
||||
// object storage serves the picture from the API itself, so the url is
|
||||
// relative and needs this session's bearer. A webview <img> can supply
|
||||
// neither - it resolves a relative src against wails:// and cannot set a
|
||||
// header - so the bytes are fetched here and passed as a data: URI.
|
||||
//
|
||||
// A failure is an absence with a reason, never an error. Whether the camera is
|
||||
// CONNECTED is the answer this screen exists to give; the photograph is
|
||||
// decoration, and blanking the card because a picture would not load would
|
||||
// hide the part that matters.
|
||||
func (c *Client) resolveShot(ctx context.Context, camID, at string, p Photo) Photo {
|
||||
if !p.Available || !p.Auth || p.URL == "" {
|
||||
return p
|
||||
}
|
||||
c.shotMu.Lock()
|
||||
hit, ok := c.shots[camID]
|
||||
c.shotMu.Unlock()
|
||||
if ok && hit.at == at && at != "" {
|
||||
p.URL, p.Auth = hit.uri, false
|
||||
return p
|
||||
}
|
||||
uri, err := c.fetchImage(ctx, p.URL)
|
||||
if err != nil {
|
||||
return Photo{Reason: "That camera's picture could not be loaded."}
|
||||
}
|
||||
c.shotMu.Lock()
|
||||
if c.shots == nil {
|
||||
c.shots = map[string]cachedShot{}
|
||||
}
|
||||
c.shots[camID] = cachedShot{at: at, uri: uri}
|
||||
c.shotMu.Unlock()
|
||||
p.URL, p.Auth = uri, false
|
||||
return p
|
||||
}
|
||||
|
||||
// CameraLive opens head office's live relay for one camera and returns the
|
||||
// live SSE response for the caller to read and close.
|
||||
//
|
||||
// A response rather than frames, because the consumer is the app's own
|
||||
// loopback relay: it re-emits these frames as MJPEG so an <img> can show them,
|
||||
// and buffering the stream through a channel here would only add a place for
|
||||
// frames to queue. A stale frame is worthless - the only one worth having is
|
||||
// the newest - which is the whole reason LiveHub drops rather than queues.
|
||||
//
|
||||
// There is no client timeout on this request. A live view is endless by
|
||||
// design and any deadline would cut the picture off mid-shift; the context is
|
||||
// what ends it, when the viewer navigates away.
|
||||
func (c *Client) CameraLive(ctx context.Context, cameraID string) (*http.Response, error) {
|
||||
resp, err := c.liveOnce(ctx, cameraID)
|
||||
if errors.Is(err, errTokenExpired) {
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
resp, err = c.liveOnce(ctx, cameraID)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (c *Client) liveOnce(ctx context.Context, cameraID string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
c.Base+"/api/cameras/"+url.PathEscape(cameraID)+"/live", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
if tok == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
|
||||
// c.http has a 30 s timeout, which covers the whole response and would
|
||||
// therefore sever a working live view every thirty seconds - the same
|
||||
// trap that made the server set WriteTimeout to zero for its own SSE
|
||||
// endpoint. A dedicated client, with the dial bounded instead.
|
||||
hc := &http.Client{Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
resp.Body.Close()
|
||||
_ = json.Unmarshal(body, &e)
|
||||
if e.Error == "token_expired" {
|
||||
return nil, errTokenExpired
|
||||
}
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("live view: %s", resp.Status)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -137,3 +138,43 @@ func TestVisitorIDIsPathEscaped(t *testing.T) {
|
||||
t.Errorf("path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Redeeming an installation code is the one call a fresh PC makes before it
|
||||
// has any session. When the server refuses it - wrong code, wrong head office -
|
||||
// it answers 401 with a message written for the installer. That message must
|
||||
// reach them: "session expired" on a screen where nobody has signed in sent a
|
||||
// real installer looking for a login problem that did not exist.
|
||||
func TestARefusedInstallationCodeSaysWhyNotSessionExpired(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Errorf("enrol must not carry a session, got %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
fail(w, http.StatusUnauthorized, "bad_token",
|
||||
"That installation code is not valid. Ask for a new one.")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := New(srv.URL) // deliberately no session
|
||||
|
||||
_, err := c.Bootstrap(context.Background(), "KWFH5S-EH46LT-EE4X47-OSOH7D")
|
||||
if err == nil {
|
||||
t.Fatal("a refused code must be an error")
|
||||
}
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a refused code is not a session problem, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "installation code is not valid") {
|
||||
t.Fatalf("the server's own words should reach the installer, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other side of the same rule: a 401 on a call that DID carry a session is
|
||||
// a session problem, and must still read as one.
|
||||
func TestARejectedSessionStillReadsAsSessionExpired(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusUnauthorized, "unauthorized", "Sign in again.")
|
||||
})
|
||||
err := c.do(context.Background(), http.MethodGet, "/api/auth/me", nil, nil)
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a 401 with a session should be ErrUnauthorized, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
agentconfig "github.com/loyaly/behavision-agent/pkg/config"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -20,7 +21,11 @@ type Client struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
http *http.Client
|
||||
// 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 {
|
||||
@@ -48,8 +53,12 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.User != "" {
|
||||
req.SetBasicAuth(c.User, c.Password)
|
||||
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 {
|
||||
@@ -105,6 +114,12 @@ 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)
|
||||
|
||||
@@ -12,10 +12,12 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
runtime2 "runtime"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
@@ -27,10 +29,38 @@ func main() {
|
||||
app := NewApp()
|
||||
tray := newTray(app)
|
||||
|
||||
// One process per PC, enforced by the OS rather than by hoping.
|
||||
//
|
||||
// The window hides to the tray on close, so the ordinary next thing a shop
|
||||
// assistant does is double-click the desktop shortcut again to get it
|
||||
// back. Without this lock that started a SECOND complete copy: a second
|
||||
// tray icon, a second engine supervisor on the same SQLite WAL and the
|
||||
// same port - the "start twice" failure the agent package exists to
|
||||
// prevent, on the one binary that never had the guard. Seen on a Windows
|
||||
// install as a row of Behavision icons in the tray. A second launch now
|
||||
// only brings the existing window to the front, which is what the person
|
||||
// wanted in the first place.
|
||||
var ctxRef context.Context
|
||||
single := &options.SingleInstanceLock{
|
||||
UniqueId: "ai.loyaly.behavision.desktop",
|
||||
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
|
||||
if ctxRef != nil {
|
||||
// The same four calls the tray uses, and for the same reasons:
|
||||
// this runs on Wails' own listener goroutine rather than the
|
||||
// window's thread, and the launching process holds the
|
||||
// foreground, so without the flip the window comes back behind
|
||||
// it. Double-clicking the desktop icon while it is already
|
||||
// running is the single most common way anyone reaches this.
|
||||
go openWindow(ctxRef)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Behavision",
|
||||
Width: 1280,
|
||||
Height: 820,
|
||||
SingleInstanceLock: single,
|
||||
Title: "Behavision",
|
||||
Width: 1280,
|
||||
Height: 820,
|
||||
// Small enough to still be usable on a cramped shop-counter monitor.
|
||||
MinWidth: 1024,
|
||||
MinHeight: 640,
|
||||
@@ -38,14 +68,30 @@ func main() {
|
||||
// Closing the window hides it rather than quitting: the engine must
|
||||
// keep recognising after a shop assistant clicks the X, and the tray
|
||||
// is where they get the window back.
|
||||
HideWindowOnClose: true,
|
||||
// Windows hides on close because the tray is how the window comes
|
||||
// back and how recognition is stopped. macOS has no tray here (see
|
||||
// tray_run_darwin.go), so hiding would leave a running engine with no
|
||||
// window, no tray and no way to reach either - force-quit or nothing.
|
||||
// Closing the window therefore quits, which also stops the engine
|
||||
// through OnShutdown. Same rule as the tray's Quit: never leave it
|
||||
// watching with no visible control.
|
||||
HideWindowOnClose: runtime2.GOOS == "windows",
|
||||
OnStartup: func(ctx context.Context) {
|
||||
ctxRef = ctx
|
||||
app.startup(ctx)
|
||||
tray.start(ctx)
|
||||
},
|
||||
OnBeforeClose: func(ctx context.Context) bool {
|
||||
runtime.Hide(ctx)
|
||||
return true // prevent the close
|
||||
if runtime2.GOOS != "windows" {
|
||||
return false // let it close, and OnShutdown stops the engine
|
||||
}
|
||||
// WindowHide, not Hide. They are different calls on Windows -
|
||||
// WindowHide locks the OS thread for the Win32 work and Hide does
|
||||
// not - and the tray's reopen uses WindowShow, so hiding through
|
||||
// the other one leaves the pair mismatched. Same call, opposite
|
||||
// direction.
|
||||
runtime.WindowHide(ctx)
|
||||
return true // prevent the close; the tray is how it comes back
|
||||
},
|
||||
OnShutdown: func(ctx context.Context) {
|
||||
tray.stop()
|
||||
@@ -53,6 +99,17 @@ func main() {
|
||||
app.StopEngine()
|
||||
},
|
||||
Bind: []any{app},
|
||||
// This block has to EXIST, not merely be empty. Wails computes
|
||||
// `zoomable = !Mac.DisableZoom` inside `if frontendOptions.Mac !=
|
||||
// nil`, and the variable defaults to 0 - so leaving Mac unset does not
|
||||
// mean "defaults", it means the green maximise button is created dead.
|
||||
// There was a Windows block and no Mac one, so the window could not be
|
||||
// zoomed on macOS and nothing anywhere said why.
|
||||
Mac: &mac.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
DisableZoom: false,
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
|
||||
BIN
desktop/rsrc_windows_amd64.syso
Normal file
@@ -21,6 +21,7 @@ package main
|
||||
// session and the bytes are fetched and handed over as an object URL.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
@@ -64,6 +65,12 @@ type streamProxy struct {
|
||||
target string // engine origin, e.g. http://127.0.0.1:8010
|
||||
user string
|
||||
pass string
|
||||
|
||||
// Opens head office's live relay for one camera. Set on a computer that
|
||||
// is signed in, whether or not an engine runs here - which is the whole
|
||||
// point: watching a camera in another building is precisely the case
|
||||
// where there is no engine on this machine to ask.
|
||||
live func(ctx context.Context, cameraID string) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||
@@ -71,18 +78,49 @@ func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||
// start binds a loopback listener and begins relaying. Calling it again while
|
||||
// running is a no-op, so a restarted engine cannot leave two listeners behind.
|
||||
func (p *streamProxy) start(base, user, pass string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.srv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
base = "http://" + base
|
||||
}
|
||||
if _, err := url.Parse(base); err != nil {
|
||||
return fmt.Errorf("engine base %q: %w", base, err)
|
||||
}
|
||||
if err := p.bind(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.target = strings.TrimRight(base, "/")
|
||||
p.user, p.pass = user, pass
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchRemote makes the relay able to serve head office's live view, and
|
||||
// binds it if nothing else has.
|
||||
//
|
||||
// Separate from start() because the two are independent: a shop PC has both
|
||||
// an engine and a session, an owner's laptop has only a session, and a PC
|
||||
// still being set up has only an engine. Folding them together would mean a
|
||||
// computer with no engine could not watch a camera at all - which is the one
|
||||
// computer most likely to be trying to.
|
||||
func (p *streamProxy) watchRemote(fn func(context.Context, string) (*http.Response, error)) error {
|
||||
if err := p.bind(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.live = fn
|
||||
return nil
|
||||
}
|
||||
|
||||
// bind starts the loopback listener once. Calling it again while running is a
|
||||
// no-op, so neither a restarted engine nor a second sign-in can leave two
|
||||
// listeners behind.
|
||||
func (p *streamProxy) bind() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.srv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The engine's own credential exists precisely so that the live face feed
|
||||
// is never served open - CLAUDE.md is explicit that an unauthenticated
|
||||
@@ -105,8 +143,6 @@ func (p *streamProxy) start(base, user, pass string) error {
|
||||
|
||||
p.ln = ln
|
||||
p.token = hex.EncodeToString(raw)
|
||||
p.target = strings.TrimRight(base, "/")
|
||||
p.user, p.pass = user, pass
|
||||
// No client timeout: an MJPEG stream is endless by design and any deadline
|
||||
// would cut the picture off mid-shift. The request context ends it when
|
||||
// the webview navigates away or the tile is replaced.
|
||||
@@ -130,7 +166,7 @@ func (p *streamProxy) start(base, user, pass string) error {
|
||||
func (p *streamProxy) stop() {
|
||||
p.mu.Lock()
|
||||
srv, ln := p.srv, p.ln
|
||||
p.srv, p.ln, p.token = nil, nil, ""
|
||||
p.srv, p.ln, p.token, p.live = nil, nil, "", nil
|
||||
p.mu.Unlock()
|
||||
if srv != nil {
|
||||
_ = srv.Close()
|
||||
@@ -155,6 +191,7 @@ func (p *streamProxy) urlFor(cameraID, file string) string {
|
||||
func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.RLock()
|
||||
token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client
|
||||
liveFn := p.live
|
||||
p.mu.RUnlock()
|
||||
if token == "" || client == nil {
|
||||
http.NotFound(w, r)
|
||||
@@ -190,6 +227,17 @@ func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||
// calls; it is here so that adding a still later is a change to a screen
|
||||
// rather than a change to the one file where a mistake is a credentialed
|
||||
// proxy onto the biometric API.
|
||||
// Head office's relay, not the engine. The two are different machines and
|
||||
// different credentials, so this returns rather than falling through.
|
||||
if parts[3] == "live.mjpeg" {
|
||||
if liveFn == nil {
|
||||
http.Error(w, "not signed in to head office", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
p.relayRemote(w, r, cameraID, liveFn)
|
||||
return
|
||||
}
|
||||
|
||||
var enginePath string
|
||||
switch parts[3] {
|
||||
case "stream.mjpeg":
|
||||
|
||||
149
desktop/stream_remote.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
// Watching a camera in another building, from the app.
|
||||
//
|
||||
// The shop PC sits behind a router with no inbound route, so nothing here can
|
||||
// pull its MJPEG stream - that stream is served on the shop PC's own loopback
|
||||
// and always will be. Head office's LiveHub is the way round it: the agent
|
||||
// asks outbound whether anyone is watching and pushes JPEG frames up for
|
||||
// exactly as long as somebody is. The head-office web app already consumes
|
||||
// that; this is the same feed, for the app.
|
||||
//
|
||||
// It arrives as base64 frames over SSE, which an <img> cannot render, so this
|
||||
// re-emits them as multipart MJPEG - which an <img> renders natively, through
|
||||
// the relay that already exists for the local engine. That is what keeps ONE
|
||||
// code path in the screens: a tile points at a loopback URL and does not know
|
||||
// or care which building the picture came from.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The boundary is ours to choose; it only has to be a string the JPEG bytes
|
||||
// cannot contain, and a marker line never appears inside JPEG data.
|
||||
const mjpegBoundary = "behavisionframe"
|
||||
|
||||
// A frame is base64, so ~1.33 bytes on the wire per byte of picture. The
|
||||
// engine re-encodes to 640 px for the relay and those measure ~20 KB, so this
|
||||
// is roughly a hundredfold headroom - large enough never to clip a real frame
|
||||
// and small enough that a broken or hostile stream cannot grow this process's
|
||||
// memory without bound.
|
||||
const maxFrameLine = 8 << 20
|
||||
|
||||
func (p *streamProxy) relayRemote(w http.ResponseWriter, r *http.Request,
|
||||
cameraID string, open func(context.Context, string) (*http.Response, error)) {
|
||||
|
||||
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary="+mjpegBoundary)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
// Send the headers NOW, before any frame exists. Go writes them on the
|
||||
// first body write, so without this the whole response - status line
|
||||
// included - waits for the shop computer to start pushing, and a viewer
|
||||
// whose camera is slow to answer sees the REQUEST time out rather than a
|
||||
// stream that has not painted yet. Measured against production: 30
|
||||
// seconds and not even a Content-Type.
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Reconnecting is normal, not an error. The server caps one push at five
|
||||
// minutes so that a tab left open for a week cannot leave a shop
|
||||
// uploading for a week - so a viewer who IS still there simply asks
|
||||
// again. Doing it here rather than in the page is what lets the <img>
|
||||
// survive the cap: it never sees the stream end.
|
||||
sent := 0
|
||||
for {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
n, err := p.pumpRemote(w, flusher, r.Context(), cameraID, open)
|
||||
sent += n
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
// Nothing was written and the attempt failed. Writing an error body
|
||||
// now would be writing it into a multipart stream the <img> is
|
||||
// already parsing, so the picture simply stays on whatever it last
|
||||
// showed and the screen's own "not connecting" state is the report.
|
||||
if err != nil && sent == 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pumpRemote runs one SSE connection to exhaustion and returns how many
|
||||
// frames it forwarded.
|
||||
func (p *streamProxy) pumpRemote(w http.ResponseWriter, flusher http.Flusher,
|
||||
ctx context.Context, cameraID string,
|
||||
open func(context.Context, string) (*http.Response, error)) (int, error) {
|
||||
|
||||
resp, err := open(ctx, cameraID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), maxFrameLine)
|
||||
|
||||
var event, data string
|
||||
frames := 0
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event: "):
|
||||
event = strings.TrimSpace(line[7:])
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
data = line[6:]
|
||||
case line == "":
|
||||
// End of one SSE event. `waiting` means head office has us
|
||||
// registered and the shop PC has not started pushing yet - a real
|
||||
// second or two while the agent is asked, and nothing to draw.
|
||||
if event == "frame" && data != "" {
|
||||
if err := writeMJPEGFrame(w, flusher, data); err != nil {
|
||||
return frames, err // the webview went away
|
||||
}
|
||||
frames++
|
||||
}
|
||||
event, data = "", ""
|
||||
}
|
||||
}
|
||||
return frames, sc.Err()
|
||||
}
|
||||
|
||||
func writeMJPEGFrame(w http.ResponseWriter, flusher http.Flusher, b64 string) error {
|
||||
jpg, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(jpg) == 0 {
|
||||
// One malformed frame is not a reason to tear down a working view.
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintf(w,
|
||||
"--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
|
||||
mjpegBoundary, len(jpg)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(jpg); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte("\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
// Flushed per frame. Anything held waiting for a full buffer is a tile
|
||||
// that stays blank, which is indistinguishable from the view not working.
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
103
desktop/stream_remote_live_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-desktop/internal/cloud"
|
||||
)
|
||||
|
||||
// The whole chain against the real head office and a real shop computer:
|
||||
//
|
||||
// TEST_CLOUD_EMAIL=... TEST_CLOUD_PASSWORD=... \
|
||||
// go test ./desktop/ -run RemoteLive -v
|
||||
//
|
||||
// Everything in stream_remote_test.go proves the relay against a fake that
|
||||
// agrees with me. Only this proves the part that cannot be faked: that a shop
|
||||
// computer behind a router with no inbound route actually pushes frames when
|
||||
// asked, that they survive base64 and SSE, and that what comes out of the
|
||||
// loopback relay is a multipart stream an <img> will paint.
|
||||
//
|
||||
// It also costs something to run, which is why it is opt-in: watching makes
|
||||
// the shop computer upload for as long as the test reads.
|
||||
func TestRemoteLiveFromProduction(t *testing.T) {
|
||||
email, pass := os.Getenv("TEST_CLOUD_EMAIL"), os.Getenv("TEST_CLOUD_PASSWORD")
|
||||
if email == "" || pass == "" {
|
||||
t.Skip("set TEST_CLOUD_EMAIL and TEST_CLOUD_PASSWORD to run against production")
|
||||
}
|
||||
base := os.Getenv("TEST_CLOUD_URL")
|
||||
if base == "" {
|
||||
base = "https://mcp.loyaly.ai"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
c := cloud.New(base)
|
||||
if _, err := c.Login(ctx, email, pass); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
|
||||
cams, err := c.RemoteCameras(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("cameras: %v", err)
|
||||
}
|
||||
t.Logf("%d cameras", len(cams))
|
||||
target := os.Getenv("TEST_CLOUD_CAMERA")
|
||||
for _, cam := range cams {
|
||||
conn := "waiting"
|
||||
if cam.Connected != nil {
|
||||
conn = map[bool]string{true: "connected", false: "not connecting"}[*cam.Connected]
|
||||
}
|
||||
t.Logf(" %-10s %-16s %-15s snapshot=%v", cam.CameraID, cam.Site, conn, cam.Snapshot.Available)
|
||||
if target == "" && cam.Connected != nil && *cam.Connected {
|
||||
target = cam.CameraID
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
t.Skip("no connected camera to watch")
|
||||
}
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(c.CameraLive); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
rctx, rcancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer rcancel()
|
||||
req, _ := http.NewRequestWithContext(rctx, http.MethodGet, p.urlFor(target, "live.mjpeg"), nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
start := time.Now()
|
||||
acc, buf, frames := make([]byte, 0, 1<<20), make([]byte, 32*1024), 0
|
||||
for frames < 10 {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
acc = append(acc, buf[:n]...)
|
||||
frames = bytes.Count(acc, []byte("--"+mjpegBoundary))
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
el := time.Since(start)
|
||||
t.Logf("watching %q: %d frames, %d bytes, %.1fs (%.1f fps, %.0f KB/s)",
|
||||
target, frames, len(acc), el.Seconds(),
|
||||
float64(frames)/el.Seconds(), float64(len(acc))/el.Seconds()/1024)
|
||||
|
||||
if frames < 3 {
|
||||
t.Fatalf("got %d frames from a connected camera - the shop computer is "+
|
||||
"not answering head office's request to push", frames)
|
||||
}
|
||||
// Bytes that are actually a picture, not a framing header that happens to
|
||||
// be well formed. A JPEG begins FFD8.
|
||||
if !bytes.Contains(acc, []byte{0xFF, 0xD8, 0xFF}) {
|
||||
t.Error("no JPEG start marker anywhere in the stream")
|
||||
}
|
||||
}
|
||||
209
desktop/stream_remote_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jpg is a byte sequence that is not valid JPEG and does not need to be: what
|
||||
// is under test is that the bytes arrive intact and framed, not that a decoder
|
||||
// likes them.
|
||||
var jpg = []byte{0xFF, 0xD8, 'h', 'e', 'l', 'l', 'o', 0xFF, 0xD9}
|
||||
|
||||
// sseServer answers head office's live endpoint with `pushes` frames and then
|
||||
// ends the response, which is what the server's five-minute cap does.
|
||||
func sseServer(t *testing.T, frames int, hits *int32) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(hits, 1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fl, _ := w.(http.Flusher)
|
||||
// Registered, nothing being pushed yet. Nothing may be drawn for it.
|
||||
fmt.Fprint(w, "event: waiting\ndata: \n\n")
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
for i := 0; i < frames; i++ {
|
||||
fmt.Fprintf(w, "event: frame\ndata: %s\n\n",
|
||||
base64.StdEncoding.EncodeToString(jpg))
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func openerFor(srv *httptest.Server) func(context.Context, string) (*http.Response, error) {
|
||||
return func(ctx context.Context, cam string) (*http.Response, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/live/"+cam, nil)
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point: base64 frames over SSE are not something an <img> can show,
|
||||
// and a multipart MJPEG stream is. Without this the app could only ever show a
|
||||
// still, on exactly the computers that cannot reach the camera any other way.
|
||||
func TestRemoteFramesReachTheWebviewAsMJPEG(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 3, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
u := p.urlFor("cam2", "live.mjpeg")
|
||||
if u == "" {
|
||||
t.Fatal("no relay url; the proxy did not bind")
|
||||
}
|
||||
|
||||
// The relay reconnects for as long as the viewer is there, so the read is
|
||||
// bounded by us rather than by the stream ending - exactly as an <img>
|
||||
// would behave.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/x-mixed-replace") {
|
||||
t.Fatalf("Content-Type = %q, an <img> will not treat that as a stream", ct)
|
||||
}
|
||||
|
||||
// Read the first three frames' worth and stop; the relay would otherwise
|
||||
// go on reconnecting forever, which is the behaviour being relied on.
|
||||
want := append([]byte(fmt.Sprintf("--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
|
||||
mjpegBoundary, len(jpg))), jpg...)
|
||||
got := make([]byte, 0, 4096)
|
||||
buf := make([]byte, 512)
|
||||
for len(got) < 3*len(want) {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
got = append(got, buf[:n]...)
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if n := bytes.Count(got, []byte("--"+mjpegBoundary)); n < 3 {
|
||||
t.Fatalf("got %d frames in %d bytes, want at least 3", n, len(got))
|
||||
}
|
||||
if !bytes.Contains(got, want) {
|
||||
t.Errorf("a frame was not framed as expected:\n%q", got[:min(len(got), 300)])
|
||||
}
|
||||
// `waiting` is a real state - head office has us registered and the shop
|
||||
// computer has not started pushing - and there is nothing to draw for it.
|
||||
// Emitting an empty part would blank a tile that already had a picture.
|
||||
if bytes.Contains(got, []byte("Content-Length: 0")) {
|
||||
t.Error("an empty frame was written for a waiting event")
|
||||
}
|
||||
}
|
||||
|
||||
// The server caps one push at five minutes so a tab left open for a week
|
||||
// cannot leave a shop uploading for a week. Reconnecting is therefore a normal
|
||||
// event, and doing it here rather than in the page is what lets the <img>
|
||||
// survive the cap - it never sees the stream end.
|
||||
func TestTheRelayReconnectsWhenHeadOfficeEndsAPush(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 1, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.urlFor("cam2", "live.mjpeg"), nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Two frames means two pushes, because each push carries exactly one.
|
||||
seen, buf := 0, make([]byte, 256)
|
||||
acc := make([]byte, 0, 2048)
|
||||
for seen < 2 {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
acc = append(acc, buf[:n]...)
|
||||
seen = bytes.Count(acc, []byte("--"+mjpegBoundary))
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen < 2 {
|
||||
t.Fatalf("got %d frames across reconnects, want 2", seen)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got < 2 {
|
||||
t.Errorf("head office was asked %d times, want at least 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Signed out, the relay must not pretend. There is no fallback URL to offer
|
||||
// either: the head-office endpoint needs this session's bearer, which an <img>
|
||||
// cannot send - so a tile that silently failed would be the only alternative.
|
||||
func TestTheRelayRefusesWhenNobodyIsSignedIn(t *testing.T) {
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(nil); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
resp, err := http.Get(p.urlFor("cam2", "live.mjpeg"))
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The relay is credentialed - it is a path to a live view of a shop floor -
|
||||
// and the token is the only thing standing between another local process and
|
||||
// it. live.mjpeg must be behind exactly the same door as the engine routes.
|
||||
func TestTheRemoteRouteIsBehindTheSameToken(t *testing.T) {
|
||||
var hits int32
|
||||
srv := sseServer(t, 1, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
p := newStreamProxy()
|
||||
if err := p.watchRemote(openerFor(srv)); err != nil {
|
||||
t.Fatalf("watchRemote: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
// The right shape, the wrong value.
|
||||
parts := strings.Split(p.urlFor("cam2", "live.mjpeg"), "/")
|
||||
parts[4] = strings.Repeat("0", len(parts[4]))
|
||||
bad := strings.Join(parts, "/")
|
||||
|
||||
resp, err := http.Get(bad)
|
||||
if err != nil {
|
||||
t.Fatalf("GET relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404 - and 404 rather than 403, because there is nothing here to tell an unwelcome caller they found the right door", resp.StatusCode)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) != 0 {
|
||||
t.Error("a request with the wrong token still made the shop computer upload")
|
||||
}
|
||||
}
|
||||
BIN
desktop/tray-logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
@@ -55,9 +55,7 @@ func (t *tray) start(ctx context.Context) {
|
||||
if trayDisabled() {
|
||||
return
|
||||
}
|
||||
t.once.Do(func() {
|
||||
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
||||
})
|
||||
t.once.Do(func() { startSystray(func() { t.onReady(ctx) }) })
|
||||
}
|
||||
|
||||
func (t *tray) stop() {
|
||||
@@ -99,11 +97,15 @@ func (t *tray) onReady(ctx context.Context) {
|
||||
case <-t.quit:
|
||||
return
|
||||
case <-t.mOpen.ClickedCh:
|
||||
runtime.Show(ctx)
|
||||
// In a goroutine, like Start and Stop: this sleeps, and a menu
|
||||
// loop that sleeps is a tray that ignores the next click.
|
||||
go openWindow(ctx)
|
||||
case <-t.mStart.ClickedCh:
|
||||
t.app.StartEngine()
|
||||
// Never on the menu loop itself: a stop waits for the process to
|
||||
// exit, and a menu that is deaf for the duration looks broken.
|
||||
go func() { t.app.StartEngine(); t.refresh() }()
|
||||
case <-t.mStop.ClickedCh:
|
||||
t.app.StopEngine()
|
||||
go func() { t.app.StopEngine(); t.refresh() }()
|
||||
case <-t.mLogs.ClickedCh:
|
||||
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
|
||||
case <-t.mQuit.ClickedCh:
|
||||
@@ -129,23 +131,29 @@ func (t *tray) poll(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
s := t.app.EngineStatus()
|
||||
state, label := describe(s)
|
||||
systray.SetIcon(iconFor(state))
|
||||
systray.SetTooltip("Behavision — " + label)
|
||||
if t.mStatus != nil {
|
||||
t.mStatus.SetTitle(label)
|
||||
}
|
||||
running := s.State == "running"
|
||||
if t.mStart != nil && t.mStop != nil {
|
||||
if running {
|
||||
t.mStart.Disable()
|
||||
t.mStop.Enable()
|
||||
} else {
|
||||
t.mStart.Enable()
|
||||
t.mStop.Disable()
|
||||
}
|
||||
}
|
||||
t.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refresh redraws the icon and the menu from EngineStatus - the same source
|
||||
// the window reads, so the two cannot disagree.
|
||||
func (t *tray) refresh() {
|
||||
s := t.app.EngineStatus()
|
||||
state, label := describe(s)
|
||||
systray.SetIcon(iconFor(state))
|
||||
systray.SetTooltip("Behavision — " + label)
|
||||
if t.mStatus != nil {
|
||||
t.mStatus.SetTitle(label)
|
||||
}
|
||||
running := s.State == "running" || s.State == "starting" || s.State == "backoff"
|
||||
if t.mStart != nil && t.mStop != nil {
|
||||
if running {
|
||||
t.mStart.Disable()
|
||||
t.mStop.Enable()
|
||||
} else {
|
||||
t.mStart.Enable()
|
||||
t.mStop.Disable()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,9 +168,13 @@ func describe(s EngineStatus) (state, label string) {
|
||||
case s.State == "stopped":
|
||||
return "stopped", "Stopped"
|
||||
case s.State == "failed":
|
||||
return "error", "Failed — " + firstLine(s.Error)
|
||||
// The supervisor's error is already a sentence (port in use, missing
|
||||
// library); show it whole, because it is the thing to act on.
|
||||
return "error", "Not running — " + firstLine(s.Error)
|
||||
case s.State == "backoff":
|
||||
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
|
||||
case !s.Reachable && s.Progress != nil:
|
||||
return "warn", fmt.Sprintf("Downloading %s… %d%%", s.Progress.What, s.Progress.Percent)
|
||||
case !s.Reachable:
|
||||
return "warn", "Starting…"
|
||||
case len(s.Cameras) == 0:
|
||||
@@ -202,3 +214,36 @@ func firstLine(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// openWindow brings the dashboard back, and it takes four calls rather than
|
||||
// the one that was here.
|
||||
//
|
||||
// `runtime.Show` was wrong three times over, and the first is the one that
|
||||
// made it fail rather than merely misbehave:
|
||||
//
|
||||
// 1. WRONG THREAD. Wails implements Show() as a bare `mainWindow.Show()`,
|
||||
// while WindowShow() wraps the same work in runtime.LockOSThread. Win32
|
||||
// window operations have to run on the thread owning the window's message
|
||||
// pump; this is called from the SYSTRAY's goroutine, which is never that
|
||||
// thread. An unlocked call from an arbitrary goroutine is why clicking
|
||||
// "Open dashboard" did nothing reliable.
|
||||
//
|
||||
// 2. Showing is not un-minimising. A hidden window and a minimised one are
|
||||
// different states and Show only fixes the first, so a window the user
|
||||
// minimised stayed minimised.
|
||||
//
|
||||
// 3. Windows will not let a process that is not already in the foreground
|
||||
// take it - the shell refuses, and the window comes back BEHIND whatever
|
||||
// is being looked at. Clicking a tray icon is by definition a moment when
|
||||
// this application is not in the foreground, so that is not an edge case
|
||||
// here, it is every time.
|
||||
//
|
||||
// The always-on-top flip is the ordinary way to ask for the foreground anyway.
|
||||
// It is brief and it is why this cannot run on the menu loop.
|
||||
func openWindow(ctx context.Context) {
|
||||
runtime.WindowUnminimise(ctx)
|
||||
runtime.WindowShow(ctx)
|
||||
runtime.WindowSetAlwaysOnTop(ctx, true)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
runtime.WindowSetAlwaysOnTop(ctx, false)
|
||||
}
|
||||
|
||||
28
desktop/tray_run_darwin.go
Normal file
@@ -0,0 +1,28 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
// There is no tray on macOS, and that is a decision rather than an omission.
|
||||
//
|
||||
// macOS has exactly ONE main run loop and AppKit insists that windows and
|
||||
// status items are created on it. Wails already owns that loop. Two attempts,
|
||||
// both crashing within a second of launch:
|
||||
//
|
||||
// systray.Run -> SIGTRAP inside cgo: nativeLoop() takes the
|
||||
// main loop for itself, and Wails has it
|
||||
// systray.RunWithExternalLoop -> "NSWindow should only be instantiated on
|
||||
// the main thread!" - it registers in the
|
||||
// existing NSApplication but still builds
|
||||
// AppKit objects, and Wails' OnStartup is not
|
||||
// the main thread
|
||||
//
|
||||
// Making it work needs the status item created through a main-queue dispatch
|
||||
// inside Wails' own lifecycle, which is real work for a build whose entire
|
||||
// purpose is demoing on a developer's Mac. Windows is the platform this ships
|
||||
// to and its tray is the shop manager's only control surface; here the window
|
||||
// is right there in the Dock.
|
||||
//
|
||||
// The consequence is handled rather than left: with no tray there would be no
|
||||
// way back from a hidden window and no way to quit, so on macOS closing the
|
||||
// window stops the engine and exits. See main.go.
|
||||
func startSystray(onReady func()) {}
|
||||
10
desktop/tray_run_windows.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "fyne.io/systray"
|
||||
|
||||
// systray.Run owns a message loop, and on Windows it is free to have its own:
|
||||
// the tray lives in its own thread with its own pump, beside the one Wails
|
||||
// runs for the window. A goroutine is all it needs.
|
||||
func startSystray(onReady func()) { go systray.Run(onReady, func() {}) }
|
||||
166
desktop/viewing_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-desktop/internal/cloud"
|
||||
"github.com/loyaly/behavision-desktop/internal/local"
|
||||
)
|
||||
|
||||
// Viewer mode: what the app shows on a computer that is signed in and is not
|
||||
// itself watching any cameras.
|
||||
//
|
||||
// This is the friend's-Mac case, and before it existed the app was honest and
|
||||
// useless: Live() and Cameras() read ONLY the engine on 127.0.0.1, so a laptop
|
||||
// with no engine got "engine not reachable at http://127.0.0.1:8010" and
|
||||
// "0 of 0 cameras" - on an account whose shops were running and recognising
|
||||
// people the whole time. Signing in is what the person did; the app answered
|
||||
// as if they had not.
|
||||
//
|
||||
// The engine here is a port nothing listens on, which is precisely what a PC
|
||||
// with no engine is.
|
||||
const noEngine = "http://127.0.0.1:1" // reserved, refuses immediately
|
||||
|
||||
func viewerApp(t *testing.T, srv *httptest.Server) *App {
|
||||
t.Helper()
|
||||
c := cloud.New(srv.URL)
|
||||
c.SetSession(cloud.Session{Token: "test-token"})
|
||||
return &App{
|
||||
ctx: context.Background(),
|
||||
cloud: c,
|
||||
local: local.New(noEngine, "", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveFallsBackToHeadOfficeWhenThereIsNoEngine(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/api/sites":
|
||||
// Two shops. One is fine, one is the Office1 case.
|
||||
w.Write([]byte(`[
|
||||
{"slug":"a","name":"A","cameras_up":2,"cameras_total":2,"fraction_below_gate":0.10},
|
||||
{"slug":"b","name":"B","cameras_up":1,"cameras_total":3,"fraction_below_gate":0.73}
|
||||
]`))
|
||||
case strings.HasPrefix(r.URL.Path, "/api/visits"):
|
||||
w.Write([]byte(`{"arrivals":[
|
||||
{"visit_id":"v1","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2","is_new_visitor":true},
|
||||
{"visit_id":"v2","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2"},
|
||||
{"visit_id":"v3","camera_id":"entrance"}
|
||||
]}`))
|
||||
default:
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
snap, err := viewerApp(t, srv).Live()
|
||||
if err != nil {
|
||||
t.Fatalf("Live: %v", err)
|
||||
}
|
||||
if !snap.Viewing {
|
||||
t.Fatal("the snapshot did not say it was a view of somewhere else")
|
||||
}
|
||||
if got := snap.Stats["cameras_up"]; got != 3 {
|
||||
t.Errorf("cameras_up = %v, want 3 summed across both shops", got)
|
||||
}
|
||||
if got := snap.Stats["cameras_total"]; got != 5 {
|
||||
t.Errorf("cameras_total = %v, want 5", got)
|
||||
}
|
||||
// The WORST site, never an average. Averaging 0.10 against 0.73 reports
|
||||
// 0.42 and hides the only shop anyone needs to go and fix - the same rule
|
||||
// the heartbeat already follows with worst_site.
|
||||
if got := snap.Stats["fraction_below_gate"]; got != 0.73 {
|
||||
t.Errorf("fraction_below_gate = %v, want the worst shop's 0.73", got)
|
||||
}
|
||||
// Three arrivals, two of them the same person, one unidentified. A visit
|
||||
// with no visitor_id is real footfall and an unknown person, so it counts
|
||||
// as a sighting and not as somebody known.
|
||||
g := snap.Stats["gallery"].(map[string]any)
|
||||
if g["identities"] != 1 || g["sightings"] != 3 {
|
||||
t.Errorf("gallery = %v, want 1 identity over 3 sightings", g)
|
||||
}
|
||||
if len(snap.Events) != 3 {
|
||||
t.Fatalf("got %d events, want 3", len(snap.Events))
|
||||
}
|
||||
if snap.Events[0]["type"] != "person.new" || snap.Events[1]["type"] != "person.seen" {
|
||||
t.Errorf("arrival types wrong: %v", snap.Events)
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody signed in: the local failure is the honest answer. There is nothing
|
||||
// else to show, and the person is most likely setting this PC up - telling
|
||||
// them about head office would be telling them about something they have not
|
||||
// got to yet.
|
||||
func TestLiveWithNoEngineAndNoSessionReportsTheEngine(t *testing.T) {
|
||||
a := &App{ctx: context.Background(), cloud: cloud.New("https://example.invalid"),
|
||||
local: local.New(noEngine, "", "")}
|
||||
if _, err := a.Live(); err == nil {
|
||||
t.Fatal("want the engine error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// A remote camera is flagged, because the screen has to withhold every button
|
||||
// that would talk to a camera on a network this computer cannot reach. An Edit
|
||||
// button that cannot work is worse than one that is absent.
|
||||
func TestRemoteCamerasAreFlaggedAndCarryNoCredentials(t *testing.T) {
|
||||
jpeg := base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xD9})
|
||||
var shots int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/camera-snapshots/") {
|
||||
shots++
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
b, _ := base64.StdEncoding.DecodeString(jpeg)
|
||||
w.Write(b)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`[
|
||||
{"id":"c1","camera_id":"cam2","label":"Open office","site":"Coimbatore",
|
||||
"connected":true,"snapshot_at":"2026-09-30T10:00:00Z",
|
||||
"snapshot":{"available":true,"url":"/api/camera-snapshots/c1.jpg","auth":true}}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := viewerApp(t, srv)
|
||||
cams, err := a.Cameras()
|
||||
if err != nil {
|
||||
t.Fatalf("Cameras: %v", err)
|
||||
}
|
||||
if len(cams) != 1 {
|
||||
t.Fatalf("got %d cameras, want 1", len(cams))
|
||||
}
|
||||
if cams[0]["remote"] != true {
|
||||
t.Error("the camera was not flagged remote")
|
||||
}
|
||||
// The RTSP details are a live path into the camera itself and the server
|
||||
// does not send them to a tenant at all. Nothing here may invent them.
|
||||
for _, k := range []string{"host", "port", "path", "username", "password"} {
|
||||
if _, ok := cams[0][k]; ok {
|
||||
t.Errorf("a remote camera carried %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
// The picture has to be fetched here: a webview <img> resolves a relative
|
||||
// src against wails:// and cannot send the session's bearer.
|
||||
shot := cams[0]["snapshot"].(cloud.Photo)
|
||||
if !strings.HasPrefix(shot.URL, "data:image/jpeg;base64,") || shot.Auth {
|
||||
t.Errorf("snapshot url = %q auth=%v, want an inline data URI", shot.URL, shot.Auth)
|
||||
}
|
||||
|
||||
// And fetched ONCE. This screen polls every 8 seconds and a real snapshot
|
||||
// is ~90 KB, so re-fetching an unchanged picture is megabytes an hour to
|
||||
// redraw the same frame.
|
||||
if _, err := a.Cameras(); err != nil {
|
||||
t.Fatalf("second poll: %v", err)
|
||||
}
|
||||
if shots != 1 {
|
||||
t.Errorf("fetched the same snapshot %d times across two polls", shots)
|
||||
}
|
||||
}
|
||||
577
docs/Behavision-Architecture.html
Normal file
@@ -0,0 +1,577 @@
|
||||
<title>Behavision Architecture</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--paper:#f1f4f6;--surface:#fff;--surface-2:#e7ecef;--ink:#131b22;--ink-soft:#46545f;--ink-faint:#6d7d88;--rule:#d3dbe0;
|
||||
--acc:#12707e;--acc-ink:#0b4d57;--acc-bg:#dcedf0;--good:#2f7d55;--warn:#9a6413;--bad:#a8403c;--good-bg:#e2efe8;--warn-bg:#f5ecdc;
|
||||
--sans:"Archivo","Helvetica Neue",Arial,sans-serif;--serif:"Source Serif 4",Georgia,serif;--mono:"IBM Plex Mono",ui-monospace,Menlo,monospace;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--paper:#0e141b;--surface:#161f28;--surface-2:#1d2833;--ink:#e6edf2;--ink-soft:#a7b6c1;--ink-faint:#7b8b97;--rule:#2a3742;--acc:#4fc3d6;--acc-ink:#9adfeb;--acc-bg:#13303a;--good:#6cc394;--warn:#d5a55c;--bad:#e0817c;--good-bg:#172c22;--warn-bg:#2e2617}}
|
||||
:root[data-theme="dark"]{--paper:#0e141b;--surface:#161f28;--surface-2:#1d2833;--ink:#e6edf2;--ink-soft:#a7b6c1;--ink-faint:#7b8b97;--rule:#2a3742;--acc:#4fc3d6;--acc-ink:#9adfeb;--acc-bg:#13303a;--good:#6cc394;--warn:#d5a55c;--bad:#e0817c;--good-bg:#172c22;--warn-bg:#2e2617}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--serif);font-size:1rem;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
h1,h2,h3,.eyebrow,.nav,.legend,.facts,.tag,.metric{font-family:var(--sans)}
|
||||
h1{font-size:clamp(2.2rem,5vw,3.2rem);line-height:1.02;font-weight:700;letter-spacing:-.025em;margin:0;text-wrap:balance}
|
||||
h2{font-size:1.5rem;line-height:1.15;font-weight:600;letter-spacing:-.01em;margin:0;text-wrap:balance}
|
||||
p{margin:0} code{font-family:var(--mono);font-size:.88em;background:var(--surface-2);padding:.06em .35em;border-radius:2px}
|
||||
a{color:var(--acc)} a:focus-visible{outline:2px solid var(--acc);outline-offset:3px}
|
||||
.wrap{max-width:74rem;margin:0 auto;padding-inline:20px}
|
||||
.eyebrow{font-size:.8rem;text-transform:uppercase;letter-spacing:.14em;font-weight:600;color:var(--acc)}
|
||||
|
||||
header.mast{background:var(--surface);border-bottom:1px solid var(--rule)}
|
||||
header.mast .wrap{padding-block:clamp(2.5rem,6vw,4rem) clamp(1.5rem,4vw,2.5rem);display:flex;flex-direction:column;gap:1.1rem}
|
||||
.brand{display:flex;align-items:center;gap:.7rem;font-family:var(--sans);font-weight:600;letter-spacing:.16em;text-transform:uppercase;font-size:.8rem;color:var(--ink-faint)}
|
||||
.lens{width:1rem;height:1rem;border-radius:50%;border:2px solid var(--acc)}
|
||||
.sub{font-size:1.15rem;color:var(--ink-soft);max-width:38rem;line-height:1.45}
|
||||
.nav{display:flex;flex-wrap:wrap;gap:.35rem .9rem;font-size:.8rem;margin-top:.5rem}
|
||||
.nav a{text-decoration:none;color:var(--ink-faint)} .nav a:hover{color:var(--ink)} .nav .n{font-family:var(--mono);color:var(--acc);margin-right:.35rem}
|
||||
|
||||
/* legend */
|
||||
.legend{display:flex;flex-wrap:wrap;gap:.6rem 1.6rem;font-size:.8rem;color:var(--ink-soft);align-items:center}
|
||||
.legend span{display:inline-flex;align-items:center;gap:.45rem}
|
||||
.legend svg{width:34px;height:20px;display:block}
|
||||
|
||||
/* plates */
|
||||
.plate{padding-block:clamp(2.2rem,5vw,3.5rem);border-bottom:1px solid var(--rule)}
|
||||
.plate:last-of-type{border-bottom:0}
|
||||
.head{display:grid;grid-template-columns:3.2rem 1fr;gap:1rem;align-items:baseline;margin-bottom:1.2rem}
|
||||
.head .n{font-family:var(--mono);font-size:.95rem;color:var(--acc)}
|
||||
.head p{color:var(--ink-soft);margin-top:.35rem;max-width:42rem}
|
||||
.fig{background:var(--surface);border:1px solid var(--rule);padding:clamp(.8rem,2.2vw,1.4rem);overflow-x:auto}
|
||||
.fig svg{display:block;max-width:100%;height:auto;min-width:40rem}
|
||||
.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:.9rem 2rem;margin-top:1.1rem;font-size:.86rem;line-height:1.45}
|
||||
.facts div{display:grid;grid-template-columns:.7rem 1fr;gap:.6rem}
|
||||
.facts div::before{content:"";width:.5rem;height:.5rem;border-radius:1px;background:var(--acc);margin-top:.45rem}
|
||||
.facts b{font-weight:600}
|
||||
|
||||
/* svg semantics */
|
||||
.s{stroke:currentColor;stroke-width:1.5;fill:none}
|
||||
.sa{stroke:var(--acc);stroke-width:1.75;fill:none}
|
||||
.sd{stroke:currentColor;stroke-width:1.25;fill:none;stroke-dasharray:4 4;opacity:.75}
|
||||
.fa{fill:var(--acc)} .fab{fill:var(--acc-bg)} .fs{fill:var(--surface-2)} .fg{fill:var(--good)} .fw{fill:var(--warn)} .fb{fill:var(--bad)} .fgb{fill:var(--good-bg)} .fwb{fill:var(--warn-bg)}
|
||||
.t{font-family:var(--sans);font-size:12.5px;font-weight:600;fill:currentColor}
|
||||
.ta{font-family:var(--sans);font-size:12.5px;font-weight:600;fill:var(--acc)}
|
||||
.m{font-family:var(--mono);font-size:10.5px;fill:currentColor;opacity:.68}
|
||||
.ma{font-family:var(--mono);font-size:10.5px;fill:var(--acc)}
|
||||
.l{font-family:var(--sans);font-size:10.5px;fill:currentColor;opacity:.78}
|
||||
.la{font-family:var(--sans);font-size:10.5px;fill:var(--acc)}
|
||||
.z{font-family:var(--sans);font-size:11.5px;font-weight:600;letter-spacing:1.5px;fill:currentColor;opacity:.5}
|
||||
.cap{font-family:var(--serif);font-size:12.5px;fill:currentColor;opacity:.8}
|
||||
|
||||
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
|
||||
.metric{background:var(--surface);padding:1rem 1.05rem 1.1rem;display:flex;flex-direction:column;gap:.2rem}
|
||||
.metric .v{font-size:1.9rem;font-weight:700;line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
||||
.metric .k{font-size:.8rem;color:var(--ink-faint);line-height:1.35}
|
||||
|
||||
footer{background:var(--surface);border-top:1px solid var(--rule);color:var(--ink-faint);font-size:.8rem}
|
||||
footer .wrap{padding-block:1.6rem 2.6rem}
|
||||
@media (max-width:40rem){.head{grid-template-columns:1fr;gap:.2rem}}
|
||||
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
|
||||
<!-- shared glyphs -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<defs>
|
||||
<marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker>
|
||||
<marker id="aa" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="fa"/></marker>
|
||||
<symbol id="g-cam" viewBox="0 0 24 24"><path d="M3 8h4l2-3h6l2 3h4v11H3z" class="s"/><circle cx="12" cy="13" r="3.2" class="s"/></symbol>
|
||||
<symbol id="g-db" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3" class="s"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" class="s"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" class="s"/></symbol>
|
||||
<symbol id="g-pc" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="1" class="s"/><path d="M8 20h8M12 16v4" class="s"/></symbol>
|
||||
<symbol id="g-phone" viewBox="0 0 24 24"><rect x="7" y="2" width="10" height="20" rx="2" class="s"/><path d="M11 18h2" class="s"/></symbol>
|
||||
<symbol id="g-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="1" class="s"/><path d="M8 10V7a4 4 0 0 1 8 0v3" class="s"/></symbol>
|
||||
<symbol id="g-file" viewBox="0 0 24 24"><path d="M6 2h8l5 5v15H6z" class="s"/><path d="M14 2v5h5" class="s"/></symbol>
|
||||
<symbol id="g-cloud" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1-.6-7.95A6 6 0 0 1 18 9a4 4 0 0 1 0 9z" class="s"/></symbol>
|
||||
<symbol id="g-person" viewBox="0 0 24 24"><circle cx="12" cy="8" r="3.5" class="s"/><path d="M5 20a7 7 0 0 1 14 0" class="s"/></symbol>
|
||||
<symbol id="g-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" class="s"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4" class="s"/></symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<header class="mast">
|
||||
<div class="wrap">
|
||||
<div class="brand"><span class="lens" aria-hidden="true"></span> Behavision · Technical Overview</div>
|
||||
<h1>Behavision Architecture</h1>
|
||||
<p class="sub">Face recognition for retail. An engine that sees, an agent that delivers, a platform that understands — in nine diagrams.</p>
|
||||
<nav class="nav" aria-label="Plates">
|
||||
<a href="#p1"><span class="n">01</span>System</a><a href="#p2"><span class="n">02</span>Shop PC</a><a href="#p3"><span class="n">03</span>Recognition</a><a href="#p4"><span class="n">04</span>Delivery</a><a href="#p5"><span class="n">05</span>Local & master data</a><a href="#p6"><span class="n">06</span>Clients & API</a><a href="#p7"><span class="n">07</span>Onboarding</a><a href="#p8"><span class="n">08</span>Secrets</a><a href="#p9"><span class="n">09</span>Stack & numbers</a>
|
||||
</nav>
|
||||
<div class="legend" aria-label="Diagram legend">
|
||||
<span><svg viewBox="0 0 34 20"><rect x="2" y="3" width="30" height="14" class="s"/></svg>process</span>
|
||||
<span><svg viewBox="0 0 34 20"><ellipse cx="17" cy="5" rx="11" ry="3" class="s"/><path d="M6 5v10c0 1.7 4.9 3 11 3s11-1.3 11-3V5" class="s"/></svg>store</span>
|
||||
<span><svg viewBox="0 0 34 20"><rect x="2" y="3" width="30" height="14" class="sa"/></svg>the path in focus</span>
|
||||
<span><svg viewBox="0 0 34 20"><line x1="2" y1="10" x2="30" y2="10" class="s" marker-end="url(#a)"/></svg>data</span>
|
||||
<span><svg viewBox="0 0 34 20"><line x1="2" y1="10" x2="30" y2="10" class="sd" marker-end="url(#a)"/></svg>control / pull</span>
|
||||
<span><svg viewBox="0 0 34 20"><line x1="17" y1="1" x2="17" y2="19" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3" opacity=".5"/></svg>trust boundary</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap">
|
||||
|
||||
<!-- ============================================================ 01 -->
|
||||
<section class="plate" id="p1">
|
||||
<div class="head"><span class="n">01</span><div><h2>The whole system</h2><p>Video stays inside the shop. Only visit records cross the boundary — and every connection across it is made from the inside, outward.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 520" role="img" aria-label="Cameras stream RTSP to a shop PC running the engine, the agent and the shop app. The agent publishes visits over TLS MQTT to Mosquitto in the cloud; an ingest consumer writes them to PostgreSQL; the API serves the head-office console, the mobile app and platform administration. The shop PC pulls camera settings and check jobs from the API. A dashed boundary marks the shop network, with no inbound route.">
|
||||
<text x="24" y="28" class="z">SHOP NETWORK</text><text x="520" y="28" class="z">LOYALY CLOUD</text><text x="880" y="28" class="z">PEOPLE</text>
|
||||
<line x1="486" y1="42" x2="486" y2="490" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
|
||||
<use href="#g-lock" x="474" y="492" width="24" height="24"/>
|
||||
<text x="486" y="510" text-anchor="middle" class="l" dy="8">outbound only</text>
|
||||
|
||||
<!-- cameras -->
|
||||
<use href="#g-cam" x="30" y="188" width="40" height="40"/>
|
||||
<use href="#g-cam" x="30" y="236" width="40" height="40"/>
|
||||
<text x="50" y="292" text-anchor="middle" class="m">RTSP</text>
|
||||
|
||||
<!-- shop pc -->
|
||||
<rect x="120" y="70" width="330" height="400" rx="3" class="s"/>
|
||||
<use href="#g-pc" x="134" y="82" width="22" height="22"/><text x="164" y="99" class="t">Shop PC</text>
|
||||
<rect x="150" y="122" width="270" height="78" rx="2" class="sa"/>
|
||||
<use href="#g-gear" x="162" y="134" width="20" height="20"/>
|
||||
<text x="190" y="148" class="ta">Recognition engine</text>
|
||||
<text x="190" y="166" class="m">Python · ONNX Runtime · FAISS</text>
|
||||
<text x="190" y="182" class="m">detect → track → identify</text>
|
||||
<use href="#g-db" x="384" y="160" width="26" height="26"/><text x="397" y="200" text-anchor="middle" class="m">SQLite</text>
|
||||
|
||||
<rect x="150" y="226" width="270" height="96" rx="2" class="s"/>
|
||||
<text x="164" y="248" class="t">Agent</text><text x="164" y="266" class="m">Go · supervisor · camera sync</text>
|
||||
<rect x="164" y="278" width="242" height="32" rx="2" class="fab"/>
|
||||
<use href="#g-file" x="172" y="284" width="20" height="20"/>
|
||||
<text x="200" y="299" class="ma">durable spool — one file per event</text>
|
||||
|
||||
<rect x="150" y="348" width="270" height="56" rx="2" class="s"/>
|
||||
<text x="164" y="370" class="t">Shop app</text><text x="164" y="388" class="m">Wails · window + system tray</text>
|
||||
<text x="285" y="440" text-anchor="middle" class="cap">runs with no internet;</text>
|
||||
<text x="285" y="456" text-anchor="middle" class="cap">the spool drains when it returns</text>
|
||||
|
||||
<line x1="76" y1="212" x2="148" y2="160" class="s" marker-end="url(#a)"/><text x="126" y="214" class="l">video</text>
|
||||
<line x1="285" y1="202" x2="285" y2="224" class="sa" marker-end="url(#aa)"/><text x="294" y="217" class="la">detections</text>
|
||||
<line x1="285" y1="324" x2="285" y2="346" class="s" marker-end="url(#a)"/>
|
||||
|
||||
<!-- broker -->
|
||||
<rect x="530" y="108" width="170" height="60" rx="2" class="s"/>
|
||||
<use href="#g-cloud" x="542" y="118" width="22" height="22"/><text x="572" y="133" class="t">Mosquitto</text><text x="572" y="151" class="m">MQTT · TLS · per-tenant ACL</text>
|
||||
|
||||
<!-- server -->
|
||||
<rect x="530" y="210" width="290" height="120" rx="2" class="s"/>
|
||||
<text x="544" y="232" class="t">Behavision server</text><text x="544" y="249" class="m">Go · one binary</text>
|
||||
<rect x="546" y="262" width="120" height="50" rx="2" class="s"/><text x="606" y="284" text-anchor="middle" class="t">ingest</text><text x="606" y="300" text-anchor="middle" class="m">dedupe · reinforce</text>
|
||||
<rect x="684" y="262" width="120" height="50" rx="2" class="s"/><text x="744" y="284" text-anchor="middle" class="t">API + web</text><text x="744" y="300" text-anchor="middle" class="m">48 routes · SSE</text>
|
||||
|
||||
<!-- postgres -->
|
||||
<use href="#g-db" x="656" y="388" width="40" height="40"/>
|
||||
<text x="676" y="450" text-anchor="middle" class="ta">PostgreSQL</text>
|
||||
<text x="676" y="466" text-anchor="middle" class="m">master database</text>
|
||||
|
||||
<!-- arrows cloud -->
|
||||
<path d="M422 294 L505 294 L505 138 L528 138" class="sa" marker-end="url(#aa)"/>
|
||||
<text x="462" y="284" text-anchor="middle" class="la">visits · QoS 1</text>
|
||||
<line x1="615" y1="170" x2="606" y2="260" class="s" marker-end="url(#a)"/>
|
||||
<line x1="606" y1="314" x2="668" y2="386" class="s" marker-end="url(#a)"/>
|
||||
<line x1="744" y1="314" x2="690" y2="386" class="s" marker-start="url(#a)" marker-end="url(#a)"/>
|
||||
<path d="M528 300 L470 300 L470 246 L424 246" class="sd" marker-end="url(#a)"/>
|
||||
<text x="470" y="322" text-anchor="middle" class="l">pull: cameras, checks</text>
|
||||
|
||||
<!-- people -->
|
||||
<rect x="880" y="96" width="196" height="56" rx="2" class="s"/><use href="#g-pc" x="892" y="106" width="22" height="22"/><text x="922" y="121" class="t">Head-office console</text><text x="922" y="138" class="m">owner · manager</text>
|
||||
<rect x="880" y="182" width="196" height="56" rx="2" class="s"/><use href="#g-phone" x="892" y="192" width="22" height="22"/><text x="922" y="207" class="t">Mobile app</text><text x="922" y="224" class="m">sales staff</text>
|
||||
<rect x="880" y="268" width="196" height="56" rx="2" class="s"/><use href="#g-person" x="892" y="278" width="22" height="22"/><text x="922" y="293" class="t">Platform admin</text><text x="922" y="310" class="m">creates merchants</text>
|
||||
<path d="M878 124 L846 124 L846 288 L822 288" class="s" marker-end="url(#a)"/>
|
||||
<path d="M878 210 L846 210" class="s"/>
|
||||
<path d="M878 296 L846 296" class="s"/>
|
||||
<text x="846" y="360" text-anchor="middle" class="l">https · session</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Three tiers</b>, one direction of trust: the shop initiates every connection it has.</div>
|
||||
<div><b>One server binary</b> carries ingest, the API and the head-office web app.</div>
|
||||
<div><b>One API</b> for the console, the mobile app and the shop app alike.</div>
|
||||
<div><b>Offline is a delay, not a loss</b>: visits queue on disk until the broker confirms them.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 02 -->
|
||||
<section class="plate" id="p2">
|
||||
<div class="head"><span class="n">02</span><div><h2>Inside the shop PC</h2><p>Three processes on one machine, each in the language its job is best done in, sharing one state root.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 400" role="img" aria-label="On the shop PC: the engine (Python) captures RTSP, runs detection and recognition, and keeps a SQLite gallery with a FAISS index. The agent (Go) supervises the engine, receives detections on a loopback webhook, spools them, and syncs cameras with head office. The shop app (Wails) hosts the window and tray and embeds the agent as a library. All three read and write one state root under ProgramData.">
|
||||
<!-- engine -->
|
||||
<rect x="30" y="50" width="340" height="230" rx="3" class="sa"/>
|
||||
<text x="46" y="76" class="ta">Recognition engine</text><text x="46" y="93" class="m">Python 3.10+ · private venv</text>
|
||||
<rect x="46" y="112" width="140" height="42" rx="2" class="s"/><text x="116" y="130" text-anchor="middle" class="t">capture thread</text><text x="116" y="146" text-anchor="middle" class="m">per camera · latest frame</text>
|
||||
<rect x="214" y="112" width="140" height="42" rx="2" class="s"/><text x="284" y="130" text-anchor="middle" class="t">worker thread</text><text x="284" y="146" text-anchor="middle" class="m">per camera · one track = one person</text>
|
||||
<line x1="188" y1="133" x2="212" y2="133" class="s" marker-end="url(#a)"/>
|
||||
<rect x="46" y="176" width="308" height="42" rx="2" class="s"/><text x="200" y="194" text-anchor="middle" class="t">models · ONNX Runtime</text><text x="200" y="210" text-anchor="middle" class="m">YuNet · ArcFace r50 · genderage · CoreML / DirectML</text>
|
||||
<use href="#g-db" x="60" y="232" width="30" height="30"/><text x="104" y="246" class="t">SQLite gallery</text><text x="104" y="262" class="m">identities · embeddings · sightings</text>
|
||||
<rect x="250" y="232" width="104" height="34" rx="2" class="fab"/><text x="302" y="253" text-anchor="middle" class="ma">FAISS index</text>
|
||||
<line x1="196" y1="249" x2="248" y2="249" class="sd" marker-end="url(#a)"/><text x="222" y="243" text-anchor="middle" class="l">rebuilt at boot</text>
|
||||
<text x="200" y="300" text-anchor="middle" class="m">FastAPI on 127.0.0.1:8010 · Basic auth, credential generated on first start</text>
|
||||
|
||||
<!-- agent -->
|
||||
<rect x="430" y="50" width="300" height="230" rx="3" class="s"/>
|
||||
<text x="446" y="76" class="t">Agent — Go library</text><text x="446" y="93" class="m">agent/pkg · shared by app and headless agent</text>
|
||||
<rect x="446" y="112" width="130" height="40" rx="2" class="s"/><text x="511" y="130" text-anchor="middle" class="t">supervisor</text><text x="511" y="146" text-anchor="middle" class="m">start · restart · backoff</text>
|
||||
<rect x="586" y="112" width="130" height="40" rx="2" class="s"/><text x="651" y="130" text-anchor="middle" class="t">bridge</text><text x="651" y="146" text-anchor="middle" class="m">loopback webhook</text>
|
||||
<rect x="446" y="166" width="130" height="40" rx="2" class="fab"/><text x="511" y="184" text-anchor="middle" class="ma">spool</text><text x="511" y="200" text-anchor="middle" class="m">bounded · acked per event</text>
|
||||
<rect x="586" y="166" width="130" height="40" rx="2" class="s"/><text x="651" y="184" text-anchor="middle" class="t">pump</text><text x="651" y="200" text-anchor="middle" class="m">MQTT QoS 1 · TLS</text>
|
||||
<rect x="446" y="220" width="270" height="40" rx="2" class="s"/><text x="581" y="238" text-anchor="middle" class="t">camera reconciler</text><text x="581" y="254" text-anchor="middle" class="m">pulls desired state · runs placement checks</text>
|
||||
|
||||
<!-- app -->
|
||||
<rect x="790" y="50" width="280" height="230" rx="3" class="s"/>
|
||||
<text x="806" y="76" class="t">Shop app — Wails</text><text x="806" y="93" class="m">Go + React in the system webview · 12 MB</text>
|
||||
<rect x="806" y="112" width="248" height="40" rx="2" class="s"/><text x="930" y="130" text-anchor="middle" class="t">window</text><text x="930" y="146" text-anchor="middle" class="m">Live · Customers · Cameras</text>
|
||||
<rect x="806" y="166" width="248" height="40" rx="2" class="s"/><text x="930" y="184" text-anchor="middle" class="t">system tray</text><text x="930" y="200" text-anchor="middle" class="m">green / amber / red · start · stop · quit</text>
|
||||
<rect x="806" y="220" width="248" height="40" rx="2" class="s"/><text x="930" y="238" text-anchor="middle" class="t">camera relay</text><text x="930" y="254" text-anchor="middle" class="m">loopback · no credential in the page</text>
|
||||
|
||||
<!-- links -->
|
||||
<path d="M372 133 L428 133" class="s" marker-end="url(#a)"/><text x="400" y="126" text-anchor="middle" class="l">events</text>
|
||||
<path d="M428 186 L372 186" class="sd" marker-end="url(#a)"/><text x="400" y="204" text-anchor="middle" class="l">health · stats</text>
|
||||
<path d="M732 165 L788 165" class="s" marker-start="url(#a)" marker-end="url(#a)"/><text x="760" y="158" text-anchor="middle" class="l">embeds</text>
|
||||
|
||||
<!-- state root -->
|
||||
<rect x="30" y="316" width="1040" height="60" rx="3" class="fs"/>
|
||||
<text x="50" y="340" class="t">One state root — ProgramData\Behavision</text>
|
||||
<text x="50" y="360" class="m">data\behavision.db · data\cameras.json (DPAPI) · data\api_credentials.txt · models\ · runtime\ (the engine's Python) · agent.json · spool\</text>
|
||||
<path d="M200 282 L200 314" class="sd"/><path d="M580 282 L580 314" class="sd"/><path d="M930 282 L930 314" class="sd"/>
|
||||
<text x="1050" y="360" text-anchor="end" class="ma">BEHAVISION_DATA_DIR</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Python</b> where the recognition ecosystem is — ONNX, OpenCV, FAISS are first-class.</div>
|
||||
<div><b>Go</b> for lifecycle and delivery — static binaries, cross-compiled to Windows from anywhere.</div>
|
||||
<div><b>Wails</b> for the UI — window, tray and supervisor in one process; a service cannot draw a tray icon.</div>
|
||||
<div><b>Exact search</b>: 100,000 identities in 21.9 ms. Identity is decided once per track, so this is queries per minute, not per frame.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 03 -->
|
||||
<section class="plate" id="p3">
|
||||
<div class="head"><span class="n">03</span><div><h2>Recognition: one decision per visit</h2><p>Frames become tracks; tracks accumulate evidence; a track is identified once. A "not sure" outcome is what stops one person becoming three, and a stranger becoming a regular.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 430" role="img" aria-label="Flow: frames from a camera are detected by YuNet, associated into tracks by IoU, scored for quality, aligned and embedded with ArcFace, averaged over at least three views, then compared to the gallery. Similarity at or above 0.42 is a known person; between 0.32 and 0.42 the system waits for a better view; below 0.32 the person is new and enrolled. Known matches at good quality reinforce the gallery.">
|
||||
<g class="t" text-anchor="middle">
|
||||
<rect x="20" y="60" width="120" height="66" rx="2" class="s"/><text x="80" y="88">Frames</text>
|
||||
<rect x="176" y="60" width="130" height="66" rx="2" class="s"/><text x="241" y="88">Detect</text>
|
||||
<rect x="342" y="60" width="130" height="66" rx="2" class="s"/><text x="407" y="88">Track</text>
|
||||
<rect x="508" y="60" width="130" height="66" rx="2" class="s"/><text x="573" y="88">Quality gate</text>
|
||||
<rect x="674" y="60" width="130" height="66" rx="2" class="s"/><text x="739" y="88">Align + embed</text>
|
||||
<rect x="840" y="60" width="130" height="66" rx="2" class="sa"/><text x="905" y="88" class="ta">Average ≥ 3</text>
|
||||
</g>
|
||||
<g class="m" text-anchor="middle">
|
||||
<text x="80" y="108">15 fps · latest frame</text>
|
||||
<text x="241" y="108">YuNet · 5 landmarks</text><text x="241" y="121">score ≥ 0.82</text>
|
||||
<text x="407" y="108">greedy IoU 0.3</text><text x="407" y="121">one track per person</text>
|
||||
<text x="573" y="108">sharp · size · light · frontal</text><text x="573" y="121">per-camera threshold</text>
|
||||
<text x="739" y="108">Umeyama → 112×112</text><text x="739" y="121">ArcFace r50 · 512-d</text>
|
||||
<text x="905" y="108">normalised mean</text><text x="905" y="121">≥ 4 hits</text>
|
||||
</g>
|
||||
<g class="s" marker-end="url(#a)"><line x1="142" y1="93" x2="174" y2="93"/><line x1="308" y1="93" x2="340" y2="93"/><line x1="474" y1="93" x2="506" y2="93"/><line x1="640" y1="93" x2="672" y2="93"/><line x1="806" y1="93" x2="838" y2="93"/></g>
|
||||
|
||||
<!-- decision -->
|
||||
<path d="M905 128 L905 176" class="sa" marker-end="url(#aa)"/>
|
||||
<path d="M905 180 L985 236 L905 292 L825 236 Z" class="sa"/>
|
||||
<text x="905" y="231" text-anchor="middle" class="ta">cosine vs</text><text x="905" y="246" text-anchor="middle" class="ta">gallery</text>
|
||||
|
||||
<!-- outcomes -->
|
||||
<path d="M825 236 L720 236" class="s" marker-end="url(#a)"/>
|
||||
<rect x="590" y="206" width="128" height="60" rx="2" class="fgb"/><text x="654" y="230" text-anchor="middle" class="t">known</text><text x="654" y="248" text-anchor="middle" class="m">≥ 0.42 · person.seen</text>
|
||||
<path d="M905 292 L905 330" class="s" marker-end="url(#a)"/>
|
||||
<rect x="841" y="334" width="128" height="60" rx="2" class="fwb"/><text x="905" y="358" text-anchor="middle" class="t">not sure</text><text x="905" y="376" text-anchor="middle" class="m">0.32 – 0.42 · retry ≤ 8×</text>
|
||||
<path d="M985 236 L1090 236" class="s" marker-end="url(#a)" style="display:none"/>
|
||||
<path d="M985 236 L1020 236 L1020 260" class="s" marker-end="url(#a)"/>
|
||||
<rect x="956" y="264" width="128" height="60" rx="2" class="fab"/><text x="1020" y="288" text-anchor="middle" class="t">new</text><text x="1020" y="306" text-anchor="middle" class="m">< 0.32 · enrol</text>
|
||||
|
||||
<!-- gallery + reinforcement -->
|
||||
<use href="#g-db" x="380" y="216" width="40" height="40"/>
|
||||
<text x="400" y="278" text-anchor="middle" class="t">Gallery</text><text x="400" y="294" text-anchor="middle" class="m">SQLite + FAISS · ≤ 5 views per person</text>
|
||||
<path d="M588 236 L426 236" class="sd" marker-end="url(#a)"/><text x="507" y="228" text-anchor="middle" class="l">reinforce: good quality, not a near-duplicate</text>
|
||||
<path d="M956 300 L940 300 L940 410 L400 410 L400 262" class="sd" marker-end="url(#a)"/><text x="670" y="403" text-anchor="middle" class="l">enrol as "Visitor N"</text>
|
||||
<path d="M400 214 L400 140 L905 140" class="sd" stroke-dasharray="2 3"/><text x="650" y="134" text-anchor="middle" class="l">index searched once per track</text>
|
||||
|
||||
<!-- retry loop -->
|
||||
<path d="M841 364 L780 364 L780 93" class="sd" marker-end="url(#a)"/><text x="720" y="380" text-anchor="middle" class="l">wait 0.5 s for a better frame</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Never per frame.</b> Single-frame decisions turned one walk-past into three or four "people"; averaging fixed it.</div>
|
||||
<div><b>Model-tagged embeddings.</b> Only same-model vectors share an index; swapping encoders can never mix spaces.</div>
|
||||
<div><b>Quality is per camera, match is shared.</b> Every camera writes into one gallery.</div>
|
||||
<div><b>Measured:</b> 103 tracks → 7 people, 44 correct re-recognitions, in five minutes on the office camera.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 04 -->
|
||||
<section class="plate" id="p4">
|
||||
<div class="head"><span class="n">04</span><div><h2>Delivery: durable before published</h2><p>Nothing is removed from the shop's disk until the broker has confirmed it, and the server drops what it has already seen. That pair is what makes an outage a delay and not a hole.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 380" role="img" aria-label="Swimlanes for shop PC, broker and cloud. A visit event flows from engine to bridge, is appended to the spool and derived an event id, the pump is woken, publishes at QoS 1 over TLS to Mosquitto, the broker acknowledges, only then is the spool file deleted. The ingest consumer deduplicates on the event id, writes to PostgreSQL, and rings a doorbell that wakes live SSE streams to head office. When offline the pump retries with backoff and the spool grows on disk.">
|
||||
<text x="24" y="26" class="z">SHOP PC</text><text x="560" y="26" class="z">BROKER</text><text x="790" y="26" class="z">CLOUD</text>
|
||||
<line x1="536" y1="36" x2="536" y2="350" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
|
||||
<line x1="760" y1="36" x2="760" y2="350" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
|
||||
<g class="t" text-anchor="middle">
|
||||
<rect x="24" y="70" width="110" height="56" rx="2" class="s"/><text x="79" y="94">engine</text>
|
||||
<rect x="164" y="70" width="110" height="56" rx="2" class="s"/><text x="219" y="94">bridge</text>
|
||||
<rect x="304" y="70" width="110" height="56" rx="2" class="sa"/><text x="359" y="94" class="ta">spool</text>
|
||||
<rect x="24" y="200" width="110" height="56" rx="2" class="s"/><text x="79" y="224">waker</text>
|
||||
<rect x="304" y="200" width="110" height="56" rx="2" class="s"/><text x="359" y="224">pump</text>
|
||||
<rect x="580" y="130" width="130" height="66" rx="2" class="s"/><text x="645" y="158">Mosquitto</text>
|
||||
<rect x="790" y="130" width="120" height="66" rx="2" class="s"/><text x="850" y="158">ingest</text>
|
||||
<rect x="790" y="250" width="120" height="56" rx="2" class="s"/><text x="850" y="274">API · SSE</text>
|
||||
</g>
|
||||
<g class="m" text-anchor="middle">
|
||||
<text x="79" y="113">webhook POST</text>
|
||||
<text x="219" y="113">event_id = site·cam·id·sec</text>
|
||||
<text x="359" y="113">append → fsync</text>
|
||||
<text x="79" y="243">rung AFTER append</text>
|
||||
<text x="359" y="243">QoS 1 · in order</text>
|
||||
<text x="645" y="178">TLS 8883 · ACL by tenant</text>
|
||||
<text x="850" y="178">INSERT … ON CONFLICT</text>
|
||||
<text x="850" y="293">arrivals feed</text>
|
||||
</g>
|
||||
<use href="#g-db" x="1000" y="140" width="40" height="40"/><text x="1020" y="200" text-anchor="middle" class="t">PostgreSQL</text>
|
||||
<use href="#g-pc" x="1000" y="262" width="36" height="36"/><text x="1020" y="316" text-anchor="middle" class="m">head office</text>
|
||||
|
||||
<g class="s" marker-end="url(#a)">
|
||||
<line x1="136" y1="98" x2="162" y2="98"/><line x1="276" y1="98" x2="302" y2="98"/>
|
||||
<path d="M219 128 L219 228 L136 228" /><path d="M136 228 L302 228" style="display:none"/>
|
||||
<line x1="136" y1="228" x2="302" y2="228" />
|
||||
<path d="M416 228 L470 228 L470 163 L578 163" class="sa" marker-end="url(#aa)"/>
|
||||
<line x1="712" y1="163" x2="788" y2="163"/>
|
||||
<line x1="912" y1="163" x2="998" y2="163"/>
|
||||
<line x1="850" y1="198" x2="850" y2="248"/>
|
||||
<line x1="912" y1="278" x2="998" y2="278"/>
|
||||
</g>
|
||||
<text x="228" y="245" text-anchor="middle" class="l">wake</text>
|
||||
<text x="470" y="152" text-anchor="middle" class="la">publish</text>
|
||||
<text x="750" y="156" text-anchor="middle" class="l">deliver</text>
|
||||
<text x="955" y="156" text-anchor="middle" class="l">write</text>
|
||||
<text x="862" y="228" class="l">doorbell</text>
|
||||
<text x="955" y="271" text-anchor="middle" class="l">push</text>
|
||||
|
||||
<!-- ack path -->
|
||||
<path d="M645 198 L645 320 L359 320 L359 258" class="sa" stroke-dasharray="5 4" marker-end="url(#aa)"/>
|
||||
<text x="500" y="338" text-anchor="middle" class="la">PUBACK → delete the spool file. Never before.</text>
|
||||
|
||||
<!-- offline loop -->
|
||||
<path d="M304 240 L280 240 L280 300 L304 300" class="sd" style="display:none"/>
|
||||
<rect x="160" y="284" width="126" height="44" rx="2" class="fs"/>
|
||||
<text x="223" y="302" text-anchor="middle" class="l">offline?</text><text x="223" y="318" text-anchor="middle" class="m">backoff 1 → 30 s · spool grows</text>
|
||||
<path d="M302 244 L286 300" class="sd" marker-end="url(#a)"/>
|
||||
<path d="M286 306 L350 260" class="sd" style="display:none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>QoS 1, clean session.</b> QoS 0 could delete an event the wire dropped; QoS 2 buys nothing the derived id doesn't already give.</div>
|
||||
<div><b>Ordered.</b> A failed publish stops the batch — a customer's visits are a timeline.</div>
|
||||
<div><b>Bounded and honest.</b> The spool has a cap and reports what it dropped; a corrupt entry is quarantined, never retried forever.</div>
|
||||
<div><b>Measured:</b> 120 simultaneous visits published, 120 delivered; end to end in ~3 s.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 05 -->
|
||||
<section class="plate" id="p5">
|
||||
<div class="head"><span class="n">05</span><div><h2>Local gallery, master database</h2><p>Two stores with two jobs. The shop PC's gallery recognises people in that shop, offline if need be. The platform's database knows the business: customers across shops, history, reports, tenancy.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 360" role="img" aria-label="Left: the shop PC's SQLite gallery with identities, model-tagged embeddings and sightings, plus a camera list. Right: PostgreSQL with clients, sites, users, visitors, visits, embeddings, cameras and sessions, every row carrying a client id. Between them: templates travel up with each visit; camera configuration and check jobs travel down; nothing else crosses.">
|
||||
<text x="24" y="26" class="z">SHOP PC</text><text x="640" y="26" class="z">PLATFORM</text>
|
||||
<line x1="540" y1="36" x2="540" y2="330" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
|
||||
|
||||
<use href="#g-db" x="60" y="60" width="56" height="56"/>
|
||||
<text x="140" y="80" class="ta">SQLite gallery</text>
|
||||
<text x="140" y="98" class="m">the only persistent state on the PC · WAL</text>
|
||||
<g class="m"><text x="140" y="124">identities Visitor N, label</text><text x="140" y="140">embeddings 512-d · tagged by model</text><text x="140" y="156">sightings identity × camera × time</text></g>
|
||||
<text x="140" y="184" class="t">FAISS index — rebuilt from SQLite at boot</text>
|
||||
<text x="140" y="200" class="m">exact inner product · numpy fallback identical</text>
|
||||
|
||||
<use href="#g-file" x="60" y="230" width="44" height="44"/>
|
||||
<text x="140" y="250" class="t">cameras.json</text><text x="140" y="266" class="m">passwords DPAPI-encrypted · machine-bound</text>
|
||||
<text x="140" y="296" class="t">agent.json</text><text x="140" y="312" class="m">broker login · agent token · sealed at rest</text>
|
||||
|
||||
<use href="#g-db" x="590" y="60" width="56" height="56"/>
|
||||
<text x="670" y="80" class="ta">PostgreSQL</text>
|
||||
<text x="670" y="98" class="m">every table carries client_id · self-migrating schema · 13 migrations</text>
|
||||
<g class="m">
|
||||
<text x="670" y="124">clients slug = MQTT topic prefix</text>
|
||||
<text x="670" y="140">sites · agents slug · tz · heartbeat · fraction_below_gate</text>
|
||||
<text x="670" y="156">app_users owner · manager · staff · bcrypt</text>
|
||||
<text x="670" y="172">visitors number → V-42 · per tenant</text>
|
||||
<text x="670" y="188">visits seq (feed cursor) · source_event_id (dedupe)</text>
|
||||
<text x="670" y="204">visitor_embeddings ≤ 5 · reinforced server-side</text>
|
||||
<text x="670" y="220">site_cameras password sealed AES-GCM, aad = site</text>
|
||||
<text x="670" y="236">sessions SHA-256 of tokens · revocable</text>
|
||||
<text x="670" y="252">visit_faces · camera_snapshots · audit_log</text>
|
||||
</g>
|
||||
<text x="670" y="290" class="t">Object storage (optional)</text><text x="670" y="306" class="m">presigned PUT from the shop PC · presigned GET for staff · private ACL in the signature</text>
|
||||
|
||||
<!-- flows across -->
|
||||
<path d="M420 120 L660 120" style="display:none"/>
|
||||
<path d="M380 210 L528 210 L528 190 L556 190" class="sa" marker-end="url(#aa)" style="display:none"/>
|
||||
<path d="M400 216 L520 216" class="sa" marker-end="url(#aa)"/><text x="460" y="208" text-anchor="middle" class="la">visit + template ↑</text>
|
||||
<path d="M520 244 L400 244" class="sd" marker-end="url(#a)"/><text x="460" y="262" text-anchor="middle" class="l">cameras · checks ↓</text>
|
||||
<path d="M400 290 L520 290" class="sd" marker-end="url(#a)" opacity=".5"/><text x="460" y="308" text-anchor="middle" class="l">heartbeat · health ↑</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Video, frames and raw images never cross.</b> A template and a timestamp do.</div>
|
||||
<div><b>Tenancy is a column and a rule</b>, and the two agree: the tenant comes from the session, never from the request.</div>
|
||||
<div><b>References are immutable</b> — slugs, camera ids, customer numbers — because other systems store them. Display names are free to change.</div>
|
||||
<div><b>Feed by <code>seq</code></b>, never by the camera's clock: lossless under bursts and backlogs; cursors are opaque.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 06 -->
|
||||
<section class="plate" id="p6">
|
||||
<div class="head"><span class="n">06</span><div><h2>Clients and the API</h2><p>Three kinds of people and one kind of machine, all through one API. Sessions are opaque tokens in a table, so "log that device out, now" actually works.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 400" role="img" aria-label="Head-office console, mobile app and shop app sign in with email and password and receive an opaque session; the tenant and role come from that session. They call the API's visits, visitors, sites, cameras, reports, team and assistant routes. The shop PC's agent uses its own token, issued at enrolment, against the agent routes only. Live camera video reaches head office through the agent's outbound relay.">
|
||||
<g class="t">
|
||||
<rect x="24" y="50" width="200" height="60" rx="2" class="s"/><use href="#g-pc" x="36" y="62" width="22" height="22"/><text x="66" y="77">Head-office console</text><text x="66" y="95" class="m">React · embedded in server</text>
|
||||
<rect x="24" y="130" width="200" height="60" rx="2" class="s"/><use href="#g-phone" x="36" y="142" width="22" height="22"/><text x="66" y="157">Mobile app</text><text x="66" y="175" class="m">arrivals · customers · sales</text>
|
||||
<rect x="24" y="210" width="200" height="60" rx="2" class="s"/><use href="#g-pc" x="36" y="222" width="22" height="22"/><text x="66" y="237">Shop app</text><text x="66" y="255" class="m">engine on loopback · cloud for the rest</text>
|
||||
<rect x="24" y="300" width="200" height="60" rx="2" class="sa"/><use href="#g-gear" x="36" y="312" width="22" height="22"/><text x="66" y="327" class="ta">Shop PC agent</text><text x="66" y="345" class="m">token issued once at enrolment</text>
|
||||
</g>
|
||||
|
||||
<rect x="300" y="50" width="220" height="220" rx="2" class="sa"/>
|
||||
<text x="316" y="76" class="ta">Session</text>
|
||||
<g class="m"><text x="316" y="100">256-bit opaque token</text><text x="316" y="116">stored as SHA-256 only</text><text x="316" y="132">refresh rotates in place</text><text x="316" y="148">revocable per device, instantly</text></g>
|
||||
<rect x="316" y="166" width="188" height="88" rx="2" class="fs"/>
|
||||
<text x="330" y="186" class="t">tenant ← session.client_id</text>
|
||||
<text x="330" y="206" class="m">staff arrivals · customers · sales</text>
|
||||
<text x="330" y="222" class="m">manager + cameras · team · erasure</text>
|
||||
<text x="330" y="238" class="m">owner + mint owners</text>
|
||||
|
||||
<rect x="600" y="50" width="476" height="310" rx="2" class="s"/>
|
||||
<text x="616" y="76" class="t">/api</text>
|
||||
<g class="m">
|
||||
<text x="616" y="104">/auth/login · refresh · sessions · register</text>
|
||||
<text x="616" y="124">/visits · /visits/stream ·································· SSE, cursor</text>
|
||||
<text x="616" y="144">/visitors · /history · /profile · /image · /purchases</text>
|
||||
<text x="616" y="164">/sites · /sites/{s}/check · /enrolment-code</text>
|
||||
<text x="616" y="184">/cameras · /check · /snapshot.jpg · /live ······· SSE relay</text>
|
||||
<text x="616" y="204">/reports/footfall · /reports/conversion</text>
|
||||
<text x="616" y="224">/team · /team/members · /team/{id}/password · /invitations</text>
|
||||
<text x="616" y="244">/assistant ··································· tools, never SQL</text>
|
||||
<text x="616" y="264">/admin/clients ····························· platform admin only</text>
|
||||
</g>
|
||||
<rect x="616" y="284" width="444" height="56" rx="2" class="fab"/>
|
||||
<text x="630" y="306" class="ma">/agent/* — enrol · cameras · checks · faces · upload-url · live</text>
|
||||
<text x="630" y="326" class="m">agent token only · a user session is refused</text>
|
||||
|
||||
<g class="s" marker-end="url(#a)"><line x1="226" y1="80" x2="298" y2="80"/><line x1="226" y1="160" x2="298" y2="160"/><line x1="226" y1="240" x2="298" y2="240"/><line x1="522" y1="160" x2="598" y2="160"/></g>
|
||||
<path d="M226 330 L560 330 L560 312 L614 312" class="sa" marker-end="url(#aa)"/>
|
||||
<text x="262" y="72" class="l">email + password</text>
|
||||
<text x="1090" y="385" text-anchor="end" class="cap">Ids accept names: /api/visitors/V-42 · ?site=chennai · /api/cameras/cam1 — a uuid still works everywhere.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Login is boring on purpose.</b> Unknown address and wrong password are byte-identical and cost the same time.</div>
|
||||
<div><b>Another tenant's data is 404</b>, never 403 — nothing to enumerate.</div>
|
||||
<div><b>Live video</b> at head office: the agent pushes ~13 fps only while someone watches. 259 KB/s measured.</div>
|
||||
<div><b>The assistant</b> answers from the same report tools, as the signed-in user; it has no tenant parameter to misuse.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 07 -->
|
||||
<section class="plate" id="p7">
|
||||
<div class="head"><span class="n">07</span><div><h2>Onboarding: each tier creates the next</h2><p>No credential ships inside an installer, and nobody creates their own account from nothing.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 300" role="img" aria-label="Sequence: the platform admin creates a merchant and its owner, receiving a one-time password. The owner creates sales staff, receiving a one-time password, or issues an invitation code. Staff sign in on the mobile app. The owner mints a single-use installation code; the shop PC redeems it and receives its broker login and API token. Head office then pushes the shop's cameras to the PC.">
|
||||
<g class="t" text-anchor="middle">
|
||||
<use href="#g-person" x="60" y="40" width="40" height="40"/><text x="80" y="100">Platform admin</text>
|
||||
<use href="#g-person" x="300" y="40" width="40" height="40"/><text x="320" y="100">Merchant owner</text>
|
||||
<use href="#g-phone" x="540" y="40" width="40" height="40"/><text x="560" y="100">Sales staff</text>
|
||||
<use href="#g-pc" x="780" y="40" width="40" height="40"/><text x="800" y="100">Shop PC</text>
|
||||
<use href="#g-cam" x="1000" y="40" width="40" height="40"/><text x="1020" y="100">Cameras</text>
|
||||
</g>
|
||||
<g class="s" marker-end="url(#a)">
|
||||
<line x1="120" y1="140" x2="278" y2="140"/>
|
||||
<line x1="360" y1="140" x2="518" y2="140"/>
|
||||
<line x1="360" y1="200" x2="758" y2="200"/>
|
||||
<line x1="840" y1="200" x2="998" y2="200"/>
|
||||
</g>
|
||||
<path d="M840 240 L1000 240" class="sd" marker-end="url(#a)"/>
|
||||
<g class="m" text-anchor="middle">
|
||||
<text x="199" y="130">POST /api/admin/clients</text><text x="199" y="158">company + owner, one transaction</text><text x="199" y="172" class="ma" opacity="1">owner password, shown once</text>
|
||||
<text x="439" y="130">POST /api/team/members</text><text x="439" y="158">or /team/invitations → a code they redeem</text><text x="439" y="172" class="ma" opacity="1">staff password, shown once</text>
|
||||
<text x="559" y="190">POST /api/sites/{shop}/enrolment-code</text><text x="559" y="218">single use · 7 days · redeemed by the PC:</text><text x="559" y="232" class="ma" opacity="1">broker login + agent token + CA to pin</text>
|
||||
<text x="919" y="190">head office pushes cameras</text><text x="919" y="230">the PC pulls · adopts local ones up</text><text x="919" y="258">passwords travel only to that site's agent</text>
|
||||
</g>
|
||||
<text x="24" y="288" class="cap">Single use is enforced by the UPDATE itself, so two PCs racing on one code cannot both win. A wrong, spent or expired code all read the same.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Direct or by invitation.</b> A manager can hand over a generated password, or let the salesperson choose their own via a code.</div>
|
||||
<div><b>Reset signs the lost phone out</b> in the same transaction as the new password.</div>
|
||||
<div><b>Standalone</b> is a first-class answer on the setup screen: a single-till shop with no head office runs the full product locally.</div>
|
||||
<div><b>Demo build:</b> cameras ship sealed (AES-256-GCM); the unlock code travels separately from the zip.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 08 -->
|
||||
<section class="plate" id="p8">
|
||||
<div class="head"><span class="n">08</span><div><h2>Where every secret lives</h2><p>Biometric data is treated as biometric data. Each credential has one home and one protection, and none of them is ever returned by an API.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 330" role="img" aria-label="Map of secrets: on the shop PC, camera passwords under DPAPI, the engine API credential in a 0600 file, agent token and broker password sealed in agent.json; in the database, site broker passwords and camera passwords under AES-256-GCM with the site as additional data, user passwords under bcrypt cost 12, session tokens as SHA-256; in transit, TLS for MQTT and HTTPS for the API; face templates never leave the shop as images, photos are opt-in, erasure deletes templates and objects, every image read is audited.">
|
||||
<text x="24" y="26" class="z">SHOP PC</text><text x="400" y="26" class="z">IN TRANSIT</text><text x="640" y="26" class="z">DATABASE</text><text x="900" y="26" class="z">POLICY</text>
|
||||
<line x1="376" y1="36" x2="376" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
<line x1="616" y1="36" x2="616" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
<line x1="876" y1="36" x2="876" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
<g class="t"><use href="#g-lock" x="24" y="52" width="20" height="20"/><text x="52" y="67">camera passwords</text><text x="52" y="84" class="m">DPAPI · machine-bound · has_password only</text>
|
||||
<use href="#g-lock" x="24" y="110" width="20" height="20"/><text x="52" y="125">engine API credential</text><text x="52" y="142" class="m">generated on first start · 0600 · read by the agent</text>
|
||||
<use href="#g-lock" x="24" y="168" width="20" height="20"/><text x="52" y="183">agent token · broker password</text><text x="52" y="200" class="m">sealed in agent.json · earned by enrolment, never shipped</text>
|
||||
<use href="#g-lock" x="24" y="226" width="20" height="20"/><text x="52" y="241">face templates</text><text x="52" y="258" class="m">SQLite · treated as personal data · erasure deletes outright</text>
|
||||
</g>
|
||||
<g class="t"><text x="400" y="67">MQTT</text><text x="400" y="84" class="m">TLS 8883 · pinned issuer · plaintext to any non-loopback host is refused</text>
|
||||
<text x="400" y="125">API</text><text x="400" y="142" class="m">HTTPS behind Traefik · bearer sessions</text>
|
||||
<text x="400" y="183">images</text><text x="400" y="200" class="m">presigned URLs, minutes-long · private ACL inside the signature</text>
|
||||
<text x="400" y="241">shop PC ↔ engine</text><text x="400" y="258" class="m">loopback only · relay token per run, no password in the page</text>
|
||||
</g>
|
||||
<g class="t"><text x="640" y="67">broker + camera passwords</text><text x="640" y="84" class="m">AES-256-GCM · aad = owning site · row copies don't decrypt</text>
|
||||
<text x="640" y="125">user passwords</text><text x="640" y="142" class="m">bcrypt cost 12 · 10 failures / 15 min per account</text>
|
||||
<text x="640" y="183">session tokens</text><text x="640" y="200" class="m">SHA-256 only · a dump holds no usable session</text>
|
||||
<text x="640" y="241">audit_log</text><text x="640" y="258" class="m">every face-image hand-out, every code minted, every merchant created</text>
|
||||
</g>
|
||||
<g class="t"><text x="900" y="67">video never leaves</text><text x="900" y="84" class="m">recognition runs in the shop</text>
|
||||
<text x="900" y="125">photos are opt-in</text><text x="900" y="142" class="m">store_faces defaults to off</text>
|
||||
<text x="900" y="183">tenancy is structural</text><text x="900" y="200" class="m">session decides · 404, never 403</text>
|
||||
<text x="900" y="241">erasure erases</text><text x="900" y="258" class="m">object first · 502 changes nothing</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 09 -->
|
||||
<section class="plate" id="p9">
|
||||
<div class="head"><span class="n">09</span><div><h2>The stack, and the numbers behind it</h2><p>Each layer, the choice, and the one reason that decided it.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 330" role="img" aria-label="Layer stack: clients (React console, mobile, Wails app); API and web (Go, one binary, opaque sessions); transport (Mosquitto MQTT, QoS 1, TLS); master data (PostgreSQL, self-migrating); shop agent (Go library: spool, pump, supervisor); recognition (Python: YuNet, ArcFace r50 on ONNX Runtime, FAISS, SQLite); cameras (any RTSP). Each with its deciding reason.">
|
||||
<g class="t">
|
||||
<rect x="24" y="30" width="1052" height="38" rx="2" class="s"/><text x="40" y="54">Clients</text><text x="200" y="54" class="m">React console (embedded) · mobile app · Wails shop app</text><text x="1060" y="54" text-anchor="end" class="l">one API; UI can never lag its server</text>
|
||||
<rect x="24" y="74" width="1052" height="38" rx="2" class="s"/><text x="40" y="98">API + web</text><text x="200" y="98" class="m">Go · one binary · opaque sessions in a table</text><text x="1060" y="98" text-anchor="end" class="l">instant per-device revocation; JWTs cannot</text>
|
||||
<rect x="24" y="118" width="1052" height="38" rx="2" class="s"/><text x="40" y="142">Transport</text><text x="200" y="142" class="m">Mosquitto · MQTT QoS 1 · TLS · per-tenant ACL</text><text x="1060" y="142" text-anchor="end" class="l">built for many outbound clients; ~10 MB</text>
|
||||
<rect x="24" y="162" width="1052" height="38" rx="2" class="s"/><text x="40" y="186">Master data</text><text x="200" y="186" class="m">PostgreSQL · self-applying migrations · advisory lock · checksums</text><text x="1060" y="186" text-anchor="end" class="l">transactions across tenant + owner; keyset feeds</text>
|
||||
<rect x="24" y="206" width="1052" height="38" rx="2" class="s"/><text x="40" y="230">Shop agent</text><text x="200" y="230" class="m">Go library · spool · pump · supervisor · reconciler</text><text x="1060" y="230" text-anchor="end" class="l">static binary, cross-compiled; one tested implementation</text>
|
||||
<rect x="24" y="250" width="1052" height="38" rx="2" class="sa"/><text x="40" y="274" class="ta">Recognition</text><text x="200" y="274" class="m">Python · YuNet · ArcFace r50 (ONNX Runtime) · FAISS IndexFlatIP · SQLite WAL</text><text x="1060" y="274" text-anchor="end" class="la">97.25 IJB-C · exact search · no second process</text>
|
||||
<rect x="24" y="294" width="1052" height="30" rx="2" class="fs"/><text x="40" y="314">Cameras</text><text x="200" y="314" class="m">any RTSP camera · make picker fills the stream path · placement proved by a 25-second walk-past</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="metrics" style="margin-top:1.1rem">
|
||||
<div class="metric"><span class="v">103 → 7</span><span class="k">tracks to people, 5 min, office camera — 44 re-recognitions</span></div>
|
||||
<div class="metric"><span class="v">120 / 120</span><span class="k">simultaneous visits delivered, real broker and database</span></div>
|
||||
<div class="metric"><span class="v">21.9 ms</span><span class="k">exact search over 100,000 identities</span></div>
|
||||
<div class="metric"><span class="v">~3 s</span><span class="k">camera to head-office feed</span></div>
|
||||
<div class="metric"><span class="v">14 fps</span><span class="k">live picture on the shop PC vs a 15 fps camera</span></div>
|
||||
<div class="metric"><span class="v">10 / 10</span><span class="k">install steps on a clean machine, both cameras connected</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">Behavision — Loyaly · Technical overview, 11 September 2026 · release 0.4.1 · engine 1.1.0 · schema at migration 13. All figures measured on the running system.</div>
|
||||
</footer>
|
||||
521
docs/TECHNICAL-DOSSIER.md
Normal file
@@ -0,0 +1,521 @@
|
||||
# Behavision — Technical Dossier
|
||||
|
||||
Everything below is extracted from the repository as of release 0.4.1 (engine 1.1.0, schema at migration 013). File paths, function names, thresholds, topics, ports and table definitions are the real ones. Where something lives outside the repository (the production host's proxy and container configuration) it is stated as such rather than invented.
|
||||
|
||||
> **Snapshot, not a live document.** Written against release 0.4.1 / schema
|
||||
> 013, and the repository is past that. It predates at least: the engine's
|
||||
> motion gate and one-thread detector (CPU 214% → 16%), `Gallery.health` and
|
||||
> the stalled-camera state, the `tenantOnly` guard, `POST /api/auth/password`,
|
||||
> `POST /api/customers` and the visitor merge, the admin console drill-down,
|
||||
> `GET /api/sales` and `/api/dashboard/summary`, migration 014, and the macOS
|
||||
> desktop build. Everything it *does* describe was extracted from the code and
|
||||
> was true then; nothing here was invented. For the current surface read
|
||||
> `API.md`, which is kept up to date, and `CLAUDE.md` for the decisions.
|
||||
|
||||
Companion documents: `API.md` (every route with request/response shapes), `docs/openapi.yaml` (generated), `docs/Behavision-Architecture.html` (diagrams).
|
||||
|
||||
---
|
||||
|
||||
## 1. System architecture
|
||||
|
||||
### 1.1 Repository layout
|
||||
|
||||
```
|
||||
behavision/ Recognition engine (Python 3.10+)
|
||||
__main__.py CLI: run | enroll | setup-models | calibrate | paths
|
||||
api.py FastAPI on 127.0.0.1:8010 — dashboard, cameras, stream, stats
|
||||
capture.py VideoSource: RTSP capture thread, latest-frame slot, probe_source
|
||||
detection.py FaceDetector (YuNet), Detection
|
||||
geometry.py umeyama, align_face, iou, clip_box
|
||||
recognition.py ArcFaceEncoder (ONNX Runtime), face_quality
|
||||
tracking.py Track, IouTracker
|
||||
engine.py Engine, CameraWorker, PipelineStats — the per-camera pipeline
|
||||
attributes.py AttributeEstimator (gender/age/emotion), aggregate
|
||||
commission.py CommissionRun — placement check verdicts
|
||||
gallery/store.py IdentityStore — SQLite (identities, embeddings, sightings)
|
||||
gallery/index.py VectorIndex — FAISS IndexIDMap2(IndexFlatIP) / numpy fallback
|
||||
gallery/service.py Gallery — resolve, enroll, reinforce, merge, duplicates
|
||||
events.py EventBus + LogSink / WebhookSink / EmailSink
|
||||
cameras.py CameraStore (cameras.json), protect/unprotect (DPAPI)
|
||||
config.py pydantic Config, ${ENV} expansion, RTSP URL building
|
||||
paths.py install_root / state_root / config_path resolution
|
||||
static/dashboard.html Engine's own dashboard (no build step)
|
||||
config/default.yaml Engine config (thresholds, cameras via ${ENV})
|
||||
tests/ Engine tests (204), dependency-light: no camera, no models
|
||||
|
||||
agent/ Shop-PC agent (Go 1.22, module github.com/loyaly/behavision-agent)
|
||||
main.go Headless agent binary
|
||||
cmd/behavision-setup/ Installer: venv, wheel, models, config, smoke test, demo bundle
|
||||
cmd/behavision-demo-pack/ Seals a camera list (AES-256-GCM) — build machine only
|
||||
pkg/spool/ Durable queue: one file per event, bounded, ack by delete
|
||||
pkg/mqtt/ paho adapter (client.go) + Pump + Waker (pump.go)
|
||||
pkg/bridge/ Loopback webhook the engine posts to; derives event_id; queues
|
||||
pkg/engine/ Supervisor (start/stop/restart/backoff), Health, ChildEnv
|
||||
pkg/cameras/ Syncer: pull desired cameras, adopt local, run checks
|
||||
pkg/enrol/ Redeem an installation code
|
||||
pkg/config/ agent.json with DPAPI-protected secrets
|
||||
pkg/paths/ StateRoot / InstallRoot — mirrors behavision/paths.py
|
||||
pkg/demo/ Sealed bundle: NewCode, Seal, Open
|
||||
|
||||
desktop/ Shop-PC app (Wails v2.9.2, Go + React)
|
||||
main.go wails.Run, HideWindowOnClose, tray start/stop
|
||||
app.go Methods bound to the frontend; owns Supervisor, Bridge, Pump, Syncer
|
||||
tray.go / icons.go fyne.io/systray; ICO rendered at runtime on Windows
|
||||
stream_proxy.go Loopback relay for camera MJPEG (credential never in the page)
|
||||
internal/local/ Client for the engine on 127.0.0.1:8010
|
||||
internal/cloud/ Client for the platform API (sessions, refresh, images)
|
||||
frontend/ React + Vite; src/bridge.js calls window.go.main.App.*
|
||||
|
||||
server/ Platform (Go, module github.com/loyaly/behavision-server)
|
||||
cmd/behavision-server/main.go One binary: migrate → store → hub → ingest → API → web
|
||||
Dockerfile Two-stage; static binary on alpine; EXPOSE 8080
|
||||
migrations/001..013_*.sql go:embed'ed; applied at boot under an advisory lock
|
||||
internal/api/ HTTP handlers, middleware, Hub (SSE doorbell), LiveHub (relay)
|
||||
internal/store/ PostgreSQL access (pgx) — every query tenant-scoped
|
||||
internal/ingest/ MQTT consumer: topic → site → visit/heartbeat → store
|
||||
internal/auth/ Passwords (bcrypt 12), tokens, codes, Principal + role checks
|
||||
internal/secret/ secret.Box — AES-256-GCM with AAD
|
||||
internal/blob/ S3-compatible object storage (presign, private ACL check)
|
||||
internal/assistant/ Claude tool loop; tools.go has no LLM import
|
||||
internal/contract/ The MQTT wire contract: Visit, Heartbeat, ParseTopic
|
||||
internal/migrate/ Migration runner (checksums, numeric order, baseline)
|
||||
internal/provision/ CLI: provision key|client|site|user|token
|
||||
internal/web/ go:embed of the built React console (web/dist → here)
|
||||
|
||||
web/ Head-office console (React + Vite); outDir → server/internal/web/dist
|
||||
shared/cameraMakes.js Camera make → RTSP path table, imported by web AND desktop
|
||||
installer/ build.ps1 (PyInstaller path), behavision.iss, INSTALL.txt, LAN launcher
|
||||
run-local.sh Whole platform locally: Postgres + Mosquitto in Docker, server as binary
|
||||
```
|
||||
|
||||
### 1.2 Processes and where they run
|
||||
|
||||
| Process | Language | Runs on | Listens | Talks to |
|
||||
|---|---|---|---|---|
|
||||
| Recognition engine | Python | shop PC | `127.0.0.1:8010` (Basic auth, generated) | cameras (RTSP), agent webhook (loopback) |
|
||||
| Agent (inside the desktop app, or headless) | Go | shop PC | loopback webhook, port 0 | engine API, Mosquitto (TLS 8883), platform API (HTTPS) |
|
||||
| Shop app | Go + webview | shop PC | loopback relay, port 0 | engine API, platform API |
|
||||
| Mosquitto | C | cloud | `8883` TLS (agents), `1883` internal (server) | — |
|
||||
| behavision-server | Go | cloud | `8080` (behind proxy) | PostgreSQL, Mosquitto (subscriber), object storage (optional), Anthropic API (optional) |
|
||||
| PostgreSQL + pgvector | C | cloud | `5432` internal | — |
|
||||
| Head-office console | React | browser | — | platform API |
|
||||
| Mobile app | — | phone | — | platform API |
|
||||
|
||||
### 1.3 Network, domains, TLS
|
||||
|
||||
| Endpoint | Purpose | TLS |
|
||||
|---|---|---|
|
||||
| `https://platform.loyaly.ai` | Head-office console + API (`/api/*`) | Terminated at the reverse proxy (Traefik); the server listens plain HTTP on `LISTEN_ADDR` (default `:8080`) |
|
||||
| `https://mcp.loyaly.ai/api/*` | Same API, the hostname the shop app defaults to (`BEHAVISION_CLOUD`) | Proxy |
|
||||
| `tls://mcp.loyaly.ai:8883` | MQTT for agents (`AGENT_MQTT_URL` default) | Mosquitto's own listener; certificate must carry `DNS:mcp.loyaly.ai`; agents pin the issuing CA (`AGENT_CA_FILE` delivered at enrolment) |
|
||||
| `tcp://behavision-mqtt:1883` | Server ↔ Mosquitto, internal network only (`MQTT_URL` default) | Plaintext on a private network |
|
||||
|
||||
Trust rules enforced in code:
|
||||
- `X-Forwarded-For` is trusted for the login throttle **only because** nothing reaches the server port except through the proxy (`api/throttle.go`).
|
||||
- The agent refuses `tcp://` to any non-loopback host unless `BEHAVISION_ALLOW_PLAINTEXT_MQTT=1` (`agent/pkg/mqtt/client.go`).
|
||||
- No inbound route to a shop PC is ever required: agent → broker, agent → API, app → API are all outbound.
|
||||
|
||||
### 1.4 Server configuration (environment)
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | required | PostgreSQL DSN |
|
||||
| `LISTEN_ADDR` | `:8080` | HTTP listener (behind proxy) |
|
||||
| `MQTT_URL` / `MQTT_USERNAME` / `MQTT_PASSWORD` | `tcp://behavision-mqtt:1883` | Server's subscriber credential |
|
||||
| `AGENT_MQTT_URL` | `tls://mcp.loyaly.ai:8883` | Broker URL handed to a PC at enrolment |
|
||||
| `AGENT_CA_FILE` | — | CA PEM handed to a PC at enrolment (pinned) |
|
||||
| `AGENT_MODELS_FILE` | — | Model manifest handed at enrolment |
|
||||
| `BEHAVISION_SECRET_KEY` | — | 32-byte key for `secret.Box`; enrolment and camera passwords need it |
|
||||
| `DO_SPACES_*` (`ENDPOINT`, `REGION`, `BUCKET`, `ACCESS_KEY`, `SECRET_KEY`, `PREFIX`) | prefix `behavision/v2` | Optional object storage; absent = images stored in Postgres |
|
||||
| `ANTHROPIC_API_KEY` / `ANTHROPIC_WORKSPACE_ID` / `BEHAVISION_ASSISTANT_MODEL` | model `claude-sonnet-5` | Assistant; absent = `501 assistant_off` |
|
||||
| `BEHAVISION_SKIP_MIGRATE` | — | Escape hatch; default applies migrations at boot |
|
||||
|
||||
### 1.5 Container / deployment shape
|
||||
|
||||
The repository ships `server/Dockerfile` (golang:1.25-alpine build → alpine:3.20 runtime, static binary, `EXPOSE 8080`, non-root user) and `run-local.sh`, which stands the whole platform up locally: `bv-pg` (pgvector/pgvector:pg16, port 55432), `bv-mqtt` (eclipse-mosquitto:2, port 51883, `passwd` + `acl` mounted), and the server as a local binary on 8088 with the console embedded.
|
||||
|
||||
Production host configuration (proxy routes, compose/unit files, certificate issuance) is **outside the repository**. What the code requires of it: a proxy terminating TLS for `platform.loyaly.ai` and forwarding to `LISTEN_ADDR`; Mosquitto with a TLS listener on 8883 whose certificate names `mcp.loyaly.ai`, a `passwd` file the `provision site` command adds to, and an ACL of the form `pattern write bv/%u/#`; PostgreSQL with the `vector` extension.
|
||||
|
||||
---
|
||||
|
||||
## 2. Recognition engine internals
|
||||
|
||||
### 2.1 Pipeline, function by function
|
||||
|
||||
```
|
||||
RTSP ──▶ capture.VideoSource.run() thread per camera; cv2.VideoCapture(CAP_FFMPEG)
|
||||
│ OPENCV_FFMPEG_CAPTURE_OPTIONS = rtsp_transport;tcp | stimeout;5000000 |
|
||||
│ fflags;nobuffer | flags;low_delay | max_delay;200000
|
||||
│ downscale to max_width (1280) with INTER_AREA
|
||||
│ latest frame + timestamp in a lock-protected slot
|
||||
▼
|
||||
engine.CameraWorker.run() thread per camera; takes source.latest_since(ts)
|
||||
│
|
||||
├─ detection.FaceDetector.detect(frame) cv2.FaceDetectorYN (YuNet 2023mar)
|
||||
│ score_threshold 0.82 · nms 0.3 · min_face_px 48 · max_faces 20
|
||||
│ → Detection(box, kps[5], score)
|
||||
│
|
||||
├─ recognition.face_quality(frame, box, kps) weighted: sharpness .35 · size .25 · brightness .15 · frontality .25
|
||||
│
|
||||
├─ tracking.IouTracker.update(dets, ts) greedy IoU association, iou_threshold 0.3, max_misses 25
|
||||
│ → active Track[], ended Track[] one Track == one person on camera
|
||||
│
|
||||
├─ for each active track, _should_identify(): hits ≥ 4 · quality ≥ min_quality_to_encode (0.35)
|
||||
│ ≤ max_id_attempts (8) · spaced id_retry_interval_seconds (0.5)
|
||||
│
|
||||
├─ _identify(track, frame):
|
||||
│ geometry.align_face(frame, kps) Umeyama similarity transform → 112×112 BGR chip
|
||||
│ recognition.ArcFaceEncoder.encode() BGR→RGB, (x−127.5)/127.5, NCHW float32, L2-normalised 512-d
|
||||
│ track.emb_sum += e; when emb_count ≥ min_embeddings_for_id (3): mean → normalise
|
||||
│ gallery.Gallery.resolve(mean, quality, rcfg) → Resolution(kind, identity, similarity)
|
||||
│
|
||||
├─ Resolution.kind:
|
||||
│ known sim ≥ match_threshold (0.42) → person.seen (+ reinforce if 0.32 ≤ sim < 0.55, q ≥ gate, < 5 stored)
|
||||
│ ambiguous 0.32 ≤ sim < 0.42 → wait; retry on a later frame
|
||||
│ new sim < enroll_threshold (0.32) → Gallery.enroll → "Visitor N" · person.new
|
||||
│ skipped quality < min_enroll_quality (0.65) → counted as rejected_quality
|
||||
│
|
||||
├─ attributes.AttributeEstimator.estimate() genderage.onnx on a loose 1.5× crop; FER+ on the chip;
|
||||
│ medianed over the track (attributes.aggregate)
|
||||
│
|
||||
├─ _finish_track(ended) PipelineStats.record(outcome) — exactly once per track
|
||||
│
|
||||
└─ _remember_tracks(active) boxes + labels for the live picture (no encode)
|
||||
|
||||
Live picture: CameraWorker.latest_jpeg_since(ts) — freshest CAPTURED frame + last boxes, encoded on demand.
|
||||
Events: events.EventBus → LogSink · WebhookSink (→ agent bridge) · EmailSink
|
||||
```
|
||||
|
||||
### 2.2 Models (`recognition.MODEL_CANDIDATES`, first loadable wins)
|
||||
|
||||
| Order | File | Role |
|
||||
|---|---|---|
|
||||
| 1–2 | `adaface_ir101.onnx`, `adaface_ir50.onnx` | wired, optional |
|
||||
| **3** | **`w600k_r50.onnx`** (166 MB) | **in use** — IJB-C 97.25; same-person p05 0.719 on the office camera |
|
||||
| 4 | `arcface_int8.onnx` | optional |
|
||||
| 5 | `w600k_mbf.onnx` (13 MB) | always loads; MobileFaceNet fallback (95.02) |
|
||||
| 6 | `arcface.onnx` (r100, 249 MB) | optional |
|
||||
| — | `face_detection_yunet_2023mar.onnx` | detector |
|
||||
| — | `genderage.onnx` (InsightFace buffalo_l) | attributes |
|
||||
|
||||
Every stored embedding is tagged with the model name; `IdentityStore.all_embeddings(model)` loads only same-model vectors into the index.
|
||||
|
||||
### 2.3 Local gallery
|
||||
|
||||
`gallery/store.py` — SQLite (WAL), single source of truth:
|
||||
```
|
||||
identities(id, label, kind auto|named, created_at, sighting_count)
|
||||
embeddings(id, identity_id, model, vector BLOB, quality, created_at)
|
||||
sightings(id, identity_id, camera_id, similarity, at)
|
||||
```
|
||||
`gallery/index.py` — `VectorIndex` over FAISS `IndexIDMap2(IndexFlatIP)` (exact inner product = cosine on L2-normalised vectors), rebuilt from SQLite at boot, −1 ids filtered, identical numpy fallback. Measured: 1k → 0.27 ms, 10k → 2.24 ms, 100k → 21.9 ms.
|
||||
|
||||
`gallery/service.py` — `Gallery.resolve` (three zones), `enroll`, `reinforce_identity` (refuses a view whose nearest neighbour is another identity), `merge_identities` (one transaction; human name outranks "Visitor N"; `sighting_count` recomputed; trimmed to 5 by quality), `duplicate_candidates` (k-NN across identities, O(n·k)).
|
||||
|
||||
### 2.4 What leaves the engine
|
||||
|
||||
`WebhookSink` POSTs each `person.seen` / `person.new` to the agent's loopback bridge with `identity_id`, `label`, `similarity`, `quality`, attributes, and optionally `image_path` (only when `app.store_faces: true`). The bridge fetches the identity's **best** stored embedding once per identity via `GET /api/identities/{id}/embedding`. `person.missed`, `camera.up/down` are diagnostics and never become visits.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend internals
|
||||
|
||||
### 3.1 Request path
|
||||
|
||||
```
|
||||
proxy (TLS) ─▶ net/http mux (Go 1.22 patterns, method + path)
|
||||
│
|
||||
├─ s.authed(h) Bearer token → SHA-256 → sessions row → Principal{UserID, ClientID, Role}
|
||||
│ token_expired vs unauthorized distinguished; last_used_at touched
|
||||
├─ s.adminOnly(h) Principal.Role == "admin" AND ClientID == "" (both) → else 404
|
||||
├─ s.agentAuthed(h) Agent token (hashed) → AgentPrincipal{ClientID, Client slug, SiteID, AgentID}
|
||||
└─ no wrapper login, refresh, invitation preview, register, enrol
|
||||
│
|
||||
▼
|
||||
handlers_*.go decode (unknown fields rejected) → validate → Store call → writeJSON
|
||||
│ every Store call receives p.ClientID from the session, never the body
|
||||
▼
|
||||
store/*.go (pgx) SQL with client_id in every WHERE / INSERT
|
||||
```
|
||||
|
||||
Login throttle (`api/throttle.go`): per-account 10 failures / 15 min and per-IP 60, in memory, pruned on read; success clears both. Unknown address is verified against `auth.DummyHash` so timing matches a wrong password.
|
||||
|
||||
### 3.2 Handler areas → store methods
|
||||
|
||||
| Area (file) | Routes | Store surface |
|
||||
|---|---|---|
|
||||
| `handlers_auth.go`, `handlers_sessions.go` | login, refresh, logout, me, sessions list/revoke | `UserByEmail`, `CreateSession`, `SessionByAccessHash`, `RotateSession`, `RevokeSession(s)` |
|
||||
| `handlers_team.go` | team, members, password reset, invitations, register | `Team`, `UpdateTeamMember`, `CreateMember`, `ResetMemberPassword`, `CreateInvitation`, `RedeemInvitation`, `OwnerCount` |
|
||||
| `handlers_admin.go` | admin/clients | `ListClients`, `CreateClientWithOwner` (one transaction) |
|
||||
| `handlers_arrivals.go`, `hub.go` | visits, visits/stream | `Arrivals` (keyset by `seq`), `Hub.Notify` doorbell → SSE |
|
||||
| `handlers_people.go` | visitors, history, profile, purchases, erasure | `SearchVisitors`, `VisitorHistory`, `SaveProfile`, `RecordPurchase`, `ForgetVisitor` |
|
||||
| `handlers_images.go`, `handlers_faces.go` | visitor image, face bytes | `VisitorImageKey`, `FaceImage`; `imageFor(key)` decides presigned vs `auth:true` |
|
||||
| `handlers_cameras.go`, `handlers_snapshots.go` | cameras CRUD, snapshot | `Cameras`, `CreateCamera`, `UpdateCamera`, `DeleteCamera` (tombstone), `Snapshot` |
|
||||
| `handlers_checks.go` | camera check, site check | `RequestCheck`, `ClaimChecks`, `ReleaseStaleChecks`, `RecordCheck`, `SiteCheck` |
|
||||
| `handlers_live.go`, `live.go` | cameras/{id}/live, agent live | `LiveHub` — one-slot buffer per viewer, on-demand upload |
|
||||
| `handlers_reports.go` | footfall, conversion | `Footfall`, `Conversion` — unique vs visits, first-ever "new", single currency |
|
||||
| `handlers_enrolment.go`, `handlers_agent.go` | enrol, agent cameras/checks/faces/upload-url | `RedeemEnrolment` (single-use via UPDATE), `AgentCameras`, `AgentReport`, `PutFace`, `UploadTarget` |
|
||||
| `handlers_assistant.go` | assistant | `assistant.Client.Ask` with the Principal passed at the call site |
|
||||
|
||||
### 3.3 Server-side recognition (`store/store.go`, `RecordVisit`)
|
||||
|
||||
```
|
||||
similarity = 1 - (embedding <=> $1::vector) -- pgvector cosine distance
|
||||
ORDER BY embedding <=> $1::vector LIMIT 1 -- within client_id, same model
|
||||
sim ≥ 0.42 → known visitor; reinforce if 0.32 ≤ sim < 0.55 AND quality ≥ floor AND < 5 stored
|
||||
sim < 0.42 → new visitor: clients.visitor_seq += 1 RETURNING (row-locks the client), label "Visitor N"
|
||||
INSERT visits ... ON CONFLICT (client_id, source_event_id) DO NOTHING -- idempotent
|
||||
```
|
||||
|
||||
### 3.4 Single-process composition (`cmd/behavision-server/main.go`)
|
||||
|
||||
```
|
||||
migrate.Apply(embedded FS) → store.Open → hub := api.NewHub()
|
||||
ingest.Consumer{Store, Notify: hub.Notify} ← paho client, SetOrderMatters(true), subscribed bv/+/+
|
||||
api.New(Store, Hub, LiveHub, Blob?, Assistant?) → web.Handler (embedded dist; /api/ keeps JSON 404)
|
||||
http.Server{ReadTimeout, IdleTimeout, WriteTimeout: 0} -- zero: SSE streams must outlive any write deadline
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. MQTT architecture
|
||||
|
||||
### 4.1 Identity and topics
|
||||
|
||||
Broker username = `<client-slug>.<site-slug>` (e.g. `tenext-retail.chennai`). ACL: `pattern write bv/%u/#` — a site physically cannot publish under another site's prefix.
|
||||
|
||||
```
|
||||
bv/<client>.<site>/visit Visit payload QoS 1 spooled, acked per event
|
||||
bv/<client>.<site>/heartbeat Heartbeat payload QoS 1 never spooled — only meaningful now
|
||||
bv/<client>.<site>/status reserved
|
||||
bv/<client>.<site>/cmd/... reserved (server → site)
|
||||
```
|
||||
|
||||
`contract.ParseTopic` → `Topic{Username, Client, Site, Kind, Rest}`; `Kind ∉ {visit, heartbeat, status, cmd}` is dropped as permanent.
|
||||
|
||||
### 4.2 Payloads (`server/internal/contract/contract.go`)
|
||||
|
||||
```json
|
||||
// visit
|
||||
{ "event_id": "tenext-retail.chennai|cam2|7|1757580000", // <site>|<camera>|<identity>|<unix second> — derived, never random
|
||||
"occurred_at": "2026-09-11T05:20:00Z", "camera_id": "cam2",
|
||||
"is_new": false, "similarity": 0.61, "quality": 0.70,
|
||||
"local_visitor_id": 7, "embedding": [512 floats], "model": "w600k_r50",
|
||||
"image_key": "behavision/v2/tenext-retail/chennai/2026/09/11/…jpg", // or "db:<uuid>", or absent
|
||||
"attributes": { "gender": "Male", "age": 32, "emotion": "neutral" } }
|
||||
|
||||
// heartbeat
|
||||
{ "sent_at": "…", "agent_version": "0.4.1", "engine_version": "1.1.0", "recognition_model": "w600k_r50",
|
||||
"cameras": { "cam1": true, "cam2": true }, "queued": 0, "dropped": 0, "fraction_below_gate": 0.47 }
|
||||
```
|
||||
|
||||
`Visit.Validate()` (permanent errors): `event_id` required ≤128; `occurred_at` required and not >24 h in the future; embedding must be exactly 512 and carry `model`.
|
||||
|
||||
### 4.3 Delivery semantics
|
||||
|
||||
| Stage | Component | Guarantee |
|
||||
|---|---|---|
|
||||
| Engine → agent | `WebhookSink` → `bridge.Bridge.Handle` | loopback HTTP; `event_id` derived from `<site>|<camera>|<identity>|<second>` (sighting cooldown is 30 s, so one person/camera cannot share a second) |
|
||||
| Append | `spool.Spool.Append` | one file per event, fsync, bounded (`SpoolMax`, default 50,000); on overflow drops oldest and counts `Dropped()`; corrupt entry quarantined, not retried |
|
||||
| Wake | `mqtt.Waker.Wake` **after** the append | a wake before durability is a drain that finds nothing |
|
||||
| Publish | `mqtt.Pump.Run` → `Client.Publish` | QoS 1, `CleanSession(true)`, publish bounded by a timeout as well as context (half-open TCP otherwise stalls forever); **a failed publish stops the batch** (ordering per visitor) |
|
||||
| Ack | `Spool.Ack(seq)` on PUBACK | per event, never per batch; file deleted only now |
|
||||
| Consume | `ingest.Consumer.Handle` | paho `SetOrderMatters(true)`; permanent error → `drop()` + log (message is acked, never redelivered); transient error → returned → redelivered |
|
||||
| Write | `store.RecordVisit` | `ON CONFLICT (client_id, source_event_id) DO NOTHING` — duplicates from at-least-once delivery are absorbed |
|
||||
| Notify | `hub.Notify(clientID)` | only on a genuine insert; SSE streams re-query from their own cursor |
|
||||
|
||||
Backoff: reconnect 1 → 30 s exponential; supervisor backoff resets only after a run that stayed up 60 s. `describeStall` distinguishes *broker refused the credential* (TCP opens, connect never completes) from *broker unreachable*.
|
||||
|
||||
### 4.4 Failure paths
|
||||
|
||||
| Failure | Behaviour |
|
||||
|---|---|
|
||||
| Internet down | spool grows on disk; heartbeats stop; head office shows site offline after 3 missed beats; on reconnect the backlog drains in order |
|
||||
| Broker rejects credential | pump logs "reachable but not accepted — re-link this PC"; spool retained |
|
||||
| Server down, broker up | broker holds nothing (clean session); agent's PUBACKs still arrive from the broker, so events are acked at the broker — the server's own subscription reconnects and Mosquitto delivers what it queued for the persistent server session |
|
||||
| Duplicate delivery | absorbed by `source_event_id` uniqueness |
|
||||
| Malformed event | dropped with a log line naming the site and reason; never blocks the queue |
|
||||
| Site clock wrong | `occurred_at` > 24 h ahead rejected as permanent; feed ordering uses server `seq`, so a wrong clock cannot hide a visit |
|
||||
|
||||
---
|
||||
|
||||
## 5. Database schema (PostgreSQL + pgvector, migrations 001–013)
|
||||
|
||||
### 5.1 Tables
|
||||
|
||||
```
|
||||
clients id PK · slug UQ (immutable, = MQTT prefix) · name · active · visitor_seq bigint
|
||||
sites id PK · client_id FK · slug (immutable, per client) · name · timezone · address · active
|
||||
agents id PK · client_id FK · site_id FK · mqtt_username UQ · mqtt_password_enc bytea (sealed)
|
||||
api_token_hash bytea · agent_version · engine_version · recognition_model
|
||||
last_heartbeat_at · last_event_at · fraction_below_gate · cameras_total/up · spool_queued/dropped
|
||||
app_users id PK · client_id FK (NULL = platform admin) · email (lower(email) UQ globally, 007)
|
||||
password_hash (bcrypt 12) · full_name · role owner|manager|staff|admin · active · last_login_at
|
||||
sessions id PK · user_id FK · client_id FK · access_hash UQ · refresh_hash UQ (SHA-256)
|
||||
access_expires_at · refresh_expires_at · revoked_at · device · last_used_at
|
||||
invitations id PK · client_id FK · email · full_name · role · code_hash UQ · invited_by FK
|
||||
expires_at · used_at · used_by FK · revoked_at
|
||||
site_enrolment_tokens id PK · client_id FK · site_id FK · token_hash UQ · label · expires_at · used_at · created_by
|
||||
visitors id PK · client_id FK · number bigint (per-client, immutable → "V-42") · label
|
||||
first_seen_at · last_seen_at · visit_count · deleted_at
|
||||
visitor_embeddings id PK · visitor_id FK · client_id FK · model · embedding vector(512) · quality · source_site_id FK
|
||||
visitor_profiles id PK · visitor_id FK · client_id FK · full_name · phone · email · gender · date_of_birth · notes
|
||||
consents id PK · visitor_id FK · client_id FK · scope · method · granted_at · revoked_at · evidence jsonb
|
||||
visits id PK · client_id FK · site_id FK · visitor_id FK (nullable) · source_event_id (UQ per client)
|
||||
occurred_at · received_at · camera_id text · is_new_visitor · similarity · quality
|
||||
attributes jsonb · image_key · image_deleted_at · seq bigserial (feed cursor)
|
||||
purchases id PK · client_id FK · site_id FK · visitor_id FK · visit_id FK · amount numeric(14,2)
|
||||
currency char(3) · items jsonb · source · external_ref · recorded_by · occurred_at
|
||||
site_cameras id PK · client_id FK · site_id FK · camera_id text (immutable, UQ per site) · label
|
||||
host · port · path · username · password_enc bytea (sealed, aad = site_id) · max_width
|
||||
tuning jsonb · enabled · revision · connected (nullable) · last_seen_at · snapshot_key
|
||||
snapshot_at · deleted_at (tombstone) · check_kind · check_* (requested/started/finished/result/image_key)
|
||||
camera_snapshots camera_id PK FK · client_id FK · site_id FK · image bytea · bytes · captured_at
|
||||
visit_faces id PK · client_id FK · site_id FK · image bytea · bytes · captured_at (one survives per visitor)
|
||||
audit_log id bigserial PK · client_id FK (SET NULL) · actor_id · actor_kind · action · entity · entity_id · detail jsonb · at
|
||||
schema_migrations version · checksum · applied_at · baselined
|
||||
```
|
||||
|
||||
### 5.2 Relationships
|
||||
|
||||
```
|
||||
clients ─┬─< sites ─┬─< agents
|
||||
│ ├─< site_cameras ──< camera_snapshots (1:1, PK = camera_id)
|
||||
│ ├─< site_enrolment_tokens
|
||||
│ ├─< visits
|
||||
│ ├─< purchases
|
||||
│ └─< visit_faces
|
||||
├─< app_users ─┬─< sessions
|
||||
│ └─< invitations (invited_by, used_by)
|
||||
├─< visitors ─┬─< visitor_embeddings
|
||||
│ ├─< visitor_profiles
|
||||
│ ├─< consents
|
||||
│ ├─< visits
|
||||
│ └─< purchases
|
||||
└─< audit_log (SET NULL)
|
||||
|
||||
visits ──< purchases (visit_id, SET NULL)
|
||||
```
|
||||
|
||||
Every FK onto `clients` is `ON DELETE CASCADE` except `audit_log` (`SET NULL`). Every tenant-owned table carries `client_id` directly, so no query needs a join to enforce tenancy.
|
||||
|
||||
### 5.3 Invariants enforced in the database
|
||||
|
||||
- `007` — `lower(email)` globally unique; the migration refuses to apply while duplicates exist and names them.
|
||||
- `012` — `visitors.number` per-client sequence from `clients.visitor_seq` (`UPDATE … RETURNING`, row-locked); unique on `(client_id, number)`.
|
||||
- `013` — triggers refuse changes to `clients.slug`, `sites.slug`, `site_cameras.camera_id`, `visitors.number` (`BEFORE UPDATE OF … WHEN OLD IS DISTINCT FROM NEW`). Display names are deliberately not frozen.
|
||||
- `004` — `visits.seq bigserial`; the arrivals cursor is `v1:<seq>` base64, opaque to clients.
|
||||
- `sites_slug_format` — `^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$`.
|
||||
|
||||
---
|
||||
|
||||
## 6. API specification
|
||||
|
||||
Full request/response shapes: `API.md`. Machine-readable: `docs/openapi.yaml`.
|
||||
|
||||
### 6.1 Authentication and session lifecycle
|
||||
|
||||
```
|
||||
POST /api/auth/login {email,password,device}
|
||||
→ {access_token (12 h), refresh_token (30 d), expires_at, user{id,email,full_name,role,client_id,client_name}}
|
||||
tokens: 256-bit random; only SHA-256 stored; bcrypt cost 12 verify; DummyHash for unknown addresses
|
||||
POST /api/auth/refresh {refresh_token,device}
|
||||
→ same shape; BOTH rotate; old refresh invalid immediately; client must serialise and persist before use
|
||||
401 {error:"token_expired"} → refresh once and retry
|
||||
401 {error:"bad_credentials"} / {error:"unauthorized"} → sign in
|
||||
GET/DELETE /api/auth/sessions[/{id}] · POST /api/auth/sessions/revoke-others
|
||||
```
|
||||
|
||||
### 6.2 Route families and least role
|
||||
|
||||
| Family | Routes | Least role |
|
||||
|---|---|---|
|
||||
| Auth | login, refresh, logout, me, sessions | none / authed |
|
||||
| Joining | `GET /api/auth/invitation?code=`, `POST /api/auth/register` | none |
|
||||
| Team | `GET /api/team` | authed (tenant users) |
|
||||
| | `POST /api/team/members`, `POST /api/team/{id}/password`, `PATCH /api/team/{id}`, `/api/team/invitations*` | manager |
|
||||
| Arrivals | `GET /api/visits`, `GET /api/visits/stream` (SSE) | authed |
|
||||
| Customers | `GET /api/visitors`, `/history`, `/image`, `GET /api/faces/{id}` | authed |
|
||||
| | `PUT /api/visitors/{id}/profile`, `POST /api/purchases` | staff |
|
||||
| | `DELETE /api/visitors/{id}` (erasure) | manager |
|
||||
| Shops & cameras | `GET /api/sites`, `/check`, `GET /api/cameras`, `/snapshot.jpg`, `/live` (SSE) | authed |
|
||||
| | `POST /api/sites/{site}/cameras`, `PATCH`/`DELETE /api/cameras/{id}`, `POST /api/cameras/{id}/check`, `POST /api/sites/{site}/enrolment-code` | manager |
|
||||
| Reports | `GET /api/reports/footfall`, `/conversion` | authed |
|
||||
| Assistant | `POST /api/assistant` | authed |
|
||||
| Admin | `GET`/`POST /api/admin/clients` | platform admin (role admin AND no client) |
|
||||
| Agent | `POST /api/agent/enrol` (none), then `/api/agent/{cameras, checks, faces, upload-url, live, cameras/{c}/snapshot, cameras/{c}/live}` | agent token |
|
||||
|
||||
Identifiers: any `{id}` or `site` accepts a uuid **or** the human reference (`V-42`, `chennai`, `cam1`). Unknown reference in a path → 404; in a query filter → 400. Another tenant's data → 404, never 403.
|
||||
|
||||
### 6.3 Assistant tools (`internal/assistant/tools.go`)
|
||||
|
||||
`list_sites`, `site_health`, `footfall`, `conversion`, `find_customer`, `customer_history`, `check_camera` (manager+; refuses staff in the tool, not the prompt). No tool takes a tenant id; the Principal is bound at the call site. Business tools only — never `execute_sql`. Loop bounded at 8 iterations; text produced alongside a tool call is discarded; failing tools return results, not errors.
|
||||
|
||||
---
|
||||
|
||||
## 7. Deployment topology
|
||||
|
||||
```
|
||||
INTERNET
|
||||
│
|
||||
┌───────────────┼───────────────────┐
|
||||
│ HTTPS 443 │ │ TLS 8883
|
||||
┌────────▼─────────┐ │ ┌────────▼─────────┐
|
||||
│ Traefik │ │ │ Mosquitto │
|
||||
│ platform.loyaly │ │ │ mcp.loyaly.ai │
|
||||
│ mcp.loyaly.ai │ │ │ passwd + ACL │
|
||||
│ TLS termination │ │ │ pattern write │
|
||||
└────────┬─────────┘ │ │ bv/%u/# │
|
||||
│ :8080 plain │ └────────┬─────────┘
|
||||
┌────────▼───────────────────────┐ │ :1883 internal
|
||||
│ behavision-server (one binary) │◄───────────┘ subscribe bv/+/+
|
||||
│ ├ migrate (boot) │
|
||||
│ ├ ingest consumer → Hub │
|
||||
│ ├ API (48 routes) → SSE │
|
||||
│ ├ LiveHub (camera relay) │
|
||||
│ ├ web (embedded React) │
|
||||
│ └ assistant (optional) │
|
||||
└────────┬───────────────┬───────┘
|
||||
│ │ presigned PUT/GET (optional)
|
||||
┌────────▼─────────┐ ┌──▼──────────────────┐
|
||||
│ PostgreSQL 16 │ │ Object storage │
|
||||
│ + pgvector │ │ (S3-compatible) │
|
||||
│ 17 tables │ │ private ACL │
|
||||
└──────────────────┘ └─────────────────────┘
|
||||
|
||||
════════════════════════ trust boundary: no inbound route ════════════════════════
|
||||
|
||||
SHOP NETWORK (one per shop, behind NAT)
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Cameras ──RTSP/TCP──▶ Engine :8010 ──webhook──▶ Agent │
|
||||
│ │ SQLite │ spool │
|
||||
│ │ FAISS │ │
|
||||
│ Shop app ◄─── relay ───────┘ │
|
||||
│ │
|
||||
│ outbound only: agent ──TLS 8883──▶ Mosquitto │
|
||||
│ agent ──HTTPS────▶ /api/agent/* │
|
||||
│ app ──HTTPS────▶ /api/* │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Failure domains.** A shop PC failing affects one shop; its footfall queues locally and nothing else notices except the heartbeat. Mosquitto failing stops delivery for all shops but loses nothing (every event is on a shop's disk). The server failing stops the console and API; Mosquitto retains the server's subscription backlog. PostgreSQL is the single stateful component in the cloud tier.
|
||||
|
||||
**Data residency.** Video never leaves the shop. Face templates leave the shop only as 512-float vectors inside visit events, over TLS, to the tenant's own prefix. Photographs leave only when `store_faces` is enabled, via presigned upload to a private object, or into Postgres when no bucket is configured. Every image read at head office is audited.
|
||||
|
||||
**Encrypted paths.** Camera credentials: DPAPI on the shop PC, AES-256-GCM (aad = site) in Postgres, plaintext only inside the agent's process and on the LAN RTSP connection to the camera. Broker password: sealed in `agent.json`, sealed in `agents.mqtt_password_enc`, hashed in Mosquitto's `passwd`. Sessions: SHA-256 at rest. User passwords: bcrypt 12.
|
||||
|
||||
---
|
||||
|
||||
## 8. Measured
|
||||
|
||||
| Metric | Value | Source |
|
||||
|---|---|---|
|
||||
| Identity stability | 103 tracks → 7 people, 44 re-recognitions, 5 min | engine `/api/stats`, office cam2 |
|
||||
| Same-person similarity | p05 0.719 (frontal webcam, 18,528 pairs) | `calibrate` |
|
||||
| Gallery search | 0.27 / 2.24 / 21.9 ms at 1k / 10k / 100k | `VectorIndex` benchmark |
|
||||
| Delivery under burst | 120 of 120 simultaneous visits | live broker + Postgres |
|
||||
| Camera → head office | ~3 s | end-to-end run |
|
||||
| Head-office live view | 13 fps, 259 KB/s, 0 duplicates | relay measurement |
|
||||
| Shop-PC live picture | 14.0 pictures/s vs 15 fps camera; engine CPU 90% → 62% | before/after, cam2 sub-stream |
|
||||
| Clean-machine install | 10/10 steps, both cameras connected | fresh container |
|
||||
| Server test suite | < 10 s (bcrypt cost lowered for tests only) | `go test ./...` |
|
||||
@@ -38,6 +38,13 @@ SETTING UP
|
||||
This takes several minutes. Leave the window open until it says Done.
|
||||
If anything fails it prints why, and running it again is safe.
|
||||
|
||||
DEMO RELEASE ONLY: if the release came with the cameras already set up,
|
||||
setup first asks for an unlock code. Type the code you were given. The
|
||||
camera details are sealed inside the release and cannot be read without
|
||||
it; with it, both cameras are added and the PC is set to run on its own,
|
||||
with no head office. Skip the installation-code screen - it will not
|
||||
appear.
|
||||
|
||||
3. Double-click Behavision.exe
|
||||
|
||||
The window opens and an icon appears in the system tray, next to the
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
#define MyAppExeName "Behavision.exe"
|
||||
|
||||
[Setup]
|
||||
; The Loyaly mark, on the installer and in Add/Remove Programs.
|
||||
SetupIconFile=..\brand\loyaly.ico
|
||||
AppId={{7C4B9E2A-3F51-4C86-9D0A-B1E7A2F65D11}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
|
||||
@@ -38,49 +38,79 @@ function Need($exe, $hint) {
|
||||
}
|
||||
}
|
||||
|
||||
# Run a NATIVE command and stop if it fails.
|
||||
#
|
||||
# $ErrorActionPreference = "Stop" does not do this. It governs PowerShell
|
||||
# errors; a .exe returning non-zero is not one, so the script sails past it.
|
||||
# That is not theoretical here: `npm ci` failing on a fresh Windows box would
|
||||
# have let the build continue, and `go build` would then have embedded the
|
||||
# STALE frontend/dist that is committed to this repository - producing an
|
||||
# installer that works, opens, and shows last month's UI, with nothing
|
||||
# anywhere saying so. The silent-wrong outcome, from the most likely failure.
|
||||
#
|
||||
# Output is NOT swallowed. `| Out-Null` on a failing install is how
|
||||
# run-local.sh once exited with no output at all, which took three runs to
|
||||
# diagnose; the same mistake is not worth repeating in a script that will be
|
||||
# run on a machine nobody is sitting at.
|
||||
function Run($exe) {
|
||||
$rest = $args
|
||||
& $exe @rest
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$exe $($rest -join ' ') failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
Need python "Install Python 3.11+ and tick 'Add to PATH'."
|
||||
Need go "Install Go 1.21+ from https://go.dev/dl/."
|
||||
Need npm "Install Node.js LTS from https://nodejs.org/."
|
||||
|
||||
Step "Python environment"
|
||||
Push-Location $root
|
||||
if (-not (Test-Path ".venv")) { python -m venv .venv }
|
||||
& .\.venv\Scripts\python -m pip install --upgrade pip | Out-Null
|
||||
& .\.venv\Scripts\python -m pip install -r requirements.txt pyinstaller | Out-Null
|
||||
if (-not (Test-Path ".venv")) { Run python -m venv .venv }
|
||||
Run .\.venv\Scripts\python -m pip install --upgrade pip
|
||||
Run .\.venv\Scripts\python -m pip install -r requirements.txt pyinstaller
|
||||
|
||||
Step "Engine tests"
|
||||
# The package is not worth building if the engine is broken, and finding that
|
||||
# out after the installer is signed is the expensive order to do it in.
|
||||
& .\.venv\Scripts\python -m pytest tests -q
|
||||
if ($LASTEXITCODE -ne 0) { throw "engine tests failed" }
|
||||
Run .\.venv\Scripts\python -m pytest tests -q
|
||||
|
||||
Step "Engine (PyInstaller, one-folder)"
|
||||
if (Test-Path (Join-Path $root "build")) { Remove-Item -Recurse -Force (Join-Path $root "build") }
|
||||
& .\.venv\Scripts\pyinstaller behavision.spec --noconfirm --distpath (Join-Path $dist "engine-build")
|
||||
if ($LASTEXITCODE -ne 0) { throw "pyinstaller failed" }
|
||||
Run .\.venv\Scripts\pyinstaller behavision.spec --noconfirm --distpath (Join-Path $dist "engine-build")
|
||||
|
||||
Step "Desktop app (Wails)"
|
||||
Push-Location (Join-Path $root "desktop\frontend")
|
||||
npm ci
|
||||
npm run build
|
||||
# A built dist is COMMITTED to this repository so `go build` type-checks
|
||||
# without npm (the //go:embed directive requires the directory to exist). That
|
||||
# convenience is a trap at package time: a silently failed npm build leaves
|
||||
# the old one in place and it embeds perfectly. So the marker is removed
|
||||
# first, and its reappearance is what proves this build produced the UI being
|
||||
# shipped rather than inheriting one.
|
||||
$marker = Join-Path $root "desktop\frontend\dist\index.html"
|
||||
if (Test-Path $marker) { Remove-Item -Force $marker }
|
||||
Run npm ci
|
||||
Run npm run build
|
||||
if (-not (Test-Path $marker)) { throw "npm run build reported success and produced no dist\index.html" }
|
||||
Pop-Location
|
||||
Push-Location (Join-Path $root "desktop")
|
||||
# Wails v2 talks to WebView2 through pure-Go bindings, so no cgo and no
|
||||
# toolchain beyond Go itself. Verified by cross-compiling the same package from
|
||||
# a Mac with CGO_ENABLED=0.
|
||||
# toolchain beyond Go itself. A plain go build is used on purpose: the icon
|
||||
# and the manifest are compiled in from rsrc_windows_amd64.syso (go-winres,
|
||||
# from brand/loyaly-icon-512.png), and `wails build` would add a second copy
|
||||
# of both and fail the link with duplicate resources.
|
||||
$env:CGO_ENABLED = "0"
|
||||
if (Get-Command wails -ErrorAction SilentlyContinue) {
|
||||
wails build -platform windows/amd64 -clean -ldflags "-X main.version=$Version"
|
||||
} else {
|
||||
Write-Warning "wails CLI not found - falling back to a plain go build (no icon, no manifest)."
|
||||
go build -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
|
||||
}
|
||||
Run go build -tags desktop,production -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
|
||||
Pop-Location
|
||||
|
||||
Step "Headless agent"
|
||||
Push-Location (Join-Path $root "agent")
|
||||
$env:CGO_ENABLED = "0" # the cgo resolver forces external linking
|
||||
go build -o (Join-Path $root "dist\behavision-agent.exe") .
|
||||
# `go build -o` does not create the target directory, and on a fresh clone
|
||||
# dist\ is gitignored and absent. It exists here only because PyInstaller ran
|
||||
# first and made it - an ordering dependency nothing states, so state it.
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
Run go build -o (Join-Path $root "dist\behavision-agent.exe") .
|
||||
Pop-Location
|
||||
|
||||
Step "WebView2 bootstrapper"
|
||||
@@ -106,8 +136,11 @@ Copy-Item (Join-Path $root "LICENSE") $stage -ErrorAction SilentlyContinue
|
||||
|
||||
$engineExe = Join-Path $stage "engine\behavision.exe"
|
||||
if (-not (Test-Path $engineExe)) { throw "engine exe missing at $engineExe" }
|
||||
& $engineExe paths
|
||||
if ($LASTEXITCODE -ne 0) { throw "the frozen engine cannot start - `paths` failed" }
|
||||
# The frozen engine has to START, not merely exist. A PyInstaller build that
|
||||
# is missing a native DLL links fine and dies on first launch - the classic
|
||||
# "works in the venv, dies in the bundle" - and finding that out on a shop
|
||||
# counter is the expensive order to do it in.
|
||||
Run $engineExe paths
|
||||
|
||||
Pop-Location
|
||||
|
||||
|
||||
21
installer/run-with-lan-head-office.cmd
Normal file
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
rem Start Behavision against a head office running on another PC on this LAN,
|
||||
rem instead of the production server it uses by default.
|
||||
rem
|
||||
rem For demos and pilots only. Two things are deliberately weaker than
|
||||
rem production and both are named here so nobody copies this into a shop:
|
||||
rem
|
||||
rem - head office over plain http, not https
|
||||
rem - the message broker over plain tcp. The app REFUSES plaintext MQTT to
|
||||
rem any address that is not its own machine, by design - the payloads are
|
||||
rem customer visit records - so the second line below is the documented
|
||||
rem escape hatch and must not be set anywhere that is not a demo.
|
||||
rem
|
||||
rem Edit the address to the PC running head office, then double-click this
|
||||
rem instead of Behavision.exe. Everything else - the installation code, the
|
||||
rem sign-in, the cameras - works exactly as INSTALL.txt describes.
|
||||
|
||||
set BEHAVISION_CLOUD=http://192.168.1.117:8088
|
||||
set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1
|
||||
|
||||
start "" "%~dp0Behavision.exe"
|
||||
@@ -1,11 +1,12 @@
|
||||
[project]
|
||||
name = "behavision"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
description = "Production face recognition over RTSP"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"numpy>=1.26,<2.0",
|
||||
"opencv-python>=4.8.1",
|
||||
# See requirements.txt for why numpy is uncapped and opencv is not.
|
||||
"numpy>=1.26,<3.0",
|
||||
"opencv-python>=4.8.1,<5",
|
||||
"onnxruntime>=1.16",
|
||||
"fastapi>=0.110",
|
||||
"uvicorn>=0.29",
|
||||
@@ -14,6 +15,10 @@ dependencies = [
|
||||
"python-dotenv>=1.0",
|
||||
"faiss-cpu>=1.7.4",
|
||||
"requests>=2.31",
|
||||
# DPAPI for camera passwords at rest (behavision/cameras.py). Without it the
|
||||
# store logs a warning and writes them in the clear - which is what every
|
||||
# Windows install had been doing, since nothing pulled this in.
|
||||
"pywin32>=306; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -22,5 +27,8 @@ dev = ["pytest>=8.0"]
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["behavision*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
behavision = ["static/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||