├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── firebase.json └── public ├── api.js ├── app.js ├── auth.js ├── events.js ├── images ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png ├── camera-webrtc.png ├── camera.png ├── doorbell-webrtc.png ├── doorbell.png ├── empty.png ├── failure.png ├── help.png ├── success.png └── thermostat.png ├── index.html ├── logger.js ├── style.css ├── third_party └── kjur.min.js ├── ui.js └── webrtc.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement (CLA). You (or your employer) retain the copyright to your 10 | contribution; this simply gives us permission to use and redistribute your 11 | contributions as part of the project. Head over to 12 | to see your current agreements on file or 13 | to sign a new one. 14 | 15 | You generally only need to submit a CLA once, so if you've already submitted one 16 | (even if it was for a different project), you probably don't need to do it 17 | again. 18 | 19 | ## Code reviews 20 | 21 | All submissions, including submissions by project members, require review. We 22 | use GitHub pull requests for this purpose. Consult 23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 24 | information on using pull requests. 25 | 26 | ## Community Guidelines 27 | 28 | This project follows 29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Device Access Web Application Sample 2 | 3 | ![Device Access Logo](https://www.gstatic.com/images/branding/product/2x/googleg_64dp.png) 4 | 5 | Device Access enables access, control, and management of Nest devices within partner apps, solutions, and smart home ecosystems, using the Smart Device Management (SDM) API. 6 | 7 | Developers can use the code in this repository with the directions provided below to deploy a functioning web application to control Nest cameras, doorbells, and thermostats. 8 | 9 | If you are new to Device Access, we recommend you to start with the [Building a Device Access Web Application Codelab](https://developers.google.com/nest/device-access/codelabs/web-app). 10 | 11 | 12 | ## Deploying the Sample App 13 | 14 | After creating a project on [Firebase](https://firebase.google.com/), use the steps below to deploy the sample app. 15 | 16 | Clone the sample app: 17 | 18 | `git clone https://github.com/google/device-access-sample-web-app.git` 19 | 20 | Navigate into project directory: 21 | 22 | `cd device-access-sample-web-app` 23 | 24 | Link the app with your Firebase project: 25 | 26 | `firebase use --add [PROJECT-ID]` 27 | 28 | Deploy the app to your Firebase project: 29 | 30 | `firebase deploy` 31 | 32 | You can then access the app at your Hosting URL ([https://[PROJECT-ID].web.app](#)). 33 | 34 | ## Using the Sample App 35 | 36 | Enter the OAuth credentials you created on [Google Cloud Platform](https://console.cloud.google.com/), as well as the Project Id from [Device Access Console](https://console.nest.google.com/device-access/) to complete the account linking flow for a Google account with a linked Nest device. 37 | 38 | Make sure to add the redirect url `[PROJECT-ID].web.app/auth` in list of authorized URLs for your Client Id on Google Cloud Platform to prevent `Error 400: redirect_url_mismatch`. More instructions on this can be found at [Handling OAuth](https://developers.google.com/nest/device-access/codelabs/web-app#4) section from the [Building a Device Access Web Application Codelab](https://developers.google.com/nest/device-access/codelabs/web-app). 39 | 40 | You can try out a live demo at [device-access-sample.web.app](https://device-access-sample.web.app/). 41 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "public", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /public/api.js: -------------------------------------------------------------------------------- 1 | 2 | /* Copyright 2022 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | // Device Access Variables: 19 | let streamExtensionToken = ""; 20 | 21 | 22 | /** deviceAccessRequest - Issues requests to Device Access Rest API */ 23 | function deviceAccessRequest(method, call, localpath, payload = "") { 24 | let xhr = new XMLHttpRequest(); 25 | xhr.open(method, selectedAPI + localpath); 26 | xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken); 27 | xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); 28 | 29 | xhr.onload = function () { 30 | if(xhr.status === 200) { 31 | let responsePayload = "* Payload: \n" + xhr.response; 32 | pushLog(LogType.HTTP, method + " Response", responsePayload); 33 | deviceAccessResponse(method, call, xhr.response); 34 | } else { 35 | pushError(LogType.HTTP, method + " Response", xhr.responseText); 36 | } 37 | }; 38 | 39 | let requestEndpoint = "* Endpoint: \n" + selectedAPI + localpath; 40 | let requestAuthorization = "* Authorization: \n" + 'Bearer ' + accessToken; 41 | let requestPayload = "* Payload: \n" + JSON.stringify(payload, null, 4); 42 | pushLog(LogType.HTTP, method + " Request", 43 | requestEndpoint + "\n\n" + requestAuthorization + "\n\n" + requestPayload); 44 | 45 | if (method === 'POST' && payload && payload !== "") { 46 | xhr.send(JSON.stringify(payload)); 47 | } else { 48 | xhr.send(); 49 | } 50 | } 51 | 52 | /** deviceAccessResponse - Parses responses from Device Access API calls */ 53 | function deviceAccessResponse(method, call, response) { 54 | pushLog(LogType.HTTP, method + " Response", response); 55 | let data = JSON.parse(response); 56 | // Check if response data is empty: 57 | if(!data) { 58 | pushError(LogType.ACTION, "Empty Response!", "Device Access response contains empty response!"); 59 | return; 60 | } 61 | // Based on the original request call, interpret the response: 62 | switch(call) { 63 | case 'listDevices': 64 | clearDevices(); // Clear the previously detected devices. 65 | 66 | // Check for detected devices: 67 | if(!data.devices) { 68 | pushError(LogType.ACTION, "No Devices!", "List Devices response contains no devices!"); 69 | return; 70 | } 71 | 72 | // Iterate over detected devices: 73 | for (let i = 0; i < data.devices.length; i++) { 74 | // Parse Device Id: 75 | let scannedId = data.devices[i].name; 76 | let startIndexId = scannedId.lastIndexOf('/'); 77 | let deviceId = scannedId.substring(startIndexId + 1); 78 | // Parse Device Type: 79 | let scannedType = data.devices[i].type; 80 | let startIndexType = scannedType.lastIndexOf('.'); 81 | let deviceType = scannedType.substring(startIndexType + 1); 82 | // Parse Device Structure: 83 | let scannedAssignee = data.devices[i].assignee; 84 | let startIndexStructure = scannedAssignee.lastIndexOf('/structures/'); 85 | let endIndexStructure = scannedAssignee.lastIndexOf('/rooms/'); 86 | let deviceStructure = scannedAssignee.substring(startIndexStructure + 12, endIndexStructure); 87 | 88 | // Handle special case for Displays (Skip, no support!) 89 | if(deviceType === "DISPLAY") 90 | continue; 91 | 92 | // Handle special case for Thermostats (Read Temperature Unit) 93 | if(deviceType === "THERMOSTAT") { 94 | let tempScale = data.devices[i].traits["sdm.devices.traits.Settings"].temperatureScale; 95 | if(tempScale === "FAHRENHEIT") { 96 | document.getElementById("heatUnit").innerText = "°F"; 97 | document.getElementById("coolUnit").innerText = "°F"; 98 | } else { 99 | document.getElementById("heatUnit").innerText = "°C"; 100 | document.getElementById("coolUnit").innerText = "°C"; 101 | } 102 | } 103 | 104 | // Parse Device Room: 105 | let scannedName = data.devices[i].traits["sdm.devices.traits.Info"].customName; 106 | let scannedRelations = data.devices[i].parentRelations; 107 | let scannedRoom = scannedRelations[0]["displayName"]; 108 | // Parse Device Name: 109 | let deviceName = scannedName !== "" ? scannedName : scannedRoom + " " + stringFormat(deviceType); 110 | // Parse Device Traits: 111 | let deviceTraits = Object.keys(data.devices[i].traits); 112 | 113 | // WebRTC check: 114 | let traitCameraLiveStream = data.devices[i].traits["sdm.devices.traits.CameraLiveStream"]; 115 | 116 | if(traitCameraLiveStream) { 117 | let supportedProtocols = traitCameraLiveStream.supportedProtocols; 118 | if (supportedProtocols && supportedProtocols.includes("WEB_RTC")) { 119 | deviceType += "-webrtc"; 120 | initializeWebRTC(); 121 | } 122 | } 123 | 124 | addDevice(new Device(deviceId, deviceType, deviceName, deviceStructure, deviceTraits)); 125 | } 126 | break; 127 | case 'listStructures': 128 | console.log("List Structures!"); 129 | break; 130 | case 'generateStream': 131 | timestampGenerateStreamResponse = new Date(); 132 | updateAnalytics(); 133 | console.log(`Generate Stream response - `, timestampGenerateStreamResponse); 134 | if(data["results"] && (data["results"].hasOwnProperty("streamExtensionToken") || data["results"].hasOwnProperty("mediaSessionId"))) 135 | updateStreamExtensionToken(data["results"].streamExtensionToken || data["results"].mediaSessionId); 136 | if(data["results"] && data["results"].hasOwnProperty("answerSdp")) { 137 | updateWebRTC(data["results"].answerSdp); 138 | pushLog(LogType.ACTION, "[Video Stream]", ""); 139 | } 140 | break; 141 | case 'refreshStream': 142 | timestampExtendStreamResponse = new Date(); 143 | updateAnalytics(); 144 | console.log(`Refresh Stream response - `, timestampExtendStreamResponse); 145 | if(data["results"] && (data["results"].hasOwnProperty("streamExtensionToken") || data["results"].hasOwnProperty("mediaSessionId"))) 146 | updateStreamExtensionToken(data["results"].streamExtensionToken || data["results"].mediaSessionId); 147 | break; 148 | case 'stopStream': 149 | timestampStopStreamResponse = new Date(); 150 | updateAnalytics(); 151 | console.log(`Stop Stream response - `, timestampStopStreamResponse); 152 | initializeWebRTC(); 153 | break; 154 | case 'fanMode': 155 | if(document.getElementById("btnFanMode").textContent === "Activate Fan") 156 | document.getElementById("btnFanMode").textContent = "Deactivate Fan"; 157 | else 158 | document.getElementById("btnFanMode").textContent = "Activate Fan"; 159 | break; 160 | case 'thermostatMode': 161 | console.log("Thermostat Mode!"); 162 | break; 163 | case 'temperatureSetpoint': 164 | console.log("Temperature Setpoint!"); 165 | break; 166 | default: 167 | pushError(LogType.ACTION, "Error", "Unrecognized Request Call!"); 168 | } 169 | } 170 | 171 | /** openResourcePicker - Opens Resource Picker on a new browser tab */ 172 | function openResourcePicker() { 173 | window.open(selectedResourcePicker); 174 | } 175 | 176 | 177 | /// Device Access API /// 178 | 179 | /** onListDevices - Issues a ListDevices request */ 180 | function onListDevices() { 181 | let endpoint = "/enterprises/" + projectId + "/devices"; 182 | deviceAccessRequest('GET', 'listDevices', endpoint); 183 | } 184 | 185 | /** onListStructures - Issues a ListStructures request */ 186 | function onListStructures() { 187 | let endpoint = "/enterprises/" + projectId + "/structures"; 188 | deviceAccessRequest('GET', 'listStructures', endpoint); 189 | } 190 | 191 | /** onFan - Issues a FanMode change request */ 192 | function onFan() { 193 | let endpoint = "/enterprises/" + projectId + "/devices/" + selectedDevice.id + ":executeCommand"; 194 | // Construct the payload: 195 | let payload = { 196 | "command": "sdm.devices.commands.Fan.SetTimer", 197 | "params": {} 198 | }; 199 | // Set correct FanMode based on the current selection: 200 | switch (document.getElementById("btnFanMode").textContent) { 201 | case "Activate Fan": 202 | payload.params["timerMode"] = "ON"; 203 | payload.params["duration"] = "3600s"; 204 | break; 205 | case "Deactivate Fan": 206 | payload.params["timerMode"] = "OFF"; 207 | break; 208 | default: 209 | pushError(LogType.ACTION, "Error", "Button Mode not recognized!"); 210 | return; 211 | } 212 | deviceAccessRequest('POST', 'fanMode', endpoint, payload); 213 | } 214 | 215 | /** onThermostatMode - Issues a ThermostatMode request */ 216 | function onThermostatMode() { 217 | let endpoint = "/enterprises/" + projectId + "/devices/" + selectedDevice.id + ":executeCommand"; 218 | let tempMode = document.getElementById("sctThermostatMode").value; 219 | let payload = { 220 | "command": "sdm.devices.commands.ThermostatMode.SetMode", 221 | "params": { 222 | "mode": tempMode 223 | } 224 | }; 225 | deviceAccessRequest('POST', 'thermostatMode', endpoint, payload); 226 | } 227 | 228 | /** onTemperatureSetpoint - Issues a TemperatureSetpoint request */ 229 | function onTemperatureSetpoint() { 230 | let endpoint = "/enterprises/" + projectId + "/devices/" + selectedDevice.id + ":executeCommand"; 231 | let heatCelsius = parseFloat(document.getElementById("txtHeatTemperature").value); 232 | let coolCelsius = parseFloat(document.getElementById("txtCoolTemperature").value); 233 | // Convert temperature values based on temperature unit: 234 | if(document.getElementById("heatUnit").innerText === "°F") { 235 | heatCelsius = (heatCelsius - 32) * 5 / 9; 236 | } 237 | if(document.getElementById("coolUnit").innerText === "°F") { 238 | coolCelsius = (coolCelsius - 32) * 5 / 9; 239 | } 240 | // Construct the payload: 241 | let payload = { 242 | "command": "", 243 | "params": {} 244 | }; 245 | // Set correct temperature fields based on the selected ThermostatMode: 246 | switch (document.getElementById("sctThermostatMode").value) { 247 | case "HEAT": 248 | payload.command = "sdm.devices.commands.ThermostatTemperatureSetpoint.SetHeat"; 249 | payload.params["heatCelsius"] = heatCelsius; 250 | break; 251 | case "COOL": 252 | payload.command = "sdm.devices.commands.ThermostatTemperatureSetpoint.SetCool"; 253 | payload.params["coolCelsius"] = coolCelsius; 254 | break; 255 | case "HEATCOOL": 256 | payload.command = "sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange"; 257 | payload.params["heatCelsius"] = heatCelsius; 258 | payload.params["coolCelsius"] = coolCelsius; 259 | break; 260 | default: 261 | pushError(LogType.ACTION, "Invalid Mode!", "Off and Eco modes don't allow this function!" 262 | + "\n (Try changing the Thermostat Mode to some other value)"); 263 | return; 264 | } 265 | deviceAccessRequest('POST', 'temperatureSetpoint', endpoint, payload); 266 | } 267 | 268 | /** onGenerateStream - Issues a GenerateRtspStream request */ 269 | function onGenerateStream() { 270 | clearAnalytics(true); 271 | timestampGenerateStreamRequest = new Date(); 272 | updateAnalytics(); 273 | console.log(`onGenerateStream() - `, timestampGenerateStreamRequest); 274 | 275 | let endpoint = "/enterprises/" + projectId + "/devices/" + selectedDevice.id + ":executeCommand"; 276 | let payload = { 277 | "command": "sdm.devices.commands.CameraLiveStream.GenerateRtspStream" 278 | }; 279 | deviceAccessRequest('POST', 'generateStream', endpoint, payload); 280 | } 281 | 282 | /** onExtendStream - Issues a ExtendRtspStream request */ 283 | function onExtendStream() { 284 | timestampExtendStreamRequest = new Date(); 285 | updateAnalytics(); 286 | console.log(`onExtendStream() - `, timestampExtendStreamRequest); 287 | 288 | let endpoint = "/enterprises/" + projectId + "/devices/" + selectedDevice.id + ":executeCommand"; 289 | let payload = { 290 | "command": "sdm.devices.commands.CameraLiveStream.ExtendRtspStream", 291 | "params": { 292 | "streamExtensionToken" : streamExtensionToken 293 | } 294 | }; 295 | deviceAccessRequest('POST', 'refreshStream', endpoint, payload); 296 | } 297 | 298 | /** onStopStream - Issues a StopRtspStream request */ 299 | function onStopStream() { 300 | timestampStopStreamRequest = new Date(); 301 | updateAnalytics(); 302 | console.log(`onStopStream() - `, timestampStopStreamRequest); 303 | 304 | let endpoint = "/enterprises/" + projectId + "/devices/" + selectedDevice.id + ":executeCommand"; 305 | let payload = { 306 | "command": "sdm.devices.commands.CameraLiveStream.StopRtspStream", 307 | "params": { 308 | "streamExtensionToken" : streamExtensionToken 309 | } 310 | }; 311 | deviceAccessRequest('POST', 'stopStream', endpoint, payload); 312 | } 313 | 314 | /** onGenerateStream_WebRTC - Issues a GenerateWebRtcStream request */ 315 | function onGenerateStream_WebRTC() { 316 | clearAnalytics(true); 317 | timestampGenerateWebRtcStreamRequest = new Date(); 318 | updateAnalytics(); 319 | console.log(`onGenerateStream_WebRTC() - `, timestampGenerateWebRtcStreamRequest); 320 | 321 | let endpoint = "/enterprises/" + projectId + "/devices/" + selectedDevice.id + ":executeCommand"; 322 | let payload = { 323 | "command": "sdm.devices.commands.CameraLiveStream.GenerateWebRtcStream", 324 | "params": { 325 | "offerSdp": offerSDP 326 | } 327 | }; 328 | 329 | deviceAccessRequest('POST', 'generateStream', endpoint, payload); 330 | } 331 | 332 | /** onExtendStream_WebRTC - Issues a ExtendWebRtcStream request */ 333 | function onExtendStream_WebRTC() { 334 | timestampExtendWebRtcStreamRequest = new Date(); 335 | updateAnalytics(); 336 | console.log(`onExtendStream_WebRTC() - `, timestampExtendWebRtcStreamRequest); 337 | 338 | let endpoint = "/enterprises/" + projectId + "/devices/" + selectedDevice.id + ":executeCommand"; 339 | let payload = { 340 | "command": "sdm.devices.commands.CameraLiveStream.ExtendWebRtcStream", 341 | "params": { 342 | "mediaSessionId" : streamExtensionToken 343 | } 344 | }; 345 | deviceAccessRequest('POST', 'refreshStream', endpoint, payload); 346 | } 347 | 348 | /** onStopStream_WebRTC - Issues a StopWebRtcStream request */ 349 | function onStopStream_WebRTC() { 350 | timestampStopWebRtcStreamRequest = new Date(); 351 | updateAnalytics(); 352 | console.log(`onStopStream_WebRTC() - `, timestampStopWebRtcStreamRequest); 353 | 354 | let endpoint = "/enterprises/" + projectId + "/devices/" + selectedDevice.id + ":executeCommand"; 355 | let payload = { 356 | "command": "sdm.devices.commands.CameraLiveStream.StopWebRtcStream", 357 | "params": { 358 | "mediaSessionId" : streamExtensionToken 359 | } 360 | }; 361 | deviceAccessRequest('POST', 'stopStream', endpoint, payload); 362 | } 363 | -------------------------------------------------------------------------------- /public/app.js: -------------------------------------------------------------------------------- 1 | 2 | /* Copyright 2022 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | // State Variables: 19 | let isSignedIn = false; 20 | let isSubscribed = false; 21 | let selectedDevice; 22 | 23 | 24 | /// Primary Functions /// 25 | 26 | /** init - Initializes the loaded javascript */ 27 | async function init() { 28 | readStorage(); // Reads data from browser's local storage if available 29 | await handleAuth(); // Checks incoming authorization code from /auth path 30 | await exchangeCode(); // Exchanges authorization code to an access token 31 | await refreshAccess(); // Retrieves a new access token using refresh token 32 | initializeDevices(); // Issues a list devices call if logged-in 33 | } 34 | 35 | /** readStorage - Reads data from browser's local storage if available */ 36 | function readStorage() { 37 | 38 | if (localStorage["logs"]) { 39 | // Parse local storage for logs: 40 | const parsedStorage = JSON.parse(localStorage["logs"]); 41 | // Read the parsed storage: 42 | if (Array.isArray(parsedStorage)) 43 | for (let i = 0; i < parsedStorage.length; i++) 44 | logs.push(parsedStorage[i]); 45 | // Display ingested logs: 46 | addLogEntries(logs); 47 | } 48 | 49 | if (localStorage["clientId"]) { 50 | updateClientId(localStorage["clientId"]); 51 | } 52 | if (localStorage["clientSecret"]) { 53 | updateClientSecret(localStorage["clientSecret"]); 54 | } 55 | if (localStorage["projectId"]) { 56 | updateProjectId(localStorage["projectId"]); 57 | } 58 | 59 | if (localStorage["oauthCode"]) { 60 | updateOAuthCode(localStorage["oauthCode"]); 61 | } 62 | if (localStorage["accessToken"]) { 63 | updateAccessToken(localStorage["accessToken"]); 64 | } 65 | if (localStorage["refreshToken"]) { 66 | updateRefreshToken(localStorage["refreshToken"]); 67 | } 68 | 69 | if (localStorage["isSignedIn"] === true || localStorage["isSignedIn"] === "true") { 70 | updateSignedIn(localStorage["isSignedIn"]); 71 | } 72 | // Update the App Controls based on isSignedIn: 73 | updateAppControls(); 74 | 75 | if (localStorage["subscriptionId"]) { 76 | updateSubscriptionId(localStorage["subscriptionId"]); 77 | } 78 | 79 | if (localStorage["serviceAccountKey"]) { 80 | updateServiceAccountKey(localStorage["serviceAccountKey"]); 81 | } 82 | 83 | if (localStorage["logFilter"]) { 84 | logFilter = localStorage["logFilter"].split(","); 85 | } 86 | // Update the Log Filters based on logFilter: 87 | updateLogFilter(logFilter); 88 | } 89 | 90 | /** initializeDevices - Issues a list devices call if logged-in */ 91 | function initializeDevices() { 92 | if(isSignedIn) { 93 | clickListDevices(); 94 | } 95 | } 96 | 97 | 98 | /// Helper Functions /// 99 | 100 | /** Device Object Model */ 101 | class Device { 102 | constructor(id, type, name, structure, traits) { 103 | this.id = id; 104 | this.type = type; 105 | this.name = name; 106 | this.structure = structure; 107 | this.traits = traits; 108 | } 109 | } 110 | 111 | /** addDevice - Add device to Device Control list */ 112 | function addDevice(device) { 113 | // Create an Option object 114 | let opt = document.createElement("option"); 115 | 116 | // Assign text and value to Option object 117 | opt.text = device.name; 118 | opt.value = JSON.stringify(device); 119 | 120 | // Add an Option object to Drop Down List Box 121 | document.getElementById("sctDeviceList").options.add(opt); 122 | 123 | // If this is the first device added, choose it 124 | if(document.getElementById("sctDeviceList").options.length === 1) { 125 | selectedDevice = device; 126 | showDeviceControls(); 127 | } 128 | } 129 | 130 | /** clearDevices - Clear Device Control list */ 131 | function clearDevices() { 132 | let deviceListLength = document.getElementById("sctDeviceList").options.length; 133 | for (let i = deviceListLength - 1; i >= 0; i--) { 134 | document.getElementById("sctDeviceList").options[i] = null; 135 | } 136 | hideDeviceControls(); 137 | } 138 | 139 | /** stringFormat - Formats input string to Upper Camel Case */ 140 | function stringFormat(str) { 141 | return str.replace(/(\w)(\w*)/g, 142 | function(g0,g1,g2){return g1.toUpperCase() + g2.toLowerCase();}); 143 | } 144 | -------------------------------------------------------------------------------- /public/auth.js: -------------------------------------------------------------------------------- 1 | 2 | /* Copyright 2022 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | // Server Credentials: 19 | const TOKEN_ENDPOINT = "https://www.googleapis.com/oauth2/v4/token"; 20 | const OAUTH_SCOPE = "https://www.googleapis.com/auth/sdm.service"; 21 | 22 | // Configuration Variables: 23 | let selectedAPI = "https://smartdevicemanagement.googleapis.com/v1"; 24 | let selectedEndpoint = "https://nestservices.google.com/partnerconnections/"; 25 | let selectedResourcePicker = "https://nestservices.google.com/partnerconnections"; 26 | 27 | // Partner Credentials: 28 | let clientId = ""; 29 | let clientSecret = ""; 30 | let projectId = ""; 31 | 32 | // Authentication Variables: 33 | let oauthCode = ""; 34 | let accessToken = ""; 35 | let refreshToken = ""; 36 | 37 | 38 | /** signIn - Initiates the OAuth flow for Account Linking */ 39 | function signIn() { 40 | // Calculating the redirect URI for current window 41 | let redirectURI = window.location.origin + '/auth'; 42 | 43 | // Google's OAuth 2.0 endpoint for requesting an access token 44 | let oauthEndpoint = selectedEndpoint + projectId + "/auth"; 45 | 46 | // Create
element to submit parameters to OAuth 2.0 endpoint. 47 | let form = document.createElement('form'); 48 | form.setAttribute('method', 'GET'); 49 | form.setAttribute('action', oauthEndpoint); 50 | 51 | // Parameters to pass to OAuth 2.0 endpoint. 52 | let params = { 53 | 'access_type': 'offline', 54 | 'client_id': clientId, 55 | 'include_granted_scopes': 'true', 56 | 'prompt' : 'consent', 57 | 'redirect_uri': redirectURI, 58 | 'response_type': 'code', 59 | 'scope': OAUTH_SCOPE, 60 | 'state': 'pass-through value' 61 | }; 62 | 63 | // Add form parameters as hidden input values. 64 | for (let p in params) { 65 | let input = document.createElement('input'); 66 | input.setAttribute('type', 'hidden'); 67 | input.setAttribute('name', p); 68 | input.setAttribute('value', params[p]); 69 | form.appendChild(input); 70 | } 71 | 72 | // Add form to page and submit it to open the OAuth 2.0 endpoint. 73 | document.body.appendChild(form); 74 | pushLog(LogType.HTTP, "GET Request", JSON.stringify(form, null, 4)); 75 | form.submit(); 76 | } 77 | 78 | /** signOut - Clears the local variables and auth tokens */ 79 | function signOut() { 80 | // Clear Credentials: 81 | // updateClientId(""); 82 | // updateClientSecret(""); 83 | // updateProjectId(""); 84 | 85 | // Clear Tokens: 86 | updateOAuthCode(""); 87 | updateAccessToken(""); 88 | updateRefreshToken(""); 89 | 90 | // Clear Devices: 91 | clearDevices(); 92 | 93 | // Signed Out: 94 | updateSignedIn(false); 95 | } 96 | 97 | /** handleAuth - Detects and sends oauth response code to server */ 98 | function handleAuth () { 99 | return new Promise(function (resolve, reject) { 100 | // Return if current URI does not begin with /auth: 101 | if (!window.location.pathname.startsWith("/auth")) { 102 | pushLog(LogType.ACTION, "Page Reload", window.location.pathname); 103 | resolve(); 104 | return; 105 | } 106 | 107 | pushLog(LogType.HTTP, "Page Redirect", window.location.pathname); 108 | 109 | // Retrieve query parameters from url. 110 | const queryparams = window.location.search.split("&"); 111 | 112 | // Extract key-value pairs from parameters. 113 | for (let i = 0; i < queryparams.length; i++) { 114 | const key = queryparams[i].split("=")[0]; 115 | const val = queryparams[i].split("=")[1]; 116 | 117 | // Send oAuth Code to server if found. 118 | if (key === "code") { 119 | updateOAuthCode(val); 120 | } 121 | } 122 | 123 | // Prevent back button action by injecting a previous state. 124 | window.history.pushState("object or string", "Title", "/"); 125 | 126 | resolve(); 127 | }); 128 | } 129 | 130 | /** exchangeCode - Exchanges OAuth Code to OAuth Tokens */ 131 | function exchangeCode() { 132 | return new Promise(function (resolve, reject) { 133 | // Return if there is already an access code, or no OAuth Code: 134 | if(accessToken || !oauthCode) { 135 | resolve(); 136 | return; 137 | } 138 | 139 | pushLog(LogType.ACTION, "Exchange Code", "Exchanging OAuth code for auth tokens."); 140 | 141 | // Calculate redirect URI for current window: 142 | let redirectURI = window.location.origin + '/auth'; 143 | 144 | // Request Payload: 145 | let payload = { 146 | code: oauthCode, 147 | client_id: clientId, 148 | client_secret: clientSecret, 149 | redirect_uri: redirectURI, 150 | grant_type: 'authorization_code' 151 | }; 152 | 153 | // Create Http Request: 154 | let xhr = new XMLHttpRequest(); 155 | xhr.open('POST', TOKEN_ENDPOINT); 156 | xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); 157 | 158 | // Http Response Callback: 159 | xhr.onload = function () { 160 | if(xhr.status === 200) { // HTTP OK Response 161 | // Log Http response: 162 | let responsePayload = "* Payload: \n" + xhr.responseText; 163 | pushLog(LogType.HTTP, "POST Response", responsePayload); 164 | 165 | // Process tokens and sign in: 166 | let parsedResponse = JSON.parse(xhr.responseText); 167 | updateAccessToken(parsedResponse.access_token); 168 | updateRefreshToken(parsedResponse.refresh_token); 169 | updateSignedIn(true); 170 | resolve(); 171 | 172 | } else { // HTTP Error Response 173 | pushError(LogType.HTTP, "POST Response", xhr.responseText); 174 | 175 | // Invalidate tokens and sign out: 176 | updateAccessToken(undefined); 177 | updateRefreshToken(undefined); 178 | updateSignedIn(false); 179 | resolve(); 180 | } 181 | }; 182 | 183 | // Log Http request: 184 | let requestEndpoint = "* Endpoint: \n" + TOKEN_ENDPOINT; 185 | let requestPayload = "* Payload: \n" + JSON.stringify(payload, null, 4); 186 | pushLog(LogType.HTTP, "POST Request", requestEndpoint + "\n\n" + requestPayload); 187 | 188 | // Send Http request: 189 | pushLog(LogType.HTTP, "POST Request", JSON.stringify(payload, null, 4)); 190 | xhr.send(JSON.stringify(payload)); 191 | }); 192 | } 193 | 194 | /** refreshAccess - Refreshes Access Token using the existing Refresh Token */ 195 | function refreshAccess () { 196 | return new Promise(function (resolve, reject) { 197 | // Return if there no refresh token: 198 | if(!refreshToken) { 199 | resolve(); 200 | return; 201 | } 202 | 203 | pushLog(LogType.ACTION, "Refresh Access", "Refreshing Access Token using the available Refresh Token."); 204 | 205 | // Request Payload: 206 | let payload = { 207 | refresh_token: refreshToken, 208 | client_id: clientId, 209 | client_secret: clientSecret, 210 | grant_type: 'refresh_token' 211 | }; 212 | 213 | // Create Http Request: 214 | let xhr = new XMLHttpRequest(); 215 | xhr.open('POST', TOKEN_ENDPOINT); 216 | xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); 217 | 218 | // Http Response Callback: 219 | xhr.onload = function () { 220 | if(xhr.status === 200) { // HTTP OK Response 221 | // Log Http response: 222 | let responsePayload = "* Payload: \n" + xhr.responseText; 223 | pushLog(LogType.HTTP, "POST Response", responsePayload); 224 | 225 | // Process access token: 226 | let parsedResponse = JSON.parse(xhr.responseText); 227 | updateAccessToken(parsedResponse.access_token); 228 | resolve(); 229 | } else { // HTTP Error Response 230 | pushError(LogType.HTTP, "POST Response", xhr.responseText); 231 | 232 | // Invalidate tokens: 233 | updateAccessToken(undefined); 234 | updateRefreshToken(undefined); 235 | resolve(); 236 | } 237 | }; 238 | 239 | // Log Http request: 240 | let requestEndpoint = "* Endpoint: \n" + TOKEN_ENDPOINT; 241 | let requestPayload = "* Payload: \n" + JSON.stringify(payload, null, 4); 242 | pushLog(LogType.HTTP, "POST Request", requestEndpoint + "\n\n" + requestPayload); 243 | 244 | // Send Http request: 245 | xhr.send(JSON.stringify(payload)); 246 | }); 247 | } 248 | -------------------------------------------------------------------------------- /public/events.js: -------------------------------------------------------------------------------- 1 | 2 | /* Copyright 2022 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | // Event Constants: 19 | const PUBSUB_ENDPOINT = "https://pubsub.googleapis.com/v1"; 20 | const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/token"; 21 | const EVENT_CHECK_INTERVAL = 4000; 22 | 23 | // Event Credentials: 24 | let subscriptionId = ""; 25 | let serviceAccountKey = ""; 26 | 27 | // Create a timed function for event checker: 28 | setInterval(eventChecker, EVENT_CHECK_INTERVAL); 29 | 30 | 31 | /// Event Functions /// 32 | 33 | /** eventChecker - Service routine function for issuing automated pubsub calls */ 34 | function eventChecker() { 35 | if(isSubscribed) 36 | pubsubEvents(); 37 | } 38 | 39 | /** pubsubEvents - Pubsub controller function (first gets an Access Token, then issues a pubsub pull) */ 40 | function pubsubEvents() { 41 | let parsedKey; 42 | 43 | try { 44 | parsedKey = JSON.parse(serviceAccountKey); 45 | } catch (e) { 46 | pushError(LogType.ACTION, "Parsing Error!", "Can't parse Service Account Key!"); 47 | return; 48 | } 49 | 50 | // Function to create token request header: 51 | function getHeader() { 52 | return { 53 | "alg": "RS256", 54 | "typ": "JWT" 55 | }; 56 | } 57 | 58 | // Function to create token request payload: 59 | function getPayload() { 60 | let iat = Math.round((new Date()).getTime() / 1000); 61 | let exp = iat + (60 * 60); 62 | 63 | return { 64 | "iss": parsedKey.client_email, 65 | "scope": "https://www.googleapis.com/auth/cloud-platform " 66 | + "https://www.googleapis.com/auth/pubsub", 67 | "aud": AUTH_ENDPOINT, 68 | "iat": iat, 69 | "exp": exp 70 | }; 71 | } 72 | 73 | // Signing token request with KJUR library: 74 | let signedJWT = KJUR.jws.JWS.sign(null, 75 | JSON.stringify(getHeader()), 76 | JSON.stringify(getPayload()), 77 | parsedKey.private_key); 78 | 79 | // Request body to get access token with signed JWT: 80 | let body = { 81 | "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", 82 | "assertion": signedJWT 83 | }; 84 | 85 | // Callback function for token request: 86 | function callback (responseText) { 87 | let response = JSON.parse(responseText); 88 | // if response contains an access token, issue pubsub pull: 89 | if (response["access_token"]) { 90 | pubSubPull(response["access_token"]); 91 | } else { 92 | pushError(LogType.ACTION, "Authentication Error!", "Unable to authenticate Subscription Id / Service Account Key pair!"); 93 | updateSubscribed(false) 94 | } 95 | } 96 | 97 | // Issue token request, then a pubsub pull using that token: 98 | postRequest(AUTH_ENDPOINT, body, callback); 99 | } 100 | 101 | /** pubSubPull - Function to issue a pubsub pull request */ 102 | function pubSubPull(token) { 103 | // Construct url for pubsub pull: 104 | const url = buildSubscriptionUrl() + ":pull"; 105 | 106 | // Request body for pubsub pull: 107 | const body = { 108 | "returnImmediately": false, 109 | "maxMessages": 20 110 | }; 111 | 112 | // Callback function for pubsub pull: 113 | function callback (responseText) { 114 | let response = JSON.parse(responseText); 115 | const ackIds = {"ackIds": []}; 116 | if (response.receivedMessages) { 117 | const messages = response.receivedMessages; 118 | if (messages.length > 0) { 119 | for (let i = 0; i < messages.length; i++) { 120 | let payloadRaw = atob(messages[i].message.data).toString(); 121 | let payloadString = JSON.stringify(JSON.parse(payloadRaw)); 122 | pushLog(LogType.EVENT, "Event Received", payloadString); 123 | ackIds.ackIds.push(messages[i].ackId); 124 | } 125 | } 126 | } 127 | // Acknowledge messages: 128 | if (ackIds.ackIds.length > 0) { 129 | ack(token, ackIds); 130 | } 131 | } 132 | 133 | // Issue pubsub pull: 134 | postRequest(url, body, callback, token); 135 | } 136 | 137 | /** ack - Function to issue a pubsub ack request */ 138 | function ack(token, ackIds) { 139 | // Construct url for pubsub ack: 140 | const url = buildSubscriptionUrl() + ":acknowledge"; 141 | 142 | // Callback function for pubsub ack: 143 | function callback (responseText) { 144 | console.log('ack callback', JSON.stringify(JSON.parse(responseText))); 145 | } 146 | 147 | // Issue pubsub ack: 148 | postRequest(url, ackIds, callback, token); 149 | } 150 | 151 | 152 | /// Helper Functions /// 153 | 154 | /** postRequest - Helper function to issue post request for events */ 155 | function postRequest(endpoint, payload, callback, token = null) { 156 | // Create post request: 157 | let xhr = new XMLHttpRequest(); 158 | xhr.open("POST", endpoint); 159 | 160 | // Set request headers: 161 | if (token) 162 | xhr.setRequestHeader('Authorization', 'Bearer ' + token); 163 | xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); 164 | 165 | // Set response callback: 166 | xhr.onload = function () { 167 | if(xhr.status === 200) { 168 | callback(xhr.responseText); 169 | } else { 170 | pushError(LogType.ACTION, "Error Response", xhr.response); 171 | } 172 | }; 173 | 174 | // Send request: 175 | xhr.send(JSON.stringify(payload)); 176 | } 177 | 178 | /** buildSubscriptionUrl - Function to issue a pubsub ack request */ 179 | function buildSubscriptionUrl() { 180 | if (subscriptionId.includes("projects/") || subscriptionId.includes("subscriptions/")) { 181 | let startSubscriptionId = subscriptionId.lastIndexOf('/'); 182 | subscriptionId = subscriptionId.substring(startSubscriptionId + 1); 183 | updateSubscriptionId(subscriptionId); 184 | } 185 | 186 | const parsedKey = JSON.parse(serviceAccountKey); 187 | const projectPath = "projects/" + parsedKey.project_id; 188 | const subscriptionPath = "subscriptions/" + subscriptionId; 189 | return PUBSUB_ENDPOINT + "/" + projectPath + "/" + subscriptionPath; 190 | } 191 | -------------------------------------------------------------------------------- /public/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/1.png -------------------------------------------------------------------------------- /public/images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/2.png -------------------------------------------------------------------------------- /public/images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/3.png -------------------------------------------------------------------------------- /public/images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/4.png -------------------------------------------------------------------------------- /public/images/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/5.png -------------------------------------------------------------------------------- /public/images/camera-webrtc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/camera-webrtc.png -------------------------------------------------------------------------------- /public/images/camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/camera.png -------------------------------------------------------------------------------- /public/images/doorbell-webrtc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/doorbell-webrtc.png -------------------------------------------------------------------------------- /public/images/doorbell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/doorbell.png -------------------------------------------------------------------------------- /public/images/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/empty.png -------------------------------------------------------------------------------- /public/images/failure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/failure.png -------------------------------------------------------------------------------- /public/images/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/help.png -------------------------------------------------------------------------------- /public/images/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/success.png -------------------------------------------------------------------------------- /public/images/thermostat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/device-access-sample-web-app/39a92c54689e103a7ae6e6b7affcaefa3392e164/public/images/thermostat.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Device Access Sample App 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 |
20 |

