├── package.json ├── .gitignore ├── README.md ├── index.html ├── LICENSE └── usb-power-profiling.js /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "usb-power-profiling", 3 | "version": "1.6.0", 4 | "description": "Make USB power meters usable with the Firefox Profiler", 5 | "homepage": "https://github.com/fqueze/usb-power-profiling/", 6 | "repository": "github:fqueze/usb-power-profiling", 7 | "author": "Florian Quèze", 8 | "license": "MPL-2.0", 9 | "scripts": { 10 | "start": "npx nodemon usb-power-profiling.js" 11 | }, 12 | "dependencies": { 13 | "crc-full": "^1.1.0", 14 | "node-hid": "^3.0.0", 15 | "serialport": "^12.0.0", 16 | "serve-handler": "^6.1.6", 17 | "usb": "^2.9.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Make the data from USB power meters usable in the Firefox Profiler. 2 | 3 | # Quick start 4 | The following instructions will start a server on `localhost:2121`. 5 | ``` 6 | git clone https://github.com/fqueze/usb-power-profiling.git 7 | cd usb-power-profiling 8 | npm i 9 | node usb-power-profiling.js 10 | ``` 11 | 12 | Then open [the Firefox Profiler UI](https://profiler.firefox.com/from-url/http%3A%2F%2Flocalhost%3A2121%2Fprofile/calltree/) to load the data from the meter. You can also load [http://localhost:2121/](http://localhost:2121/) in your browser to see a live power profile (updated every 5 seconds). 13 | 14 | ## External power profiling in the Firefox Profiler 15 | In Firefox 121 or later: 16 | - in `about:config`, set the `devtools.performance.recording.power.external-url` preference to `http://localhost:2121/power`. 17 | - use the 'power' preset (or any configuration that uses the 'power' feature) when starting the profiler. 18 | - When capturing the profile, the Firefox Profiler will automatically fetch additional power tracks and add them to the profile. 19 | 20 | ## Seeing a profile containing only the data from the USB power meter 21 | 22 | [Load](https://profiler.firefox.com/from-url/http%3A%2F%2Flocalhost%3A2121%2Fprofile/calltree/?v=10) `http://localhost:2121/profile` in the [Firefox Profiler](https://profiler.firefox.com). 23 | 24 | ## HTTP API 25 | - `GET /profile` will return a profile containing all the data since the script has started. You can view it by [loading it](https://profiler.firefox.com/from-url/http%3A%2F%2Flocalhost%3A2121%2Fprofile/calltree/?v=10) in the [Firefox Profiler](https://profiler.firefox.com). 26 | - `GET /power?start=&end=` returns only a power track to be added into a profile from the Gecko Profiler. The start timestamp should be `profile.meta.startTime + profile.meta.profilingStartTime` from the profile and the end timestamp should be `profile.meta.startTime + profile.meta.profilingEndTime`. 27 | - `GET /rawdata?last=` returns all the stored data in JSON format if the last timestamp is omitted, or all data more recent than the provided last timestamp if it is provided. This API is used by the live profiling web UI. 28 | 29 | # Supported devices 30 | ## Power meters known to work 31 | The example profiles are taken using a USB light, first keeping the light off for a while to record noise from the power meter, then turning the light on at different levels of brightness for about 5s, and finally turning the light off again. 32 | |Brand|Model|Example profile|Min interval between samples|Notes| 33 | |---|---|---|---|---| 34 | |ChargerLab Power-Z|FL001 Super|https://share.firefox.dev/4714rQQ|32ms| | 35 | |ChargerLab Power-Z|KM001Pro |https://share.firefox.dev/4ag8xqN|2ms| | 36 | |ChargerLab Power-Z|KT002 |https://share.firefox.dev/3RkPsvf|1ms|Samples contain timestamps in µs, and sampling is driven by the power meter, making the sampling rate very consistent (no degradation of the data when the USB bus is busy)| 37 | |ChargerLab Power-Z|KM003C |https://share.firefox.dev/3Rg6z15|1ms|Sampling driven by the computer, causing overhead on the computer and relying on the USB communication being smooth.| 38 | |Shizuku YK-Lab|YK001 |||See Power-Z KT002. Alternative names: AVHzY CT-3, Power-Z KT002, or ATORCH UT18.| 39 | |Shizuku YK-Lab|YK003C|||See AVHzY C3.| 40 | |AVHzY|CT-3 |||See Power-Z KT002. | 41 | |AVHzY|C3 |https://share.firefox.dev/41BVhcf|1ms|Samples contain timestamps in µs, and sampling is driven by the power meter, making the sampling rate very consistent (no degradation of the data when the USB bus is busy)| 42 | |AVHzY|TC66C (RD)|||See RuiDeng TC66C| 43 | |FNIRSI|C1 |https://share.firefox.dev/4asQhLh|10ms|Significant power use changes are smoothed over 500ms.| 44 | |FNIRSI|FNB48S|https://share.firefox.dev/3RjtVTl|10ms|Significant power use changes are smoothed over 120ms.| 45 | |RuiDeng|TC66C|https://share.firefox.dev/3v4AvFV|80ms|Sampling rate depends on how much data is displayed on the power meter's screen. With the full display, 100ms is the minimum interval between samples. With only the main 3 values displayed, 90ms is in the minimum between samples, with a static screen (eg. settings) the minimum interval is 80ms. Significant power changes take up to [500ms to stabilize](https://share.firefox.dev/48w6Hkc) (with a few samples showing only a part of the change).| 46 | |WITRN|C5|https://share.firefox.dev/41nqAaQ|10ms|Samples contain timestamps in ms.| 47 | |ATORCH|ACD15P|https://share.firefox.dev/3SIWpGS|1s|Very low sampling rate.| 48 | |YZXStudio|1280E|https://share.firefox.dev/3Wr9HeW|250ms|Low sampling rate. Significant power changes take up to 500ms (2 samples) to stabilize. Seems to have a low level of noise, making it possible to see a difference between low power values ( <10mW) and 0.| 49 | ## Power meters likely to work 50 | Compatibility with these devices has not been verified, but they are likely to either "just work", or work with a trivial adjustment to the code (eg. tweak a USB product id). 51 | |Brand|Model|Notes| 52 | |---|---|---| 53 | |ChargerLab Power-Z|KM002C|Same protocol as the KM003C.| 54 | |FNIRSI|FNB48|Expected to use the same protocol as the FNIRSI C1.| 55 | |FNIRSI|FNB48P|Expected to be the same as the FNIRSI FNB48S in a different package.| 56 | |FNIRSI|FNB58|Expected to use the same protocol as the FNIRSI FNB48S.| 57 | |RuiDeng|TC66|Expected to use the same protocol as the TC66C.| 58 | |WITRN|A2|Expected to use the same protocol as the C5.| 59 | |WITRN|A2L|Expected to use the same protocol as the C5.| 60 | |WITRN|A2C|Expected to use the same protocol as the C5.| 61 | |WITRN|U3|Expected to use the same protocol as the C5.| 62 | |WITRN|U3L|Expected to use the same protocol as the C5.| 63 | |WITRN|C4 / C4L|Expected to be the same as the C5 with lower data precision.| 64 | |ATORCH|C13P|Expected to be the same as the ACD15P.| 65 | 66 | # Installation on Windows 67 | Windows requires the WinUSB driver to be installed and bound in order to work. 68 | 69 | The instructions below are for the `AVHzY CT-3`. The USB ID at least will be different for other devices. 70 | - Install Zadig driver installer from https://zadig.akeo.ie/ 71 | - Plug in the USB power meter and run the Zadig application 72 | - Disable the Options -> Ignore Hubs or Composite Parents option in the menu 73 | - Select the USB device for the meter (for CT-3 it is USB ID 0483 FFFE) 74 | - Click Replace to install the default WinUSB driver 75 | 76 | With those steps, it should now be possible to run the node application above and sample power. 77 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | USB power profiling 6 | 86 | 87 | 88 |

USB live profile: W

89 |

usb-power-profiling base URL: http:///

90 |
91 |
92 | 93 | 94 | 95 |
96 |

USB, 0 samples.

97 |
    98 |
  • Energy used: Wh in
  • 99 |
  • Average power: W
  • 100 |
  • Max power: W
  • 101 |
  • Median power: W
  • 102 |
103 |
104 |

Open in the Firefox Profiler. Download as: csv, profile.

105 | 386 |
This work © 2024 by Florian Quèze is licensed under CC BY-NC 4.0CCBYNC
387 | 388 | 389 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /usb-power-profiling.js: -------------------------------------------------------------------------------- 1 | const { usb, getDeviceList } = require('usb'); 2 | const HID = require('node-hid'); 3 | const http = require('http'); 4 | const url = require('url'); 5 | const { createDecipheriv } = require('node:crypto'); 6 | const { SerialPort } = require('serialport'); 7 | const { CRC } = require('crc-full'); 8 | const serveHandler = require('serve-handler'); 9 | 10 | const CHARGER_LAB_VENDOR_ID = 0x5FC9; 11 | const FNIRSI_VENDOR_ID = 0x2E3C; 12 | const KINGMETER_VENDOR_ID = 0x416; 13 | const CHARGER_LAB_BLUE_VENDOR_ID = 0x472; 14 | const GENERIC_VENDOR_ID = 0x483; 15 | const SHIZUKU_PRODUCT_IDS = [0xFFFF, 0xFFFE, 0x374B]; 16 | const FNIRSI_PRODUCT_IDS = [0x3A, 0x3B]; 17 | const KINGMETER_PRODUCT_IDS = [0x5750, 0x5f50]; 18 | const WITRN_VENDOR_ID = 0x716; 19 | const RUIDENG_VENDOR_ID = 0x28e9; 20 | const YZXSTUDIO_VENDOR_ID = 0x1a86; 21 | 22 | const DEBUG = false;//true; 23 | const DEBUG_log = DEBUG ? console.log : () => {}; 24 | 25 | const MAX_SAMPLES = 4000000; // About 1.5h at 1kHz. 26 | 27 | const gDevices = []; 28 | var startTime, startPerformanceNow; 29 | var gClosing; 30 | 31 | function LogSampling() { 32 | console.log(new Date(), "Sampling..."); 33 | } 34 | 35 | function roundToNanoSecondPrecision(timeMs) { 36 | return Math.round(timeMs * 1e6) / 1e6; 37 | } 38 | 39 | function int32Bytes(number) { 40 | const buffer = Buffer.alloc(4); 41 | buffer.writeInt32LE(number); 42 | return buffer; 43 | } 44 | 45 | function resetDevice(device) { 46 | return new Promise((resolve, reject) => { 47 | if (process.env.DISABLE_USB_POWER_METER_RESET) { 48 | resolve(); 49 | } else { 50 | device.reset(error => { 51 | if (error) { 52 | console.log("failed to reset device", error); 53 | reject(error); 54 | } else { 55 | resolve(); 56 | } 57 | }); 58 | } 59 | } 60 | ); 61 | } 62 | 63 | function sendBuffer(endPointOut, buffer) { 64 | return new Promise((resolve, reject) => { 65 | endPointOut.transfer(buffer, err => { 66 | if (err) { 67 | console.log("error while sending:", err); 68 | reject(err); 69 | } 70 | resolve(); 71 | }); 72 | }); 73 | } 74 | 75 | function findBulkInOutEndPoints(device) { 76 | let endPointIn, endPointOut; 77 | for (let interface of device.interfaces) { 78 | let claimed = false; 79 | for (let endPoint of interface.endpoints) { 80 | if (endPoint.transferType != usb.LIBUSB_TRANSFER_TYPE_BULK) { 81 | continue; 82 | } 83 | if (endPoint.direction == "in" && !endPointIn) { 84 | endPointIn = endPoint; 85 | if (!claimed) { 86 | claimed = true; 87 | interface.claim(); 88 | } 89 | } 90 | if (endPoint.direction == "out" && !endPointOut) { 91 | endPointOut = endPoint; 92 | if (interface.isKernelDriverActive()) { 93 | try { 94 | // Only required on linux to be able to claim 95 | // the interface, otherwise a LIBUSB_ERROR_BUSY error 96 | // is thrown 97 | interface.detachKernelDriver(); 98 | } catch (e) { 99 | // Throws failure on non-linux platforms 100 | } 101 | } 102 | if (!claimed) { 103 | claimed = true; 104 | interface.claim(); 105 | } 106 | } 107 | } 108 | } 109 | return [endPointIn, endPointOut]; 110 | } 111 | 112 | function addSample(self, time, power) { 113 | // Limit the time precision to 1 µs. 114 | time = Math.round(time * 1000) / 1000; 115 | 116 | if (self.sampleTimes.length < MAX_SAMPLES) { 117 | // Increase the buffer size if we have not reached MAX_SAMPLES yet. 118 | self.sampleTimes.push(time); 119 | self.samples.push(power); 120 | } else { 121 | // Otherwise, treat it as a ring buffer to avoid the cost of moving memory. 122 | self.samples[self.firstSample] = power; 123 | self.sampleTimes[self.firstSample] = time; 124 | self.firstSample = (self.firstSample + 1) % MAX_SAMPLES; 125 | } 126 | } 127 | 128 | function reorderSamples(self) { 129 | if (self.firstSample == 0) { 130 | return; 131 | } 132 | 133 | let samplesEnd = self.samples.splice(0, self.firstSample); 134 | self.samples = self.samples.concat(samplesEnd); 135 | let sampleTimesEnd = self.sampleTimes.splice(0, self.firstSample); 136 | self.sampleTimes = self.sampleTimes.concat(sampleTimesEnd); 137 | self.firstSample = 0; 138 | } 139 | 140 | function PowerZDevice(device) { 141 | this.endPointIn = null; 142 | this.endPointOut = null; 143 | } 144 | 145 | PowerZDevice.prototype = { 146 | async sample() { 147 | const CMD_GET_DATA = 0x0C; 148 | const ATT_ADC = 0x001; 149 | await sendBuffer(this.endPointOut, 150 | Buffer.from([CMD_GET_DATA, 0, ATT_ADC << 1, 0])); 151 | 152 | let data = await new Promise((resolve, reject) => { 153 | this.endPointIn.transfer(64, (error, data) => { 154 | if (error) { 155 | console.log("error reading data", error); 156 | reject(error); 157 | } 158 | resolve(data); 159 | }); 160 | }); 161 | 162 | let v = data.readInt32LE(8); 163 | let i = data.readInt32LE(12); 164 | return (v / 1e6) * (i / 1e6); 165 | }, 166 | 167 | async startSampling() { 168 | try { 169 | await resetDevice(this.device); 170 | [this.endPointIn, this.endPointOut] = findBulkInOutEndPoints(this.device); 171 | } catch(e) { 172 | console.log(e); 173 | } 174 | 175 | if (!this.endPointOut || !this.endPointIn) { 176 | console.log("failed to find endpoints"); 177 | return; 178 | } 179 | 180 | LogSampling(); 181 | let previousW = 0; 182 | let w = 0; 183 | this.interval = setInterval(async () => { 184 | if (this._promise) { 185 | return; 186 | } 187 | do { 188 | try { 189 | this._promise = this.sample(); 190 | w = Math.abs(await this._promise); 191 | this._promise = null; 192 | if (gClosing) { 193 | return; 194 | } 195 | } catch(e) { 196 | if (e.code == "ERR_BUFFER_OUT_OF_BOUNDS") { 197 | // We sometimes get this error on the first sample when the previous 198 | // shutdown wasn't clean. 199 | // The next samples work fine, so just ignore the error. 200 | continue; 201 | } 202 | console.log("aborting sampling", e); 203 | return this.stopSampling(); 204 | } 205 | } while (w == previousW); 206 | previousW = w; 207 | addSample(this, 208 | roundToNanoSecondPrecision(performance.now() - startPerformanceNow), 209 | w); 210 | }, 1); 211 | }, 212 | 213 | async stopSampling() { 214 | clearInterval(this.interval); 215 | if (this._promise) { 216 | try { 217 | await this._promise; 218 | } catch(e) {} 219 | } 220 | this.endPointIn = null; 221 | this.endPointOut = null; 222 | } 223 | }; 224 | 225 | function FnirsiDevice(device) { 226 | } 227 | 228 | FnirsiDevice.prototype = { 229 | _crc: new CRC("CRC", 8, 0x39, 0x42), 230 | checksum(data) { return this._crc.compute(data.slice(1,63)); }, 231 | 232 | COMMON_PREFIX: 0xAA, 233 | // Not sure what this does, things work as well without sending it. 234 | CMD_INIT: 0x81, 235 | // Request samples from the device. Every 40ms the device will send a data 236 | // packet containing 4 samples. Sampling will stop after 1s. 237 | CMD_START_SAMPLING: 0x82, 238 | // If sent before the end of the 1s sampling time, this command will make 239 | // sampling continue for another 1s. Not sure why this command is needed, 240 | // as just repeatedly sending CMD_START_SAMPLING seems to work just fine. 241 | CMD_CONTINUE_SAMPLING: 0x83, 242 | 243 | sendCommand(cmd) { 244 | var outData = new Array(64).fill(0); 245 | outData[0] = this.COMMON_PREFIX; 246 | outData[1] = cmd; 247 | outData[63] = this.checksum(outData); 248 | return this.hidDevice.write(outData); 249 | }, 250 | 251 | async startSampling() { 252 | var previousSampleTime = performance.now(); 253 | const {idVendor, idProduct} = this.device.deviceDescriptor; 254 | this.hidDevice = new HID.HID(idVendor, idProduct); 255 | this.hidDevice.on('data', data => { 256 | if (data[0] != this.COMMON_PREFIX || data[1] != 0x04) { 257 | // ignore when not a data packet. 258 | return; 259 | } 260 | 261 | const timeBetweenPackets = 40; 262 | const samplesPerPacket = 4; 263 | const intervalBetweenSamples = timeBetweenPackets / samplesPerPacket; 264 | // The sampling is driven by the device, so it happens at a consistent 265 | // rate. We sometimes have late packets, adjust the timestamps. 266 | let now = performance.now(); 267 | const delta = now - previousSampleTime - timeBetweenPackets; 268 | // Only adjust if the delay is reasonable. 269 | if (delta > intervalBetweenSamples / 4 && 270 | delta < timeBetweenPackets) { 271 | now -= delta; 272 | } 273 | previousSampleTime = now; 274 | 275 | const sampleTime = now - startPerformanceNow; 276 | if (data[63] != this.checksum(data)) { 277 | console.log("Invalid CRC:", data[63], "computed:", 278 | this.checksum(data)); 279 | } 280 | 281 | for (sampleId = 0; sampleId < samplesPerPacket; ++sampleId) { 282 | const offset = 2 + 15 * sampleId; 283 | const voltage = data.readInt32LE(offset) / 1e5; 284 | const current = data.readInt32LE(offset + 4) / 1e5; 285 | const timeOffset = 286 | intervalBetweenSamples * (samplesPerPacket - 1 - sampleId); 287 | addSample(this, roundToNanoSecondPrecision(sampleTime - timeOffset), 288 | voltage * current) 289 | } 290 | }); 291 | this.hidDevice.on('error', err => { 292 | console.log("hid device error:", err); 293 | clearInterval(this.timerId); 294 | }); 295 | 296 | await this.sendCommand(this.CMD_START_SAMPLING); 297 | LogSampling(); 298 | 299 | this.timerId = setInterval(async () => { 300 | if (gClosing) { 301 | return; 302 | } 303 | // Could send CMD_CONTINUE_SAMPLING instead, but if somehow we are late 304 | // and sampling had already stopped, it would be ignored. 305 | // CMD_START_SAMPLING works in all cases. 306 | try { 307 | await this.sendCommand(this.CMD_START_SAMPLING); 308 | } catch(e) { 309 | console.log("error sending command:", e); 310 | } 311 | }, 500); // Should be at least every second. 312 | }, 313 | 314 | stopSampling() { 315 | clearInterval(this.timerId); 316 | this.hidDevice = null; 317 | } 318 | }; 319 | 320 | function ShizukuDevice(device) { 321 | this.endPointIn = null; 322 | this.endPointOut = null; 323 | this.lastRequestId = 0; 324 | this.expectedReplies = {}; 325 | } 326 | 327 | ShizukuDevice.prototype = { 328 | samplingInterval: 1, // value in ms. 329 | BEGIN_DATA: 0xA5, 330 | END_DATA: 0x5A, 331 | CMD_STOP: 0x7, 332 | CMD_START_SAMPLING: 0x9, 333 | 334 | checksum(data) { return data.reduce((acc, curr) => acc ^ curr); }, 335 | 336 | async sendCommand(cmd, args = []) { 337 | const promise = new Promise(resolve => { 338 | this.expectedReplies[this.lastRequestId] = resolve; 339 | }); 340 | const COMMON_REQUEST_PREFIX = 0x1; 341 | const data = [COMMON_REQUEST_PREFIX, cmd, this.lastRequestId++, 0, ...args]; 342 | DEBUG_log("sending command", Buffer.from(data)); 343 | await sendBuffer(this.endPointOut, 344 | Buffer.from([this.BEGIN_DATA, 345 | ...int32Bytes(data.length), ...data, 346 | this.checksum(data), this.END_DATA])); 347 | return promise; 348 | }, 349 | 350 | samplingRequestId: -1, 351 | initialTimeStamp: 0, 352 | initialPerformanceNow: 0, 353 | _pendingData: null, 354 | ondata(data) { 355 | if (this._pendingData) { 356 | DEBUG_log("using _pendingData", this._pendingData, "and new data", data); 357 | data = Buffer.concat([this._pendingData, data]); 358 | this._pendingData = null; 359 | } 360 | 361 | if (data.length < 1 || data[0] != this.BEGIN_DATA) { 362 | console.log("ignoring a bogus piece of data", data); 363 | return; 364 | } 365 | 366 | const minLength = 367 | 1 /* BEGIN_DATA */ + 4 /* 32bit length */ + 368 | 1 /* checksum */ + 1 /* END_DATA */; 369 | if (data.length < minLength) { 370 | DEBUG_log("not a full data packet, keeping this piece of data for later", data); 371 | this._pendingData = data; 372 | return; 373 | } 374 | 375 | const length = data.readInt32LE(1); 376 | if (data.length < minLength + length) { 377 | DEBUG_log("not a full data packet, keeping this piece of data for later", data); 378 | this._pendingData = data; 379 | return; 380 | } 381 | 382 | const payloadStart = 1 + 4; // 1 for the header, 4 for the 32 bit length 383 | let payload = data.slice(payloadStart, payloadStart + length); 384 | if (this.checksum(payload) != data[payloadStart + length]) { 385 | console.log("invalid checksum, expected:", this.checksum(payload), 386 | "got:", data[payloadStart + length], 387 | "payload:", payload); 388 | return; 389 | } 390 | 391 | let nextData = null; 392 | if (data.length > minLength + length) { 393 | nextData = data.slice(minLength + length); 394 | DEBUG_log("next data", nextData); 395 | } 396 | 397 | // Check if we received a reply we were waiting for. 398 | // Unsure about the meaning of the first 2 bytes. 399 | if (payload.length == 4 && 400 | payload[3] == 0x80 && // seems to mean "reply" 401 | this.expectedReplies.hasOwnProperty(payload[2])) { 402 | DEBUG_log("got a reply we were waiting for", payload); 403 | this.expectedReplies[payload[2]](); 404 | delete this.expectedReplies[payload[2]]; 405 | if (nextData) { 406 | DEBUG_log("processing next data"); 407 | this.ondata(nextData); 408 | } 409 | return; 410 | } 411 | 412 | // At this point ignore anything that doesn't look like a sample. 413 | if (payload.length < 4 /* header */ + 414 | 2 * 4 /* 2 32 bit floats */ + 415 | 8 /* 64 bit timestamp */ || 416 | payload[0] != 0x4 || // Not sure what this means. Maybe 'data' packet? 417 | payload[1] != 0 || // ?? Maybe this was a 16 bit value. 418 | payload[2] != this.samplingRequestId || 419 | payload[3] != 0) { // Seems to be 0x80 for replies and 0 otherwise. 420 | console.log("ignoring unexpected payload", payload, JSON.stringify(this.expectedReplies)); 421 | if (nextData) { 422 | DEBUG_log("processing next data"); 423 | this.ondata(nextData); 424 | } 425 | return; 426 | } 427 | 428 | const voltage = payload.readFloatLE(4); 429 | const current = Math.abs(payload.readFloatLE(8)); 430 | // Seems to be the time in µs since the power meter was started. 431 | const timestamp = payload.readBigUInt64LE(payload.length - 8); 432 | if (!this.initialTimeStamp) { 433 | this.initialTimeStamp = timestamp; 434 | this.initialPerformanceNow = performance.now() - startPerformanceNow; 435 | } 436 | const time = 437 | this.initialPerformanceNow + Number(timestamp - this.initialTimeStamp) / 1000; 438 | const power = Math.round(voltage * current * 1e4) / 1e4; 439 | DEBUG_log(new Date(), power); 440 | addSample(this, roundToNanoSecondPrecision(time), power); 441 | 442 | if (nextData) { 443 | DEBUG_log("processing next data"); 444 | this.ondata(nextData); 445 | } 446 | }, 447 | 448 | async startSampling() { 449 | this.deviceName = this.deviceName.replace(/ in Application Mode$/, ""); 450 | 451 | try { 452 | await resetDevice(this.device); 453 | } catch(e) { 454 | // resetDevice already logs the error. 455 | } 456 | 457 | try { 458 | [this.endPointIn, this.endPointOut] = findBulkInOutEndPoints(this.device); 459 | } catch(e) { 460 | console.log(e); 461 | } 462 | 463 | if (!this.endPointOut || !this.endPointIn) { 464 | console.log("failed to find endpoints"); 465 | return; 466 | } 467 | 468 | // When sampling every 1ms, the default of keeping 3 data blocks pending 469 | // in the kernel is not enough, as it's easy for our thread to be blocked 470 | // for more than 3ms. 471 | // 1024 is the maximum and allows us to recover if the main thread was 472 | // blocked up to about 1.8s. 473 | this.endPointIn.startPoll(1024); 474 | this.endPointIn.on('data', data => this.ondata(data)) 475 | this.endPointIn.on('error', err => { 476 | console.log("Error:", err); 477 | this.endPointOut = null; 478 | }); 479 | 480 | DEBUG_log("sending CMD_STOP before we start sampling"); 481 | await this.sendCommand(this.CMD_STOP); 482 | 483 | this.samplingRequestId = this.lastRequestId; 484 | await this.sendCommand(this.CMD_START_SAMPLING, 485 | int32Bytes(this.samplingInterval)); 486 | LogSampling(); 487 | }, 488 | 489 | async stopSampling() { 490 | if (!this.endPointOut) { 491 | if (DEBUG) { 492 | console.log("already in the process of stopping sampling"); 493 | } 494 | return; 495 | } 496 | 497 | DEBUG_log("sending CMD_STOP"); 498 | const stopPromise = this.sendCommand(this.CMD_STOP); 499 | this.endPointOut = null; 500 | await stopPromise; 501 | 502 | await new Promise(resolve => this.endPointIn.stopPoll(resolve)); 503 | this.endPointIn = null; 504 | } 505 | }; 506 | 507 | function KingMeterDevice(device) { 508 | } 509 | 510 | KingMeterDevice.prototype = { 511 | _crc: CRC.default("CRC16_MODBUS"), 512 | COMMON_PREFIX: 0x55, 513 | 514 | // The following 4 commands are sent by the original Windows software 515 | // after finding the device. 516 | // They don't seem to be needed to get samples, so they are probably used to 517 | // retrieve data like the firmware version number. 518 | // [0x10, 0x02], 519 | // [0x10, 0x03], 520 | // [0x10, 0x04], 521 | // [0x22, 0x80, 0xf1, 0x00], 522 | 523 | // Will get 200 samples on the KingMeter, and only one on Atorch devices. 524 | CMD_GET_SAMPLES: [0x22, 0x05, 0x0b, 0x00], 525 | 526 | // The name of these commands matches the label of the UI element in the 527 | // original Windows software. The actual sampling rate they produce doesn't 528 | // really match, and is indicated in the comments. 529 | CMD_1000SPS: [0x31, 0x1a, 0xb1, 0x01, 0x04], // every 2ms 530 | CMD_100SPS: [0x31, 0x1a, 0xb1, 0x01, 0x03], // every 10ms 531 | CMD_50SPS: [0x31, 0x1a, 0xb1, 0x01, 0x02], // every 20ms 532 | CMD_10SPS: [0x31, 0x1a, 0xb1, 0x01, 0x01], // every 100ms 533 | CMD_1SPS: [0x31, 0x1a, 0xb1, 0x01, 0x00], // every 100ms 534 | 535 | sendCommand(cmd) { 536 | // All commands are sent in a 64 buffer. The first byte is always the same, 537 | // the second byte seems to be the length of the command, then there's a 538 | // simple checksum on one byte followed by a CRC16. 539 | var outData = new Array(64).fill(0); 540 | let i = 0; 541 | outData[i++] = this.COMMON_PREFIX; 542 | outData[i++] = cmd.length + 1; 543 | while (i < cmd.length + 2) { 544 | outData[i] = cmd[i - 2]; 545 | ++i; 546 | } 547 | let array = outData.slice(0, i); 548 | outData[i++] = array.reduce((a,b) => (a + b) & 0xff); 549 | if (this.isAtorch) { 550 | outData[i++] = 0xee; 551 | outData[i] = 0xff; 552 | } else { 553 | let checksum = this._crc.compute(array); 554 | outData[i++] = checksum & 0xff; 555 | outData[i] = checksum >> 8; 556 | } 557 | return this.hidDevice.write(outData); 558 | }, 559 | 560 | async startSampling() { 561 | const {idVendor, idProduct} = this.device.deviceDescriptor; 562 | this.hidDevice = new HID.HID(idVendor, idProduct); 563 | this.isAtorch = ["ACD15P", "C13P"].some(str => this.deviceName.includes(str)); 564 | 565 | this.hidDevice.on('data', data => { 566 | // Only care about samples. 567 | if (data[0] != 0xAA || data[1] != (this.isAtorch ? 0x40 : 0x25)) { 568 | DEBUG_log("got unrecognized data", data.toString('hex')); 569 | // The KingMeter periodically sends the aa 40 62 05 0b prefix followed 570 | // by 25 nul bytes, 31 bytes of unknown data and 3 more nul bytes. 571 | return; 572 | } 573 | 574 | if (DEBUG) { 575 | let sample; 576 | if (this.isAtorch) { 577 | // First 8 bytes seem constant: aa 40 62 05 0b 00 eb 01 578 | // Example of the following data: 579 | // c6 a6 4f 00 35 0f 01 00 b6 87 05 00 8f 23 00 00 39 16 00 00 cb 2d 0d 00 52 75 32 00 90 d9 b7 01 580 | // voltage current power d+ d- cc1 cc2 temp 581 | // e89f87020000900a0000fc714e00010000002a0100000100 582 | // Unknown data at bytes 40-64, seems constant or almost constant. 583 | sample = { 584 | v: data.readInt32LE(8) / 1e6, 585 | i: data.readInt32LE(12) / 1e6, 586 | p: data.readInt32LE(16) / 1e6, 587 | "d+": data.readInt32LE(20) / 1e6, 588 | "d-": data.readInt32LE(24) / 1e6, 589 | "cc1": data.readInt32LE(28) / 1e6, 590 | "cc2": data.readInt32LE(32) / 1e6, 591 | temp: data.readInt32LE(36) / 1e6, 592 | unknown: data.slice(40), 593 | }; 594 | } else { 595 | // All data packets seem to start with the same 6 bytes: aa 25 62 05 0b 01 596 | // Example of the following data: 597 | // cd 27 4f 00 1d 21 03 00 0e 7a 0f 00 16 00 00 00 cc 00 00 00 82 27 4f 00 b7 4b 03 00 07 01 01 598 | // voltage1 current1 power d+ d- voltage2 current2 temp 599 | // 600 | // The voltage1/current1 and voltage2/current2 values don't match. 601 | // There might be an input and output measurement. 602 | // The power value matches neither v1 * c1 nor v2 * c2, but it is close. 603 | // The meaning of the value in byte 37 is unknown. It seems to be sometimes 604 | // 1, 2 or 4. Could be the charging protocol. 605 | // Bytes 38-63 are always 0. 606 | // Byte 64 contains another unknown value. It doesn't change enough 607 | // between samples to feel like a checksum. It is 0x20 most of the time, 608 | // but sometimes has other values (0, 1, 8, 0x21, 0x40, 0x61). 609 | sample = { 610 | v1: data.readInt32LE(6) / 1e6, 611 | v2: data.readInt32LE(26) / 1e6, 612 | i1: data.readInt32LE(10) / 1e6, 613 | i2: data.readInt32LE(30) / 1e6, 614 | p: data.readInt32LE(14) / 1e6, 615 | "d+": data.readInt32LE(18) / 1e3, 616 | "d-": data.readInt32LE(22) / 1e3, 617 | temp: data.readInt16LE(34) / 10, 618 | unknown1: data[36], 619 | unknown2: data[63], 620 | }; 621 | } 622 | console.log(sample); 623 | } 624 | 625 | addSample(this, 626 | roundToNanoSecondPrecision(performance.now() - startPerformanceNow), 627 | data.readInt32LE(this.isAtorch ? 16 : 14) / 1e6); 628 | }); 629 | this.hidDevice.on('error', err => { 630 | console.log("hid device error:", err); 631 | clearInterval(this.timerId); 632 | }); 633 | 634 | if (!this.isAtorch) { 635 | await this.sendCommand(this.CMD_1000SPS); 636 | } 637 | await this.sendCommand(this.CMD_GET_SAMPLES); 638 | 639 | LogSampling(); 640 | this.timerId = setInterval(async () => { 641 | if (gClosing) { 642 | return; 643 | } 644 | 645 | try { 646 | await this.sendCommand(this.CMD_GET_SAMPLES); 647 | } catch(e) { 648 | console.log("error sending command:", e); 649 | } 650 | }, this.isAtorch ? 1000 651 | : 200); // The timer can be longer for slower sampling rates. 652 | }, 653 | 654 | stopSampling() { 655 | clearInterval(this.timerId); 656 | this.hidDevice = null; 657 | } 658 | }; 659 | 660 | function PowerZBlueDevice(device) { 661 | // expected product id: 0x2 662 | } 663 | 664 | PowerZBlueDevice.prototype = { 665 | _crc: CRC.default("CRC16_MODBUS"), 666 | checksum(data) { return this._crc.compute(data.slice(0,62)); }, 667 | 668 | async startSampling() { 669 | const {idVendor, idProduct} = this.device.deviceDescriptor; 670 | this.hidDevice = new HID.HID(idVendor, idProduct); 671 | 672 | this.hidDevice.on('data', data => { 673 | if (this.checksum(data) != data.readUInt16LE(62)) { 674 | console.log(data.toString("hex"), 675 | "Invalid CRC:", data.readUInt16LE(62), "computed:", 676 | this.checksum(data)); 677 | return; 678 | } 679 | 680 | if (DEBUG) { 681 | // First 3 bytes seem to always be [0, 3, 0x3b]. 682 | // Then there are 7 32bit BigEndian floats. 683 | const sample = { 684 | v: data.readFloatBE(3), 685 | i: data.readFloatBE(7), 686 | p: data.readFloatBE(11), 687 | "d+": data.readFloatBE(15), 688 | "d-": data.readFloatBE(19), 689 | temp1: data.readFloatBE(23), 690 | temp2: data.readFloatBE(27), // temp2 seems to == temp1 691 | // The rest is unknown. Example of the unknown data: 692 | // 01 00 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 06 01 00 00 00 00 00 00 28 08 693 | // The first unknown byte is sometimes 0, sometimes 1. 694 | // The other bytes don't change from one sample to another. 695 | unknown: data.slice(31, 62), 696 | }; 697 | console.log(sample); 698 | } 699 | 700 | addSample(this, 701 | roundToNanoSecondPrecision(performance.now() - startPerformanceNow), 702 | data.readFloatBE(11)); 703 | }); 704 | this.hidDevice.on('error', err => { 705 | console.log("hid device error:", err); 706 | }); 707 | LogSampling(); 708 | }, 709 | 710 | stopSampling() { 711 | this.hidDevice = null; 712 | } 713 | }; 714 | 715 | function findVoltageOffset(data) { 716 | var candidates = [ 717 | [offset => data.readInt32LE(offset) / 1e6, "Int32LE / 1e6"], 718 | [offset => data.readInt32LE(offset) / 1e5, "Int32LE / 1e5"], 719 | [offset => data.readInt32LE(offset) / 1e4, "Int32LE / 1e4"], 720 | [offset => data.readInt32BE(offset) / 1e6, "Int32BE / 1e6"], 721 | [offset => data.readInt32BE(offset) / 1e5, "Int32BE / 1e5"], 722 | [offset => data.readInt32BE(offset) / 1e4, "Int32BE / 1e4"], 723 | [offset => data.readFloatLE(offset), "FloatLE"], 724 | [offset => data.readFloatBE(offset), "FloatBE"], 725 | ]; 726 | for (let offset = 0; offset < data.length - 4; ++offset) { 727 | for (let [fun, desc] of candidates) { 728 | let val = fun(offset); 729 | if (val > 5 && val < 5.5) { 730 | console.log("Offset:", offset, val, desc); 731 | } 732 | } 733 | } 734 | } 735 | 736 | function WitrnDevice(device) { 737 | } 738 | 739 | WitrnDevice.prototype = { 740 | async startSampling() { 741 | const {idVendor, idProduct} = this.device.deviceDescriptor; 742 | this.hidDevice = new HID.HID(idVendor, idProduct); 743 | 744 | let initialTimeStamp = 0, initialPerformanceNow = 0; 745 | let timestampWrapArounds = 0; 746 | let lastTime_s = 0; 747 | this.hidDevice.on('data', data => { 748 | if (data[0] != 0xff || data[1] != 0x55) { 749 | // All the data packets seem to start with 0xff 0x55. 750 | DEBUG_log("ignoring unexpected packet", data[0], data[1]); 751 | return; 752 | } 753 | 754 | const sum = array => array.reduce((acc, val) => acc + val); 755 | const payloadChecksum = sum(data.slice(8, 62)) % 256; 756 | const packetChecksum = (sum(data.slice(0, 8)) + payloadChecksum) % 256; 757 | if (data[data.length - 2] != payloadChecksum || 758 | data[data.length - 1] != packetChecksum) { 759 | console.log(data.toString("hex"), 760 | "Invalid checksums:", [data[data.length - 2], data[data.length - 1]], 761 | "computed:", [payloadChecksum, packetChecksum]); 762 | return; 763 | } 764 | 765 | const v = data.readFloatLE(46); 766 | const i = data.readFloatLE(50); 767 | 768 | const time_s = data[2]; // time in seconds, on a single byte (ie. % 256) 769 | const time_ms = data[3]; // time in ms, on a single byte (ie. % 256) 770 | // data[4]: time_ms / 30 771 | // data[5]: time_ms / 100 772 | const time_ms_mod100 = data[6]; // time in ms, % 100 773 | // data[7]: time_ms / 80 774 | if (time_s < 10 && lastTime_s > 250) { 775 | ++timestampWrapArounds; 776 | } 777 | const timestamp = 778 | (time_s + timestampWrapArounds * 256) * 1000 + 779 | [0, 1, 2, 3].map(i => (256 * i + time_ms) % 1000).find(ms => ms % 100 == time_ms_mod100); 780 | lastTime_s = time_s; 781 | 782 | if (DEBUG) { 783 | const sample = { 784 | timestamp, 785 | v, 786 | i, 787 | "d-": data.readFloatLE(34), 788 | "d+": data.readFloatLE(30), 789 | time: data.readUInt32LE(26), // time in seconds since the meter started. 790 | rectime: data.readUInt32LE(22), // time in seconds displayed as 'rec' on the device 791 | wh: data.readFloatLE(18), 792 | ah: data.readFloatLE(14), 793 | unknown1: data.slice(10, 14), // Very stable, or even constant. 794 | unknown2: data.readFloatLE(38), // Always 25 795 | unknown3: data.readFloatLE(42), // -73. 796 | }; 797 | console.log(sample); 798 | } 799 | 800 | if (!initialTimeStamp) { 801 | initialTimeStamp = timestamp; 802 | initialPerformanceNow = performance.now() - startPerformanceNow; 803 | } 804 | const time = 805 | initialPerformanceNow + Number(timestamp - initialTimeStamp); 806 | addSample(this, roundToNanoSecondPrecision(time), v * i); 807 | }); 808 | this.hidDevice.on('error', err => { 809 | console.log("hid device error:", err); 810 | }); 811 | LogSampling(); 812 | }, 813 | 814 | stopSampling() { 815 | this.hidDevice = null; 816 | } 817 | }; 818 | 819 | function RuiDengDevice(device) { 820 | } 821 | 822 | RuiDengDevice.prototype = { 823 | _crc: CRC.default("CRC16_MODBUS"), 824 | checksum(data) { return this._crc.compute(data.slice(0,60)); }, 825 | _key: Buffer.from([ 826 | 88, 33, -6, 86, 1, -78, -16, 38, 827 | -121, -1, 18, 4, 98, 42, 79, -80, 828 | -122, -12, 2, 96, -127, 111, -102, 11, 829 | -89, -15, 6, 97, -102, -72, 114, -120 830 | ]), 831 | requestSample() { 832 | this._lastSampleRequestTime = Date.now(); 833 | this.serialDevice.write("getva"); 834 | }, 835 | ondata(data) { 836 | let buf = this.decipher.update(data); 837 | if (buf.length < 64) { 838 | console.log("received data is too short", buf.length, buf); 839 | return; 840 | } 841 | 842 | if (this.checksum(buf) != buf.readUInt32LE(60)) { 843 | console.log(buf.toString("hex"), 844 | "Invalid CRC:", buf.readUInt32LE(60), "computed:", 845 | this.checksum(buf)); 846 | return; 847 | } 848 | 849 | let prefix = buf.slice(0, 4).toString('ascii'); 850 | if (prefix == 'pac1') { 851 | let power = buf.readUInt32LE(56) * 1e-4; 852 | addSample(this, 853 | roundToNanoSecondPrecision(performance.now() - startPerformanceNow), 854 | power); 855 | if (DEBUG) { 856 | let sample = { 857 | productName: buf.slice(4, 8).toString('ascii'), 858 | version: buf.slice(8, 12).toString('ascii'), 859 | serialNumber: buf.readUInt32LE(12), 860 | unknown: buf.slice(16, 44), 861 | sessionCount: buf.readUInt32LE(44), 862 | voltage: buf.readUInt32LE(48) * 1e-4, 863 | current: buf.readUInt32LE(52) * 1e-5, 864 | power 865 | }; 866 | console.log(sample); 867 | } 868 | } else if (prefix == 'pac2') { 869 | if (DEBUG) { 870 | let sample = { 871 | temperature: buf.readUInt32LE(28), 872 | resistance: buf.readUInt32LE(4) * 1e-1, 873 | 'd+': buf.readUInt32LE(32) * 1e-2, 874 | 'd-': buf.readUInt32LE(36) * 1e-2, 875 | }; 876 | if (buf.readUInt32LE(28) == 1) { 877 | sample.temperature *= -1; 878 | } 879 | console.log(sample); 880 | } 881 | } else if (prefix == 'pac3') { 882 | if (DEBUG) { 883 | // Always 0, print debug message if a non-zero value is present. 884 | let allZero = true; 885 | for (let i = 4; i < 60; ++i) { 886 | if (buf[i] != 0) { 887 | console.log("pac3 was expected to be all zero, but found data:", 888 | buf.slice(4, 60).toString('hex')); 889 | break; 890 | } 891 | } 892 | } 893 | this.requestSample(); 894 | } else { 895 | console.log("unknown data packet:", buf.toString('hex')); 896 | } 897 | }, 898 | async startSampling() { 899 | let serialDevices = (await SerialPort.list()).filter(d => d.manufacturer == "RuiDeng"); 900 | if (!serialDevices.length) { 901 | console.log("serial device not found"); 902 | return; 903 | } 904 | DEBUG_log("Found serial devices", serialDevices); 905 | this.decipher = createDecipheriv("AES-256-ECB", this._key, null); 906 | this.decipher.setAutoPadding(false); 907 | this.serialDevice = new SerialPort({path: serialDevices[0].path, baudRate: 115200}); 908 | await new Promise((resolve, reject) => this.serialDevice.on("open", err => { 909 | if (err) { 910 | reject("error opening serial port:" + err); 911 | } else { 912 | resolve(); 913 | } 914 | })); 915 | 916 | this.serialDevice.flush(); // discard pending data 917 | this.serialDevice.on("error", console.log); 918 | this.serialDevice.on("data", data => { this.ondata(data); }); 919 | 920 | this.requestSample(); 921 | this.interval = setInterval(() => { 922 | let lastSampleAge = Date.now() - this._lastSampleRequestTime; 923 | if (lastSampleAge > 200) { 924 | DEBUG_log("The last sample is too old (" + lastSampleAge + 925 | "ms), restarting sampling."); 926 | this.requestSample(); 927 | } 928 | }, 100); 929 | LogSampling(); 930 | }, 931 | 932 | stopSampling() { 933 | clearInterval(this.interval); 934 | this.decipher = null; 935 | this.serialDevice.close(); 936 | this.serialDevice = null; 937 | } 938 | }; 939 | 940 | function YzxStudioDevice(device) { 941 | } 942 | 943 | YzxStudioDevice.prototype = { 944 | ondata(data) { 945 | if (data.length < 28) { 946 | console.log("received data is too short", data.length, data); 947 | return; 948 | } 949 | 950 | if (data[0] != 0xAB) { 951 | console.log("incorrect first byte", data[0]); 952 | return; 953 | } 954 | 955 | const sum = array => array.reduce((acc, val) => acc + val); 956 | const checksum = sum(data.slice(0, 27)) % 256; 957 | if (checksum != data[27]) { 958 | console.log(data.toString("hex"), 959 | "Invalid checksum:", data[27], "computed:", checksum); 960 | return; 961 | } 962 | 963 | let v = data.readInt32LE(3) * 1e-4; 964 | let i = data.readUInt32LE(7) * 1e-4; 965 | if (DEBUG) { 966 | let sample = { 967 | v, i, 968 | Ah: data.readUInt32LE(11) * 1e-4, 969 | Wh: data.readUInt32LE(15) * 1e-4, 970 | // The T_ms value is changing in 250ms increment, but it only moves when 971 | // the power is > 0, making it unsuitable for our sample times. 972 | T_ms: data.readUInt32LE(19) * 1e1, 973 | "d+": data.readUInt16LE(23) * 1e-3, 974 | "d-": data.readUInt16LE(25) * 1e-3, 975 | }; 976 | console.log(sample, "power", data.readInt32LE(3) * 1e-4 * data.readUInt32LE(7) * 1e-4); 977 | } 978 | 979 | addSample(this, roundToNanoSecondPrecision(performance.now() - startPerformanceNow), v * i); 980 | }, 981 | async startSampling() { 982 | this.deviceName = "YZXStudio"; 983 | 984 | let serialDevices = (await SerialPort.list()).filter(d => d.vendorId == "1a86"); 985 | if (!serialDevices.length) { 986 | console.log("serial device not found"); 987 | return; 988 | } 989 | DEBUG_log("Found serial devices", serialDevices); 990 | this.serialDevice = new SerialPort({path: serialDevices[0].path, baudRate: 115200}); 991 | await new Promise((resolve, reject) => this.serialDevice.on("open", err => { 992 | if (err) { 993 | reject("error opening serial port:" + err); 994 | } else { 995 | resolve(); 996 | } 997 | })); 998 | 999 | this.serialDevice.flush(); // discard pending data 1000 | this.serialDevice.on("error", console.log); 1001 | this.serialDevice.on("data", data => { this.ondata(data); }); 1002 | LogSampling(); 1003 | }, 1004 | 1005 | stopSampling() { 1006 | this.serialDevice.close(); 1007 | this.serialDevice = null; 1008 | } 1009 | }; 1010 | 1011 | const SUPPORTED_DEVICES = {} 1012 | SUPPORTED_DEVICES[CHARGER_LAB_VENDOR_ID] = PowerZDevice; 1013 | SUPPORTED_DEVICES[FNIRSI_VENDOR_ID] = FnirsiDevice; 1014 | SUPPORTED_DEVICES[KINGMETER_VENDOR_ID] = KingMeterDevice; 1015 | SUPPORTED_DEVICES[CHARGER_LAB_BLUE_VENDOR_ID] = PowerZBlueDevice; 1016 | SUPPORTED_DEVICES[WITRN_VENDOR_ID] = WitrnDevice; 1017 | SUPPORTED_DEVICES[RUIDENG_VENDOR_ID] = RuiDengDevice; 1018 | SUPPORTED_DEVICES[YZXSTUDIO_VENDOR_ID] = YzxStudioDevice; 1019 | 1020 | async function getDeviceName(device) { 1021 | let manufacturer = await new Promise((resolve, reject) => { 1022 | device.getStringDescriptor(device.deviceDescriptor.iManufacturer, (err, manufacturer) => { 1023 | if (err) { 1024 | reject(); 1025 | } else { 1026 | resolve(manufacturer); 1027 | } 1028 | }) 1029 | }); 1030 | 1031 | return new Promise((resolve, reject) => { 1032 | device.getStringDescriptor(device.deviceDescriptor.iProduct, (err, productName) => { 1033 | if (!err && productName) { 1034 | resolve(`${manufacturer} — ${productName}`); 1035 | } else { 1036 | reject(); 1037 | } 1038 | }); 1039 | }); 1040 | } 1041 | 1042 | async function getDeviceSerialNumber(device) { 1043 | return new Promise((resolve, reject) => { 1044 | device.getStringDescriptor(device.deviceDescriptor.iSerialNumber, (err, number) => { 1045 | if (err) { 1046 | reject(); 1047 | } else { 1048 | resolve(number); 1049 | } 1050 | }) 1051 | }); 1052 | } 1053 | 1054 | async function tryDevice(device) { 1055 | const {idVendor, idProduct} = device.deviceDescriptor; 1056 | let dev; 1057 | if (idVendor == GENERIC_VENDOR_ID) { 1058 | if (SHIZUKU_PRODUCT_IDS.includes(idProduct)) { 1059 | dev = new ShizukuDevice(device); 1060 | } else if (FNIRSI_PRODUCT_IDS.includes(idProduct)) { 1061 | dev = new FnirsiDevice(device); 1062 | } else if (KINGMETER_PRODUCT_IDS.includes(idProduct)) { 1063 | dev = new KingMeterDevice(device); 1064 | } 1065 | } else if (idVendor in SUPPORTED_DEVICES) { 1066 | dev = new SUPPORTED_DEVICES[idVendor](device); 1067 | } 1068 | 1069 | if (dev) { 1070 | try { 1071 | device.open(); 1072 | dev.deviceName = await getDeviceName(device); 1073 | dev.serialNumber = await getDeviceSerialNumber(device); 1074 | console.log(new Date(), 1075 | "Found device:", dev.deviceName, 1076 | "Serial Number:", dev.serialNumber, 1077 | "Vendor Id: 0x" + idVendor.toString(16), 1078 | "Product Id: 0x" + idProduct.toString(16), 1079 | `Address: ${device.busNumber}:${device.deviceAddress}`); 1080 | const envSerialNumber = process.env.USB_POWER_METER_SERIAL_NUMBER; 1081 | if (envSerialNumber && dev.serialNumber != envSerialNumber) { 1082 | console.log("Not sampling this power meter as its serial number is not " + 1083 | envSerialNumber); 1084 | return; 1085 | } 1086 | dev.device = device; 1087 | 1088 | let existingDeviceIndex = 1089 | gDevices.findIndex(d => dev.serialNumber == d.serialNumber && 1090 | idVendor == d.device.deviceDescriptor.idVendor && 1091 | idProduct == d.device.deviceDescriptor.idProduct); 1092 | if (existingDeviceIndex != -1) { 1093 | let existingDev = gDevices[existingDeviceIndex]; 1094 | dev.samples = existingDev.samples; 1095 | dev.sampleTimes = existingDev.sampleTimes; 1096 | dev.firstSample = existingDev.firstSample; 1097 | gDevices[existingDeviceIndex] = dev; 1098 | } else { 1099 | dev.samples = []; 1100 | dev.sampleTimes = []; 1101 | dev.firstSample = 0; 1102 | gDevices.push(dev); 1103 | } 1104 | 1105 | await dev.startSampling(); 1106 | } catch(e) { 1107 | console.log(e); 1108 | } 1109 | } else if (DEBUG) { 1110 | try { 1111 | device.open(); 1112 | console.log(new Date(), 1113 | "found unknown device:", await getDeviceName(device), 1114 | "Serial Number:", await getDeviceSerialNumber(device), 1115 | "Vendor Id: 0x" + idVendor.toString(16), 1116 | "Product Id: 0x" + idProduct.toString(16), 1117 | device); 1118 | device.close(); 1119 | } catch(e) { console.log(e); } 1120 | } 1121 | } 1122 | 1123 | async function startSampling() { 1124 | initialize(); 1125 | 1126 | startTime = Date.now(); 1127 | startPerformanceNow = performance.now(); 1128 | 1129 | const devices = getDeviceList(); 1130 | for (let device of devices) { 1131 | await tryDevice(device); 1132 | } 1133 | if (gDevices.length == 0) { 1134 | console.log("No device found") 1135 | } 1136 | } 1137 | 1138 | async function stopSampling() { 1139 | if (gDevices.length == 0) { 1140 | console.log("No device found"); 1141 | } else { 1142 | await Promise.all(gDevices.map(d => d.stopSampling())); 1143 | } 1144 | } 1145 | 1146 | var initialized = false; 1147 | 1148 | function initialize() { 1149 | if (initialized) { 1150 | return; 1151 | } 1152 | 1153 | process.on('SIGINT', async function() { 1154 | if (gClosing) { 1155 | return; 1156 | } 1157 | gClosing = true; 1158 | await stopSampling(); 1159 | process.exit(); 1160 | }); 1161 | 1162 | usb.on('attach', device => { 1163 | console.log(new Date(), "Device attached"); 1164 | tryDevice(device); 1165 | }); 1166 | usb.on('detach', function(device) { 1167 | if (!(device.deviceDescriptor.idVendor in SUPPORTED_DEVICES)) { 1168 | return; 1169 | } 1170 | 1171 | if (!gDevices.length) { 1172 | return; 1173 | } 1174 | 1175 | const dev = gDevices.find(d => device.busNumber == d.device.busNumber && device.deviceAddress == d.device.deviceAddress); 1176 | if (dev) { 1177 | console.log(dev.deviceName, "has been detached"); 1178 | dev.device.close(); 1179 | dev.device = null; 1180 | gDevices.splice(gDevices.indexOf(dev), 1); 1181 | } else { 1182 | console.log("detach", device); 1183 | } 1184 | }); 1185 | 1186 | initialized = true; 1187 | } 1188 | 1189 | 1190 | function sendJSON(res, data, forceGC = false) { 1191 | res.statusCode = 200; 1192 | res.setHeader('Content-Type', 'application/json'); 1193 | res.setHeader('Access-Control-Allow-Origin', '*'); 1194 | let json = JSON.stringify(data); 1195 | if (forceGC && global.gc) { 1196 | data = null; 1197 | global.gc(); 1198 | } 1199 | res.end(json); 1200 | } 1201 | 1202 | function sendError(res, error) { 1203 | res.statusCode = 500; 1204 | res.setHeader('Content-Type', 'text/plain'); 1205 | res.end(error + '\n'); 1206 | console.log(error); 1207 | } 1208 | 1209 | const baseProfile = '{"meta":{"interval":1,"startTime":0,"abi":"","misc":"","oscpu":"","platform":"","processType":0,"extensions":{"id":[],"name":[],"baseURL":[],"length":0},"categories":[{"name":"Other","color":"grey","subcategories":["Other"]}],"product":"Home power profiling","stackwalk":0,"toolkit":"","version":27,"preprocessedProfileVersion":48,"appBuildID":"","sourceURL":"","physicalCPUs":0,"logicalCPUs":0,"CPUName":"","symbolicationNotSupported":true,"markerSchema":[]},"libs":[],"pages":[],"threads":[{"processType":"default","processStartupTime":0,"processShutdownTime":null,"registerTime":0,"unregisterTime":null,"pausedRanges":[],"name":"GeckoMain","isMainThread":true,"pid":"0","tid":0,"samples":{"weightType":"samples","weight":null,"eventDelay":[],"stack":[],"time":[],"length":0},"markers":{"data":[],"name":[],"startTime":[],"endTime":[],"phase":[],"category":[],"length":0},"stackTable":{"frame":[0],"prefix":[null],"category":[0],"subcategory":[0],"length":1},"frameTable":{"address":[-1],"inlineDepth":[0],"category":[null],"subcategory":[0],"func":[0],"nativeSymbol":[null],"innerWindowID":[0],"implementation":[null],"line":[null],"column":[null],"length":1},"funcTable":{"isJS":[false],"relevantForJS":[false],"name":[0],"resource":[-1],"fileName":[null],"lineNumber":[null],"columnNumber":[null],"length":1},"resourceTable":{"lib":[],"name":[],"host":[],"type":[],"length":0},"nativeSymbols":{"libIndex":[],"address":[],"name":[],"functionSize":[],"length":0}}],"counters":[]}'; 1210 | 1211 | function WattSecondToPicoWattHour(value) { 1212 | return value / 3600 * 1e12; 1213 | } 1214 | 1215 | function counterObject(name, description, times, samples, geckoFormat = false) { 1216 | let time, count; 1217 | 1218 | // Before calling the expensive .filter method, check if we have any zero. 1219 | let hasZeros = false; 1220 | for (let i = 1; i < samples.length - 2; ++i) { 1221 | if (samples[i] == 0) { 1222 | hasZeros = true; 1223 | break; 1224 | } 1225 | } 1226 | if (hasZeros) { 1227 | DEBUG_log("some zeros to filter out"); 1228 | time = []; 1229 | // Remove consecutive 0 samples. 1230 | count = samples.filter((sample, index) => { 1231 | let keep = 1232 | sample != 0 || 1233 | index == 0 || index == samples.length - 1 || 1234 | samples[index - 1] != 0 || samples[index + 1] != 0; 1235 | if (keep) { 1236 | time.push(times[index]) 1237 | } 1238 | return keep; 1239 | }); 1240 | } else { 1241 | // Fast path 1242 | time = times; 1243 | count = samples; 1244 | } 1245 | 1246 | let rv = { 1247 | name, 1248 | category: "power", 1249 | description, 1250 | }; 1251 | 1252 | if (geckoFormat) { 1253 | let data = []; 1254 | for (let i = 0; i < time.length; ++i) { 1255 | data.push([time[i], count[i]]); 1256 | } 1257 | rv.samples = { 1258 | schema: {time:0, count:1}, 1259 | data 1260 | }; 1261 | } else { 1262 | rv.pid = "0"; 1263 | rv.mainThreadIndex = 0; 1264 | rv.samples = { 1265 | time, count, length: count.length 1266 | }; 1267 | } 1268 | return rv; 1269 | } 1270 | 1271 | function profileFromData() { 1272 | if (!gDevices.length) { 1273 | throw "No device is being sampled"; 1274 | } 1275 | 1276 | let profile = JSON.parse(baseProfile); 1277 | profile.meta.startTime = startTime; 1278 | profile.meta.product = new Date(startTime).toLocaleDateString("fr-FR", {timeZone: "Europe/Paris"}) + " — USB power"; 1279 | profile.meta.physicalCPUs = 1; 1280 | profile.meta.CPUName = gDevices.map(d => `${d.deviceName} (${d.serialNumber})`).join(", "); 1281 | 1282 | for (let dev of gDevices) { 1283 | reorderSamples(dev); 1284 | } 1285 | const threadSampleTimes = 1286 | gDevices.length == 1 ? gDevices[0].sampleTimes 1287 | : [].concat(...gDevices.map(dev => dev.sampleTimes)) 1288 | .sort((a, b) => a - b); 1289 | let firstThread = profile.threads[0]; 1290 | let threadSamples = firstThread.samples; 1291 | threadSamples.stack = new Array(threadSampleTimes.length).fill(0); 1292 | threadSamples.time = threadSampleTimes; 1293 | threadSamples.length = threadSampleTimes.length; 1294 | 1295 | firstThread.stringArray = ["(root)"]; 1296 | 1297 | for (let dev of gDevices) { 1298 | let {deviceName, sampleTimes} = dev; 1299 | let timeInterval = i => i == 0 ? 1 : (sampleTimes[i] - sampleTimes[i - 1]) / 1000; 1300 | let samples = []; 1301 | for (let i = 0; i < sampleTimes.length; ++i) { 1302 | let sample = dev.samples[i]; 1303 | let interval = timeInterval(i); 1304 | samples.push(Math.max(0, Math.round(WattSecondToPicoWattHour(sample) * interval))); 1305 | } 1306 | if (!samples.some(s => s > 0)) { 1307 | continue; 1308 | } 1309 | profile.counters.push(counterObject("USB power", deviceName, sampleTimes, samples)); 1310 | } 1311 | 1312 | return profile; 1313 | } 1314 | 1315 | function getPowerData(start, end) { 1316 | let timeStart = parseFloat(start) - startTime; 1317 | let timeEnd = parseFloat(end) - startTime; 1318 | if (timeEnd < 0) { 1319 | throw "The requested end time is before this instance of the script was started." 1320 | } 1321 | 1322 | let counters = []; 1323 | for (let device of gDevices) { 1324 | reorderSamples(device); 1325 | const {samples, sampleTimes, deviceName} = device; 1326 | 1327 | let startIndex = 0; 1328 | while (sampleTimes[startIndex] < timeStart) { 1329 | ++startIndex; 1330 | } 1331 | 1332 | let endIndex = startIndex; 1333 | while (sampleTimes[endIndex] <= timeEnd) { 1334 | ++endIndex; 1335 | } 1336 | 1337 | let times = sampleTimes.slice(startIndex, endIndex).map(t => roundToNanoSecondPrecision(t - timeStart)); 1338 | let timeInterval = i => i == 0 ? 1 : (times[i] - times[i - 1]) / 1000; 1339 | let counter = counterObject("USB power", deviceName, times, 1340 | samples.slice(startIndex, endIndex) 1341 | .map((sample, i) => Math.round(WattSecondToPicoWattHour(sample) * timeInterval(i))), true); 1342 | 1343 | counters.push(counter); 1344 | } 1345 | 1346 | return counters; 1347 | } 1348 | 1349 | function resetPowerData() { 1350 | for (let device of gDevices) { 1351 | device.samples = []; 1352 | device.sampleTimes = []; 1353 | device.firstSample = 0; 1354 | } 1355 | } 1356 | 1357 | const app = (req, res) => { 1358 | console.log(new Date(), req.url); 1359 | 1360 | if (req.url.startsWith("/power")) { 1361 | if (!gDevices.length) { 1362 | sendError(res, "power: no device is being sampled"); 1363 | return; 1364 | } 1365 | 1366 | const query = url.parse(req.url, true).query; 1367 | if (!query.start && !query.end) { 1368 | sendError(res, "power: The /power API requires sppecifying start and end timestamps," 1369 | + " see https://github.com/fqueze/usb-power-profiling/tree/main#http-api"); 1370 | return; 1371 | } 1372 | 1373 | let timeEnd = parseFloat(query.end) - startTime; 1374 | if (timeEnd < 0) { 1375 | sendError(res, "power: The requested end time is before this instance of the script was started."); 1376 | return; 1377 | } 1378 | 1379 | sendJSON(res, getPowerData(query.start, query.end), true); 1380 | return; 1381 | } 1382 | 1383 | if (req.url.startsWith("/rawdata")) { 1384 | if (!gDevices.length) { 1385 | sendError(res, "power: no device is being sampled"); 1386 | return; 1387 | } 1388 | 1389 | const query = url.parse(req.url, true).query; 1390 | 1391 | let data = []; 1392 | for (let device of gDevices) { 1393 | reorderSamples(device); 1394 | const {samples, sampleTimes, deviceName} = device; 1395 | 1396 | let startIndex = 0; 1397 | if (query.last) { 1398 | let lastTime = parseFloat(query.last); 1399 | while (sampleTimes[startIndex] <= lastTime) { 1400 | ++startIndex; 1401 | } 1402 | } 1403 | 1404 | if (startIndex) { 1405 | data.push({ 1406 | deviceName, 1407 | sampleTimes: sampleTimes.slice(startIndex), 1408 | samples: samples.slice(startIndex) 1409 | }) 1410 | } else { 1411 | data.push({ deviceName, startTime, sampleTimes, samples }); 1412 | } 1413 | } 1414 | sendJSON(res, data); 1415 | return; 1416 | } 1417 | 1418 | if (req.url.startsWith("/profile")) { 1419 | try { 1420 | sendJSON(res, profileFromData(), true); 1421 | } catch (err) { 1422 | sendError(res, 'profile: ' + err); 1423 | } 1424 | return; 1425 | } 1426 | 1427 | // This is for debugging USB error recovery: GET /wait?time=200 blocks the 1428 | // main thread for 200ms, during which no USB data is processed. 1429 | if (req.url.startsWith("/wait")) { 1430 | const waitTime = url.parse(req.url, true).query.time || 500; 1431 | let startTime = Date.now(); 1432 | while (Date.now() - startTime < waitTime) 1433 | ; 1434 | sendError(res, 'wait: ' + (Date.now() - startTime)); 1435 | return; 1436 | } 1437 | 1438 | if (req.url == "/reset") { 1439 | resetPowerData(); 1440 | res.end('Power data reset'); 1441 | return; 1442 | } 1443 | 1444 | if (req.url == "/" || req.url == "/index.html") { 1445 | serveHandler(req, res, { public: '.' }); 1446 | } 1447 | }; 1448 | 1449 | var server; 1450 | 1451 | async function runPowerCollectionServer(customPort) { 1452 | const port = customPort || process.env.PORT || 2121; 1453 | server = http.createServer(app) 1454 | server.listen(port, "0.0.0.0", () => { 1455 | console.log(`Ensure devtools.performance.recording.power.external-url is set to http://localhost:${port}/power in 'about:config'.`); 1456 | }); 1457 | } 1458 | 1459 | if (require.main === module) { 1460 | startSampling().then(() => { 1461 | runPowerCollectionServer(); 1462 | }); 1463 | } 1464 | 1465 | module.exports = { 1466 | startSampling, 1467 | stopSampling, 1468 | getPowerData, 1469 | resetPowerData, 1470 | profileFromData 1471 | } 1472 | --------------------------------------------------------------------------------