diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4869b55 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Type-check / build + run: npm run build + + - name: Run hermetic tests + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f1a4bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +recordings/ +*.log + +# Raw image-generator output (the finished hero lives in assets/). +nanobanana-output/ + +# Secrets — the refresh token grants full account access. Never commit it. +.ring-token.json +.env +config.local.json + +# Stray image-generator outputs in assets (keep only curated hero.jpg) +assets/*.png diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0156a19 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Eli Fayerman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..5238832 --- /dev/null +++ b/NOTICE @@ -0,0 +1,36 @@ +Ring Local Recorder +Copyright (c) 2026 Eli Fayerman +Licensed under the MIT License (see LICENSE). + +================================================================================ +NOT AFFILIATED WITH RING +================================================================================ + +This project is an independent, community-built tool. It is NOT affiliated with, +authorized, maintained, sponsored, or endorsed by Ring LLC, Amazon.com, Inc., or +any of their subsidiaries or affiliates. "Ring" and related marks are trademarks +of their respective owners and are used here only for identification purposes. + +================================================================================ +THIRD-PARTY COMPONENTS +================================================================================ + +This software depends on, but does not redistribute, the following packages. +They are installed from the npm registry at install time under their own +licenses: + + - ring-client-api (MIT): https://github.com/dgreif/ring + Provides the Ring account/streaming integration. Uses Ring's unofficial + API; see the legal notes in README.md. + - rxjs (Apache-2.0) + - werift (MIT): WebRTC transport + - @homebridge/camera-utils (MIT) + - @eneris/push-receiver (MIT): push notification delivery + +ffmpeg: + Recording relies on ffmpeg. By default the binary is provided at install time + by the "ffmpeg-for-homebridge" package; alternatively you may point the tool at + a system ffmpeg via the RING_FFMPEG environment variable. ffmpeg is licensed + under the LGPL/GPL depending on how it was built. This project does NOT bundle + or redistribute any ffmpeg binary; it invokes whatever ffmpeg is present on + the host. See https://ffmpeg.org/legal.html. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7abaec8 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# Ring Camera Recorder + +

+ Ring Camera Recorder: self-hosted local recording for your Ring cameras +