Device Access - Sample App

21 |
22 | Help 23 | 24 | Hover over each tip to learn more! 25 | 26 |
27 |
28 | 29 | 30 | 87 | 88 | 89 |
90 |
91 |
92 |

Device Access

93 |
94 | ? 95 | 96 | 2 - Device Access panel provides controls for subscribing to events, 97 | accessing Resource Picker, as well as calling 98 | 99 | ListDevices and 100 | 101 | ListStructures methods. 102 |

103 | Each time the page page is refreshed, the app will issue a ListDevices automatically. 104 |
105 |
106 |
107 |
108 | 109 | 110 | 111 |
112 |
113 | 114 | 115 |
116 |
117 |
118 |

Device Events

119 |
120 | ? 121 | 122 | 3 - Events (Optional) allow you to receive asynchronous activity associated with 123 | target devices. To subscribe to events, you need to provide a 124 | Service Account Key 125 | and a Subscription Id. 126 |

127 | Your Service Account Key is used locally to sign the pub-sub requests. It does not leave the browser. 128 |
129 |
130 |
131 |
132 | 133 |

134 | 136 |

137 |
138 | 139 | 140 |
141 |
142 |
143 |
144 |
145 | 146 | 147 |
148 |
149 |
150 |

Device Control

151 |
152 | ? 153 | 154 | 4 - Device Control panel provides controls for devices linked to the user. 155 | You can chose the device from the dropdown. 156 |

