diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41222d9a..ed4042eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" + - name: Install dependencies + run: npm ci - name: Build & validate dataset run: node scripts/build.js - name: Generate docs @@ -41,6 +43,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" + - name: Install dependencies + run: npm ci - name: Build dataset run: node scripts/build.js - name: Copy data into docs for Pages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e7e9a39..b7cd557d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,9 @@ jobs: with: node-version: "20" + - name: Install dependencies + run: npm ci + - name: Determine version id: version run: | diff --git a/.gitignore b/.gitignore index 57ad539c..37166412 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ *.log .DS_Store .idea/ +.env diff --git a/CHANGELOG.md b/CHANGELOG.md index d40b8b60..e2a781db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) --- +## [1.6.0] — 2026-06-17 + +### Added + +- **Schema validation now enforced in the build** — every entry is validated against `schema/camera.schema.json` via Ajv. Previously the build only hand-checked five required fields, so the schema had silently drifted from the data; it is now the single source of truth and CI fails on any violation. +- **11 fields added to the schema** that the data already used but never declared: localized prices `msrp_eur`, `msrp_gbp`, `msrp_inr`, `msrp_aed`, `msrp_aud`, `msrp_cad`, `msrp_vnd`, `msrp_chf`; plus `markets`, `generation`, and `release_notes`. +- **`storage.notes`** field — free-text storage notes (e.g. external-hub requirements). +- **`hdcvi` and `mxpeg`** added to the `protocols` enum (HD-CVI coax for HiLook/Dahua analog; MxPEG for Mobotix). +- **Reolink Video Doorbell PoE** enriched — verified Frigate config (tested by blakeblacksear on v0.14, go2rtc), Home Assistant details (`local_push`, doorbell button, two-way audio, ONVIF events), plus `soc` (Novatek NT98566), `poe_class`, and outdoor `environment`. + +### Fixed + +- Removed invalid `ip_rating: null` from 3 indoor cameras (Amcrest ASH42-W, Tapo C121, Tapo C135) — the field is optional and `null` is not a valid rating. + +### Changed + +- Dataset mirroring to a downstream consumer is now opt-in via the `DATA_MIRROR_DIR` env var (configurable through a local, gitignored `.env`), replacing a hardcoded copy path in the build script. +- **Project now points to the website at [cctv-database.com](https://cctv-database.com)** — README links and `package.json` `homepage` updated. The GitHub Pages demo redirects there, with a standalone offline copy kept at `docs/demo.html`. The README now states explicitly that the dataset is CC0 and always will be. + +--- + ## [1.5.0] — 2026-06-12 ### Added diff --git a/README.md b/README.md index ae101f12..445aa164 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,15 @@ An open, structured database of 1,314 CCTV / IP camera models and their technica Camera spec sheets are scattered across vendor PDFs, retailer pages, and paywalled databases (IPVM, etc.) in inconsistent formats. This repo normalises them into one machine-readable structure so they can be compared, filtered, and reused. +**The dataset is CC0 and always will be** — free to use, copy, and redistribute with no restrictions. The website is just a convenient viewer; the data here is the source of truth. + --- ## Browse online -**[Live Demo ](https://ch-bas.github.io/cctv-camera-databse)** +**[Browse the database → cctv-database.com](https://cctv-database.com)** + +Prefer to self-host or browse offline? A [standalone demo](docs/demo.html) (just `demo.html` + `cameras.json`, no build step) is included — serve the `docs/` folder locally with any static server, e.g. `python3 -m http.server` inside `docs/`, then open it.

