├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .npmignore ├── tsconfig.json ├── package.json ├── .gitignore ├── README.md └── src └── main.ts /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "scrypted.debugHost": "127.0.0.1", 4 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | out/ 3 | node_modules/ 4 | *.map 5 | fs 6 | src 7 | .vscode 8 | dist/*.js 9 | dist/*.txt 10 | HAP-NodeJS 11 | .gitmodules 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2021", 5 | "resolveJsonModule": true, 6 | "moduleResolution": "Node16", 7 | "esModuleInterop": true, 8 | "sourceMap": true 9 | }, 10 | "include": [ 11 | "src/**/*" 12 | ] 13 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "scrypted: deploy+debug", 8 | "type": "shell", 9 | "presentation": { 10 | "echo": true, 11 | "reveal": "silent", 12 | "focus": false, 13 | "panel": "shared", 14 | "showReuseMessage": true, 15 | "clear": false 16 | }, 17 | "command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}", 18 | }, 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "address": "TCP/IP address of process to be debugged", 9 | "localRoot": "${workspaceFolder}", 10 | "name": "Attach to Remote", 11 | "port": 10443, 12 | "remoteRoot": "Absolute path to the remote directory containing the program", 13 | "request": "attach", 14 | "skipFiles": [ 15 | "/**" 16 | ], 17 | "type": "node" 18 | }, 19 | { 20 | "name": "Scrypted Debugger", 21 | "address": "${config:scrypted.debugHost}", 22 | "port": 10081, 23 | "request": "attach", 24 | "skipFiles": [ 25 | "**/plugin-console.*", 26 | "/**" 27 | ], 28 | "preLaunchTask": "scrypted: deploy+debug", 29 | "sourceMaps": true, 30 | "localRoot": "${workspaceFolder}/out", 31 | "remoteRoot": "/plugin/", 32 | "type": "node" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scrypted/sample-cameraprovider", 3 | "scripts": { 4 | "scrypted-setup-project": "scrypted-setup-project", 5 | "prescrypted-setup-project": "scrypted-package-json", 6 | "build": "scrypted-webpack", 7 | "preprepublishOnly": "scrypted-changelog", 8 | "prepublishOnly": "scrypted-changelog && WEBPACK_DEVTOOL=nosources-source-map NODE_ENV=production scrypted-webpack", 9 | "prescrypted-vscode-launch": "scrypted-webpack", 10 | "scrypted-vscode-launch": "scrypted-deploy-debug", 11 | "scrypted-deploy-debug": "scrypted-deploy-debug", 12 | "scrypted-debug": "scrypted-debug", 13 | "scrypted-deploy": "scrypted-deploy", 14 | "scrypted-changelog": "scrypted-changelog", 15 | "scrypted-package-json": "scrypted-package-json", 16 | "scrypted-readme": "scrypted-readme" 17 | }, 18 | "keywords": [ 19 | "scrypted", 20 | "plugin", 21 | "Nanit" 22 | ], 23 | "scrypted": { 24 | "name": "Nanit Camera Plugin", 25 | "type": "DeviceProvider", 26 | "interfaces": [ 27 | "DeviceCreator", 28 | "DeviceProvider", 29 | "Settings" 30 | ] 31 | }, 32 | "devDependencies": { 33 | "@scrypted/sdk": "^0.2.87", 34 | "@types/node": "^18.15.11" 35 | }, 36 | "version": "0.0.10", 37 | "dependencies": { 38 | "axios": "^1.4.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | <<<<<<< HEAD 2 | .DS_Store 3 | out/ 4 | node_modules/ 5 | dist/ 6 | ======= 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Snowpack dependency directory (https://snowpack.dev/) 52 | web_modules/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional stylelint cache 64 | .stylelintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variable files 82 | .env 83 | .env.development.local 84 | .env.test.local 85 | .env.production.local 86 | .env.local 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and not Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # vuepress v2.x temp and cache directory 110 | .temp 111 | .cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | >>>>>>> 1927793a515b7cca891b53be8f86e518d706e3d7 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Nanit Camera 3 | ## How to install: 4 | - Install Node and npm -> https://docs.npmjs.com/downloading-and-installing-node-js-and-npm 5 | - Install https://www.scrypted.app/ and follow instructions on the website. 6 | - Once you have Scrypted running and can access it...continue 7 | 8 | - Open this plugin directory in VS Code 9 | - In a terminal cd into this project directory 10 | - run `npm install` 11 | - run `npm run build` 12 | - run `npm run scrypted-deploy 127.0.0.1` NOTE: you can replace `127.0.0.1` with the ip address of the server you installed scrypted on 13 | 14 | The `Terminal` area may show an authentication failure and prompt you to log in to the Scrypted Management Console with `npx scrypted login`. You will only need to do this once. You can then relaunch afterwards. The command if your scrypted instance is remote is `npx scrypted login ip:port` 15 | 16 | - Launch Scrypted, go to "Devices" 17 | - You should see a device named `Nanit Camera Plugin`, click it 18 | - Enter your email and password on the right, then click save. 19 | - You'll receive the mfa token enter that in the "Two Factor Code" and click save again 20 | - Wait a few seconds then reload the page: Refresh Token, Access Token and Expiration should all have values 21 | - Now go back to devices and you should see a new device that is named the same as your Nanit Device. Click it and then click the video and it should be streaming! 22 | 23 | 24 | ## Troubleshooting 25 | 26 | If you aren't seeing the video load, first try clearing the Expiration value in the `Nanit Camera Plugin` and click save. This will force the plugin to get a new token. 27 | 28 | If you are still having issues then clear the `access_token` and `refresh_token` values and click save. 29 | 30 | Finally, Login again with your username and password + two factor auth by following instructions in above section 31 | 32 | ## Other Notes 33 | It is currently setup as a Battery camera in Scrypted. The only reason this is done is so that Scrypted doesn't pre-buffer. When the camera is not battery Scrypted will stay connected to the stream 24/7, instead of on demand when the rtsp/homekit stream is requested. I suspect if we stay connected to the Nanit stream 24/7 they would take notice eventually. 34 | 35 | If you want to disable this. Remove the ScryptedInterface.Battery from line main.ts. 36 | ``` 37 | const interfaces = [ 38 | ScryptedInterface.Camera, 39 | ScryptedInterface.VideoCamera, 40 | ScryptedInterface.MotionSensor, 41 | ScryptedInterface.Battery //REMOVE THIS 42 | ]; 43 | ``` 44 | 45 | The Snapshot Photos are not working right now. You may see a "Failed Snapshot" screen until I can get that working. 46 | 47 | 48 | ## Importing into Home Assistant 49 | ### Method 1 50 | - Under the camera, make sure the rebroadcast plugin is enabled. 51 | - In the Camera settings go to the Stream and there should be a "RTSP Rebroadcast URL" box. Copy that value 52 | - In HomeAssistant add a camera entity -> https://www.home-assistant.io/integrations/generic/ 53 | - The copied value is your "stream source" 54 | 55 | ### Method 2 56 | - https://github.com/koush/scrypted/wiki/Installation:-Home-Assistant-OS 57 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Battery, BinarySensor, Camera, Device, DeviceCreator, DeviceCreatorSettings, DeviceDiscovery, DeviceProvider, FFmpegInput, Intercom, MediaObject, MediaStreamOptions, MotionSensor, PictureOptions, ResponseMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedInterfaceProperty, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from '@scrypted/sdk'; 2 | import sdk from '@scrypted/sdk'; 3 | import { StorageSettings } from "@scrypted/sdk/storage-settings" 4 | import path from 'path'; 5 | import axios, { AxiosRequestConfig } from 'axios' 6 | import { fail } from 'assert'; 7 | 8 | const { log, deviceManager, mediaManager } = sdk; 9 | 10 | 11 | class NanitCameraDevice extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, MotionSensor, BinarySensor, Battery { 12 | constructor(public plugin: NanitCameraPlugin, nativeId: string) { 13 | super(nativeId); 14 | } 15 | 16 | async takePicture(options?: PictureOptions): Promise { 17 | this.console.log("trying to take a photo") 18 | let ffmpegInputVal: FFmpegInput; 19 | ffmpegInputVal = this.ffmpegInput(options); 20 | ffmpegInputVal.videoDecoderArguments = ['-vframes', '1', '-q:v', '2'] 21 | return mediaManager.createMediaObject(Buffer.from(JSON.stringify(ffmpegInputVal)), ScryptedMimeTypes.FFmpegInput); 22 | } 23 | 24 | async getPictureOptions(): Promise { 25 | // can optionally provide the different resolutions of images that are available. 26 | // used by homekit, if available. 27 | return; 28 | } 29 | 30 | async getVideoStream(options?: MediaStreamOptions): Promise { 31 | this.console.log("Attempting to confirm access token to retrieve video stream") 32 | await this.plugin.tryLogin(); 33 | this.console.log("Login Succeeded. Returning video stream") 34 | let ffmpegInputVal: FFmpegInput; 35 | 36 | if (!this.nativeId) { 37 | throw new Error("missing nativeId"); 38 | } 39 | if (!this.plugin.access_token) { 40 | throw new Error("missing access token"); 41 | } 42 | this.batteryLevel = 100; 43 | ffmpegInputVal = this.ffmpegInput(options); 44 | 45 | 46 | return mediaManager.createMediaObject(Buffer.from(JSON.stringify(ffmpegInputVal)), ScryptedMimeTypes.FFmpegInput); 47 | } 48 | 49 | ffmpegInput(options?: MediaStreamOptions): FFmpegInput { 50 | this.console.log("Creating stream with camera:" + this.nativeId) 51 | const file = "rtmps://media-secured.nanit.com/nanit/"+ this.nativeId! +"."+this.plugin.access_token; 52 | 53 | return { 54 | url: undefined, 55 | inputArguments: [ 56 | '-i', file, 57 | ] 58 | }; 59 | } 60 | 61 | async getVideoStreamOptions(): Promise { 62 | return [{ 63 | id: this.nativeId + "-stream", 64 | allowBatteryPrebuffer: false, 65 | video: { 66 | codec: 'h264', 67 | } 68 | }]; 69 | } 70 | 71 | 72 | async startIntercom(media: MediaObject): Promise { 73 | const ffmpegInput: FFmpegInput = JSON.parse((await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput)).toString()); 74 | // something wants to start playback on the camera speaker. 75 | // use their ffmpeg input arguments to spawn ffmpeg to do playback. 76 | // some implementations read the data from an ffmpeg pipe output and POST to a url (like unifi/amcrest). 77 | throw new Error('not implemented'); 78 | } 79 | 80 | async stopIntercom(): Promise { 81 | } 82 | 83 | // most cameras have have motion and doorbell press events, but dont notify when the event ends. 84 | // so set a timeout ourselves to reset the state. 85 | triggerBinaryState() { 86 | this.binaryState = true; 87 | setTimeout(() => this.binaryState = false, 10000); 88 | } 89 | 90 | // most cameras have have motion and doorbell press events, but dont notify when the event ends. 91 | // so set a timeout ourselves to reset the state. 92 | triggerMotion() { 93 | this.motionDetected = true; 94 | setTimeout(() => this.motionDetected = false, 10000); 95 | } 96 | } 97 | 98 | class NanitCameraPlugin extends ScryptedDeviceBase implements DeviceProvider, Settings, DeviceCreator { 99 | devices = new Map(); 100 | access_token = ''; 101 | mfa_token = ''; 102 | failedCount = 0; 103 | 104 | 105 | settingsStorage = new StorageSettings(this, { 106 | email: { 107 | title: 'Email', 108 | onPut: async () => this.clearAndTrySyncDevices(), 109 | }, 110 | password: { 111 | title: 'Password', 112 | type: 'password', 113 | onPut: async () => this.clearAndTrySyncDevices(), 114 | }, 115 | twoFactorCode: { 116 | title: 'Two Factor Code', 117 | description: 'Optional: If 2 factor is enabled on your account, enter the code sent to your email or phone number.', 118 | type: "string", 119 | onPut: async (oldValue, newValue) => { 120 | await this.tryLogin(newValue); 121 | await this.syncDevices(0); 122 | }, 123 | noStore: true, 124 | }, 125 | refresh_token: { 126 | title: 'refresh_token' 127 | }, 128 | access_token: { 129 | title: 'access_token' 130 | }, 131 | expiration: { 132 | title: 'expiration', 133 | onPut: async () => this.syncDevices(0), 134 | }, 135 | }); 136 | 137 | constructor() { 138 | super(); 139 | this.console.log("calling syncDevices from constructor") 140 | this.syncDevices(0); 141 | } 142 | 143 | async getCreateDeviceSettings(): Promise { 144 | return [ 145 | { 146 | key: 'name', 147 | title: 'Name', 148 | }, 149 | { 150 | key: 'baby_uid', 151 | title: 'baby_uid', 152 | } 153 | ]; 154 | } 155 | 156 | async createDevice(settings: DeviceCreatorSettings): Promise { 157 | const nativeId = settings.baby_uid?.toString(); 158 | 159 | await deviceManager.onDeviceDiscovered({ 160 | nativeId, 161 | type: ScryptedDeviceType.Camera, 162 | interfaces: [ 163 | ScryptedInterface.VideoCamera, 164 | ScryptedInterface.Camera, 165 | ], 166 | name: settings.name?.toString(), 167 | }); 168 | return nativeId; 169 | } 170 | 171 | onDeviceEvent(eventInterface: string, eventData: any): Promise { 172 | this.console.log("Device Event occured " + eventInterface) 173 | return Promise.resolve(); 174 | } 175 | 176 | clearAndTrySyncDevices() { 177 | // add code to clear any r 178 | this.console.log("clearAndTrySyncDevices called"); 179 | this.access_token = ''; 180 | this.settingsStorage.putSetting("access_token", ''); 181 | this.syncDevices(0); 182 | } 183 | 184 | async clearAndLogin() { 185 | this.console.log("clearAndLogin called called"); 186 | this.access_token = ''; 187 | this.settingsStorage.putSetting("access_token", ''); 188 | return this.tryLogin(''); 189 | } 190 | 191 | async tryLogin(twoFactorCode?: string) { 192 | this.console.log("trying login..."); 193 | 194 | const settings: Setting[] = await this.getSettings(); 195 | const email: String = this.settingsStorage.getItem("email"); 196 | const password: String = this.settingsStorage.getItem("password"); 197 | let saved_access_token = this.settingsStorage.getItem("access_token") 198 | const expiration = this.settingsStorage.getItem("expiration") 199 | const refresh_token = this.settingsStorage.getItem("refresh_token") 200 | 201 | if (saved_access_token) { 202 | this.access_token = saved_access_token; 203 | } 204 | 205 | if (!email || !password) { 206 | this.console.log("Email and password required"); 207 | throw new Error("Email and password required"); 208 | return; 209 | } 210 | if ( this.access_token && expiration > Date.now()) { 211 | //we already have a good access token that isn't expired 212 | this.console.log("Access Token Already Exists and is not expired. Going to call babies api to ensure we are logged in") 213 | //verify we are actually logged in 214 | const authenticatedConfig:AxiosRequestConfig = { 215 | headers:{ 216 | "nanit-api-version": 1, 217 | "Authorization": "Bearer " + this.access_token 218 | }, 219 | validateStatus: function (status) { 220 | return (status >= 200 && status < 300) || status == 401; // default 221 | } 222 | }; 223 | 224 | 225 | 226 | return axios.get("https://api.nanit.com/babies", authenticatedConfig).then((response) => { 227 | //we are authenticated nothing to do 228 | 229 | if (response.status == 401 && this.failedCount < 2) { 230 | this.console.log('failed to auth but received 401 so will clear tokens and try again') 231 | this.failedCount++; 232 | return this.clearAndLogin() 233 | } else if (this.failedCount > 2){ 234 | return Promise.reject("Exceeded fail count"); 235 | } else { 236 | this.failedCount = 0; 237 | this.console.log("Confirmed we are authenticated. Stream should Work") 238 | } 239 | }).catch((error) => { 240 | if (error.response.status == 401 && this.failedCount < 2) { 241 | this.console.log('OLD| SHOULD NOT EXECUTE | failed to auth but received 401 so will clear tokens and try again') 242 | this.failedCount++; 243 | return this.clearAndLogin() 244 | } else { 245 | throw new Error("Failed to authenticate") 246 | } 247 | }) 248 | } 249 | 250 | const config = { 251 | headers:{ 252 | "nanit-api-version": 1 253 | } 254 | }; 255 | if (refresh_token) { 256 | this.console.log("we have a refresh token...calling the token refresh api"); 257 | return axios.post("https://api.nanit.com/tokens/refresh",{"refresh_token":refresh_token}, config).then((response) => { 258 | this.console.log("Received new access token"); 259 | this.failedCount = 0; 260 | this.access_token = response.data.access_token; 261 | this.settingsStorage.putSetting("access_token", response.data.access_token) 262 | this.settingsStorage.putSetting("refresh_token", response.data.refresh_token) 263 | this.settingsStorage.putSetting("expiration", Date.now() + (1000 * 60 * 60 * 4)) 264 | }).catch((error) => { 265 | this.console.log("Failed to talk to nanit"+ error); 266 | }); 267 | } 268 | 269 | if (!twoFactorCode || ! this.mfa_token) { 270 | this.console.log("calling the login api without mfa. will need to call again to get access/refresh token"); 271 | return axios.post("https://api.nanit.com/login",{"email":email,"password":password},config).then((response) => { 272 | 273 | this.console.log("Login successful. setting mfa token and will recall login") 274 | this.mfa_token = response.data.mfa_token; 275 | }).catch((error) => { 276 | this.mfa_token = error.response.data.mfa_token; 277 | if ( this.mfa_token) { 278 | this.console.log("response from email/pass login:" + error.response) 279 | } else { 280 | this.console.log("Failed to talk to nanit"+ error); 281 | } 282 | 283 | }); 284 | } 285 | 286 | this.console.log("calling the login api with mfa to get access and refresh token"); 287 | 288 | return axios.post("https://api.nanit.com/login",{"email":email,"password":password, "mfa_token": this.mfa_token, "mfa_code": twoFactorCode},config).then((response) => { 289 | this.failedCount = 0; 290 | this.console.log("response from email/pass/mfa login. Received new access token and refresh token") 291 | this.access_token = response.data.access_token; 292 | this.settingsStorage.putSetting("access_token", response.data.access_token) 293 | this.settingsStorage.putSetting("refresh_token", response.data.refresh_token) 294 | this.settingsStorage.putSetting("expiration", Date.now() + (1000 * 60 * 60 * 4)) 295 | }).catch((error) => { 296 | this.console.log("Failed to talk to nanit"+ error); 297 | throw new Error(error.message) 298 | }); 299 | 300 | } 301 | 302 | getSettings(): Promise { 303 | return this.settingsStorage.getSettings(); 304 | } 305 | 306 | putSetting(key: string, value: SettingValue): Promise { 307 | return this.settingsStorage.putSetting(key, value); 308 | } 309 | 310 | async syncDevices(duration: number) { 311 | this.console.log("Sync Devices") 312 | await this.tryLogin(); 313 | const config = { 314 | headers:{ 315 | "nanit-api-version": 1, 316 | "Authorization": "Bearer " + this.access_token 317 | } 318 | }; 319 | 320 | 321 | const babies: any[] = (await axios.get("https://api.nanit.com/babies", config)).data.babies; 322 | const devices: Device[] = []; 323 | for (const camera of babies) { 324 | const nativeId = camera.uid; 325 | const interfaces = [ 326 | ScryptedInterface.Camera, 327 | ScryptedInterface.VideoCamera, 328 | ScryptedInterface.MotionSensor, 329 | ScryptedInterface.Battery 330 | ]; 331 | 332 | const device: Device = { 333 | info: { 334 | model: 'Nanit Cam', 335 | manufacturer: 'Nanit', 336 | }, 337 | nativeId, 338 | name: camera.name, 339 | 340 | type: ScryptedDeviceType.Camera, 341 | interfaces, 342 | }; 343 | devices.push(device); 344 | } 345 | 346 | await deviceManager.onDevicesChanged({ 347 | devices, 348 | }); 349 | this.console.log('discovered devices'); 350 | } 351 | 352 | async getDevice(nativeId: string) { 353 | this.console.log("get device with id " + nativeId) 354 | if (!this.devices.has(nativeId)) { 355 | const camera = new NanitCameraDevice(this, nativeId); 356 | 357 | this.devices.set(nativeId, camera); 358 | } 359 | return this.devices.get(nativeId); 360 | } 361 | 362 | async releaseDevice(id: string, nativeId: string): Promise { 363 | 364 | } 365 | } 366 | 367 | export default NanitCameraPlugin; 368 | --------------------------------------------------------------------------------