+ +Self-hosted tool that **locally records the live view from Ring cameras you own**. It captures +the freely available live stream (not Ring's paid cloud storage), so it works **without a Ring +Protect subscription**. It records your own cameras and your own footage. Read the +[Legal & responsible use](#legal--responsible-use) section before using or sharing it. + +> **You are recording the live stream, not downloading cloud history.** Without a Ring +> Protect subscription, Ring stores nothing in the cloud, so there is no event history to +> retrieve. This tool grabs the *live view* feed (the same one the app shows you for free) +> and writes it to disk, either on demand or automatically when a motion/doorbell event +> fires. That is the only no-subscription path. + +## What it does + +- Authenticates to your Ring account once (email + password + 2FA) and stores a + **refresh token**. Your password is never saved or hardcoded. +- Lists your cameras. +- Records the live stream to `recordings/{camera}_{ISO8601}.mp4`. +- Auto-records a clip when a camera reports **motion** or a doorbell **ding**. +- Manual "record now for N seconds" and continuous modes. +- Optional retention: delete clips older than N days. + +## Requirements + +- Node.js >= 20 (tested on 22). +- `ffmpeg` available on `PATH` (`ffmpeg -version` should work). +- A Ring account with at least one camera. + +## Setup + +```bash +npm install + +# One-time interactive login. Prompts for email, password, and the 2FA code Ring +# texts/emails you, then writes the refresh token to .ring-token.json. +npm run auth + +# Optional: copy and edit the config. +cp config.example.json config.json + +# List your cameras (confirms auth works and shows names/ids for the `cameras` filter). +npm run list +``` + +## Usage + +```bash +# Long-running service: watches for motion/ding and auto-records per config.json. +npm run build && npm start +# (during development you can skip the build step with: npm run dev) + +# Record a single clip right now (default camera selection, default length): +npm run record -- --camera "Front Door" --seconds 30 + +# Full end-to-end verification (run after `npm run auth`): authenticates, lists +# cameras, captures a clip, probes it for video/audio, and (optionally) waits for +# a real motion/ding to confirm auto-recording. Trigger motion when prompted. +npm run verify -- --watch-motion 60 +``` + +## Tests + +```bash +npm test # builds, then runs hermetic logic tests (no Ring account needed) +``` + +Covers the filename convention, retention pruning, and the motion/ding trigger state +machine (rising-edge detection, no-overlap-while-recording, cooldown suppression, +doorbell-only-on-doorbots). The live network path is exercised separately by +`npm run verify` once you have a token. + +## Configuration + +Config is layered: built-in defaults < `config.json` < `config.local.json` < environment +variables. See `config.example.json`. Key fields: + +| Field | Default | Meaning | +|-------|---------|---------| +| `tokenPath` | `.ring-token.json` | Where the refresh token lives. | +| `outputDir` | `recordings` | Where clips are written. | +| `clipLengthSeconds` | `30` | Default clip length for events + manual record. | +| `cameras` | `"all"` | `"all"` or an array of name substrings / numeric ids. | +| `recordOnMotion` | `true` | Auto-record on motion events. | +| `recordOnDing` | `true` | Auto-record on doorbell press. | +| `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. | + +Environment overrides: `RING_TOKEN_PATH`, `RING_OUTPUT_DIR`, `RING_CLIP_SECONDS`, +`RING_RETENTION_DAYS`, `RING_DEBUG=1`. + +## Triggering policy + +When a motion/ding event fires, `src/events.ts` decides whether to start a clip. The +default `shouldTrigger()` policy: + +- **Skips** if a recording for that camera is already running (no overlapping ffmpeg jobs). +- 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). + +## Running as a background service + +- **systemd**: see `deploy/ring-camera-recorder.service` (header has install steps). +- **pm2**: `pm2 start deploy/ecosystem.config.cjs`. + +Run `npm run auth` manually first: the login is interactive and the service only reads +the saved token. + +## Limitations (no subscription) + +- **Live-only.** No access to past clips / cloud event history; those require Ring Protect. +- Motion/ding **push notifications are still delivered free**, which is what triggers + auto-recording. +- Battery-powered cameras spin up the radio for each live stream, so frequent + motion-triggered recording will drain the battery faster than passive monitoring. +- Ring may throttle accounts that open live streams very aggressively; keep clip lengths + and cooldowns reasonable for 24/7 use, or use a locally-streaming camera instead. + +## Security + +The refresh token in `.ring-token.json` grants full access to your Ring account. It is +gitignored, written with `0600` permissions, and rotated tokens are persisted +automatically. Treat that file like a password. + +`npm audit` reports a high-severity advisory in a transitive WebRTC dependency (`ip` via +`werift`). It is **not** in this project's code, is low-risk for personal use, and +**cannot be fixed without breaking streaming**. Do not run `npm audit fix --force`. See +[`SECURITY.md`](./SECURITY.md) for the full assessment and how to report issues. + +## Contributing + +Issues and pull requests are welcome. This is a small, best-effort project maintained in +spare time. Keep PRs focused, and note that the Ring integration depends on an +unofficial upstream API that can break without notice (see [`SECURITY.md`](./SECURITY.md)). +For anything security-related, follow `SECURITY.md` rather than opening a public issue. + +By contributing, you agree your contributions are licensed under the MIT License (see +[`LICENSE`](./LICENSE)). + +## Legal & responsible use + +**This is not legal advice.** Use this tool at your own risk. By using it you accept +responsibility for complying with the laws and agreements that apply to you. + +- **Your own cameras only.** Use this exclusively with cameras on a Ring account you + control, authenticating with your own credentials. It is a tool for recording *your own* + footage, not for accessing anyone else's account, cameras, or data. + +- **Audio recording / consent.** Clips include **audio**. Many jurisdictions have + two-party / all-party consent ("wiretapping") laws. In some U.S. states and other + countries, recording other people's audio without consent can carry **civil and even + criminal** liability, independent of Ring. Recording your own property is generally fine; + capturing other people's conversations may not be. Know your local laws before recording, + and consider disabling audio where required. + +- **Ring Terms of Service.** This relies on `ring-client-api`, which uses Ring's + **unofficial** API. Automated/unofficial access may violate Ring's / Amazon's Terms of + Service, and **Ring could suspend accounts** that use it. There is no guarantee of + continued access; Ring can change or block the API at any time (e.g. the 2026 + `/oauth/v2` auth migration; see the upstream `dgreif/ring` issues). + +- **Not affiliated with Ring.** This project is independent and is **not** affiliated with, + authorized, endorsed, or sponsored by Ring LLC or Amazon. "Ring" is a trademark of its + owner, used here only to describe interoperability. See [`NOTICE`](./NOTICE). + +- **No warranty.** Provided "as is" under the [MIT License](./LICENSE), without warranty of + any kind. The authors are not liable for account suspensions, lost recordings, missed + events, or any other damages arising from use. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9526925 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ +# Security + +## Reporting a vulnerability + +Please report security issues privately via GitHub's "Report a vulnerability" +(Security Advisories) on this repository, or by opening a minimal issue that does +**not** include any secrets, tokens, or personal footage. We'll respond as soon as +we reasonably can. This is a volunteer, best-effort project (see the no-warranty +note in [LICENSE](./LICENSE)). + +## Handling of credentials + +- This tool never stores your Ring password. The interactive login exchanges it for + a **refresh token**, written to `.ring-token.json` with `0600` permissions. +- That token grants full access to your Ring account; treat it like a password. +- `.ring-token.json`, `.env`, and `config.local.json` are git-ignored. **Never commit + them.** Run `git status` before your first commit to confirm. +- Rotated tokens are persisted automatically; the token value is never written to logs. +- `RING_DEBUG=1` enables verbose `ring-client-api` logging, which may include sensitive + request details. Use it only for local troubleshooting, never in shared logs. + +## Known dependency advisory (`npm audit`) + +`npm audit` reports high-severity findings that all trace to a single transitive +dependency: the `ip` package's `isPublic` SSRF miscategorization +([GHSA-2p57-rm9w-gvfp](https://github.com/advisories/GHSA-2p57-rm9w-gvfp)), pulled in +via `werift` / `werift-ice` (the WebRTC NAT-traversal stack) beneath `ring-client-api`. + +- **Not in this project's code.** Nothing here calls `ip` directly; it lives in the + WebRTC ICE candidate layer. +- **Do NOT run `npm audit fix --force`.** Its only "fix" downgrades `ring-client-api` + to `9.13.0` (pre-WebRTC), which breaks streaming entirely. This project intentionally + pins `ring-client-api@14.3.0`. +- **Cannot be resolved downstream.** The current latest `ring-client-api` still depends + on the affected versions; it will clear once `werift` updates upstream. +- **Low practical risk for this use case.** The `ip` SSRF is relevant when an attacker + controls connection targets and the app relies on `ip.isPublic()` for SSRF protection. + Here, the WebRTC session connects to *your own* Ring account over authenticated, + DTLS-encrypted media; there is no untrusted-peer attack surface in a personal recorder. + +We track this and will bump `ring-client-api` when an upstream release clears the advisory. diff --git a/assets/hero.jpg b/assets/hero.jpg new file mode 100644 index 0000000..11bdf31 Binary files /dev/null and b/assets/hero.jpg differ diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..9c31783 --- /dev/null +++ b/config.example.json @@ -0,0 +1,11 @@ +{ + "tokenPath": ".ring-token.json", + "outputDir": "recordings", + "clipLengthSeconds": 30, + "cameras": "all", + "recordOnMotion": true, + "recordOnDing": true, + "motionCooldownSeconds": 20, + "retentionDays": 14, + "retentionSweepMinutes": 60 +} diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs new file mode 100644 index 0000000..a95cab1 --- /dev/null +++ b/deploy/ecosystem.config.cjs @@ -0,0 +1,23 @@ +// pm2 process definition. Usage: +// npm install && npm run build +// npm run auth # interactive, once +// pm2 start deploy/ecosystem.config.cjs +// pm2 logs ring-camera-recorder +// pm2 save && pm2 startup # survive reboots +module.exports = { + apps: [ + { + name: 'ring-camera-recorder', + script: 'dist/index.js', + cwd: __dirname + '/..', + autorestart: true, + restart_delay: 10000, + max_restarts: 20, + env: { + // RING_OUTPUT_DIR: '/var/lib/ring-recordings', + // RING_RETENTION_DAYS: '14', + // RING_DEBUG: '1', + }, + }, + ], +}; diff --git a/deploy/ring-camera-recorder.service b/deploy/ring-camera-recorder.service new file mode 100644 index 0000000..67ff527 --- /dev/null +++ b/deploy/ring-camera-recorder.service @@ -0,0 +1,34 @@ +# systemd unit for Ring Camera Recorder. +# +# Install: +# 1. Build first: npm install && npm run build +# 2. Edit User/paths below to match your machine. +# 3. sudo cp deploy/ring-camera-recorder.service /etc/systemd/system/ +# 4. sudo systemctl daemon-reload +# 5. sudo systemctl enable --now ring-camera-recorder +# 6. journalctl -u ring-camera-recorder -f # follow logs +# +# Note: run `npm run auth` MANUALLY once (it is interactive) before enabling the +# service. The service only reads the already-saved refresh token. + +[Unit] +Description=Ring Camera Recorder (local live recording) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Replace with the unprivileged user and the absolute path where you cloned this repo. +User=youruser +WorkingDirectory=/home/youruser/ring-camera-recorder +ExecStart=/usr/bin/node dist/index.js +Restart=on-failure +RestartSec=10 +# Token rotation writes back to the token file, so the working dir must be writable. +# Environment overrides (optional) — these win over config.json: +# Environment=RING_OUTPUT_DIR=/var/lib/ring-recordings +# Environment=RING_RETENTION_DAYS=14 +# Environment=RING_DEBUG=1 + +[Install] +WantedBy=multi-user.target diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4a6ce6f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2009 @@ +{ + "name": "ring-camera-recorder", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ring-camera-recorder", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "ring-client-api": "14.3.0" + }, + "bin": { + "ring-camera-recorder": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@eneris/push-receiver": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@eneris/push-receiver/-/push-receiver-4.3.0.tgz", + "integrity": "sha512-COn7fZIh1P+3FYEpE87E2KoACW/gYTxwURykYqQ1lKMO1O/vdfrWP4ECsew/Kto9Mec34J46hvD5I/2nNa8kfA==", + "license": "MIT", + "dependencies": { + "http_ece": "^1.2.1", + "jsonwebtoken": "^9.0.2", + "long": "^5.2.3", + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fidm/asn1": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz", + "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@fidm/x509": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz", + "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==", + "license": "MIT", + "dependencies": { + "@fidm/asn1": "^1.0.4", + "tweetnacl": "^1.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@homebridge/camera-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@homebridge/camera-utils/-/camera-utils-3.0.0.tgz", + "integrity": "sha512-lw+DflCBEgbcfeBfXrPGhbzYn63bj61hpETVwqTO3FCThIMa3VoFDUNEy7jQq6OBS+iaMeHSuv4gy6w8yxFK7A==", + "license": "MIT", + "dependencies": { + "execa": "^9.5.1", + "ffmpeg-for-homebridge": "^2.1.7", + "pick-port": "^2.1.0", + "rxjs": "^7.8.1", + "systeminformation": "^5.23.5" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@minhducsun2002/leb128": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@minhducsun2002/leb128/-/leb128-1.0.0.tgz", + "integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@shinyoshiaki/binary-data": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@shinyoshiaki/binary-data/-/binary-data-0.6.1.tgz", + "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==", + "license": "MIT", + "dependencies": { + "generate-function": "^2.3.1", + "is-plain-object": "^2.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@shinyoshiaki/jspack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@shinyoshiaki/jspack/-/jspack-0.0.6.tgz", + "integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", + "license": "MIT" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/ffmpeg-for-homebridge": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ffmpeg-for-homebridge/-/ffmpeg-for-homebridge-2.2.2.tgz", + "integrity": "sha512-A1bmqv9BTrpGZPCQ0XpWk4gmzheBGEv3eFQDKBKA74hwG/tJdt6JqcF+jlmlp5s/OqRdDrEsyveL2T+GvNf/fA==", + "hasInstallScript": true, + "dependencies": { + "tar": "^7.5.12" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http_ece": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.1.tgz", + "integrity": "sha512-+tzLoMYgXvicu60sVFoswTiu6BiQ6EX3DORRJQ3W2dNpNWCyZ3tcmRFZZ3jgVyw8ziWUCeUARKCkYDY6JgFx+w==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/int64-buffer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.0.tgz", + "integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==", + "license": "MIT" + }, + "node_modules/ip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", + "license": "MIT" + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mp4box": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-0.5.4.tgz", + "integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==", + "license": "BSD-3-Clause" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pick-port": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/pick-port/-/pick-port-2.2.1.tgz", + "integrity": "sha512-uc29LvLYMTwN8chzRVwv8D9rkIa3hj7rvjX57JeBJ3VJpWK/3j0BxuZ5VwklAEgulpiDtPfxbwnaH952MyDBCQ==", + "license": "ISC", + "dependencies": { + "debug": "^4.4.3" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mediasoup" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/ring-client-api": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/ring-client-api/-/ring-client-api-14.3.0.tgz", + "integrity": "sha512-JUGDMvAv3Y5DbaYbYYvr9JIeHonrtCmS46C/vRahg0OshnsOyurH+wG7+3os4caXhnQnTlikL0+lc4xNNKE+Mg==", + "funding": [ + { + "type": "paypal", + "url": "https://www.paypal.me/dustingreif" + }, + { + "type": "github", + "url": "https://github.com/sponsors/dgreif" + } + ], + "license": "MIT", + "dependencies": { + "@eneris/push-receiver": "4.3.0", + "@homebridge/camera-utils": "^3.0.0", + "colors": "1.4.0", + "debug": "^4.4.3", + "json-bigint": "^1.0.0", + "rxjs": "^7.8.2", + "systeminformation": "^5.30.7", + "undici": "^7.19.2", + "uuid": "^13.0.0", + "werift": "0.22.4" + }, + "bin": { + "ring-auth-cli": "lib/ring-auth-cli.js", + "ring-device-data-cli": "lib/ring-device-data-cli.js" + }, + "engines": { + "node": "^20 || ^22 || ^24" + } + }, + "node_modules/rx.mini": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/rx.mini/-/rx.mini-1.4.0.tgz", + "integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA==" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/systeminformation": { + "version": "5.31.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", + "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/tar": { + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/turbo-crc32": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/turbo-crc32/-/turbo-crc32-1.0.1.tgz", + "integrity": "sha512-8yyRd1ZdNp+AQLGqi3lTaA2k81JjlIZOyFQEsi7GQWBgirnQOxjqVtDEbYHM2Z4yFdJ5AQw0fxBLLnDCl6RXoQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/werift": { + "version": "0.22.4", + "resolved": "https://registry.npmjs.org/werift/-/werift-0.22.4.tgz", + "integrity": "sha512-N/Jgh82cjhxTusQSJzAmgQhrqPcUrytlybrh45VoggusHEsw7Q8S8uhPD+5nvgDM8FYyjJ0BcMGg5vbvalWi0A==", + "license": "MIT", + "dependencies": { + "@fidm/x509": "^1.2.1", + "@minhducsun2002/leb128": "^1.0.0", + "@noble/curves": "^1.8.1", + "@peculiar/x509": "^1.12.3", + "@shinyoshiaki/binary-data": "^0.6.1", + "@shinyoshiaki/jspack": "^0.0.6", + "aes-js": "^3.1.2", + "buffer": "^6.0.3", + "buffer-crc32": "^1.0.0", + "debug": "4.4.0", + "fast-deep-equal": "^3.1.3", + "int64-buffer": "1.1.0", + "ip": "^2.0.1", + "mp4box": "^0.5.3", + "multicast-dns": "^7.2.5", + "turbo-crc32": "^1.0.1", + "tweetnacl": "^1.0.3", + "werift-common": "*", + "werift-dtls": "*", + "werift-ice": "*", + "werift-rtp": "*", + "werift-sctp": "*" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/werift-common": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/werift-common/-/werift-common-0.0.3.tgz", + "integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==", + "license": "MIT", + "dependencies": { + "@shinyoshiaki/jspack": "^0.0.6", + "debug": "^4.4.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/werift-dtls": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/werift-dtls/-/werift-dtls-0.5.7.tgz", + "integrity": "sha512-z2fjbP7fFUFmu/Ky4bCKXzdgPTtmSY1DYi0TUf3GG2zJT4jMQ3TQmGY8y7BSSNGetvL4h3pRZ5un0EcSOWpPog==", + "license": "MIT", + "dependencies": { + "@fidm/x509": "^1.2.1", + "@noble/curves": "^1.3.0", + "@peculiar/x509": "^1.9.2", + "@shinyoshiaki/binary-data": "^0.6.1", + "date-fns": "^2.29.3", + "lodash": "^4.17.21", + "rx.mini": "^1.2.2", + "tweetnacl": "^1.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/werift-ice": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/werift-ice/-/werift-ice-0.2.2.tgz", + "integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==", + "license": "MIT", + "dependencies": { + "@shinyoshiaki/jspack": "^0.0.6", + "buffer-crc32": "^1.0.0", + "debug": "^4.3.4", + "int64-buffer": "^1.0.1", + "ip": "^2.0.1", + "lodash": "^4.17.21", + "multicast-dns": "^7.2.5", + "p-cancelable": "^2.1.1", + "rx.mini": "^1.2.2" + } + }, + "node_modules/werift-rtp": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/werift-rtp/-/werift-rtp-0.8.8.tgz", + "integrity": "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ==", + "license": "MIT", + "dependencies": { + "@minhducsun2002/leb128": "^1.0.0", + "@shinyoshiaki/jspack": "^0.0.6", + "aes-js": "^3.1.2", + "buffer": "^6.0.3", + "mp4box": "^0.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/werift-sctp": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/werift-sctp/-/werift-sctp-0.0.11.tgz", + "integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==", + "license": "MIT", + "dependencies": { + "@shinyoshiaki/jspack": "^0.0.6" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/werift/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9d694e6 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "ring-camera-recorder", + "version": "0.1.0", + "private": true, + "description": "Self-hosted tool to locally record the live view from Ring cameras you own. Captures the free live stream (not paid cloud storage), so it works without a Ring Protect subscription.", + "license": "MIT", + "author": "Eli Fayerman", + "type": "module", + "engines": { + "node": ">=20" + }, + "bin": { + "ring-camera-recorder": "./dist/cli.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "dev": "tsx src/index.ts", + "auth": "tsx src/auth.ts", + "record": "tsx src/cli.ts record", + "list": "tsx src/cli.ts list", + "verify": "tsx src/verify.ts", + "test": "npm run build && node test/logic.test.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "ring-client-api": "14.3.0" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.6.3", + "@types/node": "^22.9.0" + } +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..ded2519 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,85 @@ +import { createInterface, type Interface } from 'node:readline'; +import { RingRestClient } from 'ring-client-api/rest-client'; +import { loadConfig } from './config.js'; +import { writeToken, tokenFileExists } from './ring.js'; +import { log } from './log.js'; + +/** + * Interactive one-time login. Prompts for email + password + 2FA code, acquires a + * refresh token via Ring's auth API, and writes it to the configured token file. + * + * The token (not the password) is what the service uses thereafter, and Ring + * rotates it on every connect — see `onRefreshTokenUpdated` handling in ring.ts. + */ +async function main(): Promise { + const cfg = loadConfig(); + + if (tokenFileExists(cfg) && !process.argv.includes('--force')) { + log.warn(`A token already exists at ${cfg.tokenPath}. Re-run with --force to overwrite.`); + process.exit(0); + } + + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + try { + const email = (await question(rl, 'Ring email: ')).trim(); + const password = await hiddenQuestion(rl, 'Ring password: '); + + const client = new RingRestClient({ email, password }); + + let ok = false; + try { + await client.getAuth(); + ok = true; + } catch (err) { + // Expected when 2FA is enabled: the first getAuth triggers the code being + // sent and sets promptFor2fa with a human-readable instruction. Anything + // else (network, DNS, rate limit, bad credentials) should surface its cause. + if (!client.promptFor2fa) { + throw new Error(`Login failed (check email/password): ${(err as Error).message}`); + } + log.info(client.promptFor2fa); + const code = (await question(rl, '2FA code: ')).trim(); + await client.getAuth(code); + ok = true; + } + + const token = client.refreshToken; + if (!ok || !token) throw new Error('Authentication succeeded but no refresh token was returned.'); + + writeToken(cfg, token); + log.info(`Refresh token saved to ${cfg.tokenPath} (mode 0600). You can now run \`npm start\`.`); + } finally { + rl.close(); + } +} + +function question(rl: Interface, query: string): Promise { + return new Promise((resolve) => rl.question(query, resolve)); +} + +/** Prompt without echoing the typed characters (masks with '*'). */ +function hiddenQuestion(rl: Interface, query: string): Promise { + return new Promise((resolve) => { + const iface = rl as Interface & { output: NodeJS.WritableStream; _writeToOutput?: (s: string) => void }; + let muted = false; + const original = iface._writeToOutput?.bind(iface); + iface._writeToOutput = (s: string) => { + if (!muted) { + iface.output.write(s); + return; + } + // Echo a mask for typed chars but let control sequences (newline) pass. + iface.output.write(s.includes('\n') ? '\n' : '*'); + }; + rl.question(query, (answer) => { + iface._writeToOutput = original; + resolve(answer); + }); + muted = true; + }); +} + +main().catch((err) => { + log.error((err as Error).message); + process.exit(1); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..e952c1d --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,93 @@ +import { loadConfig } from './config.js'; +import { createRingApi } from './ring.js'; +import { recordClip } from './recorder.js'; +import { log } from './log.js'; + +/** + * Small CLI: + * ring-recorder list + * ring-recorder record --camera "Front Door" --seconds 30 + */ +async function main(): Promise { + const [, , command, ...rest] = process.argv; + + if (command !== 'list' && command !== 'record') { + log.error('Usage: ring-recorder '); + process.exit(2); + } + + const cfg = loadConfig(); + const api = createRingApi(cfg); // reads token; throws a friendly error if missing + + try { + if (command === 'list') { + const cameras = await api.getCameras(); + if (cameras.length === 0) { + log.warn('No cameras found on this account.'); + return; + } + log.info(`Found ${cameras.length} camera(s):`); + for (const c of cameras) { + const kind = c.isDoorbot ? 'doorbell' : 'camera'; + const battery = c.batteryLevel != null ? `${c.batteryLevel}%` : 'wired'; + process.stdout.write(` #${c.id} ${c.name} [${kind}, ${c.model}, battery: ${battery}]\n`); + } + return; + } + + if (command === 'record') { + const args = parseArgs(rest); + const seconds = args.seconds ? Number(args.seconds) : cfg.clipLengthSeconds; + if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('--seconds must be a positive number'); + + const cameras = await api.getCameras(); + const match = pickCamera(cameras, args.camera); + if (!match) { + throw new Error( + `No camera matched ${args.camera ? `"${args.camera}"` : '(none specified)'}. ` + + `Available: ${cameras.map((c) => `${c.name} (#${c.id})`).join(', ')}`, + ); + } + const result = await recordClip(match, cfg, seconds); + log.info(`Done: ${result.path}`); + return; + } + } finally { + api.disconnect(); + // ffmpeg/WebRTC teardown can leave handles open briefly; exit cleanly. + setTimeout(() => process.exit(process.exitCode ?? 0), 1500); + } +} + +function parseArgs(argv: string[]): Record { + const out: Record = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith('--')) { + out[key] = next; + i++; + } else { + out[key] = 'true'; + } + } + } + return out; +} + +function pickCamera(cameras: Awaited['getCameras']>>, sel?: string) { + if (!sel) return cameras[0]; // default to the first camera if unspecified + const byId = Number(sel); + if (Number.isFinite(byId)) { + const hit = cameras.find((c) => c.id === byId); + if (hit) return hit; + } + return cameras.find((c) => c.name.toLowerCase().includes(sel.toLowerCase())); +} + +main().catch((err) => { + log.error((err as Error).message); + process.exit(1); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..eb04d18 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,118 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = resolve(__dirname, '..'); + +export interface AppConfig { + /** Path to the JSON file holding the Ring refresh token. Created by `npm run auth`. */ + tokenPath: string; + /** Directory where recorded clips are written. */ + outputDir: string; + /** Default clip length in seconds for event-triggered and manual recordings. */ + clipLengthSeconds: number; + /** + * Camera selection. Either the string "all", or an array of camera names + * (case-insensitive substring match) / numeric device ids to include. + */ + cameras: 'all' | Array; + /** Start a recording automatically when a camera reports motion. */ + recordOnMotion: boolean; + /** Start a recording automatically when a doorbell is pressed (ding). */ + recordOnDing: boolean; + /** + * Minimum seconds between two automatic recordings for the SAME camera. + * Prevents a burst of motion events from spawning overlapping ffmpeg jobs. + */ + motionCooldownSeconds: number; + /** + * Delete clips older than this many days. 0 or null disables retention + * cleanup (keep everything). + */ + retentionDays: number | null; + /** How often (minutes) to run the retention sweep while the service runs. */ + retentionSweepMinutes: number; +} + +const DEFAULTS: AppConfig = { + tokenPath: '.ring-token.json', + outputDir: 'recordings', + clipLengthSeconds: 30, + cameras: 'all', + recordOnMotion: true, + recordOnDing: true, + motionCooldownSeconds: 20, + retentionDays: null, + retentionSweepMinutes: 60, +}; + +/** Resolve a possibly-relative path against the project root. */ +export function resolveFromRoot(p: string): string { + return isAbsolute(p) ? p : resolve(PROJECT_ROOT, p); +} + +/** + * Load config by layering: built-in DEFAULTS < config.json < config.local.json + * < environment variable overrides. Paths are resolved to absolute. + */ +export function loadConfig(): AppConfig { + let merged: AppConfig = { ...DEFAULTS }; + + for (const name of ['config.json', 'config.local.json']) { + const file = resolve(PROJECT_ROOT, name); + if (existsSync(file)) { + try { + const parsed = JSON.parse(readFileSync(file, 'utf8')); + merged = { ...merged, ...parsed }; + } catch (err) { + throw new Error(`Failed to parse ${name}: ${(err as Error).message}`); + } + } + } + + // Environment overrides (handy for systemd unit files / containers). + if (process.env.RING_TOKEN_PATH) merged.tokenPath = process.env.RING_TOKEN_PATH; + if (process.env.RING_OUTPUT_DIR) merged.outputDir = process.env.RING_OUTPUT_DIR; + if (process.env.RING_CLIP_SECONDS) merged.clipLengthSeconds = num(process.env.RING_CLIP_SECONDS, merged.clipLengthSeconds); + if (process.env.RING_RETENTION_DAYS) { + // Ignore non-numeric env input rather than silently disabling retention. + const parsed = Number(process.env.RING_RETENTION_DAYS); + if (Number.isFinite(parsed)) merged.retentionDays = parsed > 0 ? parsed : null; + } + + // Resolve paths to absolute so the rest of the app never guesses cwd. + merged.tokenPath = resolveFromRoot(merged.tokenPath); + merged.outputDir = resolveFromRoot(merged.outputDir); + + validate(merged); + return merged; +} + +function num(v: string, fallback: number): number { + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + +function validate(c: AppConfig): void { + if (!Number.isFinite(c.clipLengthSeconds) || c.clipLengthSeconds <= 0) + throw new Error('clipLengthSeconds must be a number > 0'); + if (!Number.isFinite(c.motionCooldownSeconds) || c.motionCooldownSeconds < 0) + throw new Error('motionCooldownSeconds must be a number >= 0'); + if (!Number.isFinite(c.retentionSweepMinutes) || c.retentionSweepMinutes <= 0) + throw new Error('retentionSweepMinutes must be a number > 0'); + if (c.retentionDays !== null && (!Number.isFinite(c.retentionDays) || c.retentionDays < 0)) + throw new Error('retentionDays must be a number >= 0 or null'); + if (c.cameras !== 'all' && !Array.isArray(c.cameras)) { + throw new Error('cameras must be "all" or an array of names/ids'); + } +} + +/** True if a camera (by name + id) is selected by the config filter. */ +export function cameraSelected(cfg: AppConfig, name: string, id: number): boolean { + if (cfg.cameras === 'all') return true; + return cfg.cameras.some((sel) => + typeof sel === 'number' ? sel === id : name.toLowerCase().includes(String(sel).toLowerCase()), + ); +} diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..4b42685 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,91 @@ +import type { Subscription } from 'rxjs'; +import type { RingCamera } from 'ring-client-api'; +import type { AppConfig } from './config.js'; +import { recordClip, type RecordResult } from './recorder.js'; +import { log } from './log.js'; + +/** Recorder function shape — injectable so the trigger logic is unit-testable. */ +export type RecordFn = (camera: RingCamera, cfg: AppConfig, seconds: number) => Promise; + +/** Per-camera runtime state used to debounce overlapping triggers. */ +interface CameraState { + recording: boolean; + lastStartMs: number; + motionActive: boolean; +} + +/** + * Decide whether an incoming motion/ding event should start a new recording. + * + * This is the core triggering policy. The default below: + * - skips if a recording for this camera is already in progress, and + * - enforces `motionCooldownSeconds` between the START of consecutive clips. + * + * Trade-offs you may want to change (see README "Triggering policy"): + * - "extend" instead of "skip": keep recording while motion persists (better + * coverage of long events, but unbounded clip length + battery drain). + * - shorter/zero cooldown: more clips, more overlap, more account API load. + */ +function shouldTrigger(state: CameraState, cfg: AppConfig, nowMs: number): boolean { + if (state.recording) return false; + const sinceLastMs = nowMs - state.lastStartMs; + if (sinceLastMs < cfg.motionCooldownSeconds * 1000) return false; + return true; +} + +/** + * Wire motion + ding subscriptions for one camera. Returns the RxJS + * subscriptions so the caller can tear them down on shutdown. + */ +export function watchCamera(camera: RingCamera, cfg: AppConfig, recordFn: RecordFn = recordClip): Subscription[] { + const state: CameraState = { recording: false, lastStartMs: 0, motionActive: false }; + const subs: Subscription[] = []; + + const trigger = (reason: string) => { + const now = Date.now(); + if (!shouldTrigger(state, cfg, now)) { + log.debug(`Ignoring ${reason} on "${camera.name}" (busy or within cooldown).`); + return; + } + state.recording = true; + state.lastStartMs = now; + log.info(`Trigger: ${reason} on "${camera.name}".`); + // Wrap in Promise.resolve().then(...) so a *synchronous* throw in recordFn + // still becomes a rejection (caught below) and can never leave state.recording + // stuck at true, which would permanently block this camera. + Promise.resolve() + .then(() => recordFn(camera, cfg, cfg.clipLengthSeconds)) + .catch((err) => log.error(`Recording failed for "${camera.name}": ${(err as Error).message}`)) + .finally(() => { + state.recording = false; + }); + }; + + if (cfg.recordOnMotion) { + subs.push( + // onMotionDetected emits a boolean. Trigger only on the rising edge + // (false -> true), so sustained motion that emits repeated `true`s does + // not queue extra clips once cooldown/no-overlap would otherwise allow it. + camera.onMotionDetected.subscribe((active: boolean) => { + if (active && !state.motionActive) trigger('motion'); + state.motionActive = active; + }), + ); + } + + if (cfg.recordOnDing && camera.isDoorbot) { + subs.push( + camera.onDoorbellPressed.subscribe(() => trigger('doorbell ding')), + ); + } + + const triggers: string[] = []; + if (cfg.recordOnMotion) triggers.push('motion'); + if (cfg.recordOnDing && camera.isDoorbot) triggers.push('ding'); + log.info( + `Watching "${camera.name}" (#${camera.id})` + + (triggers.length ? ` for: ${triggers.join(', ')}` : ' (no auto-triggers enabled)'), + ); + + return subs; +} diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 0000000..3d9071f --- /dev/null +++ b/src/files.ts @@ -0,0 +1,75 @@ +import { mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { log } from './log.js'; + +/** + * Build a recording filename: {camera}_{ISO8601}.mp4 + * + * The camera name is slugified so it is always filesystem-safe, and the + * timestamp uses a colon-free ISO 8601 variant (colons are illegal on some + * filesystems and awkward in shells). Milliseconds are kept so two recordings + * of the same camera started within the same second don't collide and overwrite + * each other. Example: + * FrontDoor_2026-06-17T14-03-22-500Z.mp4 + */ +export function clipFilename(cameraName: string, when: Date): string { + const slug = slugify(cameraName); + const ts = when.toISOString().replace(/[:.]/g, '-'); + return `${slug}_${ts}.mp4`; +} + +export function slugify(name: string): string { + return ( + name + .normalize('NFKD') + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') || 'camera' + ); +} + +/** Ensure the output directory exists; returns the absolute path passed in. */ +export function ensureDir(dir: string): string { + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * Delete .mp4 files in `dir` whose mtime is older than `retentionDays`. + * Returns the list of deleted file paths. A null/0 retention is a no-op. + */ +export function pruneOldClips(dir: string, retentionDays: number | null, now: Date): string[] { + if (!retentionDays || retentionDays <= 0) return []; + const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1000; + const deleted: string[] = []; + + let entries: string[]; + try { + entries = readdirSync(dir); + } catch (err) { + // Output dir not created yet is fine; surface real failures (permissions, + // I/O) instead of silently disabling retention. + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + + for (const entry of entries) { + if (!entry.endsWith('.mp4')) continue; + const full = join(dir, entry); + try { + const st = statSync(full); + if (st.isFile() && st.mtimeMs < cutoff) { + unlinkSync(full); + deleted.push(full); + } + } catch (err) { + // A file vanishing mid-sweep (ENOENT) is expected; surface anything else + // (permissions, I/O) so expired clips aren't silently left in place. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + log.warn(`Retention: could not process ${full}: ${(err as Error).message}`); + } + } + } + return deleted; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5905db7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,48 @@ +import type { Subscription } from 'rxjs'; +import { loadConfig } from './config.js'; +import { createRingApi, getSelectedCameras } from './ring.js'; +import { watchCamera } from './events.js'; +import { startRetention } from './retention.js'; +import { log } from './log.js'; + +/** + * Long-running service: connect to Ring, watch the selected cameras for motion / + * doorbell events, and auto-record live clips. Also runs retention cleanup. + */ +async function main(): Promise { + const cfg = loadConfig(); + log.info('Starting Ring local recorder.'); + log.info(`Output: ${cfg.outputDir} | clip: ${cfg.clipLengthSeconds}s | cameras: ${JSON.stringify(cfg.cameras)}`); + + const api = createRingApi(cfg); + const cameras = await getSelectedCameras(api, cfg); + + if (cameras.length === 0) { + log.error('No cameras to watch. Check the `cameras` filter in config.json. Exiting.'); + process.exit(1); + } + + const subs: Subscription[] = []; + for (const camera of cameras) { + subs.push(...watchCamera(camera, cfg)); + } + + const stopRetention = startRetention(cfg); + log.info(`Ready. Watching ${cameras.length} camera(s). Press Ctrl-C to stop.`); + + const shutdown = (signal: string) => { + log.info(`Received ${signal}, shutting down.`); + for (const s of subs) s.unsubscribe(); + stopRetention(); + api.disconnect(); + // Give in-flight ffmpeg writes a moment to flush before exit. + setTimeout(() => process.exit(0), 1500); + }; + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +main().catch((err) => { + log.error(`Fatal: ${(err as Error).message}`); + process.exit(1); +}); diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..ec8c1b4 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,38 @@ +/** + * Minimal leveled logger. Writes a single line per event with an ISO timestamp + * so output is greppable and friendly to `journalctl`/pm2 log capture. + */ +type Level = 'info' | 'warn' | 'error' | 'debug'; + +const DEBUG = process.env.RING_DEBUG === '1' || process.env.RING_DEBUG === 'true'; + +function emit(level: Level, msg: string, extra?: unknown): void { + if (level === 'debug' && !DEBUG) return; + const stamp = new Date().toISOString(); + const line = `${stamp} [${level.toUpperCase()}] ${msg}`; + const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout; + if (extra !== undefined) { + let extraStr: string; + if (typeof extra === 'string') { + extraStr = extra; + } else { + try { + extraStr = JSON.stringify(extra); + } catch { + // Circular refs (some Error objects, API responses) must not crash the + // logger — it is frequently called from catch blocks. + extraStr = String(extra); + } + } + stream.write(`${line} ${extraStr}\n`); + } else { + stream.write(`${line}\n`); + } +} + +export const log = { + info: (msg: string, extra?: unknown) => emit('info', msg, extra), + warn: (msg: string, extra?: unknown) => emit('warn', msg, extra), + error: (msg: string, extra?: unknown) => emit('error', msg, extra), + debug: (msg: string, extra?: unknown) => emit('debug', msg, extra), +}; diff --git a/src/recorder.ts b/src/recorder.ts new file mode 100644 index 0000000..9b75606 --- /dev/null +++ b/src/recorder.ts @@ -0,0 +1,109 @@ +import { join } from 'node:path'; +import { statSync, rmSync } from 'node:fs'; +import { firstValueFrom } from 'rxjs'; +import type { RingCamera } from 'ring-client-api'; +import type { AppConfig } from './config.js'; +import { clipFilename, ensureDir } from './files.js'; +import { log } from './log.js'; + +/** + * ffmpeg output args for a single timestamped clip. + * + * `+frag_keyframe+empty_moov+default_base_moof` makes a *fragmented* MP4: the + * header is written up front and self-contained fragments are flushed as the + * stream arrives. If the process is killed mid-recording (Ctrl-C on the service, + * a crash, a dropped stream), the file remains playable up to the last whole + * fragment — instead of the "moov atom not found" corruption you get from a + * plain MP4 that only writes its index at the very end. + */ +function clipOutputArgs(seconds: number, outPath: string): string[] { + return [ + '-t', String(seconds), + '-movflags', '+frag_keyframe+empty_moov+default_base_moof', + '-f', 'mp4', + outPath, + ]; +} + +export interface RecordResult { + camera: string; + path: string; + bytes: number; + seconds: number; +} + +/** + * Record a single live clip from `camera` for `seconds` to the output dir. + * + * Uses the library's `recordToFile`, which opens a WebRTC live call, transcodes + * via ffmpeg, and resolves when the duration elapses (`-t `). Throws if + * the live call never starts (camera offline / throttled / auth lapsed). + */ +export async function recordClip( + camera: RingCamera, + cfg: AppConfig, + seconds: number, +): Promise { + ensureDir(cfg.outputDir); + const startedAt = new Date(); + const filename = clipFilename(camera.name, startedAt); + const outPath = join(cfg.outputDir, filename); + + log.info(`Recording ${seconds}s from "${camera.name}" → ${filename}`); + + // Open a live WebRTC call and transcode to a fragmented MP4 (see clipOutputArgs). + // The call resolves via onCallEnded when ffmpeg exits (duration reached). A hung + // call would otherwise hang forever, so cap the wait with a generous margin. + const startTimeoutMs = 30_000; + const hardTimeoutMs = (seconds + 30) * 1000; + // Cap the call setup too: streamVideo() performs WebRTC signaling that can hang + // on flaky networks or the unofficial API, and the onCallEnded timeout below + // only starts counting once the session object exists. + const session = await withTimeout( + camera.streamVideo({ output: clipOutputArgs(seconds, outPath) }), + startTimeoutMs, + `live stream for "${camera.name}" did not start within ${startTimeoutMs / 1000}s`, + ); + try { + await withTimeout( + firstValueFrom(session.onCallEnded), + hardTimeoutMs, + `live call for "${camera.name}" exceeded ${hardTimeoutMs / 1000}s`, + ); + } catch (err) { + session.stop(); // ensure the WebRTC session + ffmpeg are torn down on timeout + throw err; + } + + let bytes = 0; + try { + bytes = statSync(outPath).size; + } catch { + throw new Error(`Recording finished but no file was written at ${outPath}`); + } + if (bytes === 0) { + // A 0-byte file means the live stream never produced data — clean it up so it + // doesn't masquerade as a real clip (and survive a retention sweep). + rmSync(outPath, { force: true }); + throw new Error(`Recording produced an empty file (stream never started) for "${camera.name}"`); + } + + log.info(`Saved ${filename} (${(bytes / 1024 / 1024).toFixed(2)} MB)`); + return { camera: camera.name, path: outPath, bytes, seconds }; +} + +function withTimeout(p: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(message)), ms); + p.then( + (v) => { + clearTimeout(t); + resolve(v); + }, + (e) => { + clearTimeout(t); + reject(e); + }, + ); + }); +} diff --git a/src/retention.ts b/src/retention.ts new file mode 100644 index 0000000..3326fa2 --- /dev/null +++ b/src/retention.ts @@ -0,0 +1,32 @@ +import type { AppConfig } from './config.js'; +import { pruneOldClips } from './files.js'; +import { log } from './log.js'; + +/** + * Run one retention sweep now, then on an interval. Returns a stop() to clear + * the timer on shutdown. No-op (returns a no-op stop) if retention is disabled. + */ +export function startRetention(cfg: AppConfig): () => void { + if (!cfg.retentionDays || cfg.retentionDays <= 0) { + log.info('Retention disabled (keeping all clips).'); + return () => {}; + } + + const sweep = () => { + try { + const deleted = pruneOldClips(cfg.outputDir, cfg.retentionDays, new Date()); + if (deleted.length) { + log.info(`Retention: deleted ${deleted.length} clip(s) older than ${cfg.retentionDays}d.`); + } + } catch (err) { + log.warn(`Retention sweep failed: ${(err as Error).message}`); + } + }; + + sweep(); // run immediately on startup + const handle = setInterval(sweep, cfg.retentionSweepMinutes * 60 * 1000); + handle.unref?.(); // don't keep the process alive solely for the sweep timer + log.info(`Retention: deleting clips older than ${cfg.retentionDays}d, sweeping every ${cfg.retentionSweepMinutes}m.`); + + return () => clearInterval(handle); +} diff --git a/src/ring.ts b/src/ring.ts new file mode 100644 index 0000000..7378b6b --- /dev/null +++ b/src/ring.ts @@ -0,0 +1,95 @@ +import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, renameSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { RingApi, type RingCamera } from 'ring-client-api'; +import type { AppConfig } from './config.js'; +import { cameraSelected } from './config.js'; +import { log } from './log.js'; + +interface TokenFile { + refreshToken: string; + /** ISO timestamp of the last time we wrote this file — informational only. */ + updatedAt?: string; +} + +export function tokenFileExists(cfg: AppConfig): boolean { + return existsSync(cfg.tokenPath); +} + +export function readToken(cfg: AppConfig): string { + if (!existsSync(cfg.tokenPath)) { + throw new Error( + `No refresh token at ${cfg.tokenPath}. Run \`npm run auth\` first to log in.`, + ); + } + let raw: TokenFile; + try { + raw = JSON.parse(readFileSync(cfg.tokenPath, 'utf8')) as TokenFile; + } catch (err) { + throw new Error( + `Token file ${cfg.tokenPath} is unreadable or not valid JSON (${(err as Error).message}). Re-run \`npm run auth\`.`, + ); + } + if (!raw || !raw.refreshToken) { + throw new Error(`Token file ${cfg.tokenPath} is missing "refreshToken". Re-run \`npm run auth\`.`); + } + return raw.refreshToken; +} + +/** Write the token with 0600 perms (it grants full account access). */ +export function writeToken(cfg: AppConfig, refreshToken: string): void { + const body: TokenFile = { refreshToken, updatedAt: new Date().toISOString() }; + mkdirSync(dirname(cfg.tokenPath), { recursive: true }); + // Write to a temp file then atomically rename, so an interruption (or a + // concurrent rotation) can't leave a truncated, unparseable token file that + // would break the next startup. + const tmp = `${cfg.tokenPath}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2), { mode: 0o600 }); + renameSync(tmp, cfg.tokenPath); + try { + chmodSync(cfg.tokenPath, 0o600); // enforce perms even if the file pre-existed + } catch { + /* best effort on filesystems without unix perms */ + } +} + +/** + * Construct a RingApi from the stored token and wire up rotation persistence. + * + * Ring rotates refresh tokens continuously and expires the old one shortly after + * use, so an unattended service MUST persist every rotation — otherwise it works + * once and then fails to re-auth on the next restart. That is what + * `onRefreshTokenUpdated` is for. + */ +export function createRingApi(cfg: AppConfig): RingApi { + const refreshToken = readToken(cfg); + + const api = new RingApi({ + refreshToken, + // Poll camera status so battery/online changes are reflected; cheap. + cameraStatusPollingSeconds: 20, + // ffmpeg: bundled ffmpeg-for-homebridge by default. Override via RING_FFMPEG. + ...(process.env.RING_FFMPEG ? { ffmpegPath: process.env.RING_FFMPEG } : {}), + debug: process.env.RING_DEBUG === '1' || process.env.RING_DEBUG === 'true', + }); + + api.onRefreshTokenUpdated.subscribe(({ newRefreshToken, oldRefreshToken }) => { + if (!oldRefreshToken) return; // initial emit on first connect — nothing to replace yet + log.info('Ring refresh token rotated; persisting new token.'); + writeToken(cfg, newRefreshToken); + }); + + return api; +} + +/** Get cameras filtered by the config `cameras` selector. */ +export async function getSelectedCameras(api: RingApi, cfg: AppConfig): Promise { + const all = await api.getCameras(); + const selected = all.filter((c) => cameraSelected(cfg, c.name, c.id)); + if (selected.length === 0) { + log.warn( + `No cameras matched the config filter ${JSON.stringify(cfg.cameras)}. ` + + `Found: ${all.map((c) => `${c.name} (#${c.id})`).join(', ') || '(none)'}`, + ); + } + return selected; +} diff --git a/src/verify.ts b/src/verify.ts new file mode 100644 index 0000000..de93e34 --- /dev/null +++ b/src/verify.ts @@ -0,0 +1,168 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { Subscription } from 'rxjs'; +import type { RingCamera } from 'ring-client-api'; +import { loadConfig } from './config.js'; +import { createRingApi } from './ring.js'; +import { recordClip } from './recorder.js'; +import { log } from './log.js'; + +const execFileP = promisify(execFile); + +/** + * End-to-end verification harness. Run AFTER `npm run auth` has saved a token. + * + * npm run verify -- [--camera "Name"] [--seconds 10] [--watch-motion 60] + * + * Runs the goal's verify checklist against the real account: + * 1. authenticate + list real cameras + * 2. capture a live clip to disk + * 3. probe the clip for playable video (and report audio) + * 4. (optional) wait for a real motion/ding event and confirm auto-recording + */ +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const seconds = numArg(args.seconds, 10, 1, '--seconds'); + const watchMotion = numArg(args['watch-motion'], 0, 0, '--watch-motion'); + const cfg = loadConfig(); + const api = createRingApi(cfg); + + let failures = 0; + const step = (n: number, msg: string) => log.info(`[${n}] ${msg}`); + + try { + // 1. AUTH + LIST --------------------------------------------------------- + step(1, 'Authenticating and listing cameras…'); + const cameras = await api.getCameras(); + if (cameras.length === 0) throw new Error('Authenticated but found 0 cameras on the account.'); + for (const c of cameras) { + const battery = c.batteryLevel != null ? `${c.batteryLevel}%` : 'wired'; + log.info(` #${c.id} ${c.name} [${c.isDoorbot ? 'doorbell' : 'camera'}, ${c.model}, ${battery}]`); + } + log.info(` ✓ Auth OK, ${cameras.length} camera(s) listed.`); + + const target = pickCamera(cameras, args.camera); + if (!target) throw new Error(`No camera matched "${args.camera}".`); + + // 2. CAPTURE ------------------------------------------------------------- + step(2, `Capturing a ${seconds}s live clip from "${target.name}"…`); + const clip = await recordClip(target, cfg, seconds); + log.info(` ✓ Wrote ${clip.path} (${(clip.bytes / 1024 / 1024).toFixed(2)} MB).`); + + // 3. PROBE --------------------------------------------------------------- + step(3, 'Probing the clip for playable streams…'); + const streams = await probeStreams(clip.path); + const hasVideo = streams.includes('video'); + const hasAudio = streams.includes('audio'); + if (!hasVideo) { + failures++; + log.error(' ✗ No video stream found — clip is not playable.'); + } else { + log.info(' ✓ Video stream present, clip is playable.'); + } + if (hasAudio) log.info(' ✓ Audio stream present.'); + else log.warn(' ! No audio stream (Ring audio can be off in the app, or omitted for this device).'); + + // 4. MOTION TRIGGER (optional) ------------------------------------------ + if (watchMotion > 0) { + step(4, `Waiting up to ${watchMotion}s for a real motion/ding on "${target.name}" — trigger it now (walk in front / press the doorbell)…`); + const fired = await waitForTriggeredRecording(target, cfg, watchMotion); + if (fired) log.info(' ✓ Motion/ding fired and an auto-recording completed end-to-end.'); + else { + failures++; + log.error(` ✗ No motion/ding event within ${watchMotion}s. Re-run with a longer --watch-motion and trigger motion.`); + } + } else { + step(4, 'Skipping motion test (pass --watch-motion and trigger motion to verify it live).'); + } + + log.info(failures === 0 ? '\nVERIFY: all checks passed.' : `\nVERIFY: ${failures} check(s) failed.`); + } finally { + api.disconnect(); + setTimeout(() => process.exit(failures === 0 ? 0 : 1), 1500); + } +} + +/** Return the list of codec_types ("video"/"audio") present in a media file. */ +async function probeStreams(file: string): Promise { + const { stdout } = await execFileP('ffprobe', [ + '-v', 'error', + '-show_entries', 'stream=codec_type', + '-of', 'json', + file, + ]); + const parsed = JSON.parse(stdout) as { streams?: Array<{ codec_type?: string }> }; + return (parsed.streams ?? []).map((s) => s.codec_type ?? '').filter(Boolean); +} + +/** + * Subscribe to motion + ding for one camera; on the first event, record a short + * clip and resolve true once it's on disk. Resolves false on timeout. + */ +function waitForTriggeredRecording(camera: RingCamera, cfg: ReturnType, timeoutSec: number): Promise { + return new Promise((resolve) => { + const subs: Subscription[] = []; + let done = false; + const finish = (val: boolean) => { + if (done) return; + done = true; + subs.forEach((s) => s.unsubscribe()); + clearTimeout(timer); + resolve(val); + }; + let triggered = false; + const onTrigger = (reason: string) => { + if (triggered) return; // record once; ignore further events for this camera + triggered = true; + subs.forEach((s) => s.unsubscribe()); // stop the event streams immediately + log.info(` → ${reason} detected; recording a clip to prove the trigger path…`); + recordClip(camera, cfg, Math.min(cfg.clipLengthSeconds, 10)) + .then(() => finish(true)) + .catch((e) => { + log.error(` recording after trigger failed: ${(e as Error).message}`); + finish(false); + }); + }; + subs.push(camera.onMotionDetected.subscribe((active: boolean) => active && onTrigger('motion'))); + if (camera.isDoorbot) subs.push(camera.onDoorbellPressed.subscribe(() => onTrigger('doorbell ding'))); + const timer = setTimeout(() => finish(false), timeoutSec * 1000); + }); +} + +function pickCamera(cameras: RingCamera[], sel?: string): RingCamera | undefined { + if (!sel) return cameras[0]; + const id = Number(sel); + if (Number.isFinite(id)) { + const hit = cameras.find((c) => c.id === id); + if (hit) return hit; + } + return cameras.find((c) => c.name.toLowerCase().includes(sel.toLowerCase())); +} + +/** Parse a numeric CLI arg, rejecting non-numeric input instead of letting NaN flow into ffmpeg/timeout math. */ +function numArg(raw: string | undefined, fallback: number, min: number, flag: string): number { + if (raw === undefined) return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || n < min) throw new Error(`${flag} must be a number >= ${min} (got "${raw}")`); + return n; +} + +function parseArgs(argv: string[]): Record { + const out: Record = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const next = argv[i + 1]; + if (next && !next.startsWith('--')) { + out[a.slice(2)] = next; + i++; + } else out[a.slice(2)] = 'true'; + } + } + return out; +} + +main().catch((err) => { + log.error((err as Error).message); + process.exit(1); +}); diff --git a/test/logic.test.mjs b/test/logic.test.mjs new file mode 100644 index 0000000..b3a98e2 --- /dev/null +++ b/test/logic.test.mjs @@ -0,0 +1,86 @@ +// Hermetic logic tests — no Ring account or network required. +// Run with: npm test (builds to dist/ first, then runs this against dist). +import { clipFilename, slugify, pruneOldClips, ensureDir } from '../dist/files.js'; +import { watchCamera } from '../dist/events.js'; +import { mkdtempSync, writeFileSync, utimesSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const tick = () => new Promise((r) => setTimeout(r, 5)); +let pass = 0, fail = 0; +const ok = (c, m) => (c ? (pass++, console.log(' PASS', m)) : (fail++, console.log(' FAIL', m))); + +console.log('files: naming + retention'); +{ + const name = clipFilename('Front Door!', new Date('2026-06-17T14:03:22.500Z')); + ok(/^Front-Door_2026-06-17T14-03-22-500Z\.mp4$/.test(name), 'filename keeps ms, colons stripped (no same-second collision)'); + ok(slugify(' Backyard / Garage ') === 'Backyard-Garage', 'slugify sanitizes unsafe chars'); + ok(slugify('***') === 'camera', 'slugify falls back to "camera" for empty result'); + + const dir = mkdtempSync(join(tmpdir(), 'ring-ret-')); + ensureDir(dir); + const old = join(dir, 'Cam_old.mp4'); writeFileSync(old, 'x'); + writeFileSync(join(dir, 'Cam_fresh.mp4'), 'x'); + writeFileSync(join(dir, 'notes.txt'), 'x'); + const tenDaysAgo = Date.now() / 1000 - 10 * 86400; + utimesSync(old, tenDaysAgo, tenDaysAgo); + const deleted = pruneOldClips(dir, 7, new Date()); + const left = readdirSync(dir).sort(); + ok(deleted.length === 1 && deleted[0].endsWith('Cam_old.mp4'), 'pruned exactly the >7d clip'); + ok(left.includes('Cam_fresh.mp4'), 'kept the fresh clip'); + ok(left.includes('notes.txt'), 'left non-mp4 file untouched'); + ok(pruneOldClips(dir, null, new Date()).length === 0, 'retention=null is a no-op'); +} + +console.log('events: motion/ding trigger state machine'); +function obs() { + const subs = []; + return { subscribe(fn) { subs.push(fn); return { unsubscribe() {} }; }, next(v) { subs.forEach((f) => f(v)); } }; +} +const fakeCamera = (isDoorbot) => ({ name: 'TestCam', id: 42, isDoorbot, onMotionDetected: obs(), onDoorbellPressed: obs() }); +const cfg = (over = {}) => ({ clipLengthSeconds: 10, recordOnMotion: true, recordOnDing: true, motionCooldownSeconds: 0, ...over }); +function spy() { + const calls = []; let resolveLast; + const fn = (camera, c, seconds) => { calls.push({ seconds }); return new Promise((res) => { resolveLast = () => res({ camera: camera.name, path: 'x', bytes: 1, seconds }); }); }; + return { fn, calls, finish: () => resolveLast && resolveLast() }; +} + +{ + const cam = fakeCamera(false); const s = spy(); + watchCamera(cam, cfg(), s.fn); + cam.onMotionDetected.next(false); await tick(); + ok(s.calls.length === 0, 'motion=false does not trigger'); + cam.onMotionDetected.next(true); await tick(); + ok(s.calls.length === 1 && s.calls[0].seconds === 10, 'rising edge (false->true) records one clip'); + cam.onMotionDetected.next(true); await tick(); + ok(s.calls.length === 1, 'sustained true (no new rising edge) does not re-trigger'); + s.finish(); await tick(); + cam.onMotionDetected.next(false); await tick(); + cam.onMotionDetected.next(true); await tick(); + ok(s.calls.length === 2, 'a new rising edge after motion ends triggers again'); +} +{ + const cam = fakeCamera(false); const s = spy(); + watchCamera(cam, cfg({ motionCooldownSeconds: 3600 }), s.fn); + cam.onMotionDetected.next(true); await tick(); s.finish(); await tick(); + cam.onMotionDetected.next(false); await tick(); + cam.onMotionDetected.next(true); await tick(); + ok(s.calls.length === 1, 'new rising edge within cooldown is skipped'); +} +{ + const cam = fakeCamera(true); const s = spy(); + watchCamera(cam, cfg({ recordOnMotion: false }), s.fn); + cam.onMotionDetected.next(true); await tick(); + ok(s.calls.length === 0, 'motion ignored when recordOnMotion=false'); + cam.onDoorbellPressed.next({}); await tick(); + ok(s.calls.length === 1, 'doorbell ding triggers a recording'); +} +{ + const cam = fakeCamera(false); const s = spy(); + watchCamera(cam, cfg(), s.fn); + cam.onDoorbellPressed.next({}); await tick(); + ok(s.calls.length === 0, 'ding on non-doorbot does nothing'); +} + +console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'}: ${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c1cd15c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +}