Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ Every fact has **one** home; other files link to it instead of repeating it.
- Sensor kinds (`settings.SENSOR_KINDS`): `single_state`, `objects`, `reading`. A new feature says
which kind(s) it applies to.
- New engine code goes into the part (mixin) it belongs to. A long-running loop must survive any
exception (log it once, try again) — only `CancelledError` may end it.
exception (log it once, try again) — only `CancelledError` may end it. Database work in an
`async def` goes through `asyncio.to_thread` (`tests/test_event_loop.py` fails otherwise).
- What is sent to Home Assistant must not change at every check unless the result does (no
timestamps, no needless decimals, pictures through `_send_on_change`): Home Assistant stores
every change as a new row (`docs/SCOPE.md` §8).
- Model registries (`backbones.json`, `detectors.json`, `readers.json`, `faces.json`): entries are never changed
or removed once released (their texts, measured footprint and similarity limits may be); a new model gets a new id, pinned by Hugging Face revision and SHA-256,
and is checked against real inputs in the tests (one public ONNX conversion turned out to be
Expand Down Expand Up @@ -150,12 +154,15 @@ Many users run Home Assistant on phones and tablets.
watch it `--headed` or step through it `--ui`; after a failure `npx playwright show-trace
test-results/<test>/trace.zip`.
- **Demo sensors:** `python scripts/seed_demo.py --url <app> --images <camera>` seeds one sensor of
each kind (Driveway with Emma and "Our car", Garage door, Water meter) on any instance: locally
each kind (Driveway with Tom and "Our car", Garage door, Water meter) on any instance: locally
with `--images http://127.0.0.1:8765/photo` (the fake camera), or on an installed app with
`--app <slug>` (photos from GitHub). Running it twice changes nothing.
- **Docs screenshots** (`docs/promo/`): seed the demo sensors on a local backend with a local MQTT
broker (so the header shows *MQTT connected*), then `node e2e/promo.mjs <app URL>` in
`visionstate/frontend` (1440 px wide, device scale 1.2).
`visionstate/frontend` (1440 px wide, device scale 1.2). Not a broker another VisionState is
connected to: both use the same client ID and push each other off every few seconds (on a test
broker that is in use, `VISIONSTATE_DISCOVERY_PREFIX` keeps the entities out of Home Assistant,
but not that).
The Home Assistant screenshots (`ha-*.png`) are taken by hand on a real Home Assistant. Show the
maintainer before publishing.

Expand All @@ -182,5 +189,9 @@ Many users run Home Assistant on phones and tablets.
them resizes what is observed, WebKit reports "ResizeObserver loop completed with undelivered
notifications" (seen once in CI on the dashboard, never locally). Measure in a timeout instead,
like `measure` in `DetectionBoxes.svelte` (not the next animation frame: none comes in a hidden tab).
- The Chrome tab Claude drives counts as hidden (`document.visibilityState`); views that only poll
while visible look fine there. Use `setVisibility` (e2e) or override it by hand.
- The Chrome tab Claude drives counts as hidden (`document.visibilityState`), and every page polls
only while visible (`lib/poll.ts`, live frames, history): there nothing updates by itself. Use
`setVisibility` (e2e) or override it by hand.
- A backend test that swaps in a fake camera after `TestClient(create_app(...))` has started may
see the first check use the real one (and the sensor go *unavailable*): wait for the state you
expect with `wait_for`, never read it once.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,17 @@ Click a few examples of each state; it learns in about a second.

### 🧍 Objects & people

*Emma is home*, *our car is in the driveway*, a cat on the lawn, an unknown person at the door.
*Tom is home*, *our car is in the driveway*, a cat and a bicycle on it, an unknown person at the door.

Pick from 80 objects — no training. Name people by their face once; every camera knows them.

<p align="center">
<img src="docs/promo/objects.png" alt="An object sensor recognising Emma by her face and telling our car from any car" width="900"><br>
<em>Emma recognised by her face, and our own car told apart from any other car.</em>
<img src="docs/promo/objects.png" alt="An object sensor recognising Tom by his face and telling our car from any car" width="900"><br>
<em>Tom recognised by his face, and our own car told apart from any other car.</em>
</p>

<p align="center">
<img src="docs/promo/people.png" alt="The People page with the faces taught for Emma" width="900"><br>
<img src="docs/promo/people.png" alt="The People page with the faces taught for Tom" width="900"><br>
<em>A few photos from your phone are enough to start; faces never leave your Home Assistant.</em>
</p>

Expand Down Expand Up @@ -86,7 +86,7 @@ on for each reading; a battery camera can trigger a check when it has a new pict

<p align="center">
<img src="docs/promo/ha-states.png" alt="A garage door state sensor as a Home Assistant device" width="340">
<img src="docs/promo/ha-objects.png" alt="The driveway object sensor as a Home Assistant device, with Emma and our car" width="340">
<img src="docs/promo/ha-objects.png" alt="The driveway object sensor as a Home Assistant device, with Tom and our car" width="340">
</p>

- **State sensor** — the state with its confidence, the last frame, a button to check now and a switch to pause it.
Expand Down
19 changes: 12 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ one event loop:

- **I/O** (camera, MQTT, Home Assistant) is async.
- **Database and file work** runs in worker threads (`asyncio.to_thread`): SQLite with WAL, one
short session per unit of work (`Database.session()`).
short session per unit of work (`Database.session()`). Never directly in an `async def`:
`tests/test_event_loop.py` fails on a session (or `load_sensor`) opened there.
- **RTSP grabs** run on threads of their own (`sources.FrameGrabber`, `RUNTIME["rtsp_workers"]`),
so a camera that does not answer holds none of the default pool's.
- **AI inference and training** runs in worker threads too, at most
`RUNTIME["max_concurrent_inferences"]` at a time (the semaphore `Runtime._sem`).
- **Long-running loops** (one per sensor, MQTT, the Home Assistant event stream, clean-up) catch
Expand All @@ -47,13 +50,13 @@ API as `request.app.state.runtime`.

| File | What it does |
|---|---|
| `__main__.py`, `main.py` | Entry point (logging, uvicorn), FastAPI app, Ingress-only guard, static UI |
| `__main__.py`, `main.py` | Entry point (logging, uvicorn), FastAPI app, Ingress-only guard, static UI (hashed assets kept by the browser: `HashedAssets`) |
| `settings.py` | **Every** default, limit and tunable, the app options (`Settings`), the `merge_*` helpers that fill a stored setting with defaults |
| `db.py` | SQLAlchemy tables and the schema migrations (`MIGRATIONS`, `SCHEMA_VERSION`) |
| `storage.py` | Image files on disk: training samples, history frames, thumbnails |
| `storage.py` | Image files on disk: training samples, history frames, thumbnails; written whole or not at all |
| `engine/` | The runtime — see below |
| `api/` | The REST API (`/api/v1/…`), one file per topic: `sensors.py` (CRUD, quality, export), `history.py` (the history: one filter for the list, its count and facets; history frames), `samples.py` (training images), `teach.py` (taught boxes; what is taught from a history frame is written into its row: `remember_taught`), `faces.py` (teaching a face on a sensor; the people and their faces, shared by every sensor), `cameras.py` (camera and entity lists, the light, previews), `review.py` (review queue and answers), `imports.py` (import), `backup.py` (backup of everything, restore), `system.py` (UI config, status, `alive` for Home Assistant's watchdog, AI models and deleting downloaded ones, storage, review rules, review reminder, teaching and face limits), `common.py` (input models and views shared by them) |
| `sources.py` | Camera sources: Home Assistant camera, HTTP snapshot, RTSP; address checks and size limits; the Home Assistant REST client (states, services, notify services, the app's page) |
| `sources.py` | Camera sources: Home Assistant camera, HTTP snapshot, RTSP (on threads of its own; a stream grabbed often is kept open: `RtspStream`); address checks and size limits; the Home Assistant REST client (states, services, notify services, the app's page) |
| `mqtt.py` | MQTT topics, Home Assistant discovery messages, the bridge (connect, publish, commands) |
| `ha_events.py` | Home Assistant WebSocket: state changes of trigger entities, entity registry |
| `lights.py` | A sensor's light: switched on for checks and while someone looks, shared by both |
Expand All @@ -62,7 +65,7 @@ API as `request.app.state.runtime`.
| `faces.py` | Faces in person boxes (registry `faces.json`, never bundled): finding them (YuNet), the quality gate, aligning, evening out the light (`even_light`), embedding, telling who it is |
| `classifier.py` | The small per-sensor classifier ("head") on top of the backbone |
| `teach.py` | Object sensors: comparing detected boxes with the boxes the user taught |
| `imaging.py` | Decoding, regions (ROI), crops, change signatures, drawing boxes |
| `imaging.py` | Decoding (also decoded small where that is enough: change signatures, thumbnails), regions (ROI), crops, change signatures, drawing boxes |
| `uploads.py`, `bundle.py` | Uploaded files (images, ZIP, video) → frames; sensor export/import bundles |
| `backup.py` | A backup of everything (database copy + files, by the parts of `settings.BACKUP_PARTS`) and restoring one: staged, swapped in, undone on failure |
| `redact.py` | Masks credentials in URLs, messages and log lines |
Expand Down Expand Up @@ -93,7 +96,7 @@ Runtime ── ChecksMixin ──┬─ ObjectChecksMixin ── FacesMixin, Tea
| `teaching.py` | Taught boxes of object sensors (index, embeddings) |
| `faces.py` | Faces on object sensors: the face model (downloaded and loaded in the background, let go when unused), the index of the people's taught faces (shared by every sensor), the face step of a check (`apply_faces`), each person's own entities (where they are seen, when they were last seen) |
| `training.py` | Training the state sensors' heads; embeddings of samples |
| `publishing.py` | Discovery and values to Home Assistant, the review queue entity, commands from it |
| `publishing.py` | Discovery and values to Home Assistant (pictures and object attributes only when they change, or now and then: `_send_on_change`), the review queue entity, commands from it |
| `history.py` | History clean-up by age and size, disk use |
| `reminders.py` | The review reminder: a notification in Home Assistant (and a push) when frames waited long |

Expand Down Expand Up @@ -133,7 +136,8 @@ error is kept in `LiveState.error` and shown in the UI.
the move kept training images in `<media>/samples/`: they are read there until
`engine.HistoryMixin.move_samples` has copied them (`settings.SAMPLES_MOVE`). A file is removed
only after the row that lists it (`db.Database.after_commit`), and an image that is gone anyway
is left out of training and of the taught boxes instead of stopping the sensor.
is left out of training and of the taught boxes instead of stopping the sensor. The history clean-up
(`engine.HistoryMixin.cleanup_history`) also removes frames no row lists, once they are old.
- **Object sensors' taught boxes** are `sample` rows with `object_label`, `detected`, `box` and
`score`; own labels live in `sensor.objects["custom"]`. Readings and object events are
`prediction` rows (see `db.py`).
Expand All @@ -150,6 +154,7 @@ error is kept in `LiveState.error` and shown in the UI.
| `lib/app.svelte.ts` | Shared state: config from `GET /api/v1/config`, status polling, the last restored backup (Settings is made again with it) |
| `lib/router.svelte.ts` | Hash routes (`paths`), a page's query (`route.query`, `setQuery`) |
| `lib/history.ts` | The history filter: from and to the page URL, and as the API takes it |
| `lib/poll.ts` | Polling that rests while the page is hidden (`pollWhileVisible`: status, dashboard, sensor page) |
| `lib/ui.ts` | UI constants (tabs per sensor kind, wizard texts); `tokens.css` colours, type, spacing |
| `lib/objects.ts`, `lib/reading.ts`, `lib/roi.ts`, `lib/format.ts` | Helpers: names and colours of objects and own labels, reading values and units, region geometry, formatting |
| `pages/` | Dashboard, the new sensor wizard (`NewSensor.svelte`; its Detect step per kind in `pages/wizard/`), Review, History, People, Settings, and `SensorPage` with one file per tab in `pages/sensor/` (its History tab is `HistoryView`) |
Expand Down
Loading
Loading