CCTV Camera Database — browse, search, filter, and inspect 1,296 cameras across 64 brands diff --git a/cameras/amcrest/ash42-w.json b/cameras/amcrest/ash42-w.json index 29ea0d04..c2836452 100644 --- a/cameras/amcrest/ash42-w.json +++ b/cameras/amcrest/ash42-w.json @@ -29,7 +29,6 @@ "protocols": [ "rtsp" ], - "ip_rating": null, "audio": { "microphone": true, "speaker": true, diff --git a/cameras/reolink/video-doorbell-poe.json b/cameras/reolink/video-doorbell-poe.json index 5d52e6e8..abe07d46 100644 --- a/cameras/reolink/video-doorbell-poe.json +++ b/cameras/reolink/video-doorbell-poe.json @@ -24,7 +24,8 @@ "range_m": 5 }, "power": { - "method": "PoE (IEEE 802.3af) / 12-24VAC doorbell wiring / 24VDC" + "method": "PoE (IEEE 802.3af) / 12-24VAC doorbell wiring / 24VDC", + "poe_class": 2 }, "storage": { "onboard": true, @@ -52,6 +53,10 @@ "works with Reolink NVR" ], "release_year": 2022, + "environment": [ + "outdoor" + ], + "soc": "Novatek NT98566", "sources": [ "https://reolink.com/product/reolink-video-doorbell-poe/" ], @@ -88,11 +93,19 @@ }, "rtsp_url_template": "rtsp://{user}:{pass}@{ip}:554/h264Preview_01_main", "best_substream": "rtsp://{user}:{pass}@{ip}:554/h264Preview_01_sub", - "notes": "Portrait 3:4 aspect ratio (white model 1920x2560, black model is 4:3 at 2560x1920). Set detect to 720x960 for white or 960x720 for black to maintain correct ratio. For two-way audio: use go2rtc as the RTSP proxy — Frigate does not natively handle talk-back. In go2rtc config add the camera as an RTSP source, then enable WebRTC with opus re-encoding for the back-channel: 'streams: doorbell: - rtsp://{user}:{pass}@{ip}:554/h264Preview_01_main - ffmpeg:doorbell#audio=opus'. Button press events are not exposed via RTSP; use HA Reolink integration's Visitor binary_sensor or subscribe to ONVIF events via MQTT." + "notes": "Portrait 3:4 aspect ratio (white model 1920x2560, black model is 4:3 at 2560x1920). Set detect to 720x960 for white or 960x720 for black to maintain correct ratio. For two-way audio: use go2rtc as the RTSP proxy — Frigate does not natively handle talk-back. In go2rtc config add the camera as an RTSP source, then enable WebRTC with opus re-encoding for the back-channel: 'streams: doorbell: - rtsp://{user}:{pass}@{ip}:554/h264Preview_01_main - ffmpeg:doorbell#audio=opus'. Button press events are not exposed via RTSP; use HA Reolink integration's Visitor binary_sensor or subscribe to ONVIF events via MQTT.", + "verified": true, + "tested_by": "blakeblacksear", + "tested_version": "0.14", + "recommended_stream_type": "go2rtc" }, "home_assistant": { "integration": "reolink", - "notes": "Native Reolink integration auto-discovers via ONVIF (port 8000). Doorbell button press is exposed as a 'Visitor' binary_sensor entity. Two-way audio works in the Reolink app; for HA dashboard intercom, use go2rtc WebRTC card with opus back-channel." + "notes": "Native Reolink integration auto-discovers via ONVIF (port 8000). Doorbell button press is exposed as a 'Visitor' binary_sensor entity. Two-way audio works in the Reolink app; for HA dashboard intercom, use go2rtc WebRTC card with opus back-channel.", + "connection_type": "local_push", + "doorbell_button": true, + "two_way_audio": true, + "onvif_events": true }, "blue_iris": { "profile": "Reolink", diff --git a/cameras/tapo/c121.json b/cameras/tapo/c121.json index b16c3dd5..6c694666 100644 --- a/cameras/tapo/c121.json +++ b/cameras/tapo/c121.json @@ -35,7 +35,6 @@ "rtsp", "onvif" ], - "ip_rating": null, "audio": { "microphone": true, "speaker": true, diff --git a/cameras/tapo/c135.json b/cameras/tapo/c135.json index d21bfae0..eca11693 100644 --- a/cameras/tapo/c135.json +++ b/cameras/tapo/c135.json @@ -35,7 +35,6 @@ "rtsp", "onvif" ], - "ip_rating": null, "audio": { "microphone": true, "speaker": true, diff --git a/data/cameras.json b/data/cameras.json index 15b3ab8a..e8593d56 100644 --- a/data/cameras.json +++ b/data/cameras.json @@ -2679,7 +2679,6 @@ "protocols": [ "rtsp" ], - "ip_rating": null, "audio": { "microphone": true, "speaker": true, @@ -80367,7 +80366,8 @@ "range_m": 5 }, "power": { - "method": "PoE (IEEE 802.3af) / 12-24VAC doorbell wiring / 24VDC" + "method": "PoE (IEEE 802.3af) / 12-24VAC doorbell wiring / 24VDC", + "poe_class": 2 }, "storage": { "onboard": true, @@ -80395,6 +80395,10 @@ "works with Reolink NVR" ], "release_year": 2022, + "environment": [ + "outdoor" + ], + "soc": "Novatek NT98566", "sources": [ "https://reolink.com/product/reolink-video-doorbell-poe/" ], @@ -80431,11 +80435,19 @@ }, "rtsp_url_template": "rtsp://{user}:{pass}@{ip}:554/h264Preview_01_main", "best_substream": "rtsp://{user}:{pass}@{ip}:554/h264Preview_01_sub", - "notes": "Portrait 3:4 aspect ratio (white model 1920x2560, black model is 4:3 at 2560x1920). Set detect to 720x960 for white or 960x720 for black to maintain correct ratio. For two-way audio: use go2rtc as the RTSP proxy — Frigate does not natively handle talk-back. In go2rtc config add the camera as an RTSP source, then enable WebRTC with opus re-encoding for the back-channel: 'streams: doorbell: - rtsp://{user}:{pass}@{ip}:554/h264Preview_01_main - ffmpeg:doorbell#audio=opus'. Button press events are not exposed via RTSP; use HA Reolink integration's Visitor binary_sensor or subscribe to ONVIF events via MQTT." + "notes": "Portrait 3:4 aspect ratio (white model 1920x2560, black model is 4:3 at 2560x1920). Set detect to 720x960 for white or 960x720 for black to maintain correct ratio. For two-way audio: use go2rtc as the RTSP proxy — Frigate does not natively handle talk-back. In go2rtc config add the camera as an RTSP source, then enable WebRTC with opus re-encoding for the back-channel: 'streams: doorbell: - rtsp://{user}:{pass}@{ip}:554/h264Preview_01_main - ffmpeg:doorbell#audio=opus'. Button press events are not exposed via RTSP; use HA Reolink integration's Visitor binary_sensor or subscribe to ONVIF events via MQTT.", + "verified": true, + "tested_by": "blakeblacksear", + "tested_version": "0.14", + "recommended_stream_type": "go2rtc" }, "home_assistant": { "integration": "reolink", - "notes": "Native Reolink integration auto-discovers via ONVIF (port 8000). Doorbell button press is exposed as a 'Visitor' binary_sensor entity. Two-way audio works in the Reolink app; for HA dashboard intercom, use go2rtc WebRTC card with opus back-channel." + "notes": "Native Reolink integration auto-discovers via ONVIF (port 8000). Doorbell button press is exposed as a 'Visitor' binary_sensor entity. Two-way audio works in the Reolink app; for HA dashboard intercom, use go2rtc WebRTC card with opus back-channel.", + "connection_type": "local_push", + "doorbell_button": true, + "two_way_audio": true, + "onvif_events": true }, "blue_iris": { "profile": "Reolink", @@ -87615,7 +87627,6 @@ "rtsp", "onvif" ], - "ip_rating": null, "audio": { "microphone": true, "speaker": true, @@ -87775,7 +87786,6 @@ "rtsp", "onvif" ], - "ip_rating": null, "audio": { "microphone": true, "speaker": true, diff --git a/docs/cameras.json b/docs/cameras.json index 15b3ab8a..e8593d56 100644 --- a/docs/cameras.json +++ b/docs/cameras.json @@ -2679,7 +2679,6 @@ "protocols": [ "rtsp" ], - "ip_rating": null, "audio": { "microphone": true, "speaker": true, @@ -80367,7 +80366,8 @@ "range_m": 5 }, "power": { - "method": "PoE (IEEE 802.3af) / 12-24VAC doorbell wiring / 24VDC" + "method": "PoE (IEEE 802.3af) / 12-24VAC doorbell wiring / 24VDC", + "poe_class": 2 }, "storage": { "onboard": true, @@ -80395,6 +80395,10 @@ "works with Reolink NVR" ], "release_year": 2022, + "environment": [ + "outdoor" + ], + "soc": "Novatek NT98566", "sources": [ "https://reolink.com/product/reolink-video-doorbell-poe/" ], @@ -80431,11 +80435,19 @@ }, "rtsp_url_template": "rtsp://{user}:{pass}@{ip}:554/h264Preview_01_main", "best_substream": "rtsp://{user}:{pass}@{ip}:554/h264Preview_01_sub", - "notes": "Portrait 3:4 aspect ratio (white model 1920x2560, black model is 4:3 at 2560x1920). Set detect to 720x960 for white or 960x720 for black to maintain correct ratio. For two-way audio: use go2rtc as the RTSP proxy — Frigate does not natively handle talk-back. In go2rtc config add the camera as an RTSP source, then enable WebRTC with opus re-encoding for the back-channel: 'streams: doorbell: - rtsp://{user}:{pass}@{ip}:554/h264Preview_01_main - ffmpeg:doorbell#audio=opus'. Button press events are not exposed via RTSP; use HA Reolink integration's Visitor binary_sensor or subscribe to ONVIF events via MQTT." + "notes": "Portrait 3:4 aspect ratio (white model 1920x2560, black model is 4:3 at 2560x1920). Set detect to 720x960 for white or 960x720 for black to maintain correct ratio. For two-way audio: use go2rtc as the RTSP proxy — Frigate does not natively handle talk-back. In go2rtc config add the camera as an RTSP source, then enable WebRTC with opus re-encoding for the back-channel: 'streams: doorbell: - rtsp://{user}:{pass}@{ip}:554/h264Preview_01_main - ffmpeg:doorbell#audio=opus'. Button press events are not exposed via RTSP; use HA Reolink integration's Visitor binary_sensor or subscribe to ONVIF events via MQTT.", + "verified": true, + "tested_by": "blakeblacksear", + "tested_version": "0.14", + "recommended_stream_type": "go2rtc" }, "home_assistant": { "integration": "reolink", - "notes": "Native Reolink integration auto-discovers via ONVIF (port 8000). Doorbell button press is exposed as a 'Visitor' binary_sensor entity. Two-way audio works in the Reolink app; for HA dashboard intercom, use go2rtc WebRTC card with opus back-channel." + "notes": "Native Reolink integration auto-discovers via ONVIF (port 8000). Doorbell button press is exposed as a 'Visitor' binary_sensor entity. Two-way audio works in the Reolink app; for HA dashboard intercom, use go2rtc WebRTC card with opus back-channel.", + "connection_type": "local_push", + "doorbell_button": true, + "two_way_audio": true, + "onvif_events": true }, "blue_iris": { "profile": "Reolink", @@ -87615,7 +87627,6 @@ "rtsp", "onvif" ], - "ip_rating": null, "audio": { "microphone": true, "speaker": true, @@ -87775,7 +87786,6 @@ "rtsp", "onvif" ], - "ip_rating": null, "audio": { "microphone": true, "speaker": true, diff --git a/docs/demo.html b/docs/demo.html new file mode 100644 index 00000000..7ced77d8 --- /dev/null +++ b/docs/demo.html @@ -0,0 +1,1337 @@ + + + + + + CCTV Camera Database + + + + +

+ This is an offline snapshot. For the latest version, visit cctv-database.com. +
+
+ + +
+
+
+
+
📷
+

CCTV Camera Database

+
+

Open dataset of IP camera specifications. Search, filter, and compare — no account needed.

+
+ +
+
+ + +
Loading…
+ + +
+
+ + + / +
+
+
+ + +
+ +
+ + +
+
+ + + any +
+ +
+ + +
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + + + + +
+
+ +
+ + +
+ / search · Esc clear · pages +
+ + + +
+ + +
+ + + +
+
+ +

Compare Cameras

+
+
+
+ + + + diff --git a/docs/index.html b/docs/index.html index 20e81c62..98f5a9d7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,1334 +1,31 @@ - + - CCTV Camera Database - + CCTV Camera Database — moved to cctv-database.com + + + + -
- - -
-
-
-
-
📷
-

CCTV Camera Database

-
-

Open dataset of IP camera specifications. Search, filter, and compare — no account needed.

-
- -
-
- - -
Loading…
- - -
-
- - - / -
-
-
- - -
- -
- - -
-
- - - any -
- -
- - -
- - -
-
- - -
-
- - - -
-
- - -
-
- - - - - -
-
- -
- - -
- / search · Esc clear · pages -
- - - -
- - -
- - - -
-
- -

Compare Cameras

-
-
-
- - +
+

CCTV Camera Database

+

This database now lives at cctv-database.com.

+

Redirecting… if nothing happens, click here.

+

+ Raw data is still available here as cameras.json · + source on GitHub +

+
diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..513108d2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,93 @@ +{ + "name": "cctv-camera-database", + "version": "1.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cctv-camera-database", + "version": "1.5.0", + "license": "CC0-1.0", + "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/package.json b/package.json index 15348212..4ee3d974 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cctv-camera-database", - "version": "1.5.0", + "version": "1.6.0", "description": "An open, structured database of CCTV / IP camera specifications.", "scripts": { "build": "node scripts/build.js && node scripts/gen-docs.js", @@ -8,7 +8,7 @@ "add-camera": "node scripts/add-camera.js" }, "license": "CC0-1.0", - "homepage": "https://github.com/ch-bas/cctv-camera-database#readme", + "homepage": "https://cctv-database.com", "bugs": { "url": "https://github.com/ch-bas/cctv-camera-database/issues" }, @@ -27,5 +27,8 @@ "dahua", "reolink" ], - "devDependencies": {} + "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" + } } diff --git a/schema/camera.schema.json b/schema/camera.schema.json index 97c4b7b8..11e44946 100644 --- a/schema/camera.schema.json +++ b/schema/camera.schema.json @@ -192,6 +192,11 @@ }, "voltage": { "type": "string" + }, + "poe_class": { + "type": "integer", + "enum": [0, 1, 2, 3, 4, 5, 6, 7, 8], + "description": "PoE class (e.g. 3 for PoE Class 3)." } } }, @@ -210,6 +215,10 @@ }, "cloud": { "type": "boolean" + }, + "notes": { + "type": "string", + "description": "Free-text storage notes, e.g. external hub requirement." } } }, @@ -222,7 +231,9 @@ "rtsp", "rtmp", "http", - "p2p" + "p2p", + "hdcvi", + "mxpeg" ] } }, @@ -264,9 +275,74 @@ "release_year": { "type": "integer" }, + "end_of_sale_year": { + "type": "integer" + }, + "end_of_support_year": { + "type": "integer" + }, + "environment": { + "type": "array", + "items": { + "type": "string", + "enum": ["indoor", "outdoor"] + }, + "description": "Indoor and/or outdoor suitability." + }, + "soc": { + "type": "string", + "description": "System on Chip (SoC), e.g. Ingenic T31 (for Thingino/OpenIPC compatibility)." + }, "msrp_usd": { "type": "number" }, + "msrp_eur": { + "type": "number", + "description": "MSRP in EUR." + }, + "msrp_gbp": { + "type": "number", + "description": "MSRP in GBP." + }, + "msrp_inr": { + "type": "number", + "description": "MSRP in INR." + }, + "msrp_aed": { + "type": "number", + "description": "MSRP in AED." + }, + "msrp_aud": { + "type": "number", + "description": "MSRP in AUD." + }, + "msrp_cad": { + "type": "number", + "description": "MSRP in CAD." + }, + "msrp_vnd": { + "type": "number", + "description": "MSRP in VND." + }, + "msrp_chf": { + "type": "number", + "description": "MSRP in CHF." + }, + "markets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Regions/countries where the camera is sold, e.g. ['EU', 'DE', 'MENA', 'global']." + }, + "generation": { + "type": "string", + "description": "Product generation/line, e.g. 'Wisenet 9'." + }, + "release_notes": { + "type": "string", + "description": "Free-text notes about the release (launch events, pending specs, etc.)." + }, "sources": { "type": "array", "items": { @@ -309,6 +385,23 @@ }, "notes": { "type": "string" + }, + "verified": { + "type": "boolean", + "description": "Whether this configuration has been verified working with Frigate." + }, + "tested_by": { + "type": "string", + "description": "The community member or source that verified the config." + }, + "tested_version": { + "type": "string", + "description": "Frigate version tested (e.g. '0.14')." + }, + "recommended_stream_type": { + "type": "string", + "enum": ["go2rtc", "ffmpeg", "rtsp"], + "description": "Recommended stream input type (e.g. go2rtc for WebRTC/audio, ffmpeg for scaling, native rtsp)." } } }, @@ -322,6 +415,23 @@ }, "notes": { "type": "string" + }, + "connection_type": { + "type": "string", + "enum": ["local", "local_push", "local_poll", "cloud", "cloud_push", "cloud_poll"], + "description": "Integration connectivity type." + }, + "doorbell_button": { + "type": "boolean", + "description": "Whether the doorbell button press event is exposed." + }, + "two_way_audio": { + "type": "boolean", + "description": "Whether two-way audio is supported in Home Assistant." + }, + "onvif_events": { + "type": "boolean", + "description": "Whether ONVIF motion or other events are supported." } } }, diff --git a/scripts/build.js b/scripts/build.js index f7e603cc..a403beb7 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -9,10 +9,32 @@ */ const fs = require("fs"); const path = require("path"); +const Ajv = require("ajv"); +const addFormats = require("ajv-formats"); const ROOT = path.resolve(__dirname, ".."); const CAMERAS_DIR = path.join(ROOT, "cameras"); const DATA_DIR = path.join(ROOT, "data"); +const SCHEMA_PATH = path.join(ROOT, "schema", "camera.schema.json"); + +// Load a local, gitignored .env (if present) so optional settings like +// DATA_MIRROR_DIR can be configured without touching tracked files. No-op for +// CI and contributors who have no .env. Avoids depending on Node's --env-file +// flag, whose availability varies by version. +(function loadDotenv() { + const envPath = path.join(ROOT, ".env"); + if (!fs.existsSync(envPath)) return; + for (const raw of fs.readFileSync(envPath, "utf8").split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let val = line.slice(eq + 1).trim(); + if (/^(".*"|'.*')$/.test(val)) val = val.slice(1, -1); + if (!(key in process.env)) process.env[key] = val; + } +})(); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { @@ -36,27 +58,37 @@ function loadCameras() { } function validate(cameras) { + // Schema is the source of truth: required fields, id slug pattern, enums, + // types, and additionalProperties: false are all enforced by Ajv below. + const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, "utf8")); + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + const validateSchema = ajv.compile(schema); + const seen = new Set(); - const required = ["id", "brand", "model", "type", "resolution"]; let ok = true; for (const cam of cameras) { - for (const key of required) { - if (!(key in cam)) { - console.error(`✗ ${cam.id || "?"}: missing required field "${key}"`); - ok = false; + if (!validateSchema(cam)) { + ok = false; + for (const err of validateSchema.errors) { + const where = err.instancePath || "(root)"; + const extra = err.params && err.params.additionalProperty + ? ` ("${err.params.additionalProperty}")` + : ""; + console.error(`✗ ${cam.id || "?"}: ${where} ${err.message}${extra}`); } } + // Uniqueness can't be expressed in the schema, so check it here. if (cam.id && seen.has(cam.id)) { console.error(`✗ duplicate id "${cam.id}"`); ok = false; } seen.add(cam.id); - if (cam.id && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(cam.id)) { - console.error(`✗ ${cam.id}: id must be a lowercase slug`); - ok = false; - } } - if (!ok) process.exit(1); + if (!ok) { + console.error("\nValidation failed. See errors above."); + process.exit(1); + } } function toCsv(cameras) { @@ -88,14 +120,25 @@ function main() { const jsonData = JSON.stringify(cameras, null, 2) + "\n"; fs.writeFileSync(path.join(DATA_DIR, "cameras.json"), jsonData); fs.writeFileSync(path.join(DATA_DIR, "cameras.csv"), toCsv(cameras)); + const outputs = ["data/cameras.json", "data/cameras.csv"]; // Also copy into docs/ so GitHub Pages can serve it const DOCS_DIR = path.join(ROOT, "docs"); if (fs.existsSync(DOCS_DIR)) { fs.writeFileSync(path.join(DOCS_DIR, "cameras.json"), jsonData); + outputs.push("docs/cameras.json"); + } + + // Optional: mirror cameras.json into a downstream consumer's directory. + // Enable by setting DATA_MIRROR_DIR=/path/to/dir — a no-op when unset, so it + // needs no knowledge of any particular downstream app (CI/contributors skip it). + const mirrorDir = process.env.DATA_MIRROR_DIR; + if (mirrorDir && fs.existsSync(mirrorDir)) { + fs.writeFileSync(path.join(mirrorDir, "cameras.json"), jsonData); + outputs.push(path.join(mirrorDir, "cameras.json")); } - console.log(`✓ Built ${cameras.length} camera(s) → data/cameras.json + data/cameras.csv + docs/cameras.json`); + console.log(`✓ Built ${cameras.length} camera(s) → ${outputs.join(" + ")}`); } main();