@camera.ui/sdk 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/CHANGELOG.md +0 -8
- package/CONTRIBUTING.md +0 -1
- package/LICENSE.md +0 -22
- package/docs/.vitepress/config.ts +0 -77
- package/docs/.vitepress/theme/index.ts +0 -5
- package/docs/.vitepress/theme/style.css +0 -117
- package/docs/index.md +0 -16
- package/docs/logo.png +0 -0
- package/docs/public/apple-touch-icon.png +0 -0
- package/docs/public/favicon-16.ico +0 -0
- package/docs/public/favicon.ico +0 -0
- package/docs/public/logo.svg +0 -1
- package/examples/README.md +0 -7
- package/examples/getting-started.md +0 -535
- package/scripts/build-example-docs.mjs +0 -62
- package/tsconfig.node.json +0 -31
- package/typedoc.json +0 -42
package/package.json
CHANGED
package/CHANGELOG.md
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
All notable changes to this project will be documented in this file.
|
|
2
|
-
|
|
3
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
4
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
-
|
|
6
|
-
## [X.X.X] - ???
|
|
7
|
-
|
|
8
|
-
- Initial Release
|
package/CONTRIBUTING.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# Contributing
|
package/LICENSE.md
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2023-2026 seydx <hi@seydx.dev>
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
-
a copy of this software and associated documentation files (the
|
|
7
|
-
"Software"), to deal in the Software without restriction, including
|
|
8
|
-
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
-
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
-
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
-
the following conditions:
|
|
12
|
-
|
|
13
|
-
The above copyright notice and this permission notice shall be
|
|
14
|
-
included in all copies or substantial portions of the Software.
|
|
15
|
-
|
|
16
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
-
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
-
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
-
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
-
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
-
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
-
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { createRequire } from 'node:module';
|
|
2
|
-
import { defineConfig } from 'vitepress';
|
|
3
|
-
|
|
4
|
-
const require = createRequire(import.meta.url);
|
|
5
|
-
const typedocSidebar = require('../api/typedoc-sidebar.json');
|
|
6
|
-
|
|
7
|
-
// https://vitepress.dev/reference/site-config
|
|
8
|
-
export default defineConfig({
|
|
9
|
-
srcDir: '.',
|
|
10
|
-
base: '/sdk/node/',
|
|
11
|
-
title: 'camera.ui Node SDK',
|
|
12
|
-
description: 'TypeScript SDK for building camera.ui plugins',
|
|
13
|
-
lastUpdated: true,
|
|
14
|
-
head: [
|
|
15
|
-
['link', { rel: 'icon', type: 'image/x-icon', sizes: '32x32', href: '/sdk/node/favicon.ico' }],
|
|
16
|
-
['link', { rel: 'icon', type: 'image/x-icon', sizes: '16x16', href: '/sdk/node/favicon-16.ico' }],
|
|
17
|
-
['link', { rel: 'apple-touch-icon', sizes: '180x180', href: '/sdk/node/apple-touch-icon.png' }],
|
|
18
|
-
['meta', { name: 'theme-color', content: '#df2a4c' }],
|
|
19
|
-
],
|
|
20
|
-
themeConfig: {
|
|
21
|
-
logo: '/logo.svg',
|
|
22
|
-
|
|
23
|
-
// typedocSidebar entries are alphabetical:
|
|
24
|
-
// [0] camera, [1] manager, [2] observable, [3] plugin,
|
|
25
|
-
// [4] sensor, [5] storage, [6] types
|
|
26
|
-
sidebar: [
|
|
27
|
-
{
|
|
28
|
-
text: 'Plugin API',
|
|
29
|
-
items: typedocSidebar[3].items,
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
text: 'Camera',
|
|
33
|
-
items: typedocSidebar[0].items,
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
text: 'Sensors',
|
|
37
|
-
items: typedocSidebar[4].items,
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
text: 'Storage & Schema',
|
|
41
|
-
items: typedocSidebar[5].items,
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
text: 'Manager',
|
|
45
|
-
items: typedocSidebar[1].items,
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
text: 'Observable',
|
|
49
|
-
items: typedocSidebar[2].items,
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
text: 'Types',
|
|
53
|
-
items: typedocSidebar[6].items,
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
text: 'Plugin Guide',
|
|
57
|
-
link: '/examples/getting-started',
|
|
58
|
-
},
|
|
59
|
-
],
|
|
60
|
-
|
|
61
|
-
search: {
|
|
62
|
-
provider: 'local',
|
|
63
|
-
},
|
|
64
|
-
|
|
65
|
-
socialLinks: [
|
|
66
|
-
{ icon: 'github', link: 'https://github.com/seydx/camera.ui' },
|
|
67
|
-
{ icon: 'discord', link: 'https://discord.gg/bBGnGcbz8N' },
|
|
68
|
-
{
|
|
69
|
-
icon: {
|
|
70
|
-
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="currentColor" d="M20 10.04a2.18 2.18 0 0 0-3.7-1.55a10.7 10.7 0 0 0-5.79-1.83l.99-4.65l3.23.69a1.55 1.55 0 1 0 .15-.74l-3.59-.76a.37.37 0 0 0-.44.28l-1.1 5.18a10.74 10.74 0 0 0-5.87 1.83a2.19 2.19 0 1 0-2.41 3.59a4 4 0 0 0-.05.66c0 3.36 3.91 6.09 8.74 6.09s8.74-2.73 8.74-6.09a4 4 0 0 0-.05-.66A2.19 2.19 0 0 0 20 10.04m-15 1.56a1.56 1.56 0 1 1 1.56 1.56A1.56 1.56 0 0 1 5 11.6m8.71 4.13c-1.07.81-2.39 1.21-3.71 1.16a5.85 5.85 0 0 1-3.71-1.16a.4.4 0 1 1 .55-.59A4.94 4.94 0 0 0 10 16.1a4.94 4.94 0 0 0 3.16-.96a.41.41 0 0 1 .57.04a.41.41 0 0 1-.02.55m-.16-2.55a1.56 1.56 0 1 1 1.56-1.56a1.56 1.56 0 0 1-1.55 1.56z"/></svg>',
|
|
71
|
-
},
|
|
72
|
-
link: 'https://www.reddit.com/r/cameraui/',
|
|
73
|
-
ariaLabel: 'Reddit',
|
|
74
|
-
},
|
|
75
|
-
],
|
|
76
|
-
},
|
|
77
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
@import 'vitepress/dist/client/theme-default/styles/vars.css';
|
|
2
|
-
|
|
3
|
-
/* camera.ui brand colors — mirror ui/src/plugins/preset.ts */
|
|
4
|
-
:root {
|
|
5
|
-
--cui-primary-50: #fdf4f6;
|
|
6
|
-
--cui-primary-100: #f7ccd4;
|
|
7
|
-
--cui-primary-200: #f1a3b2;
|
|
8
|
-
--cui-primary-300: #eb7b90;
|
|
9
|
-
--cui-primary-400: #e5526e;
|
|
10
|
-
--cui-primary-500: #df2a4c;
|
|
11
|
-
--cui-primary-600: #be2441;
|
|
12
|
-
--cui-primary-700: #9c1d35;
|
|
13
|
-
--cui-primary-800: #7b172a;
|
|
14
|
-
--cui-primary-900: #59111e;
|
|
15
|
-
--cui-primary-950: #380b13;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
:root {
|
|
19
|
-
--vp-c-brand-1: var(--cui-primary-500);
|
|
20
|
-
--vp-c-brand-2: var(--cui-primary-600);
|
|
21
|
-
--vp-c-brand-3: var(--cui-primary-700);
|
|
22
|
-
--vp-c-brand-soft: rgba(223, 42, 76, 0.14);
|
|
23
|
-
|
|
24
|
-
--vp-c-default-1: var(--vp-c-gray-1);
|
|
25
|
-
--vp-c-default-2: var(--vp-c-gray-2);
|
|
26
|
-
--vp-c-default-3: var(--vp-c-gray-3);
|
|
27
|
-
--vp-c-default-soft: var(--vp-c-gray-soft);
|
|
28
|
-
|
|
29
|
-
--vp-button-brand-border: transparent;
|
|
30
|
-
--vp-button-brand-text: #ffffff;
|
|
31
|
-
--vp-button-brand-bg: var(--cui-primary-500);
|
|
32
|
-
--vp-button-brand-hover-border: transparent;
|
|
33
|
-
--vp-button-brand-hover-text: #ffffff;
|
|
34
|
-
--vp-button-brand-hover-bg: var(--cui-primary-600);
|
|
35
|
-
--vp-button-brand-active-border: transparent;
|
|
36
|
-
--vp-button-brand-active-text: #ffffff;
|
|
37
|
-
--vp-button-brand-active-bg: var(--cui-primary-700);
|
|
38
|
-
|
|
39
|
-
--vp-home-hero-name-color: transparent;
|
|
40
|
-
--vp-home-hero-name-background: linear-gradient(
|
|
41
|
-
135deg,
|
|
42
|
-
var(--cui-primary-500) 0%,
|
|
43
|
-
var(--cui-primary-700) 100%
|
|
44
|
-
);
|
|
45
|
-
--vp-home-hero-image-background-image: linear-gradient(
|
|
46
|
-
135deg,
|
|
47
|
-
rgba(223, 42, 76, 0.32) 0%,
|
|
48
|
-
rgba(123, 23, 42, 0.32) 100%
|
|
49
|
-
);
|
|
50
|
-
--vp-home-hero-image-filter: blur(56px);
|
|
51
|
-
|
|
52
|
-
--vp-custom-block-tip-border: transparent;
|
|
53
|
-
--vp-custom-block-tip-text: var(--cui-primary-700);
|
|
54
|
-
--vp-custom-block-tip-bg: var(--vp-c-brand-soft);
|
|
55
|
-
--vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/* dark surface palette mirrors ui preset.ts (neutral grey, not slate) */
|
|
59
|
-
.dark {
|
|
60
|
-
--cui-surface-0: #ffffff;
|
|
61
|
-
--cui-surface-50: #f5f5f5;
|
|
62
|
-
--cui-surface-100: #d1d1d1;
|
|
63
|
-
--cui-surface-200: #adadad;
|
|
64
|
-
--cui-surface-300: #898989;
|
|
65
|
-
--cui-surface-400: #646464;
|
|
66
|
-
--cui-surface-500: #404040;
|
|
67
|
-
--cui-surface-600: #363636;
|
|
68
|
-
--cui-surface-700: #2d2d2d;
|
|
69
|
-
--cui-surface-800: #232323;
|
|
70
|
-
--cui-surface-850: #1d1d1d;
|
|
71
|
-
--cui-surface-900: #1a1a1a;
|
|
72
|
-
--cui-surface-925: #161616;
|
|
73
|
-
--cui-surface-950: #101010;
|
|
74
|
-
--cui-surface-1000: #0d0d0d;
|
|
75
|
-
|
|
76
|
-
--vp-c-brand-1: var(--cui-primary-400);
|
|
77
|
-
--vp-c-brand-2: var(--cui-primary-300);
|
|
78
|
-
--vp-c-brand-3: var(--cui-primary-200);
|
|
79
|
-
--vp-c-brand-soft: rgba(229, 82, 110, 0.16);
|
|
80
|
-
|
|
81
|
-
--vp-c-bg: var(--cui-surface-900);
|
|
82
|
-
--vp-c-bg-alt: var(--cui-surface-950);
|
|
83
|
-
--vp-c-bg-elv: var(--cui-surface-800);
|
|
84
|
-
--vp-c-bg-soft: var(--cui-surface-850);
|
|
85
|
-
|
|
86
|
-
--vp-c-divider: var(--cui-surface-800);
|
|
87
|
-
--vp-c-gutter: var(--cui-surface-950);
|
|
88
|
-
--vp-c-border: var(--cui-surface-700);
|
|
89
|
-
|
|
90
|
-
--vp-c-default-1: var(--cui-surface-800);
|
|
91
|
-
--vp-c-default-2: var(--cui-surface-700);
|
|
92
|
-
--vp-c-default-3: var(--cui-surface-600);
|
|
93
|
-
--vp-c-default-soft: rgba(64, 64, 64, 0.4);
|
|
94
|
-
|
|
95
|
-
--vp-c-text-1: rgba(255, 255, 255, 0.92);
|
|
96
|
-
--vp-c-text-2: rgba(255, 255, 255, 0.66);
|
|
97
|
-
--vp-c-text-3: rgba(255, 255, 255, 0.42);
|
|
98
|
-
|
|
99
|
-
--vp-code-bg: var(--cui-surface-925);
|
|
100
|
-
--vp-code-block-bg: var(--cui-surface-925);
|
|
101
|
-
|
|
102
|
-
--vp-button-brand-text: #ffffff;
|
|
103
|
-
--vp-button-brand-bg: var(--cui-primary-500);
|
|
104
|
-
--vp-button-brand-hover-bg: var(--cui-primary-400);
|
|
105
|
-
--vp-button-brand-active-bg: var(--cui-primary-600);
|
|
106
|
-
|
|
107
|
-
--vp-custom-block-tip-text: var(--cui-primary-200);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/* Pull search/box accents in line with brand */
|
|
111
|
-
.dark .DocSearch {
|
|
112
|
-
--docsearch-primary-color: var(--cui-primary-400);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.DocSearch {
|
|
116
|
-
--docsearch-primary-color: var(--cui-primary-500);
|
|
117
|
-
}
|
package/docs/index.md
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# camera.ui Node SDK
|
|
2
|
-
|
|
3
|
-
TypeScript SDK for building plugins that extend [camera.ui](https://github.com/seydx/camera.ui) — add cameras, run motion / object / face / license-plate / audio detection, deliver notifications, and more.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install @camera.ui/sdk
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Next steps
|
|
12
|
-
|
|
13
|
-
- **[Plugin Guide](/examples/getting-started)** — everything you need to ship a Node plugin in one page: contract, lifecycle, sensors, storage, optional interfaces.
|
|
14
|
-
- **[API Reference](/api/)** — module-by-module reference auto-generated from JSDoc.
|
|
15
|
-
|
|
16
|
-
For complete production plugins to study, see [`plugins/`](https://github.com/seydx/camera.ui/tree/main/plugins) in the camera.ui repo.
|
package/docs/logo.png
DELETED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/docs/public/favicon.ico
DELETED
|
Binary file
|
package/docs/public/logo.svg
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 233.63 233.63"><defs><style>.cls-1{fill:url(#dark_wine);}.cls-2{fill:url(#light_wine);}.cls-3{fill:none;stroke:#000;stroke-miterlimit:10;opacity:0;}.cls-4{fill:#c5c6c8;}.cls-5{fill:#1a1e21;stroke:#3a3a3a;stroke-width:2px;}.cls-6{fill:#fff;}</style><linearGradient id="dark_wine" x1="87.36" y1="158.67" x2="87.36" y2="59.85" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#730000"/><stop offset="0.49" stop-color="#a51733"/></linearGradient><linearGradient id="light_wine" x1="113.06" y1="197.59" x2="113.06" y2="80.87" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f44a6b"/><stop offset="0.28" stop-color="#df294c"/></linearGradient></defs><g id="CameraUI_U"><path id="U_Left" class="cls-1" d="M47.05,119.47s7.58,16,14.12,23.42a45.28,45.28,0,0,0,16.37,11.86A49.46,49.46,0,0,0,99,158.62c9.43-.46,20.55-3.87,29.24-11.86-3.9,2.5-9.87,5.1-15.9,4.77a25.63,25.63,0,0,1-18.38-9.09c-9.39-10.31-7.62-28.35-7.75-32.68-.35-11.42.77-21.92-1.76-36.19-.46-2.64-1.79-5.19-4.55-8-1.83-1.86-8.4-5.75-13.42-5.74-7.31,0-12.95,3.71-15.38,7.84-2,3.41-3.06,4.22-4.23,25.08a217.23,217.23,0,0,0,.22,26.76Z" transform="translate(0.5 0.5)"/><path id="U_Right" class="cls-2" d="M144.12,89.6a16.24,16.24,0,0,1,4.06-4.9c4.16-3.4,9-3.67,10.92-3.82a19.7,19.7,0,0,1,12.53,4.58c1.19,1,6.11,5.58,7.13,17.29.73,8.51,3.31,65.93-37,87.14-4.22,2.22-18,9.25-35.66,7.38a62.43,62.43,0,0,1-20.8-6.11,65.8,65.8,0,0,1-18.89-14.23c-20.21-21.67-19.36-57.46-19.36-57.46s7.58,16,14.12,23.42a45.28,45.28,0,0,0,16.37,11.86A49.46,49.46,0,0,0,99,158.62c11.66-.57,25.93-5.64,35-18.32C143.52,126.8,138.46,100.51,144.12,89.6Z" transform="translate(0.5 0.5)"/></g><g id="CameraUI_Lens"><circle id="ellipse" class="cls-3" cx="116.81" cy="116.81" r="116.31"/><g id="lens"><circle id="lens_bg" class="cls-4" cx="159.13" cy="58.39" r="18.66"/><path id="lens_inner" class="cls-5" d="M169.64,58.31a11,11,0,1,1-22,0c0-6.08,4.93-11.86,11-11.86S169.64,52.23,169.64,58.31Z" transform="translate(0.5 0.5)"/><g id="reflections"><ellipse id="dot_1" class="cls-6" cx="174.36" cy="53.69" rx="1.84" ry="1.42"/><ellipse id="dot_2" class="cls-6" cx="174.36" cy="57.08" rx="1.84" ry="1.42"/><ellipse id="dot_3" class="cls-6" cx="174.36" cy="60.46" rx="1.84" ry="1.42"/><circle id="dot_4" class="cls-6" cx="164.09" cy="63.77" r="1.66"/><path id="wide_1" class="cls-6" d="M156.63,48.9c-1.07-.88-5.52,3.09-6.31,5.43a1.53,1.53,0,0,0,0,1.26c.43.69,1.73.61,2.19.54,2.68-.44,4.71-4.09,4.8-4.37A3.21,3.21,0,0,0,156.63,48.9Z" transform="translate(0.5 0.5)"/></g></g></g></svg>
|
package/examples/README.md
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
# Plugin Guide
|
|
2
|
-
|
|
3
|
-
Everything you need to ship a camera.ui plugin in one page: contract, lifecycle, sensors, storage, optional interfaces (discovery, notifier, detection), and inter-plugin communication.
|
|
4
|
-
|
|
5
|
-
[Read the guide →](./getting-started.md)
|
|
6
|
-
|
|
7
|
-
For complete production plugins to read alongside, see [`plugins/`](https://github.com/seydx/camera.ui/tree/main/plugins) in the camera.ui repo.
|
|
@@ -1,535 +0,0 @@
|
|
|
1
|
-
# Plugin Guide
|
|
2
|
-
|
|
3
|
-
A camera.ui plugin is a Node package the host loads at runtime to extend cameras with new capabilities — a detection model, a vendor camera integration, a notifier, a smart-home bridge. This guide is the single reference for shipping one. You should be comfortable with TypeScript and have the SDK installed (`npm install @camera.ui/sdk`).
|
|
4
|
-
|
|
5
|
-
## 1. Plugin anatomy
|
|
6
|
-
|
|
7
|
-
A plugin is a folder with two files the host expects:
|
|
8
|
-
|
|
9
|
-
- `contract.ts` — the manifest. A static object describing what the plugin is.
|
|
10
|
-
- `index.ts` — the runtime. A class extending `BasePlugin`, exported as `default`.
|
|
11
|
-
|
|
12
|
-
The minimal compilable plugin:
|
|
13
|
-
|
|
14
|
-
```ts
|
|
15
|
-
// contract.ts
|
|
16
|
-
import { PluginRole } from '@camera.ui/sdk';
|
|
17
|
-
import type { PluginContract } from '@camera.ui/sdk';
|
|
18
|
-
|
|
19
|
-
export const contract: PluginContract = {
|
|
20
|
-
name: 'My Plugin',
|
|
21
|
-
role: PluginRole.SensorProvider,
|
|
22
|
-
provides: [],
|
|
23
|
-
consumes: [],
|
|
24
|
-
interfaces: [],
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export default contract;
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
```ts
|
|
31
|
-
// index.ts
|
|
32
|
-
import { BasePlugin } from '@camera.ui/sdk';
|
|
33
|
-
import type { CameraDevice } from '@camera.ui/sdk';
|
|
34
|
-
|
|
35
|
-
export default class MyPlugin extends BasePlugin {
|
|
36
|
-
async configureCameras(_cameras: CameraDevice[]): Promise<void> {}
|
|
37
|
-
async onCameraAdded(_camera: CameraDevice): Promise<void> {}
|
|
38
|
-
async onCameraReleased(_cameraId: string): Promise<void> {}
|
|
39
|
-
}
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
The host instantiates the class with three arguments: a `LoggerService`, a `PluginAPI`, and a typed `DeviceStorage`. Everything else — sensors, discovery, schemas — is opt-in.
|
|
43
|
-
|
|
44
|
-
## 2. The contract
|
|
45
|
-
|
|
46
|
-
`PluginContract` is the static manifest the host reads before starting the plugin. The fields:
|
|
47
|
-
|
|
48
|
-
- **`name`** — stable identifier; doubles as log prefix and storage namespace.
|
|
49
|
-
- **`role`** — what the plugin does at the highest level (see table below).
|
|
50
|
-
- **`provides`** — sensor types the plugin attaches to cameras (e.g. `[SensorType.Motion]`).
|
|
51
|
-
- **`consumes`** — sensor types the plugin reads from other plugins.
|
|
52
|
-
- **`interfaces`** — capability flags (`DiscoveryProvider`, `Notifier`, detection types, …).
|
|
53
|
-
|
|
54
|
-
| Role | Use when |
|
|
55
|
-
| --- | --- |
|
|
56
|
-
| `SensorProvider` | You add detection or smart-home sensors to existing cameras (motion plugin, classifier, contact sensor). |
|
|
57
|
-
| `CameraController` | You bring your own cameras and own their streams (RTSP, ONVIF, vendor SDK), but produce no sensors. |
|
|
58
|
-
| `CameraAndSensorProvider` | You bring your own cameras AND want to attach sensors to them. Most vendor integrations land here. |
|
|
59
|
-
| `Hub` | Cloud-service integration that owns its cameras end-to-end via a vendor account, OR a bridge plugin that consumes other plugins' sensors and forwards them to an external system (HomeKit, MQTT, automations) or implements a `Notifier`. |
|
|
60
|
-
|
|
61
|
-
Examples:
|
|
62
|
-
|
|
63
|
-
```ts
|
|
64
|
-
// Detection plugin attaching motion sensors
|
|
65
|
-
{ name: 'My Motion', role: PluginRole.SensorProvider,
|
|
66
|
-
provides: [SensorType.Motion], consumes: [], interfaces: [PluginInterface.MotionDetection] }
|
|
67
|
-
|
|
68
|
-
// ONVIF-style integration with discovery
|
|
69
|
-
{ name: 'ACME Cameras', role: PluginRole.CameraAndSensorProvider,
|
|
70
|
-
provides: [SensorType.Motion], consumes: [], interfaces: [PluginInterface.DiscoveryProvider] }
|
|
71
|
-
|
|
72
|
-
// HomeKit bridge consuming sensor state from other plugins
|
|
73
|
-
{ name: 'HomeKit Bridge', role: PluginRole.Hub,
|
|
74
|
-
provides: [], consumes: [SensorType.Motion, SensorType.Doorbell], interfaces: [] }
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
## 3. The plugin class
|
|
78
|
-
|
|
79
|
-
`BasePlugin<TStorage>` is generic over your storage shape so `this.storage.values.X` is typed. Its constructor takes the three host-injected dependencies in order: `logger`, `api`, `storage`.
|
|
80
|
-
|
|
81
|
-
```ts
|
|
82
|
-
import { API_EVENT, BasePlugin } from '@camera.ui/sdk';
|
|
83
|
-
import type { CameraDevice, DeviceStorage, LoggerService, PluginAPI } from '@camera.ui/sdk';
|
|
84
|
-
|
|
85
|
-
interface MyStorage {
|
|
86
|
-
pollIntervalSec: number;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export default class MyPlugin extends BasePlugin<MyStorage> {
|
|
90
|
-
private state = new Map<string, MyCameraState>();
|
|
91
|
-
|
|
92
|
-
constructor(logger: LoggerService, api: PluginAPI, storage: DeviceStorage<MyStorage>) {
|
|
93
|
-
super(logger, api, storage);
|
|
94
|
-
this.api.on(API_EVENT.SHUTDOWN, () => this.shutdown());
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async configureCameras(cameras: CameraDevice[]): Promise<void> {
|
|
98
|
-
for (const camera of cameras) await this.attach(camera);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async onCameraAdded(camera: CameraDevice): Promise<void> {
|
|
102
|
-
await this.attach(camera);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async onCameraReleased(cameraId: string): Promise<void> {
|
|
106
|
-
this.state.get(cameraId)?.dispose();
|
|
107
|
-
this.state.delete(cameraId);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private async attach(_camera: CameraDevice): Promise<void> { /* ... */ }
|
|
111
|
-
private shutdown(): void { /* drop timers, close sockets */ }
|
|
112
|
-
}
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
Things to internalize:
|
|
116
|
-
|
|
117
|
-
- `configureCameras` runs once at startup with the cameras already assigned to the plugin. A rejection aborts plugin startup.
|
|
118
|
-
- `onCameraAdded` runs whenever the user assigns a new camera at runtime. Set up the same per-camera state as `configureCameras`.
|
|
119
|
-
- `onCameraReleased` runs when a camera is removed or reassigned. Drop timers, close vendor sessions.
|
|
120
|
-
- A `Map<cameraId, X>` is the conventional pattern for per-camera state — cheap to look up, trivial to clean up.
|
|
121
|
-
- `API_EVENT.SHUTDOWN` fires when the host tears the plugin down (reload, server stop). Listeners must release everything synchronously enough for the host to stop the process. There is also `API_EVENT.FINISH_LAUNCHING`, fired once after `configureCameras` returns — useful for kicking off background work that should wait until the camera set is stable.
|
|
122
|
-
|
|
123
|
-
## 4. Adding sensors to cameras
|
|
124
|
-
|
|
125
|
-
A sensor is the unit of state the host (and other plugins) sees on a camera. Detection sensors push results from analyzing video; control sensors expose user-toggleable hardware (lights, sirens, locks); event sensors fire one-shot triggers (doorbell).
|
|
126
|
-
|
|
127
|
-
For detection, subclass the matching `*DetectorSensor` and implement the detect method. The host pushes one frame at the configured rate:
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
import { MotionDetectorSensor } from '@camera.ui/sdk';
|
|
131
|
-
import type { MotionResult, VideoFrameData } from '@camera.ui/sdk';
|
|
132
|
-
|
|
133
|
-
class MyMotionSensor extends MotionDetectorSensor {
|
|
134
|
-
constructor() {
|
|
135
|
-
super('My Motion');
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async detectMotion(_frame: VideoFrameData): Promise<MotionResult> {
|
|
139
|
-
// analyze frame.data, return detections
|
|
140
|
-
return { detected: false, detections: [] };
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
Then attach it from the plugin's `attach()` helper:
|
|
146
|
-
|
|
147
|
-
```ts
|
|
148
|
-
const sensor = new MyMotionSensor();
|
|
149
|
-
await camera.addSensor(sensor);
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
Other detector base classes follow the same shape but expect a batch (`frames: VideoFrameData[]`) and an abstract `modelSpec` getter: `ObjectDetectorSensor` (`detectObjects(frame)` — singular here), `FaceDetectorSensor.detectFaces(frames)`, `LicensePlateDetectorSensor.detectLicensePlates(frames)`, `AudioDetectorSensor.detectAudio(audio)`, `ClassifierDetectorSensor.detectClassifications(frames)`, `ClipDetectorSensor.detectEmbeddings(frames)`. Smart-home sensors expose semantic methods instead — e.g. `LightControl` gives you `setOn()` / `setOff()` / `setBrightness(value)`, `ContactSensor` gives you `setDetected(value)`, `DoorbellTrigger` gives you `trigger()`. You construct them, call `camera.addSensor`, and then call those methods when your hardware reports a change.
|
|
153
|
-
|
|
154
|
-
The host removes sensors automatically when a camera is released. Your `onCameraReleased` hook just needs to drop your reference to it.
|
|
155
|
-
|
|
156
|
-
## 5. Storage and configuration schema
|
|
157
|
-
|
|
158
|
-
User-facing settings live in storage. Storage is split into two scopes — plugin-level and sensor-level — each with its own `storageSchema` getter that returns a JSON schema array. The host renders the schemas as form fields and persists the values; you read them via `storage.values.X`.
|
|
159
|
-
|
|
160
|
-
**Plugin-level** schemas appear on the plugin's settings page. Override the getter on your `BasePlugin` subclass.
|
|
161
|
-
|
|
162
|
-
```ts
|
|
163
|
-
override get storageSchema(): JsonSchema[] {
|
|
164
|
-
return [
|
|
165
|
-
{
|
|
166
|
-
type: 'number',
|
|
167
|
-
key: 'pollIntervalSec',
|
|
168
|
-
title: 'Poll interval (seconds)',
|
|
169
|
-
description: 'How often background work runs',
|
|
170
|
-
defaultValue: 30,
|
|
171
|
-
minimum: 5,
|
|
172
|
-
maximum: 300,
|
|
173
|
-
step: 5,
|
|
174
|
-
store: true,
|
|
175
|
-
required: true,
|
|
176
|
-
onSet: async (newValue, oldValue) => {
|
|
177
|
-
this.logger.log(`Poll interval ${oldValue} -> ${newValue}`);
|
|
178
|
-
this.reschedule();
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
];
|
|
182
|
-
}
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
**Sensor-level** schemas appear on the camera detail page next to that one sensor. Override the getter on your `Sensor` subclass.
|
|
186
|
-
|
|
187
|
-
```ts
|
|
188
|
-
class ConfigurableMotion extends MotionDetectorSensor<{ sensitivity: number; mode: 'fast' | 'accurate' }> {
|
|
189
|
-
override get storageSchema(): JsonSchema[] {
|
|
190
|
-
return [
|
|
191
|
-
{
|
|
192
|
-
type: 'number',
|
|
193
|
-
key: 'sensitivity',
|
|
194
|
-
title: 'Sensitivity',
|
|
195
|
-
description: 'Higher = trigger on smaller motion',
|
|
196
|
-
defaultValue: 50,
|
|
197
|
-
minimum: 0, maximum: 100, step: 1,
|
|
198
|
-
store: true,
|
|
199
|
-
onSet: async () => this.reconfigure(),
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
type: 'string',
|
|
203
|
-
key: 'mode',
|
|
204
|
-
title: 'Mode',
|
|
205
|
-
description: 'Trade-off between speed and accuracy',
|
|
206
|
-
defaultValue: 'fast',
|
|
207
|
-
enum: ['fast', 'accurate'],
|
|
208
|
-
store: true,
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
type: 'button',
|
|
212
|
-
key: 'reset',
|
|
213
|
-
title: 'Reset to defaults',
|
|
214
|
-
description: 'Restore sensitivity / mode',
|
|
215
|
-
color: 'danger',
|
|
216
|
-
onSet: async () => {
|
|
217
|
-
await this.storage.setValue('sensitivity', 50);
|
|
218
|
-
await this.storage.setValue('mode', 'fast');
|
|
219
|
-
},
|
|
220
|
-
},
|
|
221
|
-
];
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async detectMotion(_frame: VideoFrameData): Promise<MotionResult> {
|
|
225
|
-
const sensitivity = this.storage.values.sensitivity;
|
|
226
|
-
const _mode = this.storage.values.mode;
|
|
227
|
-
void sensitivity;
|
|
228
|
-
return { detected: false, detections: [] };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
private reconfigure(): void { /* re-warm caches */ }
|
|
232
|
-
}
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
Field types:
|
|
236
|
-
|
|
237
|
-
- `number` — slider/input with optional `minimum`, `maximum`, `step`.
|
|
238
|
-
- `string` — text input; add `enum: [...]` for a dropdown, `format: 'password'` to mask, `format: 'image'` / `'qrCode'` for media display.
|
|
239
|
-
- `boolean` — toggle.
|
|
240
|
-
- `button` — fires `onSet()` on click; stores no value. Useful for actions like "Test connection" or "Reset".
|
|
241
|
-
|
|
242
|
-
`onSet(newValue, oldValue)` runs after the host has persisted the new value. Use it to re-warm caches, restart sessions, or anything else that depends on the changed setting. It's async, but the host doesn't block UI on it — keep work scoped to the plugin.
|
|
243
|
-
|
|
244
|
-
For the full schema reference (conditional visibility, submit handlers with toast feedback, array fields), see `src/storage/index.ts` in the SDK.
|
|
245
|
-
|
|
246
|
-
## 6. Optional interfaces
|
|
247
|
-
|
|
248
|
-
`BasePlugin` covers the lifecycle. Specific capabilities are unlocked by implementing one of the optional interfaces and listing it in `contract.interfaces`. The rest of this section shows each one with a working snippet.
|
|
249
|
-
|
|
250
|
-
### 6.1 DiscoveryProvider
|
|
251
|
-
|
|
252
|
-
Let users scan and adopt cameras. Three methods: `onDiscoverCameras` returns adoption candidates, `onGetCameraSettings` returns the schema for the adoption form, `onAdoptCamera` resolves the form values into a `CameraConfig` for the host to persist. Available only for camera-controlling roles (`CameraController`, `CameraAndSensorProvider`).
|
|
253
|
-
|
|
254
|
-
```ts
|
|
255
|
-
import { API_EVENT, BasePlugin } from '@camera.ui/sdk';
|
|
256
|
-
import type {
|
|
257
|
-
CameraConfig, CameraDevice, DeviceStorage, DiscoveredCamera, DiscoveryProvider,
|
|
258
|
-
JsonSchemaWithoutCallbacks, LoggerService, PluginAPI,
|
|
259
|
-
} from '@camera.ui/sdk';
|
|
260
|
-
|
|
261
|
-
interface FakeDevice { id: string; name: string; manufacturer: string; model: string; host: string; }
|
|
262
|
-
const FAKE_DEVICES: FakeDevice[] = [
|
|
263
|
-
{ id: 'fake-001', name: 'Front Door', manufacturer: 'ACME', model: 'X1', host: '192.0.2.10' },
|
|
264
|
-
];
|
|
265
|
-
|
|
266
|
-
export default class CameraProvider extends BasePlugin implements DiscoveryProvider {
|
|
267
|
-
private cameras = new Map<string, CameraDevice>();
|
|
268
|
-
|
|
269
|
-
constructor(logger: LoggerService, api: PluginAPI, storage: DeviceStorage) {
|
|
270
|
-
super(logger, api, storage);
|
|
271
|
-
this.api.on(API_EVENT.SHUTDOWN, () => this.cameras.clear());
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
async configureCameras(cameras: CameraDevice[]): Promise<void> {
|
|
275
|
-
for (const c of cameras) this.cameras.set(c.id, c);
|
|
276
|
-
}
|
|
277
|
-
async onCameraAdded(camera: CameraDevice): Promise<void> { this.cameras.set(camera.id, camera); }
|
|
278
|
-
async onCameraReleased(cameraId: string): Promise<void> { this.cameras.delete(cameraId); }
|
|
279
|
-
|
|
280
|
-
async onDiscoverCameras(): Promise<DiscoveredCamera[]> {
|
|
281
|
-
const adopted = new Set(Array.from(this.cameras.values()).map((c) => c.nativeId));
|
|
282
|
-
return FAKE_DEVICES
|
|
283
|
-
.filter((d) => !adopted.has(d.id))
|
|
284
|
-
.map(({ id, name, manufacturer, model }) => ({ id, name, manufacturer, model }));
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async onGetCameraSettings(_camera: DiscoveredCamera): Promise<JsonSchemaWithoutCallbacks[]> {
|
|
288
|
-
return [
|
|
289
|
-
{ type: 'string', key: 'username', title: 'Username', description: '', required: true },
|
|
290
|
-
{ type: 'string', key: 'password', title: 'Password', description: '', format: 'password', required: true },
|
|
291
|
-
];
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async onAdoptCamera(camera: DiscoveredCamera, settings: Record<string, unknown>): Promise<CameraConfig> {
|
|
295
|
-
const device = FAKE_DEVICES.find((d) => d.id === camera.id);
|
|
296
|
-
if (!device) throw new Error(`Unknown device: ${camera.id}`);
|
|
297
|
-
const u = encodeURIComponent(String(settings.username ?? ''));
|
|
298
|
-
const p = encodeURIComponent(String(settings.password ?? ''));
|
|
299
|
-
return {
|
|
300
|
-
name: device.name,
|
|
301
|
-
nativeId: device.id,
|
|
302
|
-
info: { manufacturer: device.manufacturer, model: device.model },
|
|
303
|
-
sources: [{
|
|
304
|
-
name: 'main',
|
|
305
|
-
role: 'high-resolution',
|
|
306
|
-
urls: [`rtsp://${u}:${p}@${device.host}/stream0`],
|
|
307
|
-
useForSnapshot: true,
|
|
308
|
-
hotMode: true,
|
|
309
|
-
preload: true,
|
|
310
|
-
prebuffer: false,
|
|
311
|
-
}],
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
For asynchronous discovery (cloud OAuth callbacks, mDNS bursts), you can also push candidates directly into the UI without waiting for the next poll: `await api.deviceManager.pushDiscoveredCameras([...])`.
|
|
318
|
-
|
|
319
|
-
### 6.2 NotifierInterface
|
|
320
|
-
|
|
321
|
-
Register as a notification target so the host's `NotificationManager` can dispatch through you. The plugin owns its device list — the manager queries through these methods rather than maintaining a shared registry.
|
|
322
|
-
|
|
323
|
-
```ts
|
|
324
|
-
import { API_EVENT, BasePlugin } from '@camera.ui/sdk';
|
|
325
|
-
import type {
|
|
326
|
-
CameraDevice, DeviceStorage, LoggerService, Notification,
|
|
327
|
-
NotifierDevice, NotifierInterface, PluginAPI,
|
|
328
|
-
} from '@camera.ui/sdk';
|
|
329
|
-
|
|
330
|
-
export default class MyNotifier extends BasePlugin implements NotifierInterface {
|
|
331
|
-
private devices: NotifierDevice[] = [];
|
|
332
|
-
|
|
333
|
-
constructor(logger: LoggerService, api: PluginAPI, storage: DeviceStorage) {
|
|
334
|
-
super(logger, api, storage);
|
|
335
|
-
this.api.on(API_EVENT.SHUTDOWN, () => this.devices.splice(0));
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async configureCameras(_cameras: CameraDevice[]): Promise<void> {}
|
|
339
|
-
async onCameraAdded(_camera: CameraDevice): Promise<void> {}
|
|
340
|
-
async onCameraReleased(_cameraId: string): Promise<void> {}
|
|
341
|
-
|
|
342
|
-
async getDevices(ownerUserId: string): Promise<NotifierDevice[]> {
|
|
343
|
-
return this.devices.filter((d) => d.ownerUserId === ownerUserId);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
async getDevice(deviceId: string): Promise<NotifierDevice | null> {
|
|
347
|
-
return this.devices.find((d) => d.id === deviceId) ?? null;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
async sendNotification(deviceId: string, n: Notification): Promise<void> {
|
|
351
|
-
const device = this.devices.find((d) => d.id === deviceId);
|
|
352
|
-
if (!device?.active) return;
|
|
353
|
-
this.logger.log(`[${device.name}] ${n.title}: ${n.body ?? ''}`);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async registerDevice(ownerUserId: string, input: Record<string, unknown>): Promise<NotifierDevice> {
|
|
357
|
-
const device: NotifierDevice = {
|
|
358
|
-
id: crypto.randomUUID(),
|
|
359
|
-
ownerUserId,
|
|
360
|
-
type: String(input.type ?? 'mobile'),
|
|
361
|
-
name: String(input.name ?? 'Unnamed device'),
|
|
362
|
-
active: true,
|
|
363
|
-
};
|
|
364
|
-
this.devices.push(device);
|
|
365
|
-
return device;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
async revokeDevice(deviceId: string): Promise<void> {
|
|
369
|
-
const idx = this.devices.findIndex((d) => d.id === deviceId);
|
|
370
|
-
if (idx >= 0) this.devices.splice(idx, 1);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
async updateDevice(deviceId: string, patch: Record<string, unknown>): Promise<NotifierDevice | null> {
|
|
374
|
-
const device = this.devices.find((d) => d.id === deviceId);
|
|
375
|
-
if (!device) return null;
|
|
376
|
-
if (typeof patch.name === 'string') device.name = patch.name;
|
|
377
|
-
if (typeof patch.active === 'boolean') device.active = patch.active;
|
|
378
|
-
return device;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
`Notification.tag` is a collapse key for dedup at both the manager and notifier level — multiple events with the same tag inside the throttle window collapse into one notification. `Notification.severity` is `'info' | 'warn' | 'error' | 'critical'`; map `critical` to whatever DND-bypass mechanism your platform offers.
|
|
384
|
-
|
|
385
|
-
### 6.3 Detection interfaces
|
|
386
|
-
|
|
387
|
-
The seven detection capabilities follow a single pattern. Each interface has:
|
|
388
|
-
|
|
389
|
-
- A required `test*` method invoked by the UI when the user uploads a clip/image and clicks "Test" — it accepts raw media bytes plus metadata and returns the same result shape the per-frame detector would.
|
|
390
|
-
- An optional pre-processed `detect*` method invoked by automations and benchmarks — it accepts already-decoded frames.
|
|
391
|
-
- An optional `*Settings()` method that returns a schema for the detection configuration UI.
|
|
392
|
-
|
|
393
|
-
All seven (`MotionDetectionInterface`, `ObjectDetectionInterface`, `AudioDetectionInterface`, `FaceDetectionInterface`, `LicensePlateDetectionInterface`, `ClassifierDetectionInterface`, `ClipDetectionInterface`) share that shape. `ClipDetectionInterface` additionally requires `getTextEmbedding(text)` for semantic search.
|
|
394
|
-
|
|
395
|
-
Motion as a worked example:
|
|
396
|
-
|
|
397
|
-
```ts
|
|
398
|
-
import type {
|
|
399
|
-
MotionDetectionInterface, MotionDetectionPluginResponse,
|
|
400
|
-
VideoFrameData, JsonSchema,
|
|
401
|
-
} from '@camera.ui/sdk';
|
|
402
|
-
|
|
403
|
-
export default class MotionPlugin extends BasePlugin implements MotionDetectionInterface {
|
|
404
|
-
// ... lifecycle methods omitted ...
|
|
405
|
-
|
|
406
|
-
async testMotionDetection(
|
|
407
|
-
videoData: Buffer | Uint8Array,
|
|
408
|
-
_config: Record<string, unknown>,
|
|
409
|
-
): Promise<MotionDetectionPluginResponse | undefined> {
|
|
410
|
-
const detections = await this.runOnEncodedClip(videoData);
|
|
411
|
-
return { detected: detections.length > 0, detections };
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
async detectMotion(
|
|
415
|
-
frames: VideoFrameData[],
|
|
416
|
-
_config?: Record<string, unknown>,
|
|
417
|
-
): Promise<MotionDetectionPluginResponse | undefined> {
|
|
418
|
-
const detections = await this.runOnFrames(frames);
|
|
419
|
-
return { detected: detections.length > 0, detections };
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
async motionDetectionSettings(): Promise<JsonSchema[] | undefined> {
|
|
423
|
-
return [
|
|
424
|
-
{ type: 'number', key: 'minArea', title: 'Min area (%)', description: '',
|
|
425
|
-
defaultValue: 1, minimum: 0, maximum: 100, step: 1, store: true },
|
|
426
|
-
];
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
private async runOnEncodedClip(_v: Buffer | Uint8Array): Promise<[]> { return []; }
|
|
430
|
-
private async runOnFrames(_f: VideoFrameData[]): Promise<[]> { return []; }
|
|
431
|
-
}
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
The image-based detection interfaces (`ObjectDetectionInterface`, `FaceDetectionInterface`, `LicensePlateDetectionInterface`, `ClassifierDetectionInterface`, `ClipDetectionInterface`) take an extra `metadata: ImageMetadata` argument with `width` / `height`. The audio interface takes `metadata: AudioMetadata` with the `mimeType`. Otherwise the wiring is identical to the motion example above — add the matching `PluginInterface.X` flag to the contract and implement the `test*` / optional `detect*` / optional settings trio.
|
|
435
|
-
|
|
436
|
-
A detection plugin almost always implements both halves: the appropriate `*DetectorSensor` subclass (Section 4) for the live pipeline, AND the matching `*DetectionInterface` here for UI test dialogs and ad-hoc benchmarks.
|
|
437
|
-
|
|
438
|
-
## 7. Logging
|
|
439
|
-
|
|
440
|
-
`this.logger` is a `LoggerService`. The methods are `log`, `warn`, `error`, `success`, `debug`, `trace`, and `attention` — each accepts a list of arguments joined with spaces by the host. `debug` and `trace` are gated by host log level.
|
|
441
|
-
|
|
442
|
-
```ts
|
|
443
|
-
this.logger.log('Plugin started');
|
|
444
|
-
this.logger.success(`Connected to vendor cloud as ${user}`);
|
|
445
|
-
this.logger.warn('Falling back to substream');
|
|
446
|
-
this.logger.error('Adopt failed:', err);
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
Every `CameraDevice` exposes `camera.logger` — same interface, but the output is prefixed with the camera name. Prefer it over `this.logger` whenever the message is about a specific camera:
|
|
450
|
-
|
|
451
|
-
```ts
|
|
452
|
-
async onCameraAdded(camera: CameraDevice): Promise<void> {
|
|
453
|
-
camera.logger.log('attached');
|
|
454
|
-
}
|
|
455
|
-
```
|
|
456
|
-
|
|
457
|
-
## 8. Inter-plugin communication
|
|
458
|
-
|
|
459
|
-
The cleanest way for one plugin to react to another's sensors is `camera.onSensorProperty<T>(type, property, callback)`. It auto-subscribes when a sensor of the requested type appears (now or later), unsubscribes when it goes away, and tears down everything when you dispose the returned handle. The callback receives `(value, timestamp, sensor)`. This is the pattern Hub plugins (HomeKit, automations) use.
|
|
460
|
-
|
|
461
|
-
A complete Hub consumer that listens to motion AND doorbell on every assigned camera:
|
|
462
|
-
|
|
463
|
-
```ts
|
|
464
|
-
import { API_EVENT, BasePlugin, DoorbellProperty, MotionProperty, SensorType } from '@camera.ui/sdk';
|
|
465
|
-
import type {
|
|
466
|
-
CameraDevice, DeviceStorage, Disposable, LoggerService, PluginAPI,
|
|
467
|
-
} from '@camera.ui/sdk';
|
|
468
|
-
|
|
469
|
-
export default class HubConsumer extends BasePlugin {
|
|
470
|
-
private subs = new Map<string, Disposable[]>();
|
|
471
|
-
|
|
472
|
-
constructor(logger: LoggerService, api: PluginAPI, storage: DeviceStorage) {
|
|
473
|
-
super(logger, api, storage);
|
|
474
|
-
this.api.on(API_EVENT.SHUTDOWN, () => this.disposeAll());
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
async configureCameras(cameras: CameraDevice[]): Promise<void> {
|
|
478
|
-
for (const camera of cameras) this.bind(camera);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
async onCameraAdded(camera: CameraDevice): Promise<void> {
|
|
482
|
-
this.bind(camera);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
async onCameraReleased(cameraId: string): Promise<void> {
|
|
486
|
-
for (const sub of this.subs.get(cameraId) ?? []) sub.dispose();
|
|
487
|
-
this.subs.delete(cameraId);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
private bind(camera: CameraDevice): void {
|
|
491
|
-
const motion = camera.onSensorProperty<boolean>(
|
|
492
|
-
SensorType.Motion, MotionProperty.Detected,
|
|
493
|
-
(detected) => { if (detected) camera.logger.log('motion started'); },
|
|
494
|
-
);
|
|
495
|
-
|
|
496
|
-
const doorbell = camera.onSensorProperty<boolean>(
|
|
497
|
-
SensorType.Doorbell, DoorbellProperty.Ring,
|
|
498
|
-
(ring) => { if (ring) camera.logger.log('doorbell rang'); },
|
|
499
|
-
);
|
|
500
|
-
|
|
501
|
-
this.subs.set(camera.id, [motion, doorbell]);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
private disposeAll(): void {
|
|
505
|
-
for (const list of this.subs.values()) for (const sub of list) sub.dispose();
|
|
506
|
-
this.subs.clear();
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
```
|
|
510
|
-
|
|
511
|
-
Two things to notice:
|
|
512
|
-
|
|
513
|
-
- The bridge keeps one `Disposable[]` per camera and disposes it in `onCameraReleased`. This is critical — `onSensorProperty` keeps an internal subscription alive until you call `.dispose()`.
|
|
514
|
-
- The bind happens in both `configureCameras` AND `onCameraAdded` for cameras that show up after startup. Same shape as for sensor-providing plugins.
|
|
515
|
-
|
|
516
|
-
For direct plugin-to-plugin RPC (e.g. asking a face plugin to compute embeddings on demand), use `api.coreManager.connectToPlugin(name)`. It returns a typed proxy of the target plugin including any optional interfaces it implements:
|
|
517
|
-
|
|
518
|
-
```ts
|
|
519
|
-
const face = await this.api.coreManager.connectToPlugin('Face Plugin');
|
|
520
|
-
const result = await face?.testFaceDetection(jpegBytes, { width: 640, height: 480 }, {});
|
|
521
|
-
```
|
|
522
|
-
|
|
523
|
-
Use `api.coreManager.getPluginsByInterface(PluginInterface.FaceDetection)` to discover candidate plugins by capability rather than by name.
|
|
524
|
-
|
|
525
|
-
## 9. Common pitfalls
|
|
526
|
-
|
|
527
|
-
- **Always release per-camera state in `onCameraReleased`.** Timers, vendor sessions, RTP sockets, `Disposable`s from `onSensorProperty` — drop them all. Leaking them keeps the camera object alive forever and prevents reassignment from working.
|
|
528
|
-
- **Don't block in `configureCameras`.** It runs on the host's startup path; a slow vendor handshake delays every other plugin. Do the network work in `API_EVENT.FINISH_LAUNCHING` instead.
|
|
529
|
-
- **Don't import from `@camera.ui/sdk/internal`.** The `internal` subpath exposes types the host uses to talk to plugins via RPC. The shapes there are not part of the stable public surface and may change without notice.
|
|
530
|
-
- **Don't construct sensors in the constructor.** The host hasn't finished wiring up `api` / `storage` until `super()` returns and `configureCameras` is called. Construct sensors inside the lifecycle hooks.
|
|
531
|
-
- **Don't log frame data.** Detection paths run dozens of times per second per camera. Use `logger.debug` / `logger.trace` (host-gated) for anything per-frame, and prefer aggregated counters over per-event logs.
|
|
532
|
-
|
|
533
|
-
## 10. Next steps
|
|
534
|
-
|
|
535
|
-
For complete production plugins to read alongside this guide, see [`plugins/`](https://github.com/seydx/camera.ui/tree/main/plugins) in the camera.ui repo. They cover everything documented above — discovery, notifier, detection, hub bridges — wired into a real UI.
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @stylistic/max-len */
|
|
2
|
-
import { readdirSync, readFileSync, writeFileSync, mkdirSync, statSync, rmSync } from 'node:fs';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
|
|
6
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
const examplesDir = join(here, '..', 'examples');
|
|
8
|
-
const outDir = join(here, '..', 'docs', 'examples');
|
|
9
|
-
const apiDir = join(here, '..', 'docs', 'api');
|
|
10
|
-
|
|
11
|
-
const apiIndex = `# API Reference
|
|
12
|
-
|
|
13
|
-
Module-by-module reference, auto-generated from the JSDoc in \`@camera.ui/sdk\`.
|
|
14
|
-
|
|
15
|
-
| Module | What's in it |
|
|
16
|
-
| --- | --- |
|
|
17
|
-
| [Plugin API](./plugin/) | \`BasePlugin\`, the manifest contract, optional interfaces (discovery, notifier, detection). |
|
|
18
|
-
| [Camera](./camera/) | Camera config, frames, streaming sessions, detection events, runtime device API. |
|
|
19
|
-
| [Sensors](./sensor/) | Detection sensors (motion, object, face, license-plate, audio, classifier, clip) and smart-home sensors (contact, doorbell, lock, garage, light, switch, ptz, security system, environmental). |
|
|
20
|
-
| [Storage & Schema](./storage/) | Schema-driven per-device config rendered as UI forms by the host. |
|
|
21
|
-
| [Manager](./manager/) | \`CoreManager\` / \`DeviceManager\` / \`DownloadManager\` for system-level services. |
|
|
22
|
-
| [Observable](./observable/) | Reactive primitives — \`Observable\`, \`Subject\`, \`BehaviorSubject\`, \`ReplaySubject\` — and operators. |
|
|
23
|
-
| [Types](./types/) | Shared types (\`LoggerService\`, \`PluginAPI\`, …). |
|
|
24
|
-
|
|
25
|
-
If you're new to the SDK, start with the [Plugin Guide](/examples/getting-started) instead — it walks through these modules in the order you'll actually use them.
|
|
26
|
-
`;
|
|
27
|
-
|
|
28
|
-
writeFileSync(join(apiDir, 'index.md'), apiIndex);
|
|
29
|
-
|
|
30
|
-
rmSync(outDir, { recursive: true, force: true });
|
|
31
|
-
mkdirSync(outDir, { recursive: true });
|
|
32
|
-
|
|
33
|
-
const indexSrc = readFileSync(join(examplesDir, 'README.md'), 'utf8');
|
|
34
|
-
const indexOut = indexSrc.replace(/\[`([^`]+)\/`\]\(\.\/\1\/\)/g, '[`$1`](/examples/$1)').replace(/^# .+/m, '# Examples');
|
|
35
|
-
writeFileSync(join(outDir, 'index.md'), indexOut);
|
|
36
|
-
|
|
37
|
-
const entries = readdirSync(examplesDir).sort();
|
|
38
|
-
|
|
39
|
-
for (const name of entries) {
|
|
40
|
-
const full = join(examplesDir, name);
|
|
41
|
-
const stat = statSync(full);
|
|
42
|
-
|
|
43
|
-
if (stat.isFile() && name.endsWith('.md') && name !== 'README.md') {
|
|
44
|
-
const src = readFileSync(full, 'utf8');
|
|
45
|
-
writeFileSync(join(outDir, name), src);
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (!stat.isDirectory()) continue;
|
|
50
|
-
|
|
51
|
-
const readme = readFileSync(join(full, 'README.md'), 'utf8');
|
|
52
|
-
const contract = readFileSync(join(full, 'contract.ts'), 'utf8');
|
|
53
|
-
const index = readFileSync(join(full, 'index.ts'), 'utf8');
|
|
54
|
-
|
|
55
|
-
const page = [readme.trimEnd(), '', '## `contract.ts`', '', '```ts', contract.trimEnd(), '```', '', '## `index.ts`', '', '```ts', index.trimEnd(), '```', ''].join(
|
|
56
|
-
'\n',
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
writeFileSync(join(outDir, `${name}.md`), page);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
console.log(`Built example pages to ${outDir}`);
|
package/tsconfig.node.json
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"outDir": "./dist",
|
|
4
|
-
"target": "es2024",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"module": "NodeNext",
|
|
7
|
-
"lib": [
|
|
8
|
-
"ES2024",
|
|
9
|
-
],
|
|
10
|
-
"declaration": false,
|
|
11
|
-
"strict": true,
|
|
12
|
-
"esModuleInterop": true,
|
|
13
|
-
"preserveConstEnums": true,
|
|
14
|
-
"skipLibCheck": true,
|
|
15
|
-
"useUnknownInCatchVariables": false,
|
|
16
|
-
"allowSyntheticDefaultImports": true,
|
|
17
|
-
"resolveJsonModule": true,
|
|
18
|
-
"experimentalDecorators": true,
|
|
19
|
-
"emitDecoratorMetadata": true,
|
|
20
|
-
"verbatimModuleSyntax": true,
|
|
21
|
-
"noImplicitAny": true,
|
|
22
|
-
},
|
|
23
|
-
"include": [
|
|
24
|
-
"src",
|
|
25
|
-
],
|
|
26
|
-
"exclude": [
|
|
27
|
-
"node_modules",
|
|
28
|
-
"dist",
|
|
29
|
-
"src/**/*.test.ts",
|
|
30
|
-
],
|
|
31
|
-
}
|
package/typedoc.json
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"plugin": [
|
|
3
|
-
"typedoc-plugin-markdown",
|
|
4
|
-
"typedoc-vitepress-theme"
|
|
5
|
-
],
|
|
6
|
-
"entryPoints": [
|
|
7
|
-
"./src/camera/index.ts",
|
|
8
|
-
"./src/sensor/index.ts",
|
|
9
|
-
"./src/plugin/index.ts",
|
|
10
|
-
"./src/manager/index.ts",
|
|
11
|
-
"./src/storage/index.ts",
|
|
12
|
-
"./src/observable/index.ts",
|
|
13
|
-
"./src/types.ts"
|
|
14
|
-
],
|
|
15
|
-
"exclude": [
|
|
16
|
-
"**/*.test.ts",
|
|
17
|
-
"**/dist/**",
|
|
18
|
-
"**/node_modules/**"
|
|
19
|
-
],
|
|
20
|
-
"externalPattern": [
|
|
21
|
-
"**/*.test.ts",
|
|
22
|
-
"**/dist/**",
|
|
23
|
-
"**/node_modules/**"
|
|
24
|
-
],
|
|
25
|
-
"excludeExternals": true,
|
|
26
|
-
"excludeInternal": true,
|
|
27
|
-
"out": "./docs/api",
|
|
28
|
-
"docsRoot": "./docs",
|
|
29
|
-
"sidebar": {
|
|
30
|
-
"autoConfiguration": true,
|
|
31
|
-
"format": "vitepress",
|
|
32
|
-
"pretty": false,
|
|
33
|
-
"collapsed": true
|
|
34
|
-
},
|
|
35
|
-
"validation": {
|
|
36
|
-
"notExported": true,
|
|
37
|
-
"invalidLink": true,
|
|
38
|
-
"rewrittenLink": true,
|
|
39
|
-
"notDocumented": false,
|
|
40
|
-
"unusedMergeModuleWith": true
|
|
41
|
-
}
|
|
42
|
-
}
|