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
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ variables. See `config.example.json`. Key fields:
| `motionCooldownSeconds` | `20` | Min gap between auto-recordings per camera. |
| `retentionDays` | `null` | Delete clips older than N days. `null` = keep all. |
| `retentionSweepMinutes` | `60` | How often retention runs while the service is up. |
| `detectionSnapshots` | `true` | Save Ring's detection-time still next to each clip. |
| `timingLog` | `true` | Append per-capture latency rows to `<outputDir>/timing.jsonl`. |

Environment overrides: `RING_TOKEN_PATH`, `RING_OUTPUT_DIR`, `RING_CLIP_SECONDS`,
`RING_RETENTION_DAYS`, `RING_DEBUG=1`.
Expand All @@ -103,9 +105,46 @@ default `shouldTrigger()` policy:
- Enforces `motionCooldownSeconds` between the *start* of consecutive clips.

If you want "extend while motion persists" instead of fixed-length clips, or a different
cooldown, edit `shouldTrigger()`; it's deliberately isolated for that. One
no-subscription caveat: a clip can only begin *after* the event arrives, so there's no
pre-roll buffer (you lose the ~1-2s before the trigger).
cooldown, edit `shouldTrigger()`; it's deliberately isolated for that.

### Startup latency (no pre-roll)

A clip can only begin *after* the event arrives, so there is no pre-roll buffer. The
gap is larger than it sounds. Measured on a battery Spotlight Cam Plus, median over 10
real motion events:

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

At walking pace that is roughly 12 m of approach that is never recorded, which is why
subjects can appear to be *leaving* rather than arriving. Almost all of it is Ring-side
and cannot be reduced from here.

Two things help:

- **`detectionSnapshots`** (on by default) saves the still Ring captured *at detection
time* as `<clip>.detection.jpg`. It predates the first video frame by the whole
window above, so it is the only view of the approach available without a Ring
Protect subscription. Costs one REST fetch and does not wake the camera. Ring's push
does not always carry one; when it doesn't, the clip is unaffected.
- **Detect earlier rather than react faster.** Widening the camera's motion zone and
raising sensitivity moves the trigger earlier in someone's approach, so the same
latency lands while they are still walking toward the camera.

`timingLog` (on by default) appends one row per capture to `<outputDir>/timing.jsonl`
with each segment above, so a settings change can be measured instead of guessed:

```json
{"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: Ring's detection to the first frame on disk.

## Running as a background service

Expand Down
4 changes: 3 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@
"recordOnDing": true,
"motionCooldownSeconds": 20,
"retentionDays": 14,
"retentionSweepMinutes": 60
"retentionSweepMinutes": 60,
"detectionSnapshots": true,
"timingLog": true
}
16 changes: 16 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ export interface AppConfig {
retentionDays: number | null;
/** How often (minutes) to run the retention sweep while the service runs. */
retentionSweepMinutes: number;
/**
* Save the snapshot Ring captured at detection time alongside each clip.
*
* Ring's motion push can carry a snapshot uuid for a still taken when motion
* was detected — seconds *before* a live stream can be negotiated. That still
* is the only view of the approach available without a Ring Protect
* subscription. Costs one REST fetch and does not wake the camera.
*/
detectionSnapshots: boolean;
/**
* Append a per-capture latency record to `<outputDir>/timing.jsonl`.
* Used to measure the gap between Ring's detection and the first frame.
*/
timingLog: boolean;
}

const DEFAULTS: AppConfig = {
Expand All @@ -45,6 +59,8 @@ const DEFAULTS: AppConfig = {
motionCooldownSeconds: 20,
retentionDays: null,
retentionSweepMinutes: 60,
detectionSnapshots: true,
timingLog: true,
};

/** Resolve a possibly-relative path against the project root. */
Expand Down
141 changes: 141 additions & 0 deletions src/detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { PushNotificationDingV2 } from 'ring-client-api';

/**
* Detection context lifted out of a Ring push notification.
*
* The point of this module is latency accounting. A clip can only start after
* the event reaches us, so the interesting question is *how much happened
* before the first frame* — and answering it needs Ring's own timestamps, not
* ours. `ringEventAtMs` is the moment Ring says it detected motion;
* `receivedAtMs` is when the push landed here. The gap between them is Ring's,
* and nothing in this repo can shrink it.
*/
export interface DetectionContext {
dingId?: string;
/** 'motion' | 'ding' | 'human' | 'other_motion' | ... (Ring's own label). */
subtype?: string;
/** Ring's detection timestamp (ms epoch), from ding.created_at or analytics.triggered_at. */
ringEventAtMs?: number;
/** UUID of the snapshot Ring captured at detection time, if the push carried one. */
snapshotUuid?: string;
/** When this process received the push (ms epoch). */
receivedAtMs: number;
}

/**
* Pull the useful fields out of a push notification.
*
* Every field is optional in practice: Ring's payload shape varies by device
* and firmware, and `img` in particular is absent on some cameras. A partial
* context is still worth recording — a missing snapshot uuid should degrade to
* "no snapshot", never to a dropped recording.
*/
export function parseNotification(n: PushNotificationDingV2, receivedAtMs: number): DetectionContext {
const ding = n?.data?.event?.ding;
const createdAt = ding?.created_at ? Date.parse(ding.created_at) : NaN;

Check warning on line 35 in src/detection.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.NaN` over `NaN`.

