commit 2061dd1f4ba6f9d0946f016c487d0ad3b02ab877 Author: T3vl <80123588+tealtwo@users.noreply.github.com> Date: Sun Sep 15 15:26:50 2024 -0500 Main Commit Added All Base Files, Fork the Scrypted Repo And Paste! diff --git a/Notes.MD b/Notes.MD new file mode 100644 index 0000000..60c9a52 --- /dev/null +++ b/Notes.MD @@ -0,0 +1,10 @@ +# Notes on How Scrypted Handles WebRTC "Cloud" Cameras (Starling is local:) +When I orignially wrote this plugin I had planned to make a camera with a seperate WebRTC NPM package, but Scrypted Makes using WebRTC cameras A LOT easier because of the built-in WebRTC plugin. +How you add a WebRTC camera is all your plugin really needs to is the handshake between the "Camera" and the "WebRTC" plugin for it to stream and work with Scrypted. I will explain how I did it below: +WebRTC requires a "offerSdp", which you send to the camera, and then the camera returns an "AnswerSdp" and due to the way Google/Starling designed this call only one API call is actually needed for all of this, the same API call also provides the "StreamId" which is then used to actually pull the stream over the WebRTC protocol. + +How I did this with the Starling Home Hub, which is **very** similar to Google's (sh*t) official SDM API, is your browser, or in this case, Scrypted WebRTC plugin generates a offerSdp with the parameters of sending AND receiving Audio, and only receiving video, so "sendrecv" for audio, and "recvonly" for video (you can see it in the "startRTCSignalingSession" function). Once this offerSdp is generated we then encode it to Base64 as Nest Cams, and therefore Starling Require it to be in Base 64 (this is to make sending the Sdp easier), to send this Sdp we use the .../stream?key POST endpoint. + +Once we send that POST request the camera will (starling) will respond with a StreamId, and the answerSdp. The offerSdp is used to tell the camera what video/audio parameters the client supports/ is requesting, where the answerSdp is telling the client what the camera supports/ what "its getting" (what the camera is sending). Since the Nest Cam will send it in Base64 encoding and the WebRTC plugin needs it to be in plain text we decode the Base64 and then the WebRTC plugin uses the StreamId along with the AnswerSdp and plays the camera stream to Scrypted NVR, or a HomeKit client etc. + +Since these StreamId's expire we need to renew them using the .../streamId/extend?key= POST request, we do this every minute to keep the Stream stable and continuous, and once we are done streaming from the camera, we use the .../streamId/stop?key= to terminate the StreamId (we make a new one for every new stream). As an example if NVR is streaming (recording) with one StreamId, it will be renewed every minute, and if a HomeKit client starts streaming the camera too, the NVR StreamId will not be touched, it will continue recording like normal. A new StreamId will be generated for the HomeKit client and renewed until the client disconnects, where then it will terminate the StreamId. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4de0b03 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Starling Home Hub Google Nest Bridge +This plugin will use your Starling Home Hubs Developer API Key and use it to grab the devices from the hub and add them to scrypted. +It currently only supports Nest Cams with WebRTC support, for now the supportd devices are written below. Thermostat support is being worked on in the nightly build of the plugin. + +Disclaimer: +This plugin is in beta development and is NOT complete, the discover devices feature has NOT been implemented [#2](https://github.com/tealtwo/starling-nest-bridge-scrypted/issues/2). It will be implemented soon but for right now you have the follow the instructions to get your device ID. + +Requirements: +1. Working Scrypted Server (With Scrypted Cloud although not required) +2. Nest Cams (See Supported Models) +3. Starling Home Hub (with developer API enabled) + +Enable Starlings API: +1. Go to your Starling Home Hubs dashboard and wait for it to load, on the bottom you will see "Starling Developer Connect". +2. Go ahead and enable this, your hub will retstart. +3. Once enabled create an API key with all permissions, and you can use that API key for this plugin. +4. MAKE SURE TO USE HTTP NOT HTTPS! THIS IS LOCAL THERE IS NO SECURITY RISK WHEN USING HTTP! + +Installation Instruction: +1. Clone the project using Git or "Download ZIP" from the master branch. +2. Extract the project and open the folder in VSC (Visual Studio Code). +3. In .vscode open "settings.json" and change the IP address to YOUR local scrypted servers IP address. +4. On the Left Bar click on the Triangle with the bug next to it. +5. On the top you will see "RUN" and a green trianle next to "Scrypted Debugger", note how you got to that. +6. In "src" you will open "main.js" and then at the top you will see two variables, deviceId and apiKey. +7. In apiKey you will put your Starling Developer Connect API key (with full permissions) inside the quotes, without any spaces. +8. To get your deviceId you will copy this command and replace "apiKey" with your api key from your starling home hub, and "ipaddress" with your hubs local IP. "http://ipaddress:3080/api/connect/v1/devices?key=apiKey" +9. Replace "http://hubAddress:3080" with http://yourhubsipaddress:3080, if you do not you will not be sending the API calls to your hub and the plugin nor your cameras will work. You have to put in your hubs LOCAL IP address. If you don't know what a local IP is you really shouldn't be doing this. +10. once you have modified the main.js file with your deviceId and apiKey you can then save it and press the green triangle you noted +earlier, it MIGHT fail the first time with some red text along with the IP address of your scrypted server, if this happens just press the button again and it should work. +11. I will note here that you may have to run a command, if you do the console will tell you to run a command, if you do it would be npx scrypted login ipaddress, once your run that it will prompt you to enter your scrypted login details. +12. Once you have ran the debugger you can go back to your Scrypted Console and the plugin along with the device should have popped up. +13. That's it the plugin is now installed! + +Supported Devices: +- Nest Cam Indoor/Outdoor (Battery) - 2021 +- Nest Cam Outdoor With Floodlight (Battery) - 2021 +- Nest Cam Indoor (Wired) - 2021 +- Nest Doorbell (Battery) - 2021*** +- Nest Doorbell (Wired) - 2022 +- Nest Camera Indoor - 2016 (Legacy)** +- Nest Camera Outdoor - 2016 (Legacy)** +- Nest Camera IQ Indoor - 2017 (Legacy)** +- Nest Camera IQ Outdoor - 2017 (Legacy)** + +Nightly Support: +- Nest Learning Thermostat 3rd Gen - 2015 +- Nest Thermostat E - 2017 +- Nest Thermostat 1st Gen - 2020 +- Nest Learning Thermostat 4th Gen - 2024 + +Legacy Camera Users: +If you are using a Legacy Nest Camera (marked above), unless you migrate your camera to the Google Home App for WebRTC support your camera will use RTSP which is NOT supported. You HAVE to migrate your camera to the Goolge Home App so it updates to use WebRTC and Starlings SDC Local API Can be used with them. RTSP is not supported [#1](https://github.com/tealtwo/starling-nest-bridge-scrypted/issues/1) + +HomeKit Users: +Why in the flying fish are you using this plugin to add your Nest Cam to HomeKit when the entire purpose of the Starling Home Hub to begin with was that you can add your Google Nest Cameras to HomeKit, adding it to HomeKit this way is a BAD idea, it will add unnecessary load to your Scrypted Server, along with adding latency. + +Google Home Users: +..., read notes. + +NOT Supported: +- Nest Hello (Battery/Wired 2018) - No Support Planned (RTSP) - Check [#1](https://github.com/tealtwo/starling-nest-bridge-scrypted/issues/1) +- Nest Hub Max (Camera) - No Support Planned (RTSP) - Check [#1](https://github.com/tealtwo/starling-nest-bridge-scrypted/issues/1) + +** = This plugin doesn't actually have year limitations, it actually supports any Nest Cam that supports the WebRTC protocol. Instead of identifying the the year or model of the camera it will simply do a WebRTC camera handshake. +*** = I have done testing on this doorbell and if you plan to use it with Scrypted NVR, (or Home Assistant Via Scrypted NVR) the battery in this doorbell will die in roughly a day or two, it is not designed for constant streaming as it mainly runs on battery power and only trickle charges IF it is wired. Do NOT use this doorbell with NVR, there are no issues with HomeKit or Alexa support though, they will work. (or Google Home but these are Google Nest Cams so don't know why you would want that. ) +[Side Note, I don't know if Google uses local RTC streaming if your on the same network as your cams but I know that this plugin will, and if Google goes through the Cloud even for local streaming this plugin would be better than the Native Google Home App] \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a713247 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "@teallvbs/starlingnestbridge/scrypted", + "scripts": { + "scrypted-setup-project": "scrypted-setup-project", + "prescrypted-setup-project": "scrypted-package-json", + "build": "scrypted-webpack", + "prepublishOnly": "NODE_ENV=production scrypted-webpack", + "prescrypted-vscode-launch": "scrypted-webpack", + "scrypted-vscode-launch": "scrypted-deploy-debug", + "scrypted-deploy-debug": "scrypted-deploy-debug", + "scrypted-debug": "scrypted-debug", + "scrypted-deploy": "scrypted-deploy", + "scrypted-readme": "scrypted-readme", + "scrypted-package-json": "scrypted-package-json" + }, + "scrypted": { + "name": "Starling Nest Bridge [Camera Name]", + "type": "Camera", + "interfaces": [ + "Settings" + ] + }, + "dependencies": { + "@scrypted/sdk": "^0.3.13", + "@types/typescript": "^2.0.0", + "axios": "^1.7.5", + "typescript": "^5.5.4" + }, + "devDependencies": { + "@types/node": "^18.15.8" + }, + "version": "b1.6.8" + } + \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..c24342a --- /dev/null +++ b/src/main.js @@ -0,0 +1,169 @@ +// Project Authored By Teal Lvbs LLC. +const { ScryptedDeviceBase, RTCSignalingSession, ScryptedInterface } = require('@scrypted/sdk'); +const axios = require('axios'); +const { connectRTCSignalingClients } = require('/Users/T/Documents/JetBrains/VSC/scrypted-main/common/src/rtc-signaling.ts'); +//The DeviceId and ApiKey for your Nest Camera & Starling Home Hub. Need to implement the "Setting" implement so you can edit these values from the Scrypted Web GUI without having to modify the code in any capacity (or we will just need ApiKey & DeviceId will auto popuplate when the autoDiscoverDevices function is properly impemented & works, issue #3 https://github.com/tealtwo/starling-nest-bridge-scrypted/issues/3) +const deviceId = "DeviceId"; +const apiKey = "ApiKey"; +const hubAddress = "http://hubAddress:3080"; +//This function controls the RTC Session for the Nest Cams Streams by Starting, Stopping, Or Extending them. As an example, if your a Scrypted NVR user since that records 24/7 when the plugin starts it will request a StreamId from the Starling Hub which it will respond with one from the camera that you requested it from (camera comes from DeviceId), given the ApiKey is valid ofcourse. Once that StreamId has been recieved and is in use it will expire after two minutes, unless it is extended using the "extendSession" function. This function will send a API request to the Starling Hub which will renew the StreamId's validity by another 2 minutes, and since we extend every minute it will never expire unless the server is restarted or the camera goes offline in which case when it comes back online it will request a new StreamId and the cycle will repeat. If a HomeKit client also request to stream from the same device a NEW StreamId will be generated and will be extended until the HomeKit client disconnects, at which point the StreamId will be terminated using the "endSession" function, while also having the NVR StreamId renewing and recording/streaming in the background. +class NestRTCSessionControl { + constructor(camera, options) { + this.camera = camera; + this.options = options; + this.refreshAt = Date.now() + 4 * 60 * 1000; + } + async setPlayback(options) { + this.audio = options.audio || false; + this.video = options.video || false; + } + async getRefreshAt() { + return this.refreshAt; + } + async extendSession() { + const streamId = this.options.streamId; + const result = await fetch(`${hubAddress}/api/connect/v1/devices/${deviceId}/stream/${streamId}/extend?key=${apiKey}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8' + } + }); + const json = await result.json(); + const extensionresult = atob(json.extensionresult); + this.refreshAt = Date.now() + 4 * 60 * 1000; + } + async endSession() { + const streamId = this.options.streamId; + await fetch(`http://${hubAddress}/api/connect/v1/devices/${deviceId}/stream/${streamId}/stop?key=${apiKey}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8' + } + }) + } +} +//This class is the actual "camera" itself, or well more like an RTCBridge as its in is name, go to "Notes.MD" for more details on why it is this way. +class StarlingNestRTCBridge extends ScryptedDeviceBase { + constructor() { + super(); + this.console.log('Starling Home Hub Bridge:'); + this.initialize(); + } + async initialize() { + this.console.log('Initializing Starling Home Hub Bridge...'); + } + getWebRTCIceServers() { + return [ + { urls: 'stun:stun.l.google.com:19302' } + ]; + } + //This function is the "blueprint" to create the offerSdp to send the Nest Cam + async createNestOfferSetup() { + return { + type: 'offer', + audio: { + direction: 'sendrecv', + }, + video: { + direction: 'recvonly', + }, + datachannel: { + label: 'dataSendChannel', + dict: { + id: 1, + }, + }, + }; + } + //This function does the Sdp creation and response, along with the StreamId, it uses the offerSdp "blueprint" from the above function + async startRTCSignalingSession(session) { + this.console.log('Plugin Initialized!'); + this.console.log('DEBUG: OFFER SETUP LOG:', await this.createNestOfferSetup()); + let answer; + let streamId; + const options = { + requiresOffer: true, + disableTrickle: true, + }; + const answerSession = { + __proxy_props: { options }, + options, + createLocalDescription: async (type, setup, sendIceCandidate) => { + if (type !== 'answer') { + throw new Error('Google Camera only supports RTC answer'); + } + if (sendIceCandidate) { + throw new Error('Does not support trickle!'); + } + return { type: 'answer', sdp: answer }; + }, + setRemoteDescription: async (description, setup) => { + try { + if (!description || !description.sdp) { + this.console.error('SDP not found in the description object. Description:', description); + throw new Error('Invalid SDP description.'); + } + const decodedOffer = description.sdp.replace('a=ice-options:trickle\r\n', '') + .replace('sendrecv', 'recvonly'); + const data = decodedOffer; + const offerSdp = Buffer.from(data).toString('base64'); + this.console.log('Sending offer SDP:', offerSdp); + const result = await fetch(`${hubAddress}/api/connect/v1/devices/${deviceId}/stream?key=${apiKey}`, { + method: 'POST', + body: JSON.stringify({ offer: offerSdp }), + headers: { 'Content-Type': 'application/json; charset=UTF-8' } + }); + if (!result.ok) { + throw new Error(`Network response was not ok: ${result.statusText}`); + } + const json = await result.json(); + if (!json.answer) { + throw new Error('Invalid response: missing "answer" field.'); + } + answer = Buffer.from(json.answer, 'base64').toString('utf-8'); + streamId = json.streamId; + this.console.log('DEBUG LOGS: (STREAMID)', streamId); + this.console.log('DEBUG LOGS (ANSWER):', answer); + await session.setRemoteDescription({ type: 'answer', sdp: answer }); + return { sdp: answer, type: 'answer' }; + } catch (error) { + this.console.error('Error in setRemoteDescription:', error); + throw new Error('Failed to set remote description.'); + } + }, + addIceCandidate: async (candidate) => { + throw new Error("Google Camera does not support trickle ICE"); + }, + getOptions: async () => { + return options; + } + }; + //This "try" function is what actually does the handshake between the WebRTC plugin and the Nest Camera, it will give the StreamId, and AnswerSdp (which has been decoded from Base64) to the WebRTC plugin which it can then use to stream the camera. + try { + await connectRTCSignalingClients(this.console, session, await this.createNestOfferSetup(), answerSession, {}); + } catch (error) { + this.console.error('Error connecting RTC signaling clients:', error); + throw new Error('Failed to connect RTC signaling clients.'); + } + return new NestRTCSessionControl(this, { streamId }); + } + //This is the auto device discovery function, which while is here is not implemented or used in any way yet. It will be implemented soon along with auto device creation. Issue #2 https://github.com/tealtwo/starling-nest-bridge-scrypted/issues/2 + async discoverDevices() { + try { + this.console.log('Discovering devices...'); + const response = await fetch(`${hubAddress}/api/connect/v1/devices?key=${apiKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=UTF-8' } + }); + const json = await response.json(); + const devices = json.results; + this.console.log('Devices discovered:', devices); + return devices; + } catch (error) { + this.console.error('Error discovering devices:', error); + throw new Error('Failed to discover devices.'); + } + } +} +export default StarlingNestRTCBridge; +//Project Authored By Teal Lvbs \ No newline at end of file