157 | Currently devices supported are Nest 158 | Thermostat, 159 | Camera and 160 | Doorbell. 161 |
162 |
163 |
164 |
165 | 166 | 169 |
170 |
171 | 172 | 204 | 205 | 222 | 223 | 242 | 243 | 262 | 263 | 282 |
283 |
284 |
285 | 286 | 287 |
288 |
289 |
290 |

Analytics

291 |
292 | 293 | 294 | 300 | 306 | 312 | 318 | 319 | 320 | 325 | 330 | 335 | 340 | 341 |
295 |
296 |

Initialize WebRTC =>
297 | setLocalDescription

298 |
299 |
301 |
302 |

Send SDP Offer =>
303 | Receive SDP Answer

304 |
305 |
307 |
308 |

Receive SDP Answer =>
309 | Connected

310 |
311 |
313 |
314 |

Connected =>
315 | Playback started

316 |
317 |
321 |
322 |

-

323 |
324 |
326 |
327 |

-

328 |
329 |
331 |
332 |

-

333 |
334 |
336 |
337 |

-

338 |
339 |
342 |
343 |
344 | 345 | 346 |
347 |
348 |
349 |

Application Logs

350 |
351 | ? 352 | 353 | 5 - Application Logs provide logs for each interaction captured by this application. 354 |