See more on https://sonarcloud.io/project/issues?id=fayerman-source_ring-camera-recorder&issues=AZ-w97F83GG3_mDb2Q8M&open=AZ-w97F83GG3_mDb2Q8M&pullRequest=10
const triggeredAt = n?.analytics?.triggered_at;

// Prefer ding.created_at (detection); fall back to analytics.triggered_at.
let ringEventAtMs: number | undefined;
if (Number.isFinite(createdAt)) ringEventAtMs = createdAt;
else if (typeof triggeredAt === 'number' && Number.isFinite(triggeredAt)) ringEventAtMs = triggeredAt;

return {
dingId: ding?.id,
subtype: ding?.subtype,
ringEventAtMs,
snapshotUuid: n?.img?.snapshot_uuid,
receivedAtMs,
};
}

/**
* Decide whether a stored notification belongs to the trigger firing now.
*
* `onMotionDetected` and `onNewNotification` are separate streams over the same
* push, so a trigger normally has a notification from milliseconds earlier. A
* *stale* one must not be attached: motion triggers can also come from a
* boolean transition with no fresh push behind it, and pairing a clip with a
* 20-minute-old detection would silently corrupt the latency numbers this
* module exists to produce. Unrelated is better than wrong.
*/
export function pickDetection(
latest: DetectionContext | undefined,
triggerAtMs: number,
maxAgeMs: number,
): DetectionContext | undefined {
if (!latest) return undefined;
const age = triggerAtMs - latest.receivedAtMs;
if (age < 0 || age > maxAgeMs) return undefined;
return latest;
}

/** Timestamps collected across one recording attempt (ms epoch). */
export interface CaptureTiming {
triggerAtMs: number;
streamOpenAtMs?: number;
firstFrameAtMs?: number;
}

export interface TimingRecord {
camera: string;
clip: string;
detectionSnapshot: string | null;
dingId: string | null;
subtype: string | null;
ringEventAt: string | null;
notificationReceivedAt: string | null;
triggerAt: string;
streamOpenAt: string | null;
firstFrameAt: string | null;
/** Ring detection -> push received here. Ring's latency; not ours. */
pushDelaySec: number | null;
/** Trigger -> live stream negotiated (WebRTC signaling + camera wake). */
streamSetupSec: number | null;
/** Stream open -> first bytes on disk (ffmpeg stream analysis). */
firstFrameSec: number | null;
/** Ring detection -> first bytes on disk. The headline number. */
blindWindowSec: number | null;
}

const iso = (ms?: number): string | null => (typeof ms === 'number' && Number.isFinite(ms) ? new Date(ms).toISOString() : null);
/** Elapsed ms between two epoch timestamps, as seconds to 2dp. */
const secs = (a?: number, b?: number): number | null =>
typeof a === 'number' && typeof b === 'number' && Number.isFinite(a) && Number.isFinite(b)
? Math.round((b - a) / 10) / 100
: null;

/**
* Build the JSONL record for one capture. Pure so the arithmetic is testable
* without a Ring account, a camera, or a clock.
*/
export function buildTimingRecord(args: {
cameraName: string;
clipFile: string;
snapshotFile: string | null;
detection?: DetectionContext;
timing: CaptureTiming;
}): TimingRecord {
const { cameraName, clipFile, snapshotFile, detection, timing } = args;
return {
camera: cameraName,
clip: clipFile,
detectionSnapshot: snapshotFile,
dingId: detection?.dingId ?? null,
subtype: detection?.subtype ?? null,
ringEventAt: iso(detection?.ringEventAtMs),
notificationReceivedAt: iso(detection?.receivedAtMs),
triggerAt: new Date(timing.triggerAtMs).toISOString(),
streamOpenAt: iso(timing.streamOpenAtMs),
firstFrameAt: iso(timing.firstFrameAtMs),
pushDelaySec: secs(detection?.ringEventAtMs, detection?.receivedAtMs),
streamSetupSec: secs(timing.triggerAtMs, timing.streamOpenAtMs),
firstFrameSec: secs(timing.streamOpenAtMs, timing.firstFrameAtMs),
blindWindowSec: secs(detection?.ringEventAtMs, timing.firstFrameAtMs),
};
}

/** Sibling path for the detection snapshot: Front_<ts>.mp4 -> Front_<ts>.detection.jpg */
export function detectionSnapshotPath(clipPath: string): string {
return clipPath.replace(/\.mp4$/i, '') + '.detection.jpg';
}
Loading
Loading