Decisions and constraints that bind future work. The code is the authority on schema, routes, config keys, and dependencies. Read the code first. This file holds only the reasoning that the code cannot carry.
- Production runs on a 2018 Intel Mac mini. It is x86_64, 16 GB, Intel UHD 630. There is no MPS, no CUDA, and no Neural Engine.
- Every wheel and model artifact must exist for
macosx_*_x86_64. An Apple-Silicon-only stack (MLX, a CoreML-only model) cannot run in production. - PyPI dropped macOS-x86_64 wheels for the ML stack (torch, onnxruntime). conda-forge keeps osx-64 builds. This is why the project uses pixi. A move back to uv or plain pip breaks production. It needs new hardware or Docker first.
- Dependency placement rule: use PyPI for everything. If PyPI has no
macOS-x86_64 wheel for a package, take that package from conda-forge. Prefer
the standard PEP 621 and PEP 735 tables over
[tool.pixi.feature.*], because IDE and build tooling read them natively. - Production storage is an external encrypted drive. The operator unlocks it by hand after login, so it can appear several minutes late. The poller and the backup agent wait up to 10 minutes at startup for this reason.
- No TLS on the web UI. Basic-auth credentials and clip bytes cross the home LAN in cleartext. The threat model is LAN-only.
- No sub-second live monitoring. The cameras record on motion, and ingest happens after the fact.
- No multi-user support. One shared password, and last-write-wins on every toggle endpoint.
- Detector accuracy beyond stock YOLO is accepted as-is. The per-cat inactivity
rule fires when the last visit of a cat starts more than
inactivity_hoursago. The rule counts only clips that name the cat, from an operator tag or a prediction. A cat tag on the missed clip plus the review mark corrects a false alert.
httpx2 replaces httpx. The chain matters, because the name changed twice:
- Upstream
httpxmade no release after November 2024. Its issues and discussions stay closed, and known defects (HTTP/2 deadlocks, async lock contention, proxy and timeout edge cases) stay open. - The project moved to
httpxyz, a fork with shims for thehttpxAPI. httpxyzwas then deprecated in favor ofhttpx2, which is the current supported successor. The project follows it.
The cost to leave is small. One module imports the client:
src/cat_watcher/amcrest_client.py. It uses Client, DigestAuth,
ConnectError, ReadTimeout, RemoteProtocolError, HTTPStatusError, and
streaming through iter_bytes.
respx keeps its own dependency on upstream httpx. respx has a different
maintainer and is not part of the concern. It holds httpx and httpcore in
pixi.lock as transitive packages. tests/fixtures/httpx2_alias.py points the
httpx and httpcore names at httpx2 and httpcore2, so no upstream code
runs. To drop the two packages from the lock, the project must drop respx and
rewrite tests/unit/test_amcrest_client.py against MockTransport. Lockfile
membership is not the risk, so the project accepts them.
fastapi carries no [standard] extra. cat-watcher-web starts uvicorn
directly, and the manifest declares jinja2 and uvicorn[standard] itself.
- Why a formatter, not a
LoggerAdapter.JsonFormatterstamps theagentandpidfields. ALoggerAdapterbinds context only for calls that go through the adapter instance. That needs a refactor of every log call site. Everylogging.getLogger(__name__)call site stays unchanged. - Why no compression. 10 MB and 7 backups cap each agent near 80 MB. Rotation alone is sufficient at that size.
- Why launchd keeps its own stdout and stderr files.
<agent>.stdout.logand<agent>.stderr.logcatch two things the JSONL file cannot. The first is output from beforesetup_logging()runs. The second is an unhandled traceback. In steady state they stay empty. If an agent dies at startup, read them first. - Alerting never reads logs.
cat_watcher.alertsreads the database. Logs are for forensics, never for control flow.
Stock yolo11n.pt is trained on COCO, where cats appear in normal poses. These
cameras point down at a litter box, so a cat appears from above, from behind,
occluded by the rim, or curled up. Spot checks show the model can confidently
call a cat a dog (COCO class 16). Production accuracy is unmeasured, and
detector.confidence_threshold = 0.35 is a guess, not a tuned value. Operator
review through the subject-tagging UI is the correction path.
WEB_FLAPPING(1h),DISK_LOW(24h), andBACKUP_STALE(24h) hard-code their cool-downs inalerts.pyinstead of readingconfig.toml. Each value follows from its rule. A flap detector with a 6-hour cool-down misses the next flap. A daily backup check with an hours-scale cool-down fires many times inside one missed run.- A suppressed alert writes no
alerts_sentrow. An open 24-hour cool-down otherwise adds about 96 rows per day per type. - A re-opened review does not unwind an alert already sent. Alert history is
immutable. A later evaluation picks up the current
effective_has_catvalue through the view.
The safety net widens a quiet camera's window to
[last_clip_at - overlap_minutes, now]. It fires POLLER_EMPTY_AFTER_QUIET
when findFile returns zero rows. These limits apply:
- The alert fires only after a successful tick. A camera that is quiet and
unreachable produces
poll_status = UNREACHABLEand no safety-net alert. Usepoll_statusandPOLLER_STUCKto detect that case. - A failed tick holds
last_polled_atin place. A long outage therefore grows the next successful window with no floor. Clips older than the camera SD retention are unrecoverable when the camera returns. _clip_already_ingestedkeys duplicates on(camera_id, source_filename). Two recordings on different days can share a filename. No collision is observed yet. If one occurs, add the clip date to the key.overlap_minutesandsafety_net_hoursare fleet-wide. Per-camera values were rejected. If camera behavior diverges, add them.
- Labels attach to a frame, not a clip. One clip can hold two cats in sequence.
- One
subjectstable covers cats and events. Cat names change per install. Event names do not. config.tomlowns the subject list. The camera list already works this way.has_manual_catis derived in a view. A stored copy can disagree with the frame rows.reviewed_atis a separate column. Without it, "no tags yet" and "operator saw nothing" look the same.
- Archived cat subjects still count toward
has_manual_cat. A tag made before the archive still means a cat was there. slugis the sync identity. A changed slug archives the old row and inserts a new one. To rename an identity, runUPDATE subjects SET slug = ...and restart the agents.- An empty
[[subjects]]list is a no-op. Nothing is archived. This guards against mass-archive on a misconfigured startup.
The sync rejects two active subjects of one kind whose display_name starts
with the same letter. The reason was the toggle-button glyph, which was
display_name[0]. The button now shows the full name. Keep the rule or drop it,
but decide.
- Under
?reviewed=no, "Previous" skips the clip you just marked reviewed. Switch to?reviewed=yesto reach it. clip_frames.activityhas no UI. At five frames per clip the elimination window is mostly invisible. Choose the vocabulary after cat labels accumulate.bbox_xyxyrides along because the detector already computes the box. Fine-tuning needs about 100 reviewed cat-positive clips before it is worth starting.
web.display_timezone serves the web UI, the CLI, and the log viewer. Do not
derive the zone from the OS. A machine timezone change moves where the poller
writes clips and orphans existing clips.file_path rows. Do not derive it from
the browser. That splits the "which calendar day" decision between the client
and the server. The name fits the web UI only. The value also drives on-disk
paths and alert text. A rename needs a config migration, so the name stays.
The /clips keys are reviewed, camera, has_cat, and date_str. The key
stays date_str, because a rename breaks operator bookmarks. An unusable value
selects the control default and appears in a notice above the form. An unknown
camera gives unfiltered results. The alternative applies the filter and shows
zero rows below a select that reads "All cameras".
- A
has_catfilter joinsclip_label_summary, so the indexix_clips_camera_hascat_startdoes not serve that path. The view computeseffective_has_catwith a correlatedEXISTS. Diagnose a future slowdown here first. - The per-camera
/statstable and/alertscut atnow - 30 days, not at a local midnight. One constant must not mean two different windows. - The per-cat
/statstable starts at local midnight 6 days before today. It counts calendar days, so a daylight-saving change does not move its start. alerts.pyuses rollingtimedeltawindows only, so the display zone changes no alert rule.
Triage. The operator scans recent activity, picks clips worth opening, and
clicks through to /clips/{id} to label. Every trade-off below serves that job.
- Layout is a compact SVG navigator above a thumbnail grid. Thumbnails dominate. The SVG answers "where are the gaps?" without taking the page.
- No inline label form. The operator must scrub the video before a verdict, so
labeling stays on
/clips/{id}. - The SVG is static, with hover and focus tooltips only. Click-to-filter and drag-select need much more JS. Revisit if filtering proves valuable.
- No auto-refresh. Polling adds out-of-band swaps, stale tooltip state, and cache invalidation when storage flaps. The "now" marker renders at request time and stays static.
- The thumb card shows no score. The border color already encodes the verdict. A number competes with the photo.
- Every range shows one marker per clip. Each marker is a keyboard-focusable link to its clip.
- The timeline ranges stop at 3d, so the markers stay legible without an aggregate. Older clips stay on disk for training.
- The thumb strip shows on every range.
- No per-camera empty state. A quiet camera renders an empty lane. This matches
/clips. _load_timeline_dataand_build_lanes_vieware split to satisfy pylint R0914. Do not merge them back.
--color-no-cat-graphicis below the WCAG 1.4.11 3:1 non-text minimum. The choice is deliberate, so cat-positive entries pop.--color-cat-graphicclears 3:1, and that is the meaningful state.focusin,focusout, and the HTMX error toast have no automated test. A browser-test harness must exist first./clipshas no 30-day cap. The per-camera/statstable and/alertsuseHISTORY_DAYS.
No source records why these values are what they are. Treat them as free to change when a measurement justifies it.
THUMB_MAX_WIDTH = 320andTHUMB_QUALITY = 80inthumbnails.py.- The contact-sheet grid steps of 2, 4, and 6 columns in
style.css.