355 | Logs are categorized into three groups: actions happened within the app, 356 | HTTP requests and pub-sub events. 357 |
358 |
359 |
360 |
361 |
362 | 363 | 364 | 365 |
366 |
367 | 368 |
369 |
370 |
371 | 372 |
373 |
374 |
375 | 376 | 377 |
378 |
379 |

380 |
381 |
382 |
383 |
384 |
385 |
386 | 389 |
390 |
391 | 392 | 393 | 394 | 397 | 398 | 399 | 402 | 403 | -------------------------------------------------------------------------------- /public/logger.js: -------------------------------------------------------------------------------- 1 | 2 | /* Copyright 2022 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | // Log Types: 19 | const LogType = { 20 | ACTION : "ACTION", 21 | HTTP : "HTTP", 22 | EVENT : "EVENT", 23 | }; 24 | 25 | // Status Status: 26 | const LogStatus = { 27 | INFO : "INFO", 28 | ERROR : "ERROR", 29 | }; 30 | 31 | // Log types that enabled by at app start: 32 | let logFilter = ["BASE", LogType.ACTION, LogType.HTTP]; 33 | 34 | // Application logs: 35 | let logs = []; 36 | let filteredLogs = []; 37 | 38 | // Log Template: 39 | class Log { 40 | constructor(type, title, text, status) { 41 | this.type = type; 42 | this.title = title; 43 | this.text = text; 44 | this.status = status; 45 | 46 | // Calculate and format the time: 47 | const currentTime = new Date(); 48 | let hours = currentTime.getHours(); 49 | let minutes = currentTime.getMinutes(); 50 | let seconds = currentTime.getSeconds(); 51 | if (hours < 10) { hours = "0" + hours; } 52 | if (minutes < 10) { minutes = "0" + minutes; } 53 | if (seconds < 10) { seconds = "0" + seconds; } 54 | 55 | this.time = hours + ":" + minutes + ":" + seconds; 56 | } 57 | } 58 | 59 | 60 | /** pushLog - Pushes a new log to the list */ 61 | function pushLog(type, title, text, status = LogStatus.INFO) { 62 | const newLog = new Log(type, title, text, status); 63 | logs.push(newLog); 64 | localStorage["logs"] = JSON.stringify(logs); 65 | addLogEntry(newLog); 66 | } 67 | 68 | /** pushError - Pushes a new error to the list */ 69 | function pushError(type, title, text) { 70 | pushLog(type, title, text, LogStatus.ERROR); 71 | } 72 | 73 | /** addLogEntry - Adds a new log entry to log container */ 74 | function addLogEntry(newLog) { 75 | 76 | // If there are no active filters, skip. 77 | if(!logFilter.includes(newLog.type)) { 78 | return; 79 | } 80 | 81 | // Add the log to the list of logs to display: 82 | filteredLogs.push(newLog); 83 | 84 | // Get source elements from the page and create a new log entry: 85 | let logContainer = document.getElementById("log-container"); 86 | let logTemplate = document.getElementsByTagName("template")[0]; 87 | let logEntry = logTemplate.content.cloneNode(true); 88 | 89 | // Add log entry into log display container: 90 | if (logContainer.children.length > 0) { 91 | logContainer.insertBefore(logEntry, logContainer.children[0]); 92 | } else { 93 | logContainer.appendChild(logEntry); 94 | } 95 | 96 | // Add mouse click callback to log entry: 97 | let targetIndex = logContainer.children.length - 1; 98 | logContainer.children[0].onclick = 99 | function(){showLogEntry(targetIndex)}; 100 | 101 | // Display log title on the log entry: 102 | logContainer.children[0].textContent = newLog.title; 103 | 104 | // If error, color log entry to Red: 105 | if(newLog.status === LogStatus.ERROR) 106 | logContainer.children[0].setAttribute("style", "color: #AA0000;"); 107 | 108 | // Show log entry: 109 | showLogEntry(logContainer.children.length - 1); 110 | } 111 | 112 | /** addLogEntries - Adds multiple log entries to log container */ 113 | function addLogEntries(newLogs) { 114 | filteredLogs = []; 115 | for (let i = 0; i < newLogs.length; i++) { 116 | addLogEntry(newLogs[i]); 117 | } 118 | } 119 | 120 | /** onFilterAction - Toggles ACTION Filter */ 121 | function onFilterAction() { 122 | if(logFilter.includes(LogType.ACTION)) { 123 | const index = logFilter.indexOf(LogType.ACTION); 124 | logFilter.splice(index, 1); 125 | } else { 126 | logFilter.push(LogType.ACTION); 127 | } 128 | updateLogFilter(logFilter); 129 | } 130 | 131 | /** onFilterHTTP - Toggles HTTP Filter */ 132 | function onFilterHTTP() { 133 | if(logFilter.includes(LogType.HTTP)) { 134 | const index = logFilter.indexOf(LogType.HTTP); 135 | logFilter.splice(index, 1); 136 | } else { 137 | logFilter.push(LogType.HTTP); 138 | } 139 | updateLogFilter(logFilter); 140 | } 141 | 142 | /** onFilterEvent - Toggles Event Filter */ 143 | function onFilterEvent() { 144 | if(logFilter.includes(LogType.EVENT)) { 145 | const index = logFilter.indexOf(LogType.EVENT); 146 | logFilter.splice(index, 1); 147 | } else { 148 | logFilter.push(LogType.EVENT); 149 | } 150 | updateLogFilter(logFilter); 151 | } 152 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | max-width: 1200px; 3 | min-width: 860px; 4 | padding: 8px; 5 | margin: auto; 6 | } 7 | 8 | .grid-container { 9 | display: grid; 10 | grid-template-columns: repeat(3, minmax(0, 1fr)); 11 | grid-template-rows: 50px 360px 136px auto; 12 | grid-column-gap: 10px; 13 | grid-row-gap: 10px; 14 | width: 100%; 15 | height: 100%; 16 | } 17 | 18 | .grid-item { 19 | background-color: #DDDDDD; 20 | border-radius: 4px; 21 | } 22 | 23 | .item-app-header { 24 | background-color: #888888; 25 | grid-column: 1 / span 3; 26 | display: flex; 27 | } 28 | 29 | .item-account-linking { 30 | height: 210px; 31 | } 32 | 33 | .item-auth-tokens { 34 | height: 140px; 35 | margin-top: 10px; 36 | padding: 0px 30px; 37 | display: flex; 38 | align-items: center; 39 | } 40 | 41 | .item-analytics { 42 | grid-column: 1 / span 3; 43 | align-items: center; 44 | } 45 | 46 | .item-device-access { 47 | height: 120px; 48 | } 49 | 50 | .item-device-events { 51 | height: 230px; 52 | margin-top: 10px; 53 | } 54 | 55 | .item-log-list { 56 | grid-column: 1; 57 | } 58 | 59 | .item-log-description { 60 | grid-column: 2 / span 2; 61 | height: 100%; 62 | overflow-wrap: break-word; 63 | } 64 | 65 | .box-container { 66 | display: grid; 67 | grid-template-rows: auto 1fr auto; 68 | height: 100%; 69 | } 70 | 71 | .box-header { 72 | height: 60px; 73 | display: flex; 74 | } 75 | 76 | .box-content { 77 | margin: 0px 20px; 78 | text-align: center; 79 | } 80 | 81 | .box-buttons { 82 | height: 50px; 83 | display: flex; 84 | align-items: center; 85 | justify-content: center; 86 | padding: 0px 30px; 87 | } 88 | 89 | .log-filters { 90 | display: flex; 91 | align-items: center; 92 | justify-content: center; 93 | grid-column-gap: 8px; 94 | margin-top: 5px; 95 | margin-bottom: 15px; 96 | } 97 | 98 | .log-container { 99 | display: grid; 100 | grid-template-columns: auto; 101 | grid-template-rows: 30px; 102 | grid-row-gap: 10px; 103 | max-height: 200px; 104 | overflow: hidden; 105 | overflow-y: scroll; 106 | justify-items: center; 107 | padding: 0px 10%; 108 | } 109 | 110 | .log-entry { 111 | background-color: #CCCCCC; 112 | color: #444444; 113 | height: 30px; 114 | line-height: 30px; 115 | padding: 0px 10px; 116 | width: 100%; 117 | } 118 | 119 | .log-entry:hover { 120 | background-color: #AAAAAA; 121 | } 122 | 123 | .button-filter { 124 | padding: 0px 15px; 125 | height: 22px; 126 | border: 1px solid #666666; 127 | border-radius: 4px; 128 | } 129 | 130 | .filter-action { 131 | background-color: #CC4444; 132 | } 133 | 134 | .filter-http { 135 | background-color: #44CC44; 136 | } 137 | 138 | .filter-event { 139 | background-color: #CC8844; 140 | } 141 | 142 | .hdr-help { 143 | align-self: center; 144 | margin-right: 16px; 145 | margin-left: -40px; 146 | width: 24px; 147 | height: 24px; 148 | } 149 | 150 | .h-help { 151 | align-self: center; 152 | margin-right: 12px; 153 | margin-left: -44px; 154 | width: 32px; 155 | height: 32px; 156 | } 157 | 158 | .hdr { 159 | margin: 0 auto; 160 | } 161 | 162 | .log-header { 163 | display: grid; 164 | grid-template-columns: auto auto min-content min-content; 165 | border-bottom: 1px solid #AAAAAA; 166 | align-items: center; 167 | column-gap: 20px; 168 | } 169 | 170 | .log-title { 171 | color: #666666; 172 | margin-left: 25px; 173 | } 174 | 175 | .log-time { 176 | float: right; 177 | text-align: center; 178 | font-family: 'Roboto', sans-serif; 179 | color: #666666; 180 | font-size: .75rem; 181 | font-weight: 500; 182 | text-transform: uppercase; 183 | margin: 0px; 184 | } 185 | 186 | .log-type { 187 | float: right; 188 | text-align: center; 189 | display: flex; 190 | justify-content: center; 191 | align-content: center; 192 | flex-direction: column; 193 | font-family: 'Roboto', sans-serif; 194 | color: #444444; 195 | font-size: .75rem; 196 | font-weight: 500; 197 | text-transform: uppercase; 198 | border-radius: 4px; 199 | height: 22px; 200 | padding: 0px 15px; 201 | margin: 0px; 202 | margin-right: 25px; 203 | } 204 | 205 | .log-text { 206 | font-family: monospace, sans-serif; 207 | color: #444444; 208 | margin: 40px 40px; 209 | font-size: 14px; 210 | white-space: pre-wrap; 211 | word-break: break-all; 212 | } 213 | 214 | .log-video { 215 | font-family: monospace, sans-serif; 216 | color: #444444; 217 | margin: 40px 40px; 218 | font-size: 14px; 219 | white-space: pre-wrap; 220 | word-break: break-all; 221 | display: flex; 222 | } 223 | 224 | .text-temp { 225 | height: 27px; 226 | width: 75%; 227 | } 228 | 229 | .text-unit { 230 | line-height: 27px; 231 | margin-left: -10px; 232 | float: right; 233 | } 234 | 235 | .text-area { 236 | width: 100%; 237 | white-space: pre-wrap; 238 | word-break: break-all; 239 | } 240 | 241 | .checkbox { 242 | height: auto; 243 | width: auto; 244 | margin-top: 7px; 245 | } 246 | 247 | .tip { 248 | position: relative; 249 | display: inline-block; 250 | } 251 | 252 | .tip .tip-text { 253 | visibility: hidden; 254 | width: 280px; 255 | background-color: #555; 256 | color: #fff; 257 | border-radius: 6px; 258 | padding: 20px 15px; 259 | position: absolute; 260 | z-index: 1; 261 | top: 125%; 262 | right: 0; 263 | opacity: 0; 264 | transition: opacity 0.3s; 265 | font-family: 'Roboto', sans-serif; 266 | font-weight: lighter; 267 | font-size: 15; 268 | } 269 | 270 | .tip .tip-text::after { 271 | content: ""; 272 | position: absolute; 273 | bottom: 100%; 274 | right: 7px; 275 | border-width: 5px; 276 | border-style: solid; 277 | border-color: transparent transparent #555 transparent; 278 | } 279 | 280 | .tip:hover .tip-text { 281 | visibility: visible; 282 | opacity: 0.90; 283 | } 284 | 285 | a:link { 286 | color: yellow; 287 | background-color: transparent; 288 | text-decoration: none; 289 | } 290 | 291 | a:visited { 292 | color: yellow; 293 | background-color: transparent; 294 | text-decoration: none; 295 | } 296 | 297 | a:hover { 298 | color: orange; 299 | background-color: transparent; 300 | text-decoration: none; 301 | } 302 | 303 | a:active { 304 | color: yellow; 305 | background-color: transparent; 306 | text-decoration: none; 307 | } 308 | 309 | h1 { 310 | font-family: 'Roboto', sans-serif; 311 | font-weight: lighter; 312 | text-align: center; 313 | line-height: 50px; 314 | color: #ffffff; 315 | margin: 0px; 316 | } 317 | 318 | h2 { 319 | font-family: 'Roboto', sans-serif; 320 | font-weight: normal; 321 | text-align: center; 322 | line-height: 60px; 323 | color: #888888; 324 | margin: 0px; 325 | } 326 | 327 | h3 { 328 | font-family: 'Roboto', sans-serif; 329 | line-height: 60px; 330 | font-weight: normal; 331 | color: #888888; 332 | margin: 0px; 333 | } 334 | 335 | h4 { 336 | font-family: 'Roboto', sans-serif; 337 | color: #444444; 338 | padding: 5px; 339 | font-size: .75rem; 340 | font-weight: 500; 341 | } 342 | 343 | button { 344 | font-family: 'Roboto', sans-serif; 345 | color: #444444; 346 | padding: 5px; 347 | font-size: .75rem; 348 | font-weight: 500; 349 | text-transform: uppercase; 350 | } 351 | 352 | button:focus { 353 | outline: 0; 354 | } 355 | 356 | button:disabled { 357 | opacity: 0.4; 358 | } 359 | 360 | td { 361 | font-family: 'Roboto', sans-serif; 362 | color: #666666; 363 | font-size: .9rem; 364 | } 365 | 366 | input { 367 | text-align: center; 368 | font-family: 'Roboto', sans-serif; 369 | color: #444444; 370 | border: none; 371 | height: 20px; 372 | width: 80%; 373 | } 374 | 375 | select { 376 | text-align: center; 377 | font-family: 'Roboto', sans-serif; 378 | color: #444444; 379 | border: none; 380 | height: 20px; 381 | width: 55%; 382 | text-align-last: center; 383 | -ms-text-align-last: center; 384 | -moz-text-align-last: center; 385 | } 386 | 387 | select:disabled { 388 | opacity: 0.2; 389 | } 390 | 391 | textarea { 392 | text-align: center; 393 | font-family: 'Roboto', sans-serif; 394 | color: #444444; 395 | border: none; 396 | height: 70px; 397 | width: 80%; 398 | overflow-x: hidden; 399 | } 400 | 401 | tbody { 402 | vertical-align: top; 403 | } 404 | 405 | video { 406 | width: 100%; 407 | background: #000000; 408 | } 409 | 410 | .disabled-text { 411 | opacity: 0.5; 412 | } 413 | 414 | ::placeholder { 415 | color: #BBBBBB; 416 | } 417 | 418 | ::-webkit-scrollbar { 419 | -webkit-appearance: none; 420 | width: 7px; 421 | } 422 | 423 | ::-webkit-scrollbar-thumb { 424 | border-radius: 4px; 425 | background-color: #888888; 426 | -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, .5); 427 | } 428 | 429 | #analyticsTable { 430 | text-align: center; 431 | border-spacing: 0 0px; 432 | margin-bottom: 10px; 433 | } 434 | 435 | #analyticsTable td { 436 | width: 25%; 437 | padding: 0px; 438 | margin: 0px; 439 | } 440 | 441 | #analyticsTable p { 442 | margin: 0px; 443 | } 444 | 445 | .analytics-row-label { 446 | border-radius: 6px 6px 0px 0px; 447 | background-color: #EFEFEF; 448 | width: 200px; 449 | display: inline-table; 450 | border-bottom: 1px solid #AAAAAA; 451 | } 452 | 453 | .analytics-row-label p { 454 | padding-top: 5px; 455 | } 456 | 457 | .analytics-row-value { 458 | border-radius: 0px 0px 6px 6px; 459 | background-color: #EFEFEF; 460 | width: 200px; 461 | display: inline-table; 462 | } 463 | 464 | .analytics-row-value p { 465 | padding: 5px 0px 5px 0px; 466 | white-space: nowrap; 467 | overflow: hidden; 468 | width: 200px; 469 | text-overflow: ellipsis; 470 | } 471 | -------------------------------------------------------------------------------- /public/ui.js: -------------------------------------------------------------------------------- 1 | 2 | /* Copyright 2022 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | /// UI Controller Functions - Buttons /// 19 | 20 | function clickSignIn() { 21 | if (isSignedIn) { 22 | pushLog(LogType.ACTION, "Sign Out", "Signing out."); 23 | signOut(); 24 | } else { 25 | pushLog(LogType.ACTION, "Sign In", "Signing in."); 26 | signIn(); 27 | } 28 | } 29 | 30 | function clickViewOAuthCode() { 31 | pushLog(LogType.ACTION, "View OAuth Code", oauthCode); 32 | } 33 | 34 | function clickViewAccessToken() { 35 | pushLog(LogType.ACTION, "View Access Token", accessToken); 36 | } 37 | 38 | function clickViewRefreshToken() { 39 | pushLog(LogType.ACTION, "View Refresh Token", refreshToken); 40 | } 41 | 42 | function clickSubscribe() { 43 | if(isSubscribed) { 44 | pushLog(LogType.ACTION, "Unsubscribe", "Unsubscribing from PubSub events on Google Cloud Platform"); 45 | updateSubscribed(false); 46 | } else { 47 | pushLog(LogType.ACTION, "Subscribe", "Subscribing to PubSub events on Google Cloud Platform"); 48 | if(!logFilter.includes(LogType.EVENT)) { 49 | onFilterEvent() 50 | } 51 | updateSubscribed(true); 52 | pubsubEvents(); 53 | } 54 | } 55 | 56 | function clickGetEvents() { 57 | pushLog(LogType.ACTION, "Get Events", "Getting PubSub events from Google Cloud Platform"); 58 | if(!logFilter.includes(LogType.EVENT)) { 59 | onFilterEvent() 60 | } 61 | pubsubEvents(); 62 | } 63 | 64 | function clickResourcePicker() { 65 | pushLog(LogType.ACTION, "Resource Picker", "Opening up Resource Picker"); 66 | openResourcePicker(); 67 | } 68 | 69 | function clickListDevices() { 70 | pushLog(LogType.ACTION, "List Devices", "Initiating List Devices call to Device Access API"); 71 | onListDevices(); 72 | } 73 | 74 | function clickListStructures() { 75 | pushLog(LogType.ACTION, "List Structures", "Initiating List Structures call to Device Access API"); 76 | onListStructures(); 77 | } 78 | 79 | function clickFanMode() { 80 | pushLog(LogType.ACTION, "Fan Mode", ""); 81 | onFan(); 82 | } 83 | 84 | function clickThermostatMode() { 85 | pushLog(LogType.ACTION, "Thermostat Mode", ""); 86 | onThermostatMode(); 87 | } 88 | 89 | function clickTemperatureSetpoint() { 90 | pushLog(LogType.ACTION, "Temperature Setpoint", ""); 91 | onTemperatureSetpoint(); 92 | } 93 | 94 | function clickGenerateStream() { 95 | pushLog(LogType.ACTION, "Camera Stream", "Initiating Camera Stream call to Device Access API"); 96 | onGenerateStream(); 97 | } 98 | 99 | function clickExtendStream() { 100 | pushLog(LogType.ACTION, "Refresh Stream", "Initiating Refresh Camera Stream call to Device Access API"); 101 | onExtendStream(); 102 | } 103 | 104 | function clickStopStream() { 105 | pushLog(LogType.ACTION, "Stop Stream", "Initiating Stop Camera Stream call to Device Access API"); 106 | onStopStream(); 107 | } 108 | 109 | function clickGenerateStream_WebRTC() { 110 | pushLog(LogType.ACTION, "Camera Stream", "Initiating Camera Stream (WebRTC) call to Device Access API"); 111 | onGenerateStream_WebRTC(); 112 | } 113 | 114 | function clickExtendStream_WebRTC() { 115 | pushLog(LogType.ACTION, "Refresh Stream", "Initiating Refresh Camera Stream call to Device Access API"); 116 | onExtendStream_WebRTC(); 117 | } 118 | 119 | function clickStopStream_WebRTC() { 120 | pushLog(LogType.ACTION, "Stop Stream", "Initiating Stop Camera Stream call to Device Access API"); 121 | onStopStream_WebRTC(); 122 | } 123 | 124 | /** clickClearLogs - Clears the list of logs, and the selected log */ 125 | function clickClearLogs() { 126 | document.getElementById("log-container").innerHTML = ''; 127 | document.getElementById("log-title").innerHTML = ''; 128 | document.getElementById("log-text").innerHTML = ''; 129 | document.getElementById("log-time").innerHTML = ''; 130 | document.getElementById("log-type").innerHTML = ''; 131 | localStorage.removeItem("logs"); 132 | logs = []; 133 | } 134 | 135 | 136 | /// UI Controller Functions - Text Input /// 137 | 138 | function typeClientId() { 139 | updateClientId(document.getElementById("txtClientId").value); 140 | } 141 | 142 | function typeClientSecret() { 143 | updateClientSecret(document.getElementById("txtClientSecret").value); 144 | } 145 | 146 | function typeProjectId() { 147 | updateProjectId(document.getElementById("txtProjectId").value); 148 | } 149 | 150 | function typeSubscriptionId() { 151 | updateSubscriptionId(document.getElementById("txtSubscriptionId").value); 152 | } 153 | 154 | function typeServiceAccountKey() { 155 | updateServiceAccountKey(document.getElementById("txtServiceAccountKey").value); 156 | } 157 | 158 | 159 | /// UI Controller Functions - Selector /// 160 | 161 | function selectDevice() { 162 | selectedDevice = JSON.parse(document.getElementById("sctDeviceList").value); 163 | pushLog(LogType.ACTION, "Select Device", "Device Selection changed to " + selectedDevice.name); 164 | showDeviceControls(); 165 | } 166 | 167 | 168 | /// UI Controller Functions - Buttons /// 169 | 170 | function updateClientId(value) { 171 | clientId = value; 172 | localStorage["clientId"] = clientId; 173 | document.getElementById("txtClientId").value = clientId; 174 | } 175 | 176 | function updateClientSecret(value) { 177 | clientSecret = value; 178 | localStorage["clientSecret"] = clientSecret; 179 | document.getElementById("txtClientSecret").value = clientSecret; 180 | } 181 | 182 | function updateProjectId(value) { 183 | projectId = value; 184 | localStorage["projectId"] = projectId; 185 | document.getElementById("txtProjectId").value = projectId; 186 | } 187 | 188 | function updateSubscriptionId(value) { 189 | subscriptionId = value; 190 | localStorage["subscriptionId"] = subscriptionId; 191 | document.getElementById("txtSubscriptionId").value = subscriptionId; 192 | } 193 | 194 | function updateServiceAccountKey(value) { 195 | serviceAccountKey = value; 196 | localStorage["serviceAccountKey"] = serviceAccountKey; 197 | document.getElementById("txtServiceAccountKey").value = serviceAccountKey; 198 | } 199 | 200 | function updateOfferSDP(value) { 201 | offerSDP = value; 202 | document.getElementById("txtOfferSDPCamera").value = offerSDP; 203 | document.getElementById("txtOfferSDPDoorbell").value = offerSDP; 204 | } 205 | 206 | function updateStreamExtensionToken(value) { 207 | streamExtensionToken = value; 208 | document.getElementById("txtExtensionToken1").value = streamExtensionToken; 209 | document.getElementById("txtExtensionToken2").value = streamExtensionToken; 210 | document.getElementById("txtExtensionToken3").value = streamExtensionToken; 211 | document.getElementById("txtExtensionToken4").value = streamExtensionToken; 212 | } 213 | 214 | function updateOAuthCode(value) { 215 | oauthCode = value; 216 | localStorage["oauthCode"] = oauthCode; 217 | 218 | if(value === "") { 219 | document.getElementById("imgOAuthCode").src = "images/empty.png"; 220 | document.getElementById("imgOAuthCode").alt = "Pending..."; 221 | document.getElementById("imgOAuthCode").title = ""; 222 | } else if(value === undefined) { 223 | document.getElementById("imgOAuthCode").src = "images/failure.png"; 224 | document.getElementById("imgOAuthCode").alt = "Failure!"; 225 | document.getElementById("imgOAuthCode").title = ""; 226 | } else { 227 | document.getElementById("imgOAuthCode").src = "images/success.png"; 228 | document.getElementById("imgOAuthCode").alt = "Success!"; 229 | document.getElementById("imgOAuthCode").title = oauthCode; 230 | } 231 | } 232 | 233 | function updateAccessToken(value) { 234 | accessToken = value; 235 | localStorage["accessToken"] = accessToken; 236 | 237 | if(value === "") { 238 | document.getElementById("imgAccessToken").src = "images/empty.png"; 239 | document.getElementById("imgAccessToken").alt = "Pending..."; 240 | document.getElementById("imgAccessToken").title = ""; 241 | } else if(value === undefined) { 242 | document.getElementById("imgAccessToken").src = "images/failure.png"; 243 | document.getElementById("imgAccessToken").alt = "Failure!"; 244 | document.getElementById("imgAccessToken").title = ""; 245 | } else { 246 | document.getElementById("imgAccessToken").src = "images/success.png"; 247 | document.getElementById("imgAccessToken").alt = "Success!"; 248 | document.getElementById("imgAccessToken").title = accessToken; 249 | } 250 | } 251 | 252 | function updateRefreshToken(value) { 253 | refreshToken = value; 254 | localStorage["refreshToken"] = refreshToken; 255 | 256 | if(value === "") { 257 | document.getElementById("imgRefreshToken").src = "images/empty.png"; 258 | document.getElementById("imgRefreshToken").alt = "Pending..."; 259 | document.getElementById("imgRefreshToken").title = ""; 260 | } else if(value === undefined) { 261 | document.getElementById("imgRefreshToken").src = "images/failure.png"; 262 | document.getElementById("imgRefreshToken").alt = "Failure!"; 263 | document.getElementById("imgRefreshToken").title = ""; 264 | } else { 265 | document.getElementById("imgRefreshToken").src = "images/success.png"; 266 | document.getElementById("imgRefreshToken").alt = "Success!"; 267 | document.getElementById("imgRefreshToken").title = refreshToken; 268 | } 269 | } 270 | 271 | function updateSignedIn(value) { 272 | isSignedIn = value; 273 | localStorage["isSignedIn"] = isSignedIn; 274 | 275 | if (isSignedIn) { 276 | document.getElementById("btnSignIn").innerText = "Sign Out"; 277 | } 278 | else { 279 | document.getElementById("btnSignIn").innerText = "Sign In"; 280 | } 281 | 282 | updateSubscribed(false); 283 | updateAppControls(); 284 | } 285 | 286 | function updateSubscribed(value) { 287 | isSubscribed = value; 288 | localStorage["isSubscribed"] = isSubscribed; 289 | 290 | if (isSubscribed) { 291 | document.getElementById("btnSubscribe").innerText = "UnSubscribe"; 292 | document.getElementById("txtSubscriptionId").disabled = true; 293 | document.getElementById("txtServiceAccountKey").disabled = true; 294 | document.getElementById("btnGetEvents").disabled = true; 295 | } 296 | else { 297 | document.getElementById("btnSubscribe").innerText = "Subscribe"; 298 | document.getElementById("txtSubscriptionId").disabled = false; 299 | document.getElementById("txtServiceAccountKey").disabled = false; 300 | document.getElementById("btnGetEvents").disabled = false; 301 | } 302 | } 303 | 304 | function updateLogFilter(value) { 305 | logFilter = value; 306 | localStorage["logFilter"] = logFilter; 307 | 308 | if (logFilter.includes(LogType.ACTION)) { 309 | document.getElementById("btnFilterAction").classList.add("filter-action"); 310 | } 311 | else { 312 | document.getElementById("btnFilterAction").classList.remove("filter-action"); 313 | } 314 | 315 | if (logFilter.includes(LogType.HTTP)) { 316 | document.getElementById("btnFilterHTTP").classList.add("filter-http"); 317 | } 318 | else { 319 | document.getElementById("btnFilterHTTP").classList.remove("filter-http"); 320 | } 321 | 322 | if (logFilter.includes(LogType.EVENT)) { 323 | document.getElementById("btnFilterEvent").classList.add("filter-event"); 324 | } 325 | else { 326 | document.getElementById("btnFilterEvent").classList.remove("filter-event"); 327 | } 328 | 329 | document.getElementById("log-container").innerHTML = ""; 330 | addLogEntries(logs); 331 | } 332 | 333 | function updateAppControls() { 334 | if(isSignedIn) { 335 | // Account Linking Controls: 336 | document.getElementById("txtClientId").disabled = true; 337 | document.getElementById("txtClientSecret").disabled = true; 338 | document.getElementById("txtProjectId").disabled = true; 339 | // Auth Token Controls: 340 | document.getElementById("imgOAuthCode").classList.remove("disabled-text"); 341 | document.getElementById("imgAccessToken").classList.remove("disabled-text"); 342 | document.getElementById("imgRefreshToken").classList.remove("disabled-text"); 343 | document.getElementById("hdrOAuthCode").classList.remove("disabled-text"); 344 | document.getElementById("hdrAccessToken").classList.remove("disabled-text"); 345 | document.getElementById("hdrRefreshToken").classList.remove("disabled-text"); 346 | document.getElementById("btnViewOAuthCode").disabled = false; 347 | document.getElementById("btnViewAccessToken").disabled = false; 348 | document.getElementById("btnViewRefreshToken").disabled = false; 349 | // Device Access Controls: 350 | document.getElementById("hdrDeviceAccess").classList.remove("disabled-text"); 351 | document.getElementById("btnResourcePicker").disabled = false; 352 | document.getElementById("btnListDevices").disabled = false; 353 | document.getElementById("btnListStructures").disabled = false; 354 | // Device Events Controls: 355 | document.getElementById("hdrDeviceEvents").classList.remove("disabled-text"); 356 | document.getElementById("txtSubscriptionId").disabled = false; 357 | document.getElementById("txtServiceAccountKey").disabled = false; 358 | document.getElementById("btnSubscribe").disabled = false; 359 | document.getElementById("btnGetEvents").disabled = false; 360 | // Device Control Controls: 361 | document.getElementById("hdrDeviceControl").classList.remove("disabled-text"); 362 | document.getElementById("sctDeviceList").disabled = false; 363 | } else { 364 | // Account Linking Controls: 365 | document.getElementById("txtClientId").disabled = false; 366 | document.getElementById("txtClientSecret").disabled = false; 367 | document.getElementById("txtProjectId").disabled = false; 368 | // Auth Token Controls: 369 | document.getElementById("imgOAuthCode").classList.add("disabled-text"); 370 | document.getElementById("imgAccessToken").classList.add("disabled-text"); 371 | document.getElementById("imgRefreshToken").classList.add("disabled-text"); 372 | document.getElementById("hdrOAuthCode").classList.add("disabled-text"); 373 | document.getElementById("hdrAccessToken").classList.add("disabled-text"); 374 | document.getElementById("hdrRefreshToken").classList.add("disabled-text"); 375 | document.getElementById("btnViewOAuthCode").disabled = true; 376 | document.getElementById("btnViewAccessToken").disabled = true; 377 | document.getElementById("btnViewRefreshToken").disabled = true; 378 | // Device Access Controls 379 | document.getElementById("hdrDeviceAccess").classList.add("disabled-text"); 380 | document.getElementById("btnResourcePicker").disabled = true; 381 | document.getElementById("btnListDevices").disabled = true; 382 | document.getElementById("btnListStructures").disabled = true; 383 | // Device Events Controls 384 | document.getElementById("hdrDeviceEvents").classList.add("disabled-text"); 385 | document.getElementById("txtSubscriptionId").disabled = true; 386 | document.getElementById("txtServiceAccountKey").disabled = true; 387 | document.getElementById("btnSubscribe").disabled = true; 388 | document.getElementById("btnGetEvents").disabled = true; 389 | // Device Control Controls: 390 | document.getElementById("hdrDeviceControl").classList.add("disabled-text"); 391 | document.getElementById("sctDeviceList").disabled = true; 392 | } 393 | } 394 | 395 | function showDeviceControls() { 396 | hideDeviceControls(); 397 | console.log("DEV", selectedDevice); 398 | let controlArea = selectedDevice.type.toLowerCase() + "-control"; 399 | document.getElementById(controlArea).removeAttribute("hidden"); 400 | } 401 | 402 | function hideDeviceControls() { 403 | document.getElementById("thermostat-control").setAttribute("hidden", true); 404 | document.getElementById("camera-control").setAttribute("hidden", true); 405 | document.getElementById("doorbell-control").setAttribute("hidden", true); 406 | document.getElementById("camera-webrtc-control").setAttribute("hidden", true); 407 | document.getElementById("doorbell-webrtc-control").setAttribute("hidden", true); 408 | } 409 | 410 | function updateAnalytics() { 411 | // Initialize webRtc => setLocalDescription success (page launch) 412 | let analyticsInitialized= "-"; 413 | if (timestampSetLocalDescriptionSuccess != undefined && timestampInitializeWebRTC != undefined) { 414 | analyticsInitialized = timestampSetLocalDescriptionSuccess - timestampInitializeWebRTC + "ms"; 415 | } 416 | 417 | // Send SDP Offer (generate stream) => Receive SDP Answer (generate stream response) 418 | let analyticsAnswerReceived = "-"; 419 | if (timestampGenerateStreamResponse != undefined && timestampGenerateWebRtcStreamRequest != undefined) { 420 | analyticsAnswerReceived = timestampGenerateStreamResponse - timestampGenerateWebRtcStreamRequest + "ms"; 421 | } 422 | 423 | // Receive SDP Answer (generate stream response) => Connected 424 | let analyticsConnected= "-"; 425 | if (timestampConnected != undefined && timestampGenerateStreamResponse != undefined) { 426 | analyticsConnected = timestampConnected - timestampGenerateStreamResponse + "ms"; 427 | } 428 | 429 | // Connected => Playback Started 430 | let analyticsPlaybackStarted = "-"; 431 | if (timestampPlaybackStarted != undefined && timestampConnected != undefined) { 432 | analyticsPlaybackStarted = timestampPlaybackStarted - timestampConnected + "ms"; 433 | } 434 | 435 | document.getElementById("analyticsInitialized").innerHTML = analyticsInitialized; 436 | document.getElementById("analyticsAnswerReceived").innerHTML = analyticsAnswerReceived; 437 | document.getElementById("analyticsConnected").innerHTML = analyticsConnected; 438 | document.getElementById("analyticsPlaybackStarted").innerHTML = analyticsPlaybackStarted; 439 | } 440 | 441 | 442 | /// Logging Functions /// 443 | 444 | function showLogEntry(index){ 445 | let logTitle = document.getElementById("log-title"); 446 | let logText = document.getElementById("log-text"); 447 | let logTime = document.getElementById("log-time"); 448 | let logType = document.getElementById("log-type"); 449 | 450 | logTitle.textContent = filteredLogs[index].title; 451 | logText.textContent = filteredLogs[index].text; 452 | logTime.textContent = filteredLogs[index].time; 453 | logType.textContent = filteredLogs[index].type; 454 | 455 | logType.classList.remove("filter-action"); 456 | logType.classList.remove("filter-http"); 457 | logType.classList.remove("filter-event"); 458 | 459 | switch (filteredLogs[index].type) { 460 | case LogType.ACTION : 461 | logType.classList.add("filter-action"); 462 | break; 463 | case LogType.HTTP : 464 | logType.classList.add("filter-http"); 465 | break; 466 | case LogType.EVENT : 467 | logType.classList.add("filter-event"); 468 | break; 469 | } 470 | 471 | if (filteredLogs[index].status === LogStatus.ERROR) { 472 | logText.setAttribute("style", "color: #AA0000;"); 473 | } else { 474 | logText.removeAttribute("style"); 475 | } 476 | 477 | if(logTitle.textContent.includes("Video Stream")) { 478 | document.getElementById("log-video").removeAttribute("style"); 479 | document.getElementById("video-stream").removeAttribute("hidden"); 480 | document.getElementById("log-text").setAttribute("hidden", ""); 481 | } else { 482 | document.getElementById("log-video").setAttribute("style", "display: none;"); 483 | document.getElementById("video-stream").setAttribute("hidden", ""); 484 | document.getElementById("log-text").removeAttribute("hidden"); 485 | } 486 | } 487 | -------------------------------------------------------------------------------- /public/webrtc.js: -------------------------------------------------------------------------------- 1 | 2 | /* Copyright 2022 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | // WebRTC Variables: 19 | let localPeerConnection; 20 | let localSendChannel; 21 | let localStream; 22 | let remoteStream; 23 | let offerSDP = ""; 24 | let initialized = false; 25 | let videoElement; 26 | 27 | 28 | // WebRTC Configurations: 29 | const localOfferOptions = { 30 | offerToReceiveVideo: 1, 31 | offerToReceiveAudio: 1, 32 | }; 33 | 34 | const mediaStreamConstraints = { 35 | audio: false, 36 | video: false, 37 | }; 38 | 39 | 40 | /// WebRTC Analytics /// 41 | 42 | // Page launch 43 | let timestampInitializeWebRTC; 44 | let timestampStartLocalStream; 45 | let timestampCreateSdpOffer; 46 | let timestampCreateSdpOfferSuccess; 47 | let timestampSetLocalDescription; 48 | let timestampSetLocalDescriptionSuccess; 49 | 50 | // Camera Stream button pressed 51 | let timestampGenerateStreamRequest; 52 | let timestampGenerateWebRtcStreamRequest; // senderSdpOffer 53 | let timestampGenerateStreamResponse; // sendSdpOffer success / timestampSdpAnswerReceived 54 | let timestampExtendStreamRequest; 55 | let timestampExtendWebRtcStreamRequest; 56 | let timestampExtendStreamResponse; 57 | let timestampStopStreamRequest; 58 | let timestampStopWebRtcStreamRequest; 59 | let timestampStopStreamResponse; 60 | let timestampSetRemoteDescription; 61 | let timestampSetRemoteDescriptionSuccess; 62 | let timestampConnected; 63 | let timestampPlaybackStarted; 64 | 65 | 66 | /// WebRTC Functions /// 67 | 68 | /** initializeWebRTC - Triggers starting a new WebRTC stream on initialization */ 69 | function initializeWebRTC() { 70 | if(initialized===true) 71 | return; 72 | timestampInitializeWebRTC = new Date(); 73 | updateAnalytics(); 74 | console.log(`initializeWebRTC() - `, timestampInitializeWebRTC); 75 | 76 | videoElement = document.getElementById('video-stream'); 77 | videoElement.addEventListener('play', (event) => { 78 | timestampPlaybackStarted = new Date(); 79 | updateAnalytics(); 80 | console.log('playback started - ', timestampPlaybackStarted); 81 | }); 82 | 83 | initialized = true; 84 | startLocalStream(); 85 | } 86 | 87 | /** startLocalStream - Starts a WebRTC stream on the browser */ 88 | function startLocalStream(mediaStream) { 89 | timestampStartLocalStream = new Date(); 90 | updateAnalytics(); 91 | console.log(`startLocalStream() - `, timestampStartLocalStream); 92 | localPeerConnection = null; 93 | localSendChannel = null; 94 | localStream = null; 95 | offerSDP = ""; 96 | 97 | remoteStream = new MediaStream(); 98 | 99 | const servers = { 'sdpSemantics': 'unified-plan', 'iceServers': [] }; 100 | localPeerConnection = new RTCPeerConnection(servers); 101 | localPeerConnection.ondatachannel = receiveChannelCallback; 102 | 103 | localSendChannel = localPeerConnection.createDataChannel('dataSendChannel', null); 104 | localPeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange); 105 | 106 | if(mediaStream) { 107 | mediaStream.getTracks().forEach((track) => { 108 | localPeerConnection.addTrack(track, mediaStream); 109 | console.log(`track added!`); 110 | }); 111 | localStream = mediaStream; 112 | } 113 | 114 | localPeerConnection.addEventListener('track', gotRemoteMediaTrack); 115 | 116 | timestampCreateSdpOffer = new Date(); 117 | updateAnalytics(); 118 | console.log('localPeerConnection createOffer start - ', timestampCreateSdpOffer); 119 | localPeerConnection.createOffer(localOfferOptions) 120 | .then(createdOffer).catch(setSessionDescriptionError); 121 | } 122 | 123 | /** createdOffer - Handles local offerSDP creation */ 124 | function createdOffer(description) { 125 | timestampCreateSdpOfferSuccess = new Date(); 126 | updateAnalytics(); 127 | console.log(`createdOffer() - `, timestampCreateSdpOfferSuccess); 128 | updateOfferSDP(description.sdp); 129 | timestampSetLocalDescription = new Date(); 130 | updateAnalytics(); 131 | console.log(`setLocalDescription() - `, timestampSetLocalDescription); 132 | localPeerConnection.setLocalDescription(description) 133 | .then(() => { 134 | setLocalDescriptionSuccess(localPeerConnection); 135 | }).catch(setSessionDescriptionError); 136 | } 137 | 138 | /** updateWebRTC - Updates WebRTC connection on receiving answerSDP */ 139 | function updateWebRTC(answerSDP) { 140 | console.log(`Answer from remotePeerConnection:\n${answerSDP} - `); 141 | if (answerSDP[answerSDP.length - 1] !== '\n') { 142 | answerSDP += '\n'; 143 | } 144 | 145 | timestampSetRemoteDescription = new Date(); 146 | updateAnalytics(); 147 | console.log(`setRemoteDescription() - `, timestampSetRemoteDescription); 148 | localPeerConnection.setRemoteDescription({ "type": "answer", "sdp": answerSDP }) 149 | .then(() => { 150 | setRemoteDescriptionSuccess(localPeerConnection); 151 | }).catch(setSessionDescriptionError); 152 | } 153 | 154 | 155 | /// Helper Functions /// 156 | 157 | /** getPeerName - Handles received peer name */ 158 | function getPeerName(peerConnection) { 159 | console.log(`getPeerName()`); 160 | return (peerConnection === localPeerConnection) ? 161 | 'localPeerConnection' : 'remotePeerConnection'; 162 | } 163 | 164 | /** gotRemoteMediaTrack - Handles received media track */ 165 | function gotRemoteMediaTrack(event) { 166 | console.log(`gotRemoteMediaTrack()`); 167 | remoteStream.addTrack(event.track); 168 | document.getElementById("video-stream").srcObject = remoteStream; 169 | console.log('Received remote track.'); 170 | } 171 | 172 | /** receiveChannelCallback - Handles received channel callback */ 173 | const receiveChannelCallback = (event) => { 174 | console.log('receiveChannelCallback'); 175 | const receiveChannel = event.channel; 176 | receiveChannel.onmessage = handleReceiveMessage; 177 | }; 178 | 179 | /** setDescriptionSuccess - Handles received success description */ 180 | function setDescriptionSuccess(peerConnection, functionName) { 181 | console.log(`setDescriptionSuccess()`); 182 | const peerName = getPeerName(peerConnection); 183 | console.log(`${peerName} ${functionName} complete`); 184 | } 185 | 186 | /** setLocalDescriptionSuccess - Handles received local success description */ 187 | function setLocalDescriptionSuccess(peerConnection) { 188 | timestampSetLocalDescriptionSuccess = new Date(); 189 | updateAnalytics(); 190 | console.log(`setLocalDescriptionSuccess() - `, timestampSetLocalDescriptionSuccess); 191 | setDescriptionSuccess(peerConnection, 'setLocalDescription'); 192 | } 193 | 194 | /** setRemoteDescriptionSuccess - Handles received remote success description */ 195 | function setRemoteDescriptionSuccess(peerConnection) { 196 | timestampSetRemoteDescriptionSuccess = new Date(); 197 | updateAnalytics(); 198 | console.log(`setRemoteDescriptionSuccess() - `, timestampSetRemoteDescriptionSuccess); 199 | setDescriptionSuccess(peerConnection, 'setRemoteDescription'); 200 | } 201 | 202 | /** setSessionDescriptionError - Handles session description error */ 203 | function setSessionDescriptionError(error) { 204 | console.log(`Failed to create session description: ${error.toString()}.`); 205 | } 206 | 207 | /** handleLocalMediaStreamError - Handles media stream error */ 208 | function handleLocalMediaStreamError(error) { 209 | console.log(`navigator.getUserMedia error: ${error.toString()}.`); 210 | } 211 | 212 | /** handleReceiveMessage - Handles receiving message */ 213 | const handleReceiveMessage = (event) => { 214 | console.log(`Incoming DataChannel push: ${event.data}`); 215 | }; 216 | 217 | /** handleConnectionChange - Handles connection change */ 218 | function handleConnectionChange(event) { 219 | console.log('ICE state change event: ', event); 220 | if (event != null && event.currentTarget != null && event.target.iceConnectionState == "connected") { 221 | if (timestampConnected == undefined) { 222 | timestampConnected = new Date(); 223 | console.log(`connected - `, timestampConnected); 224 | updateAnalytics(); 225 | } 226 | } 227 | } 228 | 229 | /** clearAnalytics - Clear analytics timestamps */ 230 | function clearAnalytics(cameraAnalyticsOnly = false) { 231 | console.log('Clearing analytics'); 232 | 233 | if (!cameraAnalyticsOnly) { 234 | // Page launch 235 | timestampInitializeWebRTC = undefined; 236 | timestampStartLocalStream = undefined; 237 | timestampCreateSdpOffer = undefined; 238 | timestampCreateSdpOfferSuccess = undefined; 239 | timestampSetLocalDescription = undefined; 240 | timestampSetLocalDescriptionSuccess = undefined; 241 | } 242 | 243 | // Camera Stream button pressed 244 | timestampGenerateStreamRequest = undefined; 245 | timestampGenerateWebRtcStreamRequest = undefined; 246 | timestampGenerateStreamResponse = undefined; 247 | timestampExtendStreamRequest = undefined; 248 | timestampExtendWebRtcStreamRequest = undefined; 249 | timestampExtendStreamResponse = undefined; 250 | timestampStopStreamRequest = undefined; 251 | timestampStopWebRtcStreamRequest = undefined; 252 | timestampStopStreamResponse = undefined; 253 | timestampSetRemoteDescription = undefined; 254 | timestampSetRemoteDescriptionSuccess = undefined; 255 | timestampConnected = undefined; 256 | timestampPlaybackStarted = undefined; 257 | 258 | updateAnalytics(); 259 | } 260 | --------------------------------------------------------------------------------