Skip to content

Save Ring's detection-time snapshot and log per-capture latency - #10

Merged
fayerman-source merged 1 commit into
mainfrom
feat/detection-snapshot-and-timing
Jul 30, 2026
Merged

Save Ring's detection-time snapshot and log per-capture latency#10
fayerman-source merged 1 commit into
mainfrom
feat/detection-snapshot-and-timing

Conversation

@fayerman-source

Copy link
Copy Markdown
Owner

Motion clips were starting after subjects had already walked past — recordings showed people leaving rather than approaching.

The measurement

Median over 10 real motion events on a battery Spotlight Cam Plus, pairing Ring's own event timestamps against clip timestamps:

Segment Typical Whose latency
Ring detects motion → push arrives here ~2.3s Ring's
Trigger → live stream negotiated ~3.7s Ring's (WebRTC signaling + camera wake)
Stream open → first bytes on disk ~2.5s ffmpeg stream analysis
Ring detection → first frame ~8.5s

~12 m of approach at walking pace, never recorded. Almost all of it is Ring-side.

This can't be fixed with pre-roll here. A stream opened after an event cannot contain footage from before it. I also confirmed the cloud route is closed without a subscription: getEvents() reports every motion event as recorded=true, recording_status=ready, but getRecordingUrl() returns "Source video not available" and videoSearch() returns [].

What this adds

The one thing that does predate the first video frame: the still Ring captures at detection time. Its uuid rides along on the motion push (img.snapshot_uuid), so it's obtainable without Ring Protect, costs one REST fetch, and doesn't wake the camera.

  • detection.ts — parses the push into a detection context (Ring's detection timestamp, ding id, subtype, snapshot uuid) and computes the latency segments. Pure, so the arithmetic is testable without an account, camera, or clock.
  • events.ts — subscribes to onNewNotification alongside the existing boolean motion stream. Fetches the snapshot in parallel with the recording, not after: Ring expires these, so waiting out a 30s clip risks losing the one frame showing the approach.
  • recorder.ts — captures triggerAt / streamOpenAt / firstFrameAt. First-frame is detected by polling for non-zero file size, i.e. time-to-first-bytes (the fMP4 header flushes just ahead of the first fragment) — within a frame interval of first-frame, and far below the multi-second latency being measured.
  • files.tsappendJsonLine, which reports failure rather than throwing.

Failure isolation. All of this is diagnostic bookkeeping hung off a recording that already succeeded, so none of it can turn a saved clip into a reported failure. A missing uuid, an expired snapshot, an unwritable log, and a camera exposing no notification stream all degrade to a null field. Each case has a test.

Config

detectionSnapshots and timingLog, both default true. Rows land in <outputDir>/timing.jsonl, one per capture:

{"camera":"Front","clip":"Front_….mp4","detectionSnapshot":"Front_….detection.jpg",
 "subtype":"motion","ringEventAt":"","firstFrameAt":"",
 "pushDelaySec":2.27,"streamSetupSec":3.75,"firstFrameSec":2.45,"blindWindowSec":8.5}

blindWindowSec is the headline number, so the effect of a motion-zone or sensitivity change can be measured against the 8.5s baseline rather than eyeballed. Backlog #4's captures.jsonl manifest can supersede this file later.

Also

Corrects the README, which claimed the pre-roll loss was "~1-2s". Measured local latency alone is ~4-6s and the full window ~8.5s — that line is what set the wrong expectation in the first place.

Verification

  • 31 tests pass (20 new), no type errors.
  • Service starts, subscribes, and shuts down cleanly against the live API.
  • A real capture returns populated timings: 4.35s stream setup, 7.01s to first bytes.
  • Two bugs were caught by the new tests during development: secs() was returning milliseconds rather than seconds, and a wiring test mixed a real-dated push with fake timings. Both fixed.

Not yet confirmed: whether img.snapshot_uuid is actually populated for this camera. It's optional in Ring's payload and needs a live motion event to verify — which is precisely what this instrumentation will tell us. The no snapshot uuid path is tested and harmless.

🤖 Generated with Claude Code

Motion clips were starting after subjects had already walked past, so
recordings showed people leaving rather than approaching. Measured on a
battery Spotlight Cam Plus over 10 real motion events, the gap between Ring
detecting motion and the first frame reaching disk is ~8.5s median:

  Ring detection -> push arrives here    ~2.3s   (Ring's)
  trigger -> live stream negotiated      ~3.7s   (WebRTC signaling + wake)
  stream open -> first bytes on disk     ~2.5s   (ffmpeg analysis)

At walking pace that is ~12 m of approach never recorded. Almost all of it is
Ring-side. A live stream opened after an event also cannot contain footage from
before it, so no amount of tuning here produces pre-roll. Confirmed the cloud
route is closed without a subscription: getEvents() reports every motion event
as recorded/ready, but getRecordingUrl() returns "Source video not available"
and videoSearch() returns empty.

What is available is the still Ring captures at detection time. Its uuid rides
along on the motion push (img.snapshot_uuid), and it predates the first video
frame by the whole window above — so it is the only view of the approach
obtainable without Ring Protect. It costs one REST fetch and does not wake the
camera.

- detection.ts: parse the push into a detection context (Ring's own detection
  timestamp, ding id, subtype, snapshot uuid) and compute the latency segments.
  Pure, so the arithmetic is testable without an account, camera or clock.
- events.ts: subscribe to onNewNotification alongside the existing boolean
  motion stream, and on trigger fetch the snapshot *in parallel* with the
  recording rather than after it — Ring expires these, so waiting out a 30s
  clip risks losing the one frame showing the approach.
- recorder.ts: capture triggerAt / streamOpenAt / firstFrameAt. First-frame is
  detected by polling the output for non-zero size; that is time-to-first-bytes
  (the fMP4 header flushes just ahead of the first fragment), which is within a
  frame interval of first-frame and far below the latency being measured.
- files.ts: appendJsonLine, which reports failure rather than throwing.

Every part of this is diagnostic bookkeeping attached to a recording that
already succeeded, so no failure in it can turn a saved clip into a reported
one: a missing uuid, an expired snapshot, an unwritable log and a camera with
no notification stream all degrade to a null field. Covered by tests.

New config: detectionSnapshots and timingLog, both default true. Latency rows
land in <outputDir>/timing.jsonl, one per capture, so the effect of a motion
zone or sensitivity change can be measured against the 8.5s baseline instead of
eyeballed. (A future captures.jsonl manifest, backlog #4, can supersede this.)

Also corrects the README, which claimed the pre-roll loss was "~1-2s". Measured
local latency alone is ~4-6s and the full window ~8.5s; that line is what set
the wrong expectation.

Verified: 31 tests pass. Service starts, subscribes and shuts down cleanly
against the live API, and a real capture returns populated timings (4.35s
stream setup, 7.01s to first bytes).
@codereviewbot-ai

codereviewbot-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

PR Review Summary

The changes in this PR look well-designed and thoroughly tested. Latency accounting, parallel detection snapshot retrieval, and timing log appending are implemented with careful error handling so diagnostic metadata collection will never interrupt core clip recording.

Key Highlights

  • Resilient Snapshot Retrieval: fetchDetectionSnapshot catches errors internally and resolves null, preventing snapshot API failures from blocking or dropping clip recordings.
  • Interval Cleanup: watchForFirstBytes guarantees clearing its setInterval via finally in recordClip.
  • Test Coverage: Extensive hermetic tests in test/detection.test.mjs cover notification parsing, timing calculations, and RxJS subscription wiring.

Recommendations

  • Minor nit in src/events.ts: Use basename from node:path instead of .split('/') to remain cross-platform safe on Windows.

🤖 Reviewed by codereviewbot.ai - Catch bugs before your team does.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@sonarqubecloud

Copy link
Copy Markdown

Comment thread src/events.ts
const path = detectionSnapshotPath(result.path);
try {
writeFileSync(path, snapshot);
snapshotFile = path.split('/').pop() ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using .split('/') to extract the file name from a path will fail on Windows systems where path separators are \. Consider using basename from node:path for cross-platform compatibility.

Suggested change
snapshotFile = path.split('/').pop() ?? null;
import { basename, join } from 'node:path';
// ...
snapshotFile = basename(path);
// ...
clipFile: basename(result.path),

@fayerman-source
fayerman-source merged commit df9a591 into main Jul 30, 2026
2 checks passed
@fayerman-source
fayerman-source deleted the feat/detection-snapshot-and-timing branch July 30, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant