├── .github └── FUNDING.yml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── base.js ├── bundles ├── ffmpeg.ts ├── homekit-bundle-webpack.config.js ├── homekit-camera-recording.ts ├── homekit-camera.ts ├── homekit-logger.ts ├── homekit-manager.ts ├── sip-bundle-webpack.config.js └── sip-manager.ts ├── config.js ├── config.json.example ├── controller-homekit.js ├── controller-webrtc.js ├── controller.js ├── install.sh ├── json-store.js ├── lib ├── api.js ├── apis │ ├── aswm.js │ ├── debug.js │ ├── door-unlock.js │ ├── ha.js │ ├── load.js │ ├── mute-ringer.js │ ├── reboot.js │ ├── register-endpoint.js │ ├── sshd-start.js │ ├── validate-setup.js │ ├── videoclips.js │ └── voicemail-messages.js ├── endpoint-registry.js ├── eventbus.js ├── ha-ws.js ├── handlers │ ├── aswm-handler.js │ ├── bt-av-media.js │ └── openwebnet-handler.js ├── homekit │ ├── homekit-bundle.js │ └── homekit-bundle.js.map ├── ini.js ├── message-parser.js ├── mqtt.js ├── multicast-listener.js ├── openwebnet-cli.js ├── openwebnet.js ├── persistent-sip-manager.js ├── rtsp-server.js ├── sip │ ├── sip-bundle.js │ └── sip-bundle.js.map ├── udp-proxy.js └── utils.js ├── libatomic.so.1.2.0 ├── package-lock.json ├── package.json ├── tsconfig.json └── webpack.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: slyoldfox 2 | buy_me_a_coffee: slyoldfox -------------------------------------------------------------------------------- /.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 | 106 | # Docusaurus cache and generated files 107 | .docusaurus 108 | 109 | # Serverless directories 110 | .serverless/ 111 | 112 | # FuseBox cache 113 | .fusebox/ 114 | 115 | # DynamoDB Local files 116 | .dynamodb/ 117 | 118 | # TernJS port file 119 | .tern-port 120 | 121 | # Stores VSCode versions used for testing VSCode extensions 122 | .vscode-test 123 | 124 | # yarn v2 125 | .yarn/cache 126 | .yarn/unplugged 127 | .yarn/build-state.yml 128 | .yarn/install-state.gz 129 | .pnp.* 130 | 131 | .DS_Store 132 | /config.json 133 | /config-homekit.json 134 | .idea 135 | /storage/ -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "runtimeVersion": "17.9.1", 7 | "request": "launch", 8 | "name": "Launch HOMEKIT controller", 9 | "outputCapture": "std", 10 | "env": { 11 | "DEBUG": "rtsp-server:*,rtsp-streaming-server:*,rtsp-stream:*,c300x-controller:*" 12 | //"DEBUG": "c300x-controller:*" 13 | //"DEBUG": "*" 14 | }, 15 | "skipFiles": [ 16 | "/**" 17 | ], 18 | "program": "${workspaceFolder}/controller-homekit.js" 19 | }, 20 | { 21 | // ssh -L 9229:127.0.0.1:9229 root2@192.168.0.XX 22 | "address": "localhost:9229/408a0f96-c43c-4b97-8bc6-a25eb55f9125", 23 | "localRoot": "${workspaceFolder}", 24 | "name": "Attach to Remote Debugger", 25 | "port": 9229, 26 | "remoteRoot": "/home/bticino/cfg/extra/c300x-controller/", 27 | "request": "attach", 28 | "skipFiles": [ 29 | "/**" 30 | ], 31 | "type": "node" 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # c300x-controller: The API that BTicino never had 2 | 3 | ## Table of Contents 4 | 5 | - [Support](#support) 6 | - [API](#api) 7 | - [Handlers](#handlers) 8 | - [Setup procedure](#setup-procedure) 9 | - [WebRTC](#webrtc) 10 | - [Homekit](#homekit) 11 | - [Home Assistant webhooks and endpoints](#home-assistant-webhooks-and-endpoints) 12 | - [Development](#development) 13 | 14 | ## Support 15 | 16 | I have put many hours of research in the BTicino intercom. 17 | 18 | Many people have been asking me if they can send me some hardware to show their support. 19 | 20 | If you want to support me ❤️🙏: 21 | 22 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/slyoldfox) 23 | 24 | ## API 25 | 26 | Supports: 27 | 28 | * Unlocking door (supporting multiple locks) 29 | * Displaying the unit temp and load 30 | * Rebooting the unit 31 | * Register endpoints to receive doorbell pressed, door locked and door unlocked events 32 | * Enable/disable voice mail and show the status 33 | * Enable/disable the ringer and show the status 34 | * Start dropbear sshd (in case it crashes) 35 | * Validates scrypted setup 36 | * Exposes the voicemail videoclips 37 | * Display the videoclip 38 | * HA proxy for custom UI dashboards (see https://github.com/slyoldfox/c300x-dashboard) 39 | * Send MQTT messages for openwebnet events and intercom status 40 | * WebRTC bundle with embedded SIP client and RTSP server 41 | * Homekit bundle with support for locks, voicemail and muting intercom, doorbell 42 | * HKSV (Homekit Secure Video) recordings on doorbell events and motion events 43 | 44 | ## Handlers 45 | 46 | Handlers automatically act on syslog messages being sent on the multicast port 7667. 47 | They are handled by `multicast-listener.js`. At the moment only 1 handler is registered which listens to the `openwebnet` messages. 48 | 49 | ## Setup procedure 50 | 51 | You can choose between an automated install using a script or a manual install. 52 | 53 | ### Automated install 54 | 55 | You can execute the `install.sh` script which will do all manual steps below for you: 56 | 57 | ``` 58 | bash -c "$(wget -qO - 'https://raw.githubusercontent.com/slyoldfox/c300x-controller/main/install.sh')" 59 | ``` 60 | 61 | Or if you rather first fetch the script and read it before executing: 62 | 63 | ``` 64 | wget 'https://raw.githubusercontent.com/slyoldfox/c300x-controller/main/install.sh' 65 | less install.sh 66 | bash install.sh 67 | ``` 68 | 69 | ### Manual install 70 | 71 | #### 1. Install `node.js` 72 | ``` 73 | mount -oremount,rw / 74 | cd /home/bticino/cfg/extra/ 75 | mkdir node 76 | wget https://nodejs.org/download/release/latest-v17.x/node-v17.9.1-linux-armv7l.tar.gz 77 | tar xvfz node-v17.9.1-linux-armv7l.tar.gz --strip-components 1 -C ./node 78 | rm node-v17.9.1-linux-armv7l.tar.gz 79 | ``` 80 | 81 | #### 2. Install `libatomic.so.1` 82 | 83 | Node will require libatomic.so.1 which isn't shipped with the device, so we need to collect it from another source. 84 | 85 | > [!IMPORTANT] 86 | > It's strongly advised to do this step on a different Linux machine, because C300x misses XZ Utils to decompress archives contained in the deb package 87 | 88 | ``` 89 | cd /tmp 90 | wget http://ftp.de.debian.org/debian/pool/main/g/gcc-10-cross/libatomic1-armhf-cross_10.2.1-6cross1_all.deb 91 | ar x libatomic1-armhf-cross_10.2.1-6cross1_all.deb 92 | tar -xf data.tar.xz 93 | cd usr/arm-linux-gnueabihf/lib/ 94 | ``` 95 | 96 | Now you should find `libatomic.so.1.2.0` lib binary. Transfer it to the intercom, to the `/lib` folder, than create the library symlink 97 | 98 | ``` 99 | cd /lib 100 | ln -s libatomic.so.1.2.0 libatomic.so.1 101 | ``` 102 | 103 | #### 3. Check that `node.js` now works fine 104 | 105 | ``` 106 | /home/bticino/cfg/extra/node/bin/node -v 107 | ``` 108 | should output the version 109 | ``` 110 | v17.9.1 111 | ``` 112 | 113 | #### 4. Install `c300x-controller` 114 | 115 | ``` 116 | cd /home/bticino/cfg/extra/ 117 | mkdir c300x-controller 118 | cd c300x-controller 119 | wget https://github.com/slyoldfox/c300x-controller/releases/latest/download/bundle.js -O /home/bticino/cfg/extra/c300x-controller/bundle.js 120 | # if using webrtc 121 | wget https://github.com/slyoldfox/c300x-controller/releases/latest/download/bundle-webrtc.js -O /home/bticino/cfg/extra/c300x-controller/bundle.js 122 | # if using homekit 123 | wget https://github.com/slyoldfox/c300x-controller/releases/latest/download/bundle-homekit.js -O /home/bticino/cfg/extra/c300x-controller/bundle.js 124 | ``` 125 | 126 | now do a check run 127 | 128 | ``` 129 | /home/bticino/cfg/extra/node/bin/node /home/bticino/cfg/extra/c300x-controller/bundle.js 130 | ``` 131 | 132 | #### 5. Edit firewall rules 133 | 134 | To be able to access the c300x-controller from the network, you have to allow incoming connections through the wireless interface to port 8080. 135 | Edit `/etc/network/if-pre-up.d/iptables` and add the following section at line 38: 136 | 137 | ``` 138 | # c300x-controller 139 | for i in 8080; do 140 | iptables -A INPUT -p tcp -m tcp --dport $i -j ACCEPT 141 | iptables -A INPUT -p tcp -m tcp --sport $i -j ACCEPT 142 | done 143 | ``` 144 | 145 | then apply changes 146 | 147 | ``` 148 | /etc/init.d/networking restart 149 | ``` 150 | 151 | and check that the controller is now reachable at http://:8080 152 | 153 | > [!WARNING] 154 | > If you prefer, at your own risk, you can entirely disable iptables firewall 155 | > 156 | > ``` 157 | > $ mv /etc/network/if-pre-up.d/iptables /home/bticino/cfg/extra/iptables.bak 158 | > $ mv /etc/network/if-pre-up.d/iptables6 /home/bticino/cfg/extra/iptables6.bak 159 | > ``` 160 | 161 | #### 6. Running it at startup 162 | 163 | Create a new init.d script under `/etc/init.d/c300x-controller` with the following content 164 | ``` 165 | #! /bin/sh 166 | 167 | ### BEGIN INIT INFO 168 | # Provides: c300x-controller 169 | # Default-Start: 2 3 4 5 170 | # Default-Stop: 0 1 6 171 | # Short-Description: c300x-controller 172 | ### END INIT INFO 173 | 174 | set -e 175 | 176 | PIDFILE=/var/run/c300x-controller 177 | DAEMON="/home/bticino/cfg/extra/node/bin/node" 178 | DAEMON_ARGS="/home/bticino/cfg/extra/c300x-controller/bundle.js" 179 | 180 | . /etc/init.d/functions 181 | 182 | case "$1" in 183 | start) 184 | echo "Starting c300x-controller" 185 | if start-stop-daemon --start --quiet --oknodo --background --make-pidfile --pidfile ${PIDFILE} --exec ${DAEMON} -- ${DAEMON_ARGS} ; then 186 | exit 0 187 | fi 188 | ;; 189 | 190 | stop) 191 | echo "Stopping c300x-controller" 192 | if start-stop-daemon --stop --quiet --oknodo --pidfile ${PIDFILE} --retry=TERM/3/KILL/2; then 193 | rm -f ${PIDFILE} 194 | exit 0 195 | fi 196 | ;; 197 | 198 | restart) 199 | echo "Restarting c300x-controller" 200 | if start-stop-daemon --stop --quiet --oknodo --retry 30 --pidfile ${PIDFILE}; then 201 | rm -f ${PIDFILE} 202 | fi 203 | usleep 150000 204 | if start-stop-daemon --start --quiet --oknodo --background --make-pidfile --pidfile ${PIDFILE} --retry=TERM/3/KILL/2 --exec ${DAEMON} -- ${DAEMON_ARGS} ; then 205 | exit 0 206 | fi 207 | ;; 208 | 209 | status) 210 | #status ${DAEMON} && exit 0 || exit $? 211 | pid=`ps -fC node | grep "$DAEMON $DAEMON_ARGS" | awk '{print $2}'` 212 | if [ "$pid" != "" ]; then 213 | echo "$DAEMON $DAEMON_ARGS (pid $pid) is running..." 214 | else 215 | echo "$DAEMON $DAEMON_ARGS stopped" 216 | fi 217 | ;; 218 | 219 | *) 220 | echo "Usage: $0 {start|stop|restart|status}" 221 | exit 1 222 | esac 223 | 224 | exit 0 225 | ``` 226 | 227 | make it executable 228 | 229 | ``` 230 | chmod 755 /etc/init.d/c300x-controller 231 | ``` 232 | 233 | then create the symlink for init.d runlevel 5 234 | 235 | ``` 236 | cd /etc/rc5.d/ 237 | ln -s ../init.d/c300x-controller S40c300x-controller 238 | ``` 239 | 240 | #### 7. Final steps 241 | 242 | Make the filesystem read-only again 243 | 244 | ``` 245 | mount -oremount,ro / 246 | ``` 247 | 248 | than reboot the unit and verify that everything is working as expected. 249 | 250 | ## WebRTC 251 | 252 | Since version 2024.5.1 - you can choose between `bundle.js`, `bundle-webrtc.js` or `bundle-homekit.js`. 253 | 254 | To use `WebRTC`, use the `bundle-webrtc.js` file instead of the `bundle.js`. 255 | 256 | In config.json add the following config: 257 | 258 | ``` 259 | "sip" : { 260 | "from": "webrtc@127.0.0.1", 261 | "to": "c300x@192.168.0.XX", 262 | "domain": "XXXXXXX.bs.iotleg.com", 263 | "debug": false 264 | } 265 | ``` 266 | 267 | Add the `webrtc` to the linphone files if you wish to receive incoming calls. 268 | 269 | When starting the WebRTC bundle, an additional RTSP server will be available at `rtsp://192.168.0.X:6554/doorbell`. 270 | 271 | This allows you to use `ffplay -f rtsp -i rtsp://192.168.0.X:6554/doorbell` or `ffmpeg -f rtsp -i rtsp://192.168.0.X:6554/doorbell` to setup the underlying SIP call and view the camera. 272 | 273 | There is also two more endpoints: 274 | 275 | * `rtsp://192.168.0.X:6554/doorbell-video` is a video only stream (no audio) 276 | * `rtsp://192.168.0.X:6554/doorbell-recorder` is used internally for HKSV recordings 277 | 278 | You can use the Home Assistant add-on or integration at https://github.com/AlexxIT/WebRTC to add a WebRTC card to your dashboard. 279 | 280 | The Home Assistant add-on or integration has the ability to run https://github.com/AlexxIT/go2rtc as an embedded process on your HA instance (or as a standalone process). 281 | 282 | You can add a stream to the Bticino intercom by specifying the following `go2rtc.yaml` 283 | 284 | ``` 285 | streams: 286 | doorbell: 287 | - "ffmpeg:rtsp://192.168.0.XX:6554/doorbell#video=copy#audio=pcma" 288 | - "exec:ffmpeg -re -fflags nobuffer -f alaw -ar 8000 -i - -ar 8000 -acodec speex -f rtp -payload_type 97 rtp://192.168.0.XX:40004#backchannel=1" 289 | ``` 290 | 291 | The `ffmpeg:rtsp://192.168.0.XX:6554/doorbell#video=copy#audio=pcma"` line talks to the RTSP server inside the c300-controller and will setup a SIP call in the background. 292 | 293 | The options `#video=copy#audio=pcma` tell go2rtc to copy the `h264` and transcode the audio (from `speex`) to `pcma` 294 | 295 | The `exec:ffmpeg ...` line specifies the `backchannel`. This is the stream from your (browser) microphone towards the intercom. 296 | It will read the microphone data from the websocket and transcode it to `speex` and send it the intercom using `rtp`. The port `40004` is the port of the UDP proxy inside the c300-controller. 297 | 298 | In `go2rtc.yaml` you might also want to configure the location of the `ffmpeg` binary if you need a more recent version. 299 | Be aware that some `ffmpeg` binaries don't support the `speex` library. 300 | 301 | ``` 302 | ffmpeg: 303 | bin: /home/hass/ffmpeg-linux-x64 # path to ffmpeg binary 304 | ``` 305 | 306 | The WebRTC card configuration looks like this: 307 | 308 | ``` 309 | type: custom:webrtc-camera 310 | url: doorbell 311 | mode: webrtc 312 | media: video,audio,microphone 313 | ``` 314 | 315 | To use the microphone you must make sure that your Home Assistant instance is running on `https://`. The microphone does not activate on `http://`, this is a browser security measure. 316 | 317 | If you managed to get this working on your local network, you will still need to fix something to make sure you can reach the stream from the internet. 318 | 319 | Have a look at https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#module-webrtc for this. 320 | 321 | In my case I forwarded port `8555` to my internal go2rtc instance and specified my IP in the `candidates` section in `go2rtc.yaml` 322 | 323 | ``` 324 | webrtc: 325 | candidates: 326 | - 216.58.210.174:8555 # if you have static public IP-address 327 | ``` 328 | _BONUS:_ 329 | 330 | If you don't wish to run the `go2rtc` embedded process in HA, you can run it natively on your intercom: 331 | 332 | Fetch the binaries for go2rtc and ffmpeg: 333 | `https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_linux_arm` 334 | `https://johnvansickle.com/ffmpeg/` (*armhf* version) 335 | 336 | Adjust the paths to where your ffmpeg binary is and adjust your port forwards. 337 | 338 | ``` 339 | ffmpeg: 340 | bin: /home/bticino/cfg/extra/ffmpeg-linux-arm # path to ffmpeg binary 341 | ``` 342 | Replace the IP above with *127.0.0.1* and all transcoding and handling is now on your intercom. 343 | 344 | Inside HA add the Webrtc with `http://192.168.0.XX:1984` (replace with the IP of your intercom). 345 | 346 | ## Homekit 347 | 348 | Since version 2024.5.1 - you can choose between `bundle.js`, `bundle-webrtc.js` or `bundle-homekit.js`. 349 | 350 | ***WARNING:*** Homekit support is experimental, work in progress and highly untested. 351 | 352 | To use `Homekit`, use the `bundle-homekit.js` file instead of the `bundle.js`. This will expose a Homekit bridge. 353 | 354 | The PIN code to pair is shown in the console or in the file `config-homekit.json` after startup. 355 | 356 | At the moment the Bridge exposes: 357 | 358 | * All locks 359 | * Mute/unmute switch 360 | * Voicemail switch (C300X only) 361 | 362 | In addition to the bridge it will also expose a doorbell in standalone accessory mode. 363 | 364 | The PIN code to pair is shown in the console or in the file `config-homekit.json` after startup in the `videoConfig` section. 365 | 366 | You can tweak the `videoConfig` settings to change: 367 | 368 | * The thumbnail displayed in Homekit with `stillImageSource` 369 | * A static image: `-i https://iili.io/JZq8pwB.jpg` 370 | * A snapshot from the video only stream: `-i rtsp://127.0.0.1:6554/doorbell-video` 371 | * A video filter with `videoFilter`, defaults to `select=gte(n\,6)` which is the 6th frame from the stream 372 | * Enable/disable `Homekit Secure Video` recordings with `hksv` 373 | * Enable debugging of the video streams with `debug` 374 | * Enable debugging of the return audio with `debugReturn` 375 | 376 | Since version 2024.7.1 - you can enable `hksv` in the `videoConfig` section. This will enable Homekit recordings when someone rings the doorbell. 377 | 378 | To use Homekit Secure Video you need an Apple Tv 2nd Gen (wired) or Homepod. 379 | 380 | Once you enable the flag and started the Homekit bundle, you should be able to pair the camera and receive options to record the stream. 381 | 382 | Make sure you set Motion Detection to `Any motion detected`. 383 | 384 | When somebody rings your doorbell, a motion clip will be recorded. There will be no audio recorded, just video. 385 | 386 | ## Home Assistant webhooks and endpoints 387 | 388 | It is possible to register a webhook in Home Assistant, in order to receive notifications about doorbell button pressed, door locked and door unlocked. 389 | Note that you need to have home assistant configured in `https`, otherwise it doesn't work. 390 | 391 | The first thing to do is to declare three new automations, like this: 392 | 393 | ``` 394 | alias: Doorbell pressed 395 | description: "" 396 | trigger: 397 | - platform: webhook 398 | allowed_methods: 399 | - POST 400 | - PUT 401 | - GET 402 | local_only: false 403 | webhook_id: doorbellPressed 404 | condition: [] 405 | action: 406 | - service: notify. 407 | data: 408 | title: Doorbell 409 | message: Ringing! 410 | data: 411 | ttl: 0 412 | priority: high 413 | notification_icon: mdi:bell-ring 414 | mode: single 415 | ``` 416 | ``` 417 | alias: Door locked 418 | description: "" 419 | trigger: 420 | - platform: webhook 421 | allowed_methods: 422 | - POST 423 | - PUT 424 | - GET 425 | local_only: false 426 | webhook_id: doorbellLocked 427 | condition: [] 428 | action: 429 | - service: notify. 430 | data: 431 | title: Doorbell 432 | message: Door locked 433 | data: 434 | ttl: 0 435 | priority: high 436 | notification_icon: mdi:gate 437 | mode: single 438 | ``` 439 | ``` 440 | alias: Door unlocked 441 | description: "" 442 | trigger: 443 | - platform: webhook 444 | allowed_methods: 445 | - POST 446 | - PUT 447 | - GET 448 | local_only: false 449 | webhook_id: doorbellUnlocked 450 | condition: [] 451 | action: 452 | - service: notify. 453 | data: 454 | title: Doorbell 455 | message: Door unlocked 456 | data: 457 | ttl: 0 458 | priority: high 459 | notification_icon: mdi:gate-alert 460 | mode: single 461 | ``` 462 | 463 | `SECURITY ALERT!` Please change your webhook ID, if anyone knows that and your home assistant address, it is simple to call that automation from external. 464 | 465 | In this mode, you have created three endpoints, that you can use to trigger the automations from the controller. 466 | These are the addresses: 467 | ``` 468 | * https:///api/webhook/doorbellPressed 469 | * https:///api/webhook/doorbellLocked 470 | * https:///api/webhook/doorbellUnlocked 471 | ``` 472 | 473 | If you call it from your browser, you should receive a notification on your device. 474 | 475 | Now, you have to register these endpoints on the controller, but before you have to encode this addresses in base64, using this site: https://www.base64encode.org/. 476 | You need to encode one URL at time, for example: 477 | ``` 478 | * https:///api/webhook/doorbellPressed -> aHR0cHM6Ly88aGEtaW5zdGFuY2U+L2FwaS93ZWJob29rL2Rvb3JiZWxsUHJlc3NlZA== 479 | * https:///api/webhook/doorbellLocked -> aHR0cHM6Ly88aGEtaW5zdGFuY2U+L2FwaS93ZWJob29rL2Rvb3JiZWxsTG9ja2Vk 480 | * https:///api/webhook/doorbellUnlocked -> aHR0cHM6Ly88aGEtaW5zdGFuY2U+L2FwaS93ZWJob29rL2Rvb3JiZWxsVW5sb2NrZWQ= 481 | ``` 482 | Once you have these three base64, you can compose the REST Api call and put it in your HA configuration.yaml, in this mode: 483 | ``` 484 | rest_command: 485 | register_doorbell: 486 | url: "http://<>:8080/register-endpoint?raw=true&identifier=webrtc&pressed=<>&locked=<>&unlocked=<>&verifyUser=false" 487 | method: post 488 | ``` 489 | 490 | Restart your HA instance. 491 | 492 | Lastly, you need to register these endpoint in your intercom. You need one more automation; this runs every 4 minutes, because after 5 minutes the endpoint are removed: 493 | 494 | ``` 495 | alias: Doorbell API registration 496 | description: "" 497 | trigger: 498 | - platform: time_pattern 499 | minutes: /4 500 | condition: [] 501 | action: 502 | - service: rest_command.register_doorbell 503 | metadata: {} 504 | data: {} 505 | mode: restart 506 | ``` 507 | 508 | If you now access to `http://:8080/register-endpoint`, you can see your endpoints registered. 509 | 510 | ## Development 511 | 512 | For development, open an ssh connection to you intercom and forward the `openwebnet` port. 513 | 514 | ``` 515 | ssh -L127.0.0.1:20000:127.0.0.1:20000 root2@192.168.0.XX 516 | ``` 517 | 518 | If you want to receive openwebnet messages you will need to login to the intercom and forward the syslog packets 519 | 520 | You can do this with `socat`, an arm build is avaialable for download here: https://github.com/therealsaumil/static-arm-bins/blob/master/socat-armel-static 521 | 522 | ``` 523 | ssh -L127.0.0.1:20000:127.0.0.1:20000 root2@192.168.0.XX /home/bticino/cfg/extra/socat-armel-static UDP4-RECVFROM:7667,reuseaddr,fork UDP4-SENDTO:192.168.0.5:7667 524 | ``` 525 | 526 | Start the controller with 527 | 528 | ``` 529 | node controller.js 530 | ``` 531 | 532 | You can create a (production) webpack bundle by executing: 533 | 534 | ``` 535 | npm run build 536 | ``` 537 | 538 | You can then run the (production) webpack bundle by executing: 539 | 540 | ``` 541 | npm start 542 | ``` 543 | 544 | > [!WARNING] 545 | > Note that some APIs might not work locally (e.g. reboot api, load) - because they use native commands on the intercom. 546 | > 547 | -------------------------------------------------------------------------------- /base.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var package_json = require('./package.json'); 3 | 4 | process.on('uncaughtException', function(e){ 5 | console.error('uncaughtException', e); 6 | }); 7 | 8 | process.on('unhandledRejection', function(e){ 9 | console.error('unhandledRejection', e); 10 | }); 11 | 12 | console.log(`======= c300x-controller ${package_json.version} for use with BTicino plugin 0.0.15 =======`) 13 | const Api = require('./lib/api') 14 | const MulticastListener = require("./lib/multicast-listener"); 15 | const udpProxy = require('./lib/udp-proxy') 16 | const EndpointRegistry = require('./lib/endpoint-registry') 17 | const mqtt = require('./lib/mqtt') 18 | 19 | const registry = EndpointRegistry.create() 20 | const api = Api.create(registry) 21 | udpProxy.create( 40004, '0.0.0.0', 4000, '127.0.0.1' ) 22 | const eventbus = require('./lib/eventbus').create() 23 | MulticastListener.create(registry, api, mqtt.create(api), eventbus) 24 | 25 | module.exports = { 26 | 'registry': registry, 27 | 'eventbus': eventbus, 28 | 'api': api 29 | } -------------------------------------------------------------------------------- /bundles/ffmpeg.ts: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/Sunoo/homebridge-camera-ffmpeg/blob/master/src/ffmpeg.ts 2 | import child_process, { ChildProcess, ChildProcessWithoutNullStreams, spawn } from 'child_process'; 3 | import os from 'os'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import readline from 'readline'; 7 | import { Writable } from 'stream'; 8 | import { Logger } from './homekit-logger'; 9 | import { StreamingDelegate } from './homekit-camera'; 10 | import https, { RequestOptions } from "https"; 11 | 12 | type FfmpegProgress = { 13 | frame: number; 14 | fps: number; 15 | stream_q: number; 16 | bitrate: number; 17 | total_size: number; 18 | out_time_us: number; 19 | out_time: string; 20 | dup_frames: number; 21 | drop_frames: number; 22 | speed: number; 23 | progress: string; 24 | }; 25 | 26 | const requestOptions : RequestOptions = { 27 | timeout: 2000, 28 | rejectUnauthorized: false, 29 | } 30 | 31 | const url: string = "https://github.com/slyoldfox/ffmpeg-for-bticino/releases/download/v2024.5.1/ffmpeg-" 32 | 33 | function get(url : string, writeStream: fs.WriteStream, callback : Function) { 34 | https.get(url, requestOptions, (res) => { 35 | 36 | // if any other status codes are returned, those needed to be added here 37 | if(res.statusCode === 301 || res.statusCode === 302) { 38 | return get(res.headers.location, writeStream, callback) 39 | } 40 | 41 | console.log("[FFMPEG] File size is: " + res.headers["content-length"]) 42 | res.pipe(writeStream) 43 | 44 | let filesize : number = Number( res.headers["content-length"] ); 45 | let fetched : number = 0 46 | let lastReport = new Date() 47 | 48 | res.on("data", (chunk) => { 49 | fetched += chunk.length 50 | if( new Date().getTime() - lastReport.getTime() > 500 ) { 51 | let pct = Math.round( fetched / filesize * 100 ) 52 | console.log(`[FFMPEG] Downloaded ${fetched}/${filesize} (${pct}%)`) 53 | lastReport = new Date() 54 | } 55 | }); 56 | 57 | res.on("end", () => { 58 | writeStream.close( () => { 59 | callback() 60 | } ) 61 | writeStream.end( () => { 62 | console.log("[FFMPEG] Download ended.") 63 | }) 64 | }); 65 | }) 66 | } 67 | 68 | function checkAndFixPermissions(ffmpeg) { 69 | const perms = fs.constants.S_IROTH | fs.constants.S_IXOTH | fs.constants.S_IRUSR | fs.constants.S_IXUSR | fs.constants.S_IRGRP | fs.constants.S_IXGRP 70 | try { 71 | fs.accessSync(ffmpeg, fs.constants.R_OK | fs.constants.X_OK ) 72 | } catch( e ) { 73 | fs.chmodSync(ffmpeg, perms ) 74 | } 75 | } 76 | 77 | function checkCorrupted(ffmpeg) { 78 | try { 79 | const stat = fs.statSync(ffmpeg) 80 | if(stat.size == 0) { 81 | fs.rmSync(ffmpeg) 82 | } else { 83 | const response = child_process.execSync(ffmpeg + " -version").toString() 84 | console.log( `[FFMPEG] valid binary file at: ${ffmpeg}`) 85 | } 86 | } 87 | catch(e) { 88 | console.error("[FFMPEG] binary file corrupt? Removing it. Error: " + e) 89 | fs.rmSync(ffmpeg) 90 | } 91 | } 92 | 93 | export async function sleep(ms: number) { 94 | await new Promise(resolve => setTimeout(resolve, ms)); 95 | } 96 | 97 | export function fetchFffmpeg(pathName) { 98 | const platform_arch = process.platform + '-' + process.arch 99 | const ffmpeg = path.join(pathName, "ffmpeg") 100 | 101 | if( fs.existsSync(ffmpeg) ) { 102 | checkAndFixPermissions(ffmpeg) 103 | checkCorrupted(ffmpeg) 104 | } 105 | 106 | if( !fs.existsSync(ffmpeg) ) { 107 | console.info(`Could not find ffmpeg at ${ffmpeg}, installing ...`) 108 | const download_url = url + platform_arch 109 | let writeStream : fs.WriteStream = fs.createWriteStream(ffmpeg) 110 | 111 | switch( platform_arch ) { 112 | case "darwin-x64": 113 | case "linux-x64": 114 | case "linux-arm": //BTicino 115 | get(download_url, writeStream, () => { 116 | checkAndFixPermissions(ffmpeg) 117 | checkCorrupted(ffmpeg) 118 | }); 119 | break; 120 | default: 121 | console.error(`Unsupported platform, install your own 'ffmpeg' binary at this path: ${ffmpeg}`) 122 | } 123 | } 124 | return ffmpeg 125 | } 126 | 127 | export async function safeKillFFmpeg(cp: ChildProcess) { 128 | if (!cp) 129 | return; 130 | if (cp.exitCode != null) 131 | return; 132 | await new Promise(async resolve => { 133 | cp.on('exit', resolve); 134 | // this will allow ffmpeg to send rtsp TEARDOWN etc 135 | try { 136 | cp.stdin.on('error', () => { }); 137 | cp.stdin.write('q\n'); 138 | } 139 | catch (e) { 140 | } 141 | 142 | await sleep(2000); 143 | for (const f of cp.stdio) { 144 | try { 145 | f?.destroy(); 146 | } 147 | catch (e) { 148 | } 149 | } 150 | cp.kill(); 151 | await sleep(2000); 152 | cp.kill('SIGKILL'); 153 | }); 154 | } 155 | 156 | export class FfmpegProcess { 157 | private readonly process: ChildProcessWithoutNullStreams; 158 | private killTimeout?: NodeJS.Timeout; 159 | readonly stdin: Writable; 160 | 161 | constructor(cameraName: string, sessionId: string, videoProcessor: string, ffmpegArgs: string, log: Logger, 162 | debug = false, delegate: StreamingDelegate, callback?) { 163 | log.debug('Stream command: ' + videoProcessor + ' ' + ffmpegArgs, cameraName, debug); 164 | 165 | let started = false; 166 | const startTime = Date.now(); 167 | this.process = spawn(videoProcessor, ffmpegArgs.split(/\s+/), { env: process.env }); 168 | this.stdin = this.process.stdin; 169 | 170 | this.process.stdout.on('data', (data) => { 171 | const progress = this.parseProgress(data); 172 | if (progress) { 173 | if (!started && progress.frame > 0) { 174 | started = true; 175 | const runtime = (Date.now() - startTime) / 1000; 176 | const message = 'Getting the first frames took ' + runtime + ' seconds.'; 177 | if (runtime < 5) { 178 | log.debug(message, cameraName, debug); 179 | } else if (runtime < 22) { 180 | log.warn(message, cameraName); 181 | } else { 182 | log.error(message, cameraName); 183 | } 184 | } 185 | } 186 | }); 187 | const stderr = readline.createInterface({ 188 | input: this.process.stderr, 189 | terminal: false 190 | }); 191 | stderr.on('line', (line: string) => { 192 | if (callback) { 193 | callback(); 194 | callback = undefined; 195 | } 196 | if (debug && line.match(/\[(panic|fatal|error)\]/)) { // For now only write anything out when debug is set 197 | log.error(line, cameraName); 198 | } else if (debug) { 199 | log.debug(line, cameraName, true); 200 | } 201 | }); 202 | this.process.on('error', (error: Error) => { 203 | log.error('FFmpeg process creation failed: ' + error.message, cameraName); 204 | if (callback) { 205 | callback(new Error('FFmpeg process creation failed')); 206 | } 207 | delegate.stopStream(sessionId); 208 | }); 209 | this.process.on('exit', (code: number, signal: NodeJS.Signals) => { 210 | if (this.killTimeout) { 211 | clearTimeout(this.killTimeout); 212 | } 213 | 214 | const message = 'FFmpeg exited with code: ' + code + ' and signal: ' + signal; 215 | 216 | if (this.killTimeout && code === 0) { 217 | log.debug(message + ' (Expected)', cameraName, debug); 218 | } else if (code == null || code === 255) { 219 | if (this.process.killed) { 220 | log.debug(message + ' (Forced)', cameraName, debug); 221 | } else { 222 | log.error(message + ' (Unexpected)', cameraName); 223 | } 224 | } else { 225 | log.error(message + ' (Error)', cameraName); 226 | delegate.stopStream(sessionId); 227 | if (!started && callback) { 228 | callback(new Error(message)); 229 | } else { 230 | delegate.controller.forceStopStreamingSession(sessionId); 231 | } 232 | } 233 | }); 234 | } 235 | 236 | parseProgress(data: Uint8Array): FfmpegProgress | undefined { 237 | const input = data.toString(); 238 | 239 | if (input.indexOf('frame=') == 0) { 240 | try { 241 | const progress = new Map(); 242 | input.split(/\r?\n/).forEach((line) => { 243 | const split = line.split('=', 2); 244 | progress.set(split[0], split[1]); 245 | }); 246 | 247 | return { 248 | frame: parseInt(progress.get('frame')!), 249 | fps: parseFloat(progress.get('fps')!), 250 | stream_q: parseFloat(progress.get('stream_0_0_q')!), 251 | bitrate: parseFloat(progress.get('bitrate')!), 252 | total_size: parseInt(progress.get('total_size')!), 253 | out_time_us: parseInt(progress.get('out_time_us')!), 254 | out_time: progress.get('out_time')!.trim(), 255 | dup_frames: parseInt(progress.get('dup_frames')!), 256 | drop_frames: parseInt(progress.get('drop_frames')!), 257 | speed: parseFloat(progress.get('speed')!), 258 | progress: progress.get('progress')!.trim() 259 | }; 260 | } catch { 261 | return undefined; 262 | } 263 | } else { 264 | return undefined; 265 | } 266 | } 267 | 268 | public stop(): void { 269 | this.process.stdin.write('q' + os.EOL); 270 | this.killTimeout = setTimeout(() => { 271 | this.process.kill('SIGKILL'); 272 | }, 2 * 1000); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /bundles/homekit-bundle-webpack.config.js: -------------------------------------------------------------------------------- 1 | // Used to regenerate homekit-bundle.js from homekit-manager.ts 2 | 3 | const webpack = require('webpack'); 4 | const header = 5 | `// ======================================================================================================================= 6 | // DO NOT EDIT, this is a generated file, generate it with $ npm run build:homekitbundle:dev or npm run build:homekitbundle:prod 7 | // =======================================================================================================================` 8 | 9 | const TerserPlugin = require('terser-webpack-plugin') 10 | 11 | module.exports = [ 12 | (env, argv) => { 13 | return { 14 | resolve: { 15 | extensions: ['.ts', '.js', '.json'] 16 | }, 17 | devtool: 'source-map', 18 | mode: 'production', 19 | entry: [ __dirname + '/homekit-manager.ts'], 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.tsx?$/, 24 | use: 'ts-loader', 25 | exclude: /node_modules/, 26 | }, 27 | ], 28 | }, 29 | target : 'node', 30 | output: { 31 | path: __dirname + '/../lib/homekit', 32 | filename: 'homekit-bundle.js', 33 | libraryTarget: 'this', 34 | }, 35 | optimization: { 36 | minimizer: [new TerserPlugin({ extractComments: false })], 37 | }, 38 | plugins: [ new webpack.BannerPlugin({banner: header, raw: true, stage: webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT}) ] 39 | } 40 | } 41 | ] -------------------------------------------------------------------------------- /bundles/homekit-camera-recording.ts: -------------------------------------------------------------------------------- 1 | import { Accessory, AudioRecordingCodecType, AudioRecordingSamplerate, CameraRecordingConfiguration, CameraRecordingDelegate, Characteristic, DoorbellController, H264Level, H264Profile, HDSProtocolSpecificErrorReason, RecordingPacket, Service, VideoCodecType } from "hap-nodejs"; 2 | import { Logger } from "./homekit-logger"; 3 | import { VideoConfig } from "./homekit-camera"; 4 | import { once } from "events"; 5 | import { AddressInfo, createServer, Server, Socket } from "net"; 6 | import { ChildProcess, spawn } from "child_process"; 7 | import assert from "assert"; 8 | import { safeKillFFmpeg } from "./ffmpeg"; 9 | 10 | // Local testing: ./ffmpeg -re -f lavfi -i "color=red:size=688x480:rate=15" -f lavfi -i "sine=frequency=1000:b=4" -profile:v baseline -preset ultrafast -g 60 -vcodec libx264 -an -tune zerolatency -f rtp "rtp://127.0.0.1:10002" -acodec speex -ar 8000 -vn -payload_type 110 -f rtp "rtp://127.0.0.1:10000" 11 | 12 | interface MP4Atom { 13 | header: Buffer; 14 | length: number; 15 | type: string; 16 | data: Buffer; 17 | } 18 | 19 | class MP4StreamingServer { 20 | readonly server: Server; 21 | 22 | /** 23 | * This can be configured to output ffmpeg debug output! 24 | */ 25 | debugMode: boolean = false; 26 | 27 | readonly ffmpegPath: string; 28 | readonly args: string[]; 29 | 30 | socket?: Socket; 31 | childProcess?: ChildProcess; 32 | destroyed = false; 33 | 34 | connectPromise: Promise; 35 | connectResolve?: () => void; 36 | 37 | constructor( debugMode : boolean, ffmpegPath: string, ffmpegInput: Array, audioOutputArgs: Array, videoOutputArgs: Array) { 38 | this.debugMode = debugMode 39 | this.connectPromise = new Promise(resolve => this.connectResolve = resolve); 40 | 41 | this.server = createServer(this.handleConnection.bind(this)); 42 | this.ffmpegPath = ffmpegPath; 43 | this.args = []; 44 | 45 | this.args.push(...ffmpegInput); 46 | 47 | this.args.push(...audioOutputArgs); 48 | 49 | this.args.push("-f", "mp4"); 50 | this.args.push(...videoOutputArgs); 51 | this.args.push("-fflags", 52 | "+genpts", 53 | "-reset_timestamps", 54 | "1"); 55 | this.args.push( 56 | //"-movflags", "frag_keyframe+empty_moov+default_base_moof", 57 | "-movflags", "frag_keyframe+empty_moov+default_base_moof+skip_sidx+skip_trailer", 58 | ); 59 | } 60 | 61 | async start() { 62 | const promise = once(this.server, "listening"); 63 | this.server.listen(); // listen on random port 64 | await promise; 65 | 66 | if (this.destroyed) { 67 | return; 68 | } 69 | 70 | const port = (this.server.address() as AddressInfo).port; 71 | this.args.push("tcp://127.0.0.1:" + port); 72 | 73 | console.log(this.ffmpegPath + " " + this.args.join(" ")); 74 | 75 | this.childProcess = spawn(this.ffmpegPath, this.args, { env: process.env, stdio: this.debugMode? "pipe": "ignore" }); 76 | if (!this.childProcess) { 77 | console.error("ChildProcess is undefined directly after the init!"); 78 | } 79 | if(this.debugMode) { 80 | this.childProcess.stdout?.on("data", data => console.log(data.toString())); 81 | this.childProcess.stderr?.on("data", data => console.log(data.toString())); 82 | } 83 | } 84 | 85 | destroy() { 86 | safeKillFFmpeg(this.childProcess) 87 | this.socket?.destroy(); 88 | //this.childProcess?.kill(); 89 | 90 | this.socket = undefined; 91 | this.childProcess = undefined; 92 | this.destroyed = true; 93 | } 94 | 95 | handleConnection(socket: Socket): void { 96 | this.server.close(); // don't accept any further clients 97 | this.socket = socket; 98 | this.connectResolve?.(); 99 | } 100 | 101 | /** 102 | * Generator for `MP4Atom`s. 103 | * Throws error to signal EOF when socket is closed. 104 | */ 105 | async* generator(): AsyncGenerator { 106 | await this.connectPromise; 107 | 108 | if (!this.socket || !this.childProcess) { 109 | console.log("Socket undefined " + !!this.socket + " childProcess undefined " + !!this.childProcess); 110 | throw new Error("Unexpected state!"); 111 | } 112 | 113 | while (true) { 114 | const header = await this.read(8); 115 | const length = header.readInt32BE(0) - 8; 116 | const type = header.slice(4).toString(); 117 | const data = await this.read(length); 118 | 119 | yield { 120 | header: header, 121 | length: length, 122 | type: type, 123 | data: data, 124 | }; 125 | } 126 | } 127 | 128 | async read(length: number): Promise { 129 | if (!this.socket) { 130 | throw Error("FFMPEG tried reading from closed socket!"); 131 | } 132 | 133 | if (!length) { 134 | return Buffer.alloc(0); 135 | } 136 | 137 | const value = this.socket.read(length); 138 | if (value) { 139 | return value; 140 | } 141 | 142 | return new Promise((resolve, reject) => { 143 | const readHandler = () => { 144 | const value = this.socket!.read(length); 145 | if (value) { 146 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 147 | cleanup(); 148 | resolve(value); 149 | } 150 | }; 151 | 152 | const endHandler = () => { 153 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 154 | cleanup(); 155 | reject(new Error(`FFMPEG socket closed during read for ${length} bytes!`)); 156 | }; 157 | 158 | const cleanup = () => { 159 | this.socket?.removeListener("readable", readHandler); 160 | this.socket?.removeListener("close", endHandler); 161 | }; 162 | 163 | if (!this.socket) { 164 | throw new Error("FFMPEG socket is closed now!"); 165 | } 166 | 167 | this.socket.on("readable", readHandler); 168 | this.socket.on("close", endHandler); 169 | }); 170 | } 171 | } 172 | 173 | export class RecordingDelegate implements CameraRecordingDelegate { 174 | private controller : DoorbellController 175 | private readonly log: Logger = new Logger() 176 | private configuration: CameraRecordingConfiguration; 177 | private handlingStreamingRequest = false; 178 | private server?: MP4StreamingServer; 179 | 180 | 181 | constructor(private videoConfig : VideoConfig, private camera : Accessory) { 182 | } 183 | 184 | updateRecordingActive(active: boolean): void { 185 | this.log.debug(`Recording: ${active}`, this.videoConfig.displayName); 186 | } 187 | updateRecordingConfiguration(newConfiguration: CameraRecordingConfiguration): void { 188 | this.configuration = newConfiguration; 189 | } 190 | async *handleRecordingStreamRequest(streamId: number): AsyncGenerator { 191 | assert(!!this.configuration); 192 | /** 193 | * With this flag you can control how the generator reacts to a reset to the motion trigger. 194 | * If set to true, the generator will send a proper endOfStream if the motion stops. 195 | * If set to false, the generator will run till the HomeKit Controller closes the stream. 196 | * 197 | * Note: In a real implementation you would most likely introduce a bit of a delay. 198 | */ 199 | const STOP_AFTER_MOTION_STOP = false; 200 | 201 | this.handlingStreamingRequest = true; 202 | 203 | assert(this.configuration.videoCodec.type === VideoCodecType.H264); 204 | 205 | const profile = this.configuration.videoCodec.parameters.profile === H264Profile.HIGH ? "high" 206 | : this.configuration.videoCodec.parameters.profile === H264Profile.MAIN ? "main" : "baseline"; 207 | 208 | const level = this.configuration.videoCodec.parameters.level === H264Level.LEVEL4_0 ? "4.0" 209 | : this.configuration.videoCodec.parameters.level === H264Level.LEVEL3_2 ? "3.2" : "3.1"; 210 | 211 | /* 212 | const videoArgs: Array = [ 213 | "-an", 214 | "-sn", 215 | "-dn", 216 | "-codec:v", 217 | "libx264", 218 | "-pix_fmt", 219 | "yuv420p", 220 | 221 | "-profile:v", profile, 222 | "-level:v", level, 223 | "-preset", "ultrafast", 224 | "-g", "15", 225 | "-b:v", `${this.configuration.videoCodec.parameters.bitRate}k`, 226 | //"-force_key_frames", `expr:eq(t,n_forced*${this.configuration.videoCodec.parameters.iFrameInterval / 1000})`, 227 | "-r", this.configuration.videoCodec.resolution[2].toString(), 228 | //"-r", "15" 229 | ]; 230 | */ 231 | 232 | 233 | //const videoArgs : Array = [ "-sn", "-dn", "-codec:v", "libx264", "-preset", "ultrafast", "-g", "60" ] 234 | const videoArgs : Array = [ "-sn", "-dn", "-codec:v", "copy" ] 235 | 236 | let samplerate: string; 237 | switch (this.configuration.audioCodec.samplerate) { 238 | case AudioRecordingSamplerate.KHZ_8: 239 | samplerate = "8"; 240 | break; 241 | case AudioRecordingSamplerate.KHZ_16: 242 | samplerate = "16"; 243 | break; 244 | case AudioRecordingSamplerate.KHZ_24: 245 | samplerate = "24"; 246 | break; 247 | case AudioRecordingSamplerate.KHZ_32: 248 | samplerate = "32"; 249 | break; 250 | case AudioRecordingSamplerate.KHZ_44_1: 251 | samplerate = "44.1"; 252 | break; 253 | case AudioRecordingSamplerate.KHZ_48: 254 | samplerate = "48"; 255 | break; 256 | default: 257 | throw new Error("Unsupported audio samplerate: " + this.configuration.audioCodec.samplerate); 258 | } 259 | 260 | const audioArgs: Array = this.controller?.recordingManagement?.recordingManagementService.getCharacteristic(Characteristic.RecordingAudioActive) 261 | ? [ 262 | "-acodec", "libfdk_aac", 263 | ...(this.configuration.audioCodec.type === AudioRecordingCodecType.AAC_LC ? 264 | ["-profile:a", "aac_low"] : 265 | ["-profile:a", "aac_eld"]), 266 | "-ar", `${samplerate}k`, 267 | "-b:a", `${this.configuration.audioCodec.bitrate}k`, 268 | "-ac", `${this.configuration.audioCodec.audioChannels}`, 269 | ] 270 | : []; 271 | 272 | this.server = new MP4StreamingServer( 273 | this.videoConfig.debug, 274 | this.videoConfig.$internalVideoProcessor, 275 | ('-f rtsp ' + this.videoConfig.source + '-recorder' ).split(/\s+/g), 276 | audioArgs, 277 | videoArgs, 278 | ); 279 | 280 | await this.server.start(); 281 | if (!this.server || this.server.destroyed) { 282 | return; // early exit 283 | } 284 | 285 | const pending: Array = []; 286 | 287 | try { 288 | for await (const box of this.server.generator()) { 289 | pending.push(box.header, box.data); 290 | 291 | const motionDetected = this.camera.getService(Service.MotionSensor)?.getCharacteristic(Characteristic.MotionDetected).value; 292 | 293 | console.log("mp4 box type " + box.type + " and length " + box.length + " motion: " + motionDetected); 294 | if (box.type === "moov" || box.type === "mdat") { 295 | const fragment = Buffer.concat(pending); 296 | pending.splice(0, pending.length); 297 | 298 | const isLast = STOP_AFTER_MOTION_STOP && !motionDetected; 299 | 300 | yield { 301 | data: fragment, 302 | isLast: isLast, 303 | }; 304 | 305 | if (isLast) { 306 | console.log("Ending session due to motion stopped!"); 307 | break; 308 | } 309 | } 310 | } 311 | } catch (error) { 312 | if (!error.message.startsWith("FFMPEG")) { // cheap way of identifying our own emitted errors 313 | console.error("Encountered unexpected error on generator " + error.stack); 314 | } 315 | } 316 | } 317 | acknowledgeStream?(streamId: number): void { 318 | this.closeRecordingStream(streamId) 319 | } 320 | closeRecordingStream(streamId: number, reason?: HDSProtocolSpecificErrorReason): void { 321 | if (this.server) { 322 | this.server.destroy(); 323 | this.server = undefined; 324 | } 325 | this.handlingStreamingRequest = false; 326 | } 327 | 328 | setController( controller : DoorbellController ) { 329 | this.controller = controller 330 | } 331 | } -------------------------------------------------------------------------------- /bundles/homekit-logger.ts: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/Sunoo/homebridge-camera-ffmpeg/blob/master/src/logger.ts 2 | export class Logger { 3 | private formatMessage(message: string, device?: string): string { 4 | let formatted = ''; 5 | if (device) { 6 | formatted += '[' + device + '] '; 7 | } 8 | formatted += message; 9 | return formatted; 10 | } 11 | 12 | public info(message: string, device?: string): void { 13 | console.info(this.formatMessage(message, device)); 14 | } 15 | 16 | public warn(message: string, device?: string): void { 17 | console.warn(this.formatMessage(message, device)); 18 | } 19 | 20 | public error(message: string, device?: string): void { 21 | console.error(this.formatMessage(message, device)); 22 | } 23 | 24 | public debug(message: string, device?: string, alwaysLog = false): void { 25 | console.debug(this.formatMessage(message, device)); 26 | } 27 | } -------------------------------------------------------------------------------- /bundles/homekit-manager.ts: -------------------------------------------------------------------------------- 1 | // 2 | // This file contains an abstraction of all the functionality needed for the intercom to talk to hap-nodejs, the code is restricted to Homekit functionality 3 | // 4 | // A static homekit-bundle.js is then be generated with $ npm run build:homekitbundle:dev or npm run build:homekitbundle:prod to use it within the c300-controller 5 | // 6 | // This file is subjected to change without backwards compatibility and probably needs heavy refactoring 7 | // 8 | 9 | import {Accessory, Bridge, Categories, Characteristic, Service, HAPStorage, uuid, CharacteristicEventTypes, DoorbellController, AudioRecordingCodecType, AudioRecordingSamplerate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraControllerOptions, DoorbellOptions, H264Level, H264Profile, MediaContainerType, SRTPCryptoSuites, VideoCodecType, Resolution, MDNSAdvertiser} from 'hap-nodejs' 10 | import { randomBytes } from 'crypto'; 11 | import { StreamingDelegate, VideoConfig } from './homekit-camera'; 12 | import { fetchFffmpeg } from './ffmpeg'; 13 | import EventBus from '../lib/eventbus'; 14 | import { RecordingDelegate } from './homekit-camera-recording'; 15 | import { Doorbell } from 'hap-nodejs/dist/lib/definitions'; 16 | 17 | const MANUFACTURER = "c300x-controller" 18 | let BUILDNUMBER = "0.0.0" 19 | let FIRMWAREVERSION = "0.0.0" 20 | let MODEL = "C100X/C300X" 21 | 22 | function setAccessoryInformation( accessory : Accessory ) { 23 | const accessoryInformationService = accessory.getService(Service.AccessoryInformation); 24 | accessoryInformationService.setCharacteristic(Characteristic.Manufacturer, MANUFACTURER); 25 | accessoryInformationService.setCharacteristic(Characteristic.Model, MODEL); 26 | accessoryInformationService.setCharacteristic(Characteristic.SerialNumber, 'v' + BUILDNUMBER); 27 | accessoryInformationService.setCharacteristic(Characteristic.FirmwareRevision, FIRMWAREVERSION); 28 | } 29 | 30 | function randomBetween(min, max) { 31 | return Math.round( Math.random() * (max - min) + min ); 32 | } 33 | 34 | class SwitchAccesory { 35 | accessory: Accessory; 36 | switchService: Service; 37 | 38 | constructor(private name : string, private eventbus : EventBus ) { 39 | const _uuid = uuid.generate('hap-nodejs:accessories:switch:' + name); 40 | this.accessory = new Accessory(name, _uuid); 41 | setAccessoryInformation(this.accessory) 42 | this.switchService = this.accessory.addService(Service.Switch, name); 43 | this.switchService.getCharacteristic(Characteristic.On) 44 | .on(CharacteristicEventTypes.SET, (value, callback) => { 45 | if( value ) { 46 | eventbus.emit('homekit:switch:on:' + name, this) 47 | } else { 48 | eventbus.emit('homekit:switch:off:' + name, this) 49 | } 50 | callback(null); 51 | }); 52 | const initialDelay = randomBetween(1000, 10000) 53 | setTimeout(() => { 54 | this.#updateSwitchState() 55 | }, initialDelay) 56 | } 57 | 58 | switchedOn( callback : Function ) { 59 | this.eventbus.on('homekit:switch:on:' + this.name, () => { 60 | callback(this) 61 | }) 62 | return this; 63 | } 64 | 65 | switchedOff( callback : Function ) { 66 | this.eventbus.on('homekit:switch:off:' + this.name, () => { 67 | callback(this) 68 | }) 69 | return this; 70 | } 71 | 72 | updateState( callback : Function ) { 73 | this.eventbus.on('homekit:switch:update:' + this.name, () => { 74 | callback().then( (value) => { 75 | this.switchService.getCharacteristic(Characteristic.On).updateValue(value); 76 | } ).finally( () => { 77 | setTimeout( ()=>{ 78 | this.#updateSwitchState() 79 | }, 60000 ) 80 | } ) 81 | }) 82 | return this 83 | } 84 | 85 | #updateSwitchState() { 86 | this.eventbus.emit('homekit:switch:update:' + this.name) 87 | } 88 | } 89 | 90 | class LockAccessory { 91 | #locked : boolean = true 92 | accessory: Accessory; 93 | lockService: Service; 94 | constructor(private id : string, name : string, private eventbus : EventBus) { 95 | const _uuid = uuid.generate('hap-nodejs:accessories:lock:' + name); 96 | this.accessory = new Accessory(name, _uuid); 97 | setAccessoryInformation(this.accessory) 98 | this.lockService = this.accessory.addService(Service.LockMechanism, name); 99 | 100 | this.lockService 101 | .getCharacteristic(Characteristic.LockTargetState) 102 | .on(CharacteristicEventTypes.SET, (value, callback) => { 103 | if (value === Characteristic.LockTargetState.UNSECURED) { 104 | this.#locked = false 105 | eventbus.emit('homekit:lock:unlock:' + id, this) 106 | callback(); 107 | 108 | setTimeout( () => { 109 | this.#locked = true 110 | eventbus.emit('homekit:lock:lock:' + id, this) 111 | }, 3000); 112 | } else if (value === Characteristic.LockTargetState.SECURED) { 113 | // Probably shouldn't happen, since the locks auto-secure 114 | callback(); 115 | this.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.SECURED); 116 | } 117 | }); 118 | 119 | this.lockService 120 | .getCharacteristic(Characteristic.LockCurrentState) 121 | .on(CharacteristicEventTypes.GET, callback => { 122 | if (this.#locked) { 123 | callback(undefined, Characteristic.LockCurrentState.SECURED); 124 | } else { 125 | callback(undefined, Characteristic.LockCurrentState.UNSECURED); 126 | } 127 | }); 128 | 129 | this.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockCurrentState.SECURED); 130 | } 131 | 132 | unlocked( callback : Function ) { 133 | this.eventbus.on('homekit:lock:unlock:' + this.id, () => { 134 | callback(this) 135 | }) 136 | return this; 137 | } 138 | } 139 | 140 | export function createHAPUsername() { 141 | const buffers = []; 142 | for (let i = 0; i < 6; i++) { 143 | buffers.push(randomBytes(1).toString('hex')); 144 | } 145 | return buffers.join(':'); 146 | } 147 | 148 | function rd() { 149 | return Math.round(Math.random() * 100000) % 10; 150 | } 151 | 152 | export function randomPinCode() { 153 | return `${rd()}${rd()}${rd()}-${rd()}${rd()}-${rd()}${rd()}${rd()}`; 154 | } 155 | 156 | export class HomekitManager { 157 | bridge: Bridge 158 | doorbell: Accessory 159 | 160 | constructor( private eventbus : EventBus, base_path : string, config, videoConfig: VideoConfig, buildNumber : string, model : string) { 161 | HAPStorage.setCustomStoragePath( base_path + "/storage") 162 | if( !videoConfig.videoProcessor ) { 163 | videoConfig.$internalVideoProcessor = fetchFffmpeg(base_path) 164 | } 165 | MODEL = model 166 | BUILDNUMBER = buildNumber 167 | this.bridge = new Bridge(config.displayName, uuid.generate('hap-nodejs:bridges:homebridge')); 168 | setAccessoryInformation(this.bridge) 169 | console.log("Bridge pairing code: " + config.pinCode) 170 | 171 | this.bridge.publish({ 172 | advertiser: videoConfig.advertiser || MDNSAdvertiser.CIAO, 173 | username: config.username, 174 | pincode: config.pinCode, 175 | category: Categories.BRIDGE, 176 | addIdentifyingMaterial: false 177 | }); 178 | } 179 | addDoorbell(videoConfig: VideoConfig) { 180 | this.doorbell = new Accessory(videoConfig.displayName, uuid.generate('hap-nodejs:accessories:doorbell:' + videoConfig.displayName)); 181 | setAccessoryInformation(this.doorbell) 182 | 183 | const streamingDelegate = new StreamingDelegate(videoConfig) 184 | const recordingDelegate = new RecordingDelegate(videoConfig, this.doorbell) 185 | const motionSensor = this.doorbell.addService(Service.MotionSensor) 186 | const controller = new DoorbellController(this.getCameraControllerOptions(videoConfig, this.doorbell, streamingDelegate, recordingDelegate)); 187 | 188 | streamingDelegate.setController( controller ) 189 | recordingDelegate.setController( controller ) 190 | 191 | this.doorbell.configureController(controller); 192 | 193 | const doorbellService = this.doorbell.getService(Service.Doorbell); 194 | 195 | this.eventbus.on('homekit:pressed', () => { 196 | console.log("HOMEKIT PRESSED EVENT AT: " + Date()) 197 | doorbellService.getCharacteristic(Characteristic.ProgrammableSwitchEvent).updateValue(Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS); 198 | }) 199 | 200 | this.eventbus.on('homekit:motion', (motionTime) => { 201 | console.log("HOMEKIT MOTION EVENT AT: " + Date()) 202 | motionSensor.getCharacteristic(Characteristic.MotionDetected).updateValue(true); 203 | setTimeout( () => { 204 | console.log("SET FALSE AT: " + Date()) 205 | motionSensor.getCharacteristic(Characteristic.MotionDetected).updateValue(false); 206 | }, motionTime || 20000 ) 207 | }) 208 | 209 | this.doorbell.publish({ 210 | advertiser: videoConfig.advertiser || MDNSAdvertiser.CIAO, 211 | username: videoConfig.username, 212 | pincode: videoConfig.pinCode, 213 | category: Categories.VIDEO_DOORBELL, 214 | }); 215 | 216 | console.log('Camera pairing code: ' + videoConfig.pinCode); 217 | return { 218 | doorbell: this.doorbell, 219 | streamingDelegate 220 | } 221 | } 222 | addLock(id: string, name: string ) { 223 | const lock = new LockAccessory(id, name, this.eventbus); 224 | 225 | this.eventbus.on('homekit:locked:' + id, (value) => { 226 | if( value === true ) { 227 | lock.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockTargetState.SECURED); 228 | lock.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); 229 | } else if( value === false ) { 230 | lock.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockTargetState.UNSECURED); 231 | lock.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 232 | } 233 | }) 234 | 235 | this.bridge.addBridgedAccessory(lock.accessory); 236 | return lock 237 | } 238 | addSwitch(name: string) { 239 | const accessory = new SwitchAccesory(name, this.eventbus ); 240 | this.bridge.addBridgedAccessory(accessory.accessory); 241 | return accessory 242 | } 243 | updateFirmwareVersion(version) { 244 | FIRMWAREVERSION = version 245 | setAccessoryInformation(this.bridge) 246 | setAccessoryInformation(this.doorbell) 247 | this.bridge.bridgedAccessories.forEach( (accessory) => { 248 | setAccessoryInformation(accessory) 249 | } ) 250 | } 251 | getCameraControllerOptions(videoConfig: VideoConfig, accesorry: Accessory, streamingDelegate : StreamingDelegate, recordingDelegate : RecordingDelegate) { 252 | const hksv = videoConfig.hksv || true 253 | const resolutions : Resolution[] = [ 254 | [320, 180, 30], 255 | [320, 240, 15], // Apple Watch requires this configuration 256 | [320, 240, 30], 257 | [480, 270, 30], 258 | [480, 360, 30], 259 | [640, 360, 30], 260 | [640, 480, 30], 261 | [1280, 720, 30], 262 | [1280, 960, 30], 263 | [1920, 1080, 30], 264 | [1600, 1200, 30] 265 | ] 266 | const options: CameraControllerOptions & DoorbellOptions = { 267 | cameraStreamCount: videoConfig.maxStreams || 2, // HomeKit requires at least 2 streams, but 1 is also just fine 268 | delegate: streamingDelegate, 269 | streamingOptions: { 270 | supportedCryptoSuites: [SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80], 271 | video: { 272 | resolutions: resolutions, 273 | codec: { 274 | profiles: [H264Profile.BASELINE, H264Profile.MAIN, H264Profile.HIGH], 275 | levels: [H264Level.LEVEL3_1, H264Level.LEVEL3_2, H264Level.LEVEL4_0] 276 | } 277 | }, 278 | audio: { 279 | twoWayAudio: !!videoConfig.returnAudioTarget, 280 | codecs: [ 281 | { 282 | type: AudioStreamingCodecType.AAC_ELD, 283 | samplerate: AudioStreamingSamplerate.KHZ_16 284 | //type: AudioStreamingCodecType.OPUS, 285 | //samplerate: AudioStreamingSamplerate.KHZ_24 286 | } 287 | ] 288 | } 289 | }, 290 | recording: hksv 291 | ? { 292 | options: { 293 | prebufferLength: 4000, 294 | mediaContainerConfiguration: [ 295 | { 296 | type: MediaContainerType.FRAGMENTED_MP4, 297 | fragmentLength: 4000, 298 | }, 299 | ], 300 | video: { 301 | type: VideoCodecType.H264, 302 | parameters: { 303 | profiles: [H264Profile.BASELINE, H264Profile.MAIN, H264Profile.HIGH], 304 | levels: [H264Level.LEVEL3_1, H264Level.LEVEL3_2, H264Level.LEVEL4_0], 305 | }, 306 | resolutions: resolutions, 307 | }, 308 | audio: { 309 | codecs: { 310 | type: AudioRecordingCodecType.AAC_LC, 311 | samplerate: AudioRecordingSamplerate.KHZ_24, 312 | bitrateMode: 0, 313 | audioChannels: 1, 314 | }, 315 | }, 316 | }, 317 | delegate: recordingDelegate as RecordingDelegate, 318 | } 319 | : undefined, 320 | sensors: hksv 321 | ? { 322 | motion: accesorry.getService(Service.MotionSensor), 323 | occupancy: undefined, 324 | } 325 | : undefined, 326 | }; 327 | return options 328 | } 329 | } -------------------------------------------------------------------------------- /bundles/sip-bundle-webpack.config.js: -------------------------------------------------------------------------------- 1 | // Used to regenerate sip-bundle.js from sip-manager.ts 2 | 3 | const webpack = require('webpack'); 4 | const header = 5 | `// ======================================================================================================================= 6 | // DO NOT EDIT, this is a generated file, generate it with $ npm run build:sipbundle:dev or npm run build:sipbundle:prod 7 | // =======================================================================================================================` 8 | 9 | module.exports = [ 10 | (env, argv) => { 11 | return { 12 | devtool: 'source-map', 13 | mode: 'production', 14 | entry: [ __dirname + '/sip-manager.ts'], 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.tsx?$/, 19 | use: 'ts-loader', 20 | exclude: /node_modules/, 21 | }, 22 | ], 23 | }, 24 | target : 'node', 25 | output: { 26 | path: __dirname + '/../lib/sip', 27 | filename: 'sip-bundle.js', 28 | libraryTarget: 'this', 29 | }, 30 | plugins: [ new webpack.BannerPlugin({banner: header, raw: true, stage: webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT}) ] 31 | } 32 | } 33 | ] -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | //=================================================================================================================== 2 | // DO NOT EDIT THIS FILE - this file contains the defaults and might be overriden during an upgrade 3 | // Add overriding configuration values to a config.json file (see config.json.example) 4 | //=================================================================================================================== 5 | 6 | const fs = require("fs") 7 | const path = require('path') 8 | const utils = require('./lib/utils') 9 | 10 | const version = require('./package.json').version; 11 | const model = utils.model() 12 | 13 | const global = { 14 | // Use the higher resolution video stream 15 | 'highResVideo': model !== 'c100x' 16 | } 17 | const doorUnlock = { 18 | // Default behaviour is device ID 20, if you need more, add them to additionalLocks in config.json 19 | openSequence: '*8*19*20##' , 20 | closeSequence: '*8*20*20##', 21 | }; 22 | 23 | const additionalLocks = {} 24 | 25 | const mqtt_config = { 26 | // Set to enable to publish events to an external MQTT server 27 | 'enabled': false, 28 | // Publish all openwebnet events (can be noisy and overload your system?) 29 | 'all_events_enabled': false, 30 | 'enable_intercom_status': false, 31 | 'status_polling_interval': 300, 32 | // Hostname or IP of the external MQTT server 33 | 'host': '', 34 | 'port': 1883, 35 | // If anonymous MQTT leave blank 36 | 'username': '', 37 | 'password': '', 38 | // MQTT Topic, will resolve to 'topic/eventname' 39 | 'topic': 'bticino', 40 | // If retain is true, the message will be retained as a "last known good" value on the broker 41 | 'retain': false, 42 | // Path of mosquitto_pub on the intercom 43 | 'exec_path': '/usr/bin/mosquitto_pub' 44 | } 45 | 46 | const sip = { 47 | 'from': undefined, 48 | 'to': undefined, 49 | 'domain': undefined, 50 | 'debug': false, 51 | 'expire': 300, 52 | 'devaddr': model === 'c100x' ? utils.detectDevAddrOnC100X() : 20 53 | } 54 | 55 | const homeassistant = { 56 | 'token': undefined, 57 | 'url': undefined 58 | } 59 | 60 | const configFile = './config.json'; 61 | 62 | const configPath = path.join(__dirname, configFile); 63 | const cwdConfigPath = path.join(process.cwd(), configFile); 64 | const extraConfigPath = path.join( "/home/bticino/cfg/extra/", configFile ) 65 | const configPaths = [configPath, cwdConfigPath, extraConfigPath] 66 | 67 | function overrideAndPrintValue( name, base, overridden ) { 68 | for(const key in overridden) { 69 | if( overridden[key] !== undefined && base[key] !== overridden[key] ) { 70 | if( name === "homeassistant" && key === "pages" ) 71 | console.log( `${name}.${key}: ${JSON.stringify( base[key], null, 2)} -> [${overridden[key].length} pages]`) 72 | else 73 | console.log( `${name}.${key}: ${JSON.stringify( base[key], null, 2)} -> ${JSON.stringify( overridden[key], null, 2 )}`) 74 | base[key] = overridden[key] 75 | } 76 | } 77 | } 78 | 79 | function detectConfig() { 80 | for(let p of configPaths) { 81 | if( fs.existsSync(p) ) return p 82 | } 83 | } 84 | 85 | const detectedPath = detectConfig() 86 | if( detectedPath ) { 87 | console.log(`FOUND config.json file at '${detectedPath}' and overriding the values from it.\r\n`) 88 | const config = JSON.parse( fs.readFileSync(detectedPath) ) 89 | overrideAndPrintValue( "global", global, config.global) 90 | overrideAndPrintValue( "doorUnlock", doorUnlock, config.doorUnlock) 91 | overrideAndPrintValue( "additionalLocks", additionalLocks, config.additionalLocks) 92 | overrideAndPrintValue( "mqtt_config", mqtt_config, config.mqtt_config) 93 | overrideAndPrintValue( "sip", sip, config.sip) 94 | overrideAndPrintValue( "homeassistant", homeassistant, config.homeassistant) 95 | console.log("") 96 | } else { 97 | console.log(`NO config.json file found in paths '${configPaths}', using built-in defaults.`) 98 | } 99 | 100 | if( global.highResVideo && utils.model() === 'c100x' ) { 101 | // If a c100x does force highResVideo, flip it back off since it doesn't support it. 102 | console.info("!!! Forcing highResVideo back to false on c100x") 103 | global.highResVideo = false 104 | } 105 | 106 | console.log(`============================== final config ===================================== 107 | \x1b[33m${JSON.stringify( { global, doorUnlock, additionalLocks, mqtt_config, sip }, null, 2 )}\x1b[0m 108 | =================================================================================`) 109 | 110 | module.exports = { 111 | doorUnlock, additionalLocks, mqtt_config, global, sip, homeassistant, version 112 | } 113 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "ignoredUnknownValue": {}, 3 | "doorUnlock": { 4 | "openSequence" : "*8*XX*20##" 5 | }, 6 | "additionalLocks": { 7 | "back-door": { "openSequence": "*8*19*21##", "closeSequence": "*8*20*21##" }, 8 | "side-door": { "openSequence": "*8*19*22##", "closeSequence": "*8*20*22##" } 9 | }, 10 | "mqtt_config": { 11 | "enabled" : true, 12 | "host": "192.168.0.2" 13 | } 14 | } -------------------------------------------------------------------------------- /controller-homekit.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // 4 | // WARNING: Work in progress - use at your own risk .. API and code may change in the future without backwards compatibility 5 | // 6 | 7 | const base = require('./base') 8 | const config = require('./config') 9 | const utils = require('./lib/utils') 10 | 11 | utils.fixMulticast() 12 | utils.firewallAllowLAN() 13 | 14 | const rtspserver = require('./lib/rtsp-server') 15 | 16 | const homekitBundle = require('./lib/homekit/homekit-bundle') 17 | const jsonstore = require('./json-store') 18 | const openwebnet = require('./lib/openwebnet') 19 | const BASE_PATH = __dirname 20 | const filestore = jsonstore.create( BASE_PATH + '/config-homekit.json') 21 | const model = utils.model().toLocaleUpperCase() 22 | 23 | rtspserver.create(base.registry, base.eventbus) 24 | utils.verifyFlexisip('webrtc@' + utils.domain()).forEach( (e) => console.error( `* ${e}`) ) 25 | 26 | const bridgeConfig = filestore.read('_bridge', () => { 27 | return { 28 | 'username': homekitBundle.createHAPUsername(), 29 | 'pinCode': homekitBundle.randomPinCode(), 30 | 'displayName': 'BTicino Bridge' 31 | } 32 | }); 33 | 34 | const videoConfig = filestore.read('videoConfig', () => { 35 | return { 36 | username: homekitBundle.createHAPUsername(), 37 | pinCode: homekitBundle.randomPinCode(), 38 | displayName: model, 39 | vcodec: 'copy', 40 | source: '-i rtsp://127.0.0.1:6554/doorbell', // or -i rtsp://192.168.0.XX:6554/doorbell in development 41 | audio: true, 42 | stillImageSource: '-i rtsp://127.0.0.1:6554/doorbell-video', 43 | debug: false, 44 | debugReturn: false, 45 | hksv: true, 46 | videoFilter: "select=gte(n\\,6)", // select frame 6 from the stream for the snapshot image, previous frames may contain invalid images 47 | returnAudioTarget: "-codec:a speex -ar 8000 -ac 1 -f rtp -payload_type 97 rtp://127.0.0.1:4000" // or rtp://192.168.0.XX:40004 in development 48 | } 49 | }) 50 | 51 | if( videoConfig.source?.indexOf("tcp://") >= 0 ) { 52 | throw new Error("Please change your videoConfig.source, tcp://127.0.0.1:8081 is deprecated and replaced by rtsp://127.0.0.1:6554/doorbell") 53 | } 54 | 55 | const homekitManager = new homekitBundle.HomekitManager( base.eventbus, BASE_PATH, bridgeConfig, videoConfig, config.version, model, videoConfig) 56 | const {doorbell, streamingDelegate} = homekitManager.addDoorbell(videoConfig) 57 | 58 | base.eventbus.on('doorbell:pressed', () => { 59 | console.log('doorbell:pressed') 60 | base.eventbus.emit('homekit:pressed') 61 | }) 62 | 63 | const locks = ["default", ...Object.keys(config.additionalLocks)] 64 | 65 | for (const lock of locks) { 66 | const doorHomekitSettings = filestore.read(lock, () => { return { 'displayName': lock, 'hidden': false } }) 67 | 68 | if( doorHomekitSettings && doorHomekitSettings.hidden ) 69 | continue 70 | 71 | let door = config.additionalLocks[lock]; 72 | const { openSequence, closeSequence } = lock === "default" ? { openSequence: config.doorUnlock.openSequence, closeSequence: config.doorUnlock.closeSequence } : { openSequence: door.openSequence, closeSequence: door.closeSequence } 73 | base.eventbus 74 | .on('lock:unlocked:' + openSequence, () => { 75 | //console.log('received lock:unlocked:' + openSequence) 76 | base.eventbus.emit('homekit:locked:' + lock, false) 77 | }).on('lock:locked:' + closeSequence, () => { 78 | //console.log('received lock:locked:' + closeSequence) 79 | base.eventbus.emit('homekit:locked:' + lock, true) 80 | }) 81 | 82 | homekitManager.addLock( lock, doorHomekitSettings.displayName ) 83 | .unlocked( () => { 84 | openwebnet.run("doorUnlock", openSequence, closeSequence) 85 | } ) 86 | } 87 | 88 | homekitManager.addSwitch('Muted' ) 89 | .switchedOn( () => {openwebnet.run("ringerMute")} ) 90 | .switchedOff( () => { openwebnet.run("ringerUnmute").then( () => utils.reloadUi() ) } ) 91 | .updateState( () => { 92 | return openwebnet.run("ringerStatus").then( (result) => { 93 | if( result === '*#8**33*0##' ) { 94 | return true 95 | } else if( result === '*#8**33*1##' ) { 96 | return false 97 | } 98 | } ) 99 | } ) 100 | 101 | if( model !== 'C100X' ) { 102 | homekitManager.addSwitch('Voicemail') 103 | .switchedOn( () => { openwebnet.run("aswmEnable") } ) 104 | .switchedOff( () => { openwebnet.run("aswmDisable") } ) 105 | .updateState( () => { 106 | return openwebnet.run("aswmStatus").then( result => { 107 | const matches = [...result.matchAll(/\*#8\*\*40\*([01])\*([01])\*/gm)] 108 | if( matches && matches.length > 0 && matches[0].length > 0 ) { 109 | return matches[0][1] === '1' 110 | } 111 | return false 112 | } ) 113 | }) 114 | } 115 | 116 | openwebnet.run("firmwareVersion").catch( ()=>{} ).then( (result) => { 117 | homekitManager.updateFirmwareVersion(result) 118 | }) 119 | 120 | const homekit = new class Api { 121 | path() { 122 | return "/homekit" 123 | } 124 | 125 | description() { 126 | return "Homekit debug page" 127 | } 128 | 129 | async handle(request, response, url, q) { 130 | if(!q.raw) { 131 | response.write("
")
132 |             response.write("Emulate homekit doorbell press
") 133 | response.write("Video thumbnail (cached)
") 134 | response.write("Video thumbnail (uncached)
") 135 | response.write("
") 136 | } 137 | 138 | if(q.press === "true") { 139 | base.eventbus.emit('homekit:pressed') 140 | } 141 | if(q.motion === "true") { 142 | base.eventbus.emit('homekit:motion', q.motionTime) 143 | } 144 | if(q.thumbnail === "true") { 145 | if(!q.raw || q.raw !== "true" ) { 146 | response.write("
Call this url with &raw=true") 147 | } else { 148 | const request = {} 149 | if(q.refresh === 'true'){ 150 | streamingDelegate.snapshotPromise = undefined 151 | } 152 | streamingDelegate.handleSnapshotRequest(request, (error, image) => { 153 | if(image) 154 | response.write(image) 155 | response.end() 156 | }) 157 | } 158 | 159 | } 160 | videoConfig.debug = q.enablevideodebug === 'true'; 161 | } 162 | } 163 | 164 | base.api.apis.set(homekit.path(), homekit ) -------------------------------------------------------------------------------- /controller-webrtc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Check the documentation if you wish to use webrtc with HA: 4 | // 5 | // https://github.com/slyoldfox/c300x-controller?tab=readme-ov-file#webrtc 6 | // 7 | 8 | const base = require('./base') 9 | const rtspserver = require('./lib/rtsp-server') 10 | const utils = require('./lib/utils') 11 | 12 | utils.verifyFlexisip('webrtc@' + utils.domain()).forEach( (e) => console.error( `* ${e}`) ) 13 | 14 | rtspserver.create(base.registry, base.eventbus) -------------------------------------------------------------------------------- /controller.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('./base') -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | { # this ensures the entire script is downloaded # 4 | 5 | set -e 6 | /usr/bin/clear 7 | 8 | EXTRA_DIR="/home/bticino/cfg/extra" 9 | NODE_DIR="${EXTRA_DIR}/node" 10 | CONTROLLER_DIR="${EXTRA_DIR}/c300x-controller" 11 | CLEANUP=0 12 | WRITABLE=0 13 | 14 | echo "DISCLAIMER:" 15 | echo "- I will and cannot take any responsibility for breaking your system when running this installation script" 16 | echo "- I will and cannot take any responsibility for the integrity of the files that are being downloaded from the internet" 17 | echo "" 18 | echo "I strongly recommend you read and understand the script(s) you are executing, you can always follow the manual installation steps on https://github.com/slyoldfox/c300x-controller" 19 | echo "" 20 | 21 | require_write() { 22 | if [ "$WRITABLE" -eq "0" ]; then 23 | echo "" 24 | echo -n "Remounting / as read,write..." 25 | /bin/mount -oremount,rw / 26 | WRITABLE=1 27 | echo "DONE" 28 | fi 29 | } 30 | 31 | cleanup() { 32 | if [ "$CLEANUP" -eq "0" ]; then 33 | echo "" 34 | echo "*** Cleaning up" 35 | /bin/rm -rf /tmp/node-v17.9.1-linux-armv7l.tar.gz 36 | /bin/rm -rf /tmp/main.tar.gz 37 | /bin/rm -rf /tmp/config.json 38 | #sometimes fails with 'mount point busy' 39 | #/bin/mount -oremount,ro / 40 | CLEANUP=1 41 | exit 42 | fi 43 | } 44 | 45 | simlink? () { 46 | test "$(readlink "${1}")"; 47 | } 48 | 49 | download_file() { 50 | if [ "${1}" != "" -a "${2}" != "" ]; then 51 | if [ "$(type -p basename)" != "" ]; then 52 | echo "*** Downloading $(basename ${2}) ..." 53 | else 54 | echo "*** Downloading ${2} ..." 55 | fi 56 | if [ "$(type -p curl)" != "" ]; then 57 | "$(type -p curl)" -L -o "${2}" "${1}" 58 | elif [ "$(type -p wget)" != "" ]; then 59 | "$(type -p wget)" -c -O "${2}" "${1}" 60 | else 61 | echo "!!! Cannot find any program for file downloading" 62 | fi 63 | fi 64 | } 65 | 66 | trap cleanup HUP PIPE INT QUIT TERM EXIT 67 | 68 | install_node() { 69 | download_file https://nodejs.org/download/release/latest-v17.x/node-v17.9.1-linux-armv7l.tar.gz /tmp/node-v17.9.1-linux-armv7l.tar.gz 70 | /bin/mkdir -p $NODE_DIR 71 | echo -n "*** Extracting node-v17.9.1-linux-armv7l.tar.gz ..." 72 | /bin/tar xfz /tmp/node-v17.9.1-linux-armv7l.tar.gz --strip-components 1 -C $NODE_DIR 73 | echo "DONE" 74 | } 75 | 76 | install_libatomic() { 77 | echo "" 78 | if test -f /lib/libatomic.so.1.2.0; then 79 | echo "*** /lib/libatomic.so.1.2.0 already exists, skipping install" 80 | else 81 | require_write 82 | download_file https://github.com/slyoldfox/c300x-controller/raw/main/libatomic.so.1.2.0 /lib/libatomic.so.1.2.0 83 | echo "" 84 | fi 85 | if simlink? "/lib/libatomic.so.1"; then 86 | echo "*** /lib/libatomic.so.1 symlink already exists, skipping install" 87 | else 88 | echo -n "*** Symlinking /lib/libatomic.so.1 -> /lib/libatomic.so.1.2.0 ..." 89 | require_write 90 | /bin/ln -s /lib/libatomic.so.1.2.0 /lib/libatomic.so.1 91 | echo "DONE" 92 | fi 93 | } 94 | 95 | test_node() { 96 | VERSION=$($NODE_DIR/bin/node -v) 97 | echo "*** Node version ${VERSION} is working :-)" 98 | } 99 | 100 | fetch_controller() { 101 | while true; do 102 | read -p "Select controller variant: 1 > Standard, 2 > WebRTC , 3 > HomeKit. (123) " variant 103 | case $variant in 104 | 1 ) VARIANT_SUFFIX=""; break;; 105 | 2 ) VARIANT_SUFFIX="-webrtc"; break;; 106 | 3 ) VARIANT_SUFFIX="-homekit"; break;; 107 | * ) echo "Please select 1, 2 or 3.";; 108 | esac 109 | done 110 | echo "Downloading c300x-controller${VARIANT_SUFFIX} ..." 111 | /bin/mkdir -p $CONTROLLER_DIR 112 | download_file "https://github.com/slyoldfox/c300x-controller/releases/latest/download/bundle${VARIANT_SUFFIX}.js" "$CONTROLLER_DIR/bundle.js" 113 | } 114 | 115 | install_controller() { 116 | echo "" 117 | if test -d $CONTROLLER_DIR; then 118 | while true; do 119 | read -p "Directory $CONTROLLER_DIR already exists, overwrite? You will NOT lose your configuration. (yn) " yn 120 | case $yn in 121 | [Yy]* ) 122 | [ ! -r "${CONTROLLER_DIR}/config.json" ] || { 123 | echo -n "*** Backing up config.json..." 124 | /bin/cp -p "${CONTROLLER_DIR}/config.json" "/tmp/config.json" 125 | echo "DONE" 126 | } 127 | [ ! -r "${CONTROLLER_DIR}/config-homekit.json" ] || { 128 | echo -n "*** Backing up config-homekit.json..." 129 | /bin/cp -p "${CONTROLLER_DIR}/config-homekit.json" "/tmp/config-homekit.json" 130 | echo "DONE" 131 | } 132 | [ ! -r "${CONTROLLER_DIR}/ffmpeg" ] || { 133 | echo -n "*** Backing up ffmpeg.." 134 | /bin/cp -p "${CONTROLLER_DIR}/ffmpeg" "/tmp/ffmpeg" 135 | echo "DONE" 136 | } 137 | [ ! -r "${CONTROLLER_DIR}/storage" ] || { 138 | echo -n "*** Backing up storage dir..." 139 | /bin/cp -p -r "${CONTROLLER_DIR}/storage" "/tmp/storage" 140 | echo "DONE" 141 | } 142 | echo -n "*** Removing directory..." 143 | /bin/rm -rf $CONTROLLER_DIR 144 | echo "DONE" 145 | fetch_controller 146 | [ ! -r "/tmp/config.json" ] || { 147 | echo -n "*** Restoring config.json..." 148 | /bin/cp -p "/tmp/config.json" "${CONTROLLER_DIR}/config.json" 149 | echo "DONE" 150 | } 151 | [ ! -r "/tmp/config-homekit.json" ] || { 152 | echo -n "*** Restoring config-homekit.json..." 153 | /bin/cp -p "/tmp/config-homekit.json" "${CONTROLLER_DIR}/config-homekit.json" 154 | echo "DONE" 155 | } 156 | [ ! -r "/tmp/ffmpeg" ] || { 157 | echo -n "*** Restoring ffmpeg..." 158 | /bin/cp -p "/tmp/ffmpeg" "${CONTROLLER_DIR}/ffmpeg" 159 | echo "DONE" 160 | } 161 | [ ! -r "/tmp/storage" ] || { 162 | echo -n "*** Restoring storage dir..." 163 | /bin/cp -r -p "/tmp/storage" "${CONTROLLER_DIR}/storage" 164 | /bin/rm -rf "/tmp/storage" 165 | echo "DONE" 166 | } 167 | break;; 168 | [Nn]* ) break;; 169 | * ) echo "Please answer yes or no.";; 170 | esac 171 | done 172 | else 173 | fetch_controller 174 | fi 175 | } 176 | 177 | disable_firewall() { 178 | require_write 179 | echo "" 180 | echo "*** Disabling firewall" 181 | echo -n "*** Moving /etc/network/if-pre-up.d/iptables to ${EXTRA_DIR}..." 182 | /bin/mv /etc/network/if-pre-up.d/iptables ${EXTRA_DIR}/iptables.bak 183 | echo "DONE" 184 | echo -n "*** Moving /etc/network/if-pre-up.d/iptables6 to ${EXTRA_DIR}..." 185 | /bin/mv /etc/network/if-pre-up.d/iptables6 ${EXTRA_DIR}/iptables6.bak 186 | echo "DONE" 187 | echo -n "*** Flushing iptables..." 188 | /usr/sbin/iptables -P INPUT ACCEPT 189 | /usr/sbin/iptables -P FORWARD ACCEPT 190 | /usr/sbin/iptables -P OUTPUT ACCEPT 191 | /usr/sbin/iptables -F 192 | echo "DONE" 193 | } 194 | 195 | insert_firewall_rule() { 196 | require_write 197 | echo "" 198 | echo "*** Modifying firewall" 199 | echo -n "*** Editing /etc/network/if-pre-up.d/iptables..." 200 | LN=$(/usr/bin/awk '/#disable all other stuff/{ print NR; exit }' /etc/network/if-pre-up.d/iptables) 201 | echo -n "inserting at line ${LN}..." 202 | /bin/sed -i "${LN} i " /etc/network/if-pre-up.d/iptables 203 | /bin/sed -i "${LN} i iptables -A INPUT -p tcp -m tcp --sport 8080 -j ACCEPT" /etc/network/if-pre-up.d/iptables 204 | /bin/sed -i "${LN} i iptables -A INPUT -p tcp -m tcp --dport 8080 -j ACCEPT" /etc/network/if-pre-up.d/iptables 205 | /bin/sed -i "${LN} i # c300x-controller" /etc/network/if-pre-up.d/iptables 206 | echo "DONE" 207 | /etc/init.d/networking stop; /etc/init.d/networking start 208 | } 209 | 210 | update_firewall() { 211 | echo "" 212 | if test -f "/etc/network/if-pre-up.d/iptables"; then 213 | echo -n "*** Checking iptables script ..." 214 | INSTALLED=$(/usr/bin/awk '/# c300x-controller/{ print NR; exit }' /etc/network/if-pre-up.d/iptables) 215 | if [ -z "${INSTALLED}" ]; then 216 | echo "needs fixing." 217 | echo "" 218 | while true; do 219 | read -p "iptables needs to be modified to allow tcp port 8080, do you want to: "$'\n'" 1) disable the firewall completely (will backup your current iptables(6) files)"$'\n'" 2) add an iptables rule to allow port 8080"$'\n'" 3) do nothing (you have to manually do it): " yn 220 | case $yn in 221 | [1]* ) disable_firewall; break;; 222 | [2]* ) insert_firewall_rule; break;; 223 | [3]* ) echo "!!! You won't be able to reach the web server on port 8080, modify /etc/network/if-pre-up.d/iptables manually."; break;; 224 | * ) echo "Please answer 1, 2 or 3.";; 225 | esac 226 | done 227 | else 228 | echo "DONE, already configured at line ${INSTALLED}." 229 | fi 230 | else 231 | echo "*** iptables already disabled ... skipping" 232 | echo -n "*** Flushing iptables..." 233 | /usr/sbin/iptables -P INPUT ACCEPT 234 | /usr/sbin/iptables -P FORWARD ACCEPT 235 | /usr/sbin/iptables -P OUTPUT ACCEPT 236 | /usr/sbin/iptables -F 237 | echo "DONE" 238 | fi 239 | } 240 | 241 | install_initd() { 242 | require_write 243 | echo "" 244 | echo -n "*** Creating startup script in /etc/init.d/c300x-controller..." 245 | /bin/cat << 'EOF' > /etc/init.d/c300x-controller 246 | #! /bin/sh 247 | 248 | ## BEGIN INIT INFO 249 | # Provides: c300x-controller 250 | # Default-Start: 2 3 4 5 251 | # Default-Stop: 0 1 6 252 | # Short-Description: c300x-controller 253 | ### END INIT INFO 254 | 255 | set -e 256 | 257 | PIDFILE=/var/run/c300x-controller 258 | DAEMON="/home/bticino/cfg/extra/node/bin/node" 259 | DAEMON_ARGS="/home/bticino/cfg/extra/c300x-controller/bundle.js" 260 | 261 | . /etc/init.d/functions 262 | 263 | case "$1" in 264 | start) 265 | echo "Starting c300x-controller" 266 | if start-stop-daemon --start --quiet --oknodo --background --make-pidfile --pidfile ${PIDFILE} --exec ${DAEMON} -- ${DAEMON_ARGS} ; then 267 | exit 0 268 | fi 269 | ;; 270 | 271 | stop) 272 | echo "Stopping c300x-controller" 273 | if start-stop-daemon --stop --quiet --oknodo --pidfile ${PIDFILE} --retry=TERM/3/KILL/2; then 274 | rm -f ${PIDFILE} 275 | exit 0 276 | fi 277 | ;; 278 | 279 | restart) 280 | echo "Restarting c300x-controller" 281 | if start-stop-daemon --stop --quiet --oknodo --retry 30 --pidfile ${PIDFILE}; then 282 | rm -f ${PIDFILE} 283 | fi 284 | usleep 150000 285 | if start-stop-daemon --start --quiet --oknodo --background --make-pidfile --pidfile ${PIDFILE} --retry=TERM/3/KILL/2 --exec ${DAEMON} -- ${DAEMON_ARGS} ; then 286 | exit 0 287 | fi 288 | ;; 289 | 290 | status) 291 | #status ${DAEMON} && exit 0 || exit $? 292 | pid=`ps -fC node | grep "$DAEMON $DAEMON_ARGS" | awk '{print $2}'` 293 | if [ "$pid" != "" ]; then 294 | echo "$DAEMON $DAEMON_ARGS (pid $pid) is running..." 295 | else 296 | echo "$DAEMON $DAEMON_ARGS stopped" 297 | fi 298 | ;; 299 | 300 | *) 301 | echo "Usage: $0 {start|stop|restart|status}" 302 | exit 1 303 | esac 304 | 305 | exit 0 306 | 307 | EOF 308 | /bin/chmod 755 /etc/init.d/c300x-controller 309 | echo "DONE" 310 | 311 | if simlink? "/etc/rc5.d/S40c300x-controller"; then 312 | echo "*** /etc/rc5.d/S40c300x-controller symlink already exists, skipping install" 313 | else 314 | echo -n "*** Symlinking /etc/rc5.d/S40c300x-controller -> /etc/init.d/c300x-controller ..." 315 | require_write 316 | /bin/ln -s /etc/init.d/c300x-controller /etc/rc5.d/S40c300x-controller 317 | echo "DONE" 318 | fi 319 | 320 | /etc/init.d/c300x-controller restart 321 | sleep 5 322 | /usr/bin/wget --spider "http://127.0.0.1:8080/load" 323 | if [ $? -eq 0 ]; then 324 | echo "" 325 | echo "*** c300x-controller is running on http port 8080, have fun :)" 326 | else 327 | echo "!!! Cannot reach c300x-controller on http port 8080" 328 | fi 329 | } 330 | 331 | install() { 332 | echo "" 333 | if test -d $NODE_DIR; then 334 | while true; do 335 | read -p "Directory $NODE_DIR already exists, overwrite? (yn) " yn 336 | case $yn in 337 | [Yy]* ) echo -n "*** Removing directory..."; /bin/rm -rf $NODE_DIR; echo "DONE"; install_node; break;; 338 | [Nn]* ) break;; 339 | * ) echo "Please answer yes or no.";; 340 | esac 341 | done 342 | else 343 | install_node 344 | fi 345 | install_libatomic 346 | test_node 347 | install_controller 348 | update_firewall 349 | install_initd 350 | } 351 | 352 | while true; do 353 | read -p "This will install c300-controller in ${EXTRA_DIR}, continue (yn)? " yn 354 | case $yn in 355 | [Yy]* ) install; break;; 356 | [Nn]* ) exit;; 357 | * ) echo "Please answer yes or no.";; 358 | esac 359 | done 360 | 361 | } # this ensures the entire script is downloaded # 362 | -------------------------------------------------------------------------------- /json-store.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | function replacer(key, value) { 4 | if(key[0] !== '$' ) return value 5 | } 6 | 7 | class JSONStore { 8 | constructor(filePath) { 9 | this.filePath = filePath; 10 | this.data = {}; 11 | 12 | // Load data from file if it exists 13 | this.load(); 14 | } 15 | 16 | load() { 17 | if( !fs.existsSync(this.filePath) ) { 18 | fs.writeFileSync(this.filePath, '{}'); 19 | } 20 | try { 21 | const data = fs.readFileSync(this.filePath); 22 | this.data = JSON.parse(data); 23 | } catch (err) { 24 | console.error('Error loading data:', err); 25 | throw err 26 | } 27 | } 28 | 29 | save() { 30 | try { 31 | const data = JSON.stringify(this.data, replacer, 2); 32 | fs.writeFileSync(this.filePath, data); 33 | } catch (err) { 34 | console.error('Error saving data:', err); 35 | } 36 | } 37 | 38 | read(key, defaults) { 39 | let data = this.data[key]; 40 | if( !data ) { 41 | data = defaults() 42 | this.write(key, data) 43 | } 44 | return data 45 | } 46 | 47 | write(key, value) { 48 | this.data[key] = value; 49 | this.save(); 50 | } 51 | } 52 | 53 | module.exports = { 54 | create(filepath) { 55 | return new JSONStore(filepath) 56 | } 57 | } -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // There might be an API here someday to expose internal functionality 4 | const http = require("http"); 5 | const config = require('../config') 6 | const querystring = require('querystring'); 7 | const url = require('url') 8 | const fs = require("fs"); 9 | 10 | const __webpack__enabled = (typeof __webpack_require__ === "function") 11 | const normalizedPath = require("path").join(__dirname, "apis"); 12 | const apis_path = "./apis/" 13 | 14 | class Api { 15 | 16 | #apis = new Map() 17 | 18 | constructor(registry) { 19 | const req = __webpack__enabled ? require.context( "./apis/" , true, /.js$/) : fs.readdirSync(normalizedPath) 20 | const keys = __webpack__enabled ? req.keys() : req; 21 | keys.forEach( key => { 22 | const API = __webpack__enabled ? req( /* webpackIgnore: true */ key) : require( /* webpackIgnore: true */ apis_path + key ); 23 | const api = new API() 24 | if (api.endpointRegistry) { 25 | api.endpointRegistry(registry) 26 | } 27 | let path = api.path(); 28 | if (path.length > 0) { 29 | if (path[0] !== '/') 30 | path = '/' + path 31 | if (this.#apis[path]) { 32 | console.log("Path already taken by another API") 33 | } else { 34 | console.log("API> " + path + " file: " + key) 35 | } 36 | } 37 | this.#apis.set(path.toString(), api) 38 | }) 39 | var server = http.createServer((request, response) => { 40 | console.log("API called url: " + request.url) 41 | let parsedUrl = url.parse(request.url) 42 | let q = parsedUrl?.query ? querystring.parse(parsedUrl.query) : {} 43 | if (parsedUrl.pathname === '/') { 44 | response.writeHead(200, { "Content-Type": "text/html" }) 45 | response.write('Bticino API') 46 | response.write(`

Version: ${config.version}

`) 47 | if (this.#apis.size === 0) { 48 | response.write("No APIs found") 49 | } else { 50 | response.write("") 55 | } 56 | response.write("") 57 | } else { 58 | var api = this.#apis.get(parsedUrl.pathname) 59 | if (api) { 60 | try { 61 | if (!q.raw) { 62 | response.writeHead(200, { "Content-Type": "text/html" }) 63 | response.write("Bticino API") 64 | response.write('') 65 | response.write("<< Back") 66 | } 67 | api.handle(request, response, parsedUrl, q) 68 | if (!q.raw) { 69 | response.write("") 70 | } 71 | } catch (e) { 72 | console.error(e) 73 | response.write(e.message) 74 | } 75 | } else { 76 | response.writeHead(404, { "Content-Type": "text/plain" }); 77 | response.write("404") 78 | } 79 | } 80 | if (!q.raw) 81 | response.end() 82 | }); 83 | server.listen(8080, '0.0.0.0'); // Don't bother with IPv6 84 | console.log("API listening on port 8080 for requests") 85 | } 86 | 87 | get apis() { 88 | return this.#apis 89 | } 90 | } 91 | 92 | module.exports = { 93 | create(registry) { 94 | return new Api(registry) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/apis/aswm.js: -------------------------------------------------------------------------------- 1 | const openwebnet = require('../openwebnet') 2 | 3 | module.exports = class Api { 4 | path() { 5 | return "/aswm" 6 | } 7 | 8 | description() { 9 | return "Enables/disables answering machine" 10 | } 11 | 12 | handle(request, response, url, q) { 13 | response.write("
")
14 | 		response.write("Enable
") 15 | response.write("Disable") 16 | response.write("
") 17 | if (q.enable) { 18 | if (q.enable === "true") { 19 | openwebnet.run("aswmEnable") 20 | } else { 21 | openwebnet.run("aswmDisable") 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/apis/debug.js: -------------------------------------------------------------------------------- 1 | const _debug = require("debug") 2 | const utils = require('../utils') 3 | const debug = utils.getDebugger("debugPage") 4 | const config = require('../../config') 5 | 6 | module.exports = class Api { 7 | path() { 8 | return "/debug" 9 | } 10 | 11 | description() { 12 | return "Enable/disable debugging at runtime" 13 | } 14 | 15 | handle(request, response, url, q) { 16 | if(q.enable) { 17 | let enable = decodeURIComponent(q.enable) 18 | _debug.enable(enable) 19 | 20 | } 21 | if(q.debugenabled) { 22 | if( q.debugenabled === "true" ) { 23 | let namespaces = decodeURIComponent(q.namespaces) 24 | if( namespaces.length > 0 ) { 25 | _debug.enable(namespaces) 26 | } 27 | } else { 28 | _debug.disable() 29 | } 30 | } 31 | if( q.sipenabled ) { 32 | config.sip.debug = q.sipenabled === "true" 33 | } 34 | 35 | debug("Debug page called") 36 | response.write("
")
37 |         response.write("debug namespace: " + _debug.names.join( " " )  +"
"); 38 | response.write("sip debug: " + config.sip.debug ); 39 | response.write("
") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/apis/door-unlock.js: -------------------------------------------------------------------------------- 1 | const openwebnet = require('../openwebnet') 2 | const config = require('../../config') 3 | 4 | module.exports = class Api { 5 | path() { 6 | return "/unlock" 7 | } 8 | 9 | description() { 10 | return "Unlocks the door" 11 | } 12 | 13 | handle(request, response, url, q) { 14 | response.write("
")
15 |         response.write("Default
") 16 | if( config.additionalLocks ) 17 | { 18 | for( const lock in config.additionalLocks ) 19 | { 20 | response.write("" + lock + "
") 21 | } 22 | } 23 | response.write("
") 24 | if( q.id ) { 25 | let door = config.additionalLocks[q.id]; 26 | if( door ) { 27 | openwebnet.run("doorUnlock", door.openSequence, door.closeSequence ) 28 | response.write("Opened lock: " + q.id + "
") 29 | } else if( q.id === "default" ) { 30 | openwebnet.run("doorUnlock", config.doorUnlock.openSequence, config.doorUnlock.closeSequence) 31 | response.write("Opened default lock
") 32 | } else { 33 | console.error("Door with id: " + q.id + " not found.") 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/apis/ha.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config') 2 | const utils = require('../utils') 3 | const haWs = require('../ha-ws') 4 | const dayjs = require('dayjs') 5 | 6 | const ENTITY_REGEX = /\$\(\s*([a-zA-Z0-9\_]+)\.([a-zA-Z0-9\_]+)\s*\)/g 7 | const SUPPORTED_TYPES = ["badges","images","switches","buttons","flow"] 8 | let CACHED_ENTITY_STATES = new Map() 9 | let CACHED_ENTITY_HISTORY_STATES = new Map() 10 | 11 | module.exports = class Api { 12 | path() { 13 | return "/homeassistant" 14 | } 15 | 16 | description() { 17 | return "Home assistant proxy" 18 | } 19 | 20 | handle(request, response, url, q) { 21 | if(!q.raw || q.raw !== "true" ) { 22 | response.write("
Call this url with &raw=true") 23 | } else { 24 | if(q.domain && q.service && q.entities) { 25 | var postData = JSON.stringify({ 26 | 'entity_id' : q.entities.split(',').map(e => e.trim()) 27 | }); 28 | utils.requestPost(config.homeassistant.url + `/api/services/${q.domain}/${q.service}`, postData, config.homeassistant.token).then((value) => { 29 | response.end() 30 | }) 31 | } else if(q.dump) { 32 | response.setHeader("Content-Type", "application/json; charset=utf-8") 33 | 34 | this.#collectEntityStates().then( () => { 35 | response.write(JSON.stringify(Array.from(CACHED_ENTITY_STATES.values()))) 36 | response.end() 37 | } ) 38 | 39 | } else { 40 | const responseItem = {} 41 | const responsePages = [] 42 | 43 | response.setHeader("Content-Type", "application/json; charset=utf-8") 44 | 45 | this.#collectEntityStates().then( () => { 46 | config.homeassistant.pages?.forEach((page, index) => { 47 | const dataItem = {} 48 | SUPPORTED_TYPES.forEach( pageType => { 49 | const apiName = "_api_" + pageType 50 | try { 51 | var fu = this[apiName] 52 | if(fu) { 53 | if( page[pageType]?.items ) { 54 | fu.apply(this, [CACHED_ENTITY_STATES, page[pageType], pageType, dataItem ]); 55 | } 56 | } else { 57 | dataItem[pageType] = [] 58 | } 59 | } catch( e ) { 60 | console.error("Error in function: " + apiName, e) 61 | } 62 | }) 63 | responsePages.push(dataItem) 64 | }); 65 | 66 | responseItem["preventReturnToHomepage"] = config.homeassistant.preventReturnToHomepage?.toString() === "true" 67 | responseItem["refreshInterval"] = config.homeassistant.refreshInterval || 2000 68 | if(responsePages.length === 0 ) { 69 | responsePages.push({ badges: [ { "state": "!!! Fix !!!\nconfig.json" } ]}) 70 | } 71 | responseItem["data"] = { "pages": responsePages} 72 | 73 | } ).catch(e => { 74 | console.error(e) 75 | }) 76 | .finally(() => { 77 | response.write(JSON.stringify(responseItem)) 78 | response.end() 79 | }) 80 | } 81 | } 82 | } 83 | 84 | _api_flow( entityStates, pageItem, pageType, responseItem) { 85 | const flowItem = { "period": pageItem.period, "starttime": this.#parseDaysJs(pageItem.starttime), "endtime": this.#parseDaysJs(pageItem.endtime) } 86 | 87 | responseItem["flow"] = flowItem 88 | const lineItems = [] 89 | this.#buildEntityState(entityStates, pageItem?.items, "items", flowItem, (entity, sensor) => { 90 | sensor.lines?.forEach(line => { 91 | lineItems.push( this.#createLineObject(line) ) 92 | }) 93 | var state = this.#parseEntityState(entity, sensor["state"]) 94 | return this.#createFlowObject(sensor, state); 95 | }) 96 | this.#applyQmlProperties( flowItem, pageItem ) 97 | 98 | flowItem["lines"] = lineItems 99 | } 100 | 101 | _api_badges( entityStates, pageItem, pageType, responseItem) { 102 | this.#buildEntityState(entityStates, pageItem?.items, pageType, responseItem, (entity, sensor) => { 103 | return { "state": this.#parseEntityState(entity, sensor["state"]) } 104 | }) 105 | } 106 | 107 | _api_switches( entityStates, pageItem, pageType, responseItem) { 108 | this.#buildEntityState(entityStates, pageItem?.items, pageType, responseItem, (entity, sensor) => { 109 | return { "entity_id": sensor.entity_id, "domain": sensor.domain, "name": sensor.name, "state": this.#parseEntityState(entity, sensor["state"]) === "on" } 110 | }) 111 | } 112 | 113 | _api_buttons(entityStates, pageItem, pageType, responseItem) { 114 | this.#buildEntity(pageItem?.items, pageType, responseItem) 115 | } 116 | 117 | _api_images(entityStates, pageItem, pageType, responseItem) { 118 | this.#buildEntity(pageItem?.items, pageType, responseItem) 119 | } 120 | 121 | #collectEntityStates() { 122 | return new Promise((resolve,reject) => { 123 | utils.requestGet(config.homeassistant.url + '/api/states', config.homeassistant.token).then(e => { 124 | const entityStates = new Map() 125 | const entities = JSON.parse( e, this.#typedJsonReviver ) 126 | entities.forEach( entity => { 127 | entityStates.set(entity.entity_id, entity) 128 | } ) 129 | CACHED_ENTITY_STATES = entityStates 130 | 131 | this.#collectEntityStatistics(() => { 132 | resolve() 133 | }) 134 | }) 135 | .catch( e => { 136 | reject(e) 137 | }) 138 | }) 139 | } 140 | 141 | #collectEntityStatistics(callback) { 142 | const entityStatistics = new Map() 143 | const statisticsQueries = [] 144 | config.homeassistant.pages?.forEach((page, index) => { 145 | const flowPage = page["flow"] 146 | if(flowPage && flowPage["period"] ) { 147 | const stateEntityHistory = [] 148 | flowPage?.items?.forEach(item => { 149 | var entity_id = item.entity_id 150 | if(entity_id) { 151 | stateEntityHistory.push( entity_id ) 152 | } else if(item.formula) { 153 | const matches = item.formula.match(ENTITY_REGEX) 154 | if( matches ) { 155 | var unique = new Set(item.formula.match(ENTITY_REGEX)?.map( item => item.substring(2, item.length - 1) )) 156 | stateEntityHistory.push(...unique) 157 | } 158 | } 159 | }) 160 | 161 | const wsQuery = haWs.query(stateEntityHistory, flowPage["period"], this.#parseDaysJs( flowPage["starttime"] ), this.#parseDaysJs( flowPage["endtime"] )) 162 | statisticsQueries.push(wsQuery) 163 | wsQuery.then(entities => { 164 | stateEntityHistory.forEach(itemName => { 165 | const x = CACHED_ENTITY_STATES.get(itemName) 166 | if( !x ) { 167 | console.log(x) 168 | } 169 | const entityStates = entities[itemName] 170 | const count = entityStates.length 171 | let min = undefined, max = 0, sum = 0 172 | 173 | entityStates.forEach(item => { 174 | item.change < min || min === undefined ? min = item.change : item.change 175 | item.change > max ? max = item.change : item.change 176 | sum += item.change 177 | }) 178 | const avg = count > 0 ? (sum / count) : 0 179 | 180 | const entityObject = { sum, avg, min, max, count } 181 | entityStatistics.set(itemName, entityObject) 182 | x["statistics"] = entityObject 183 | }) 184 | }) 185 | } 186 | }) 187 | Promise.all(statisticsQueries).then( () => { 188 | CACHED_ENTITY_HISTORY_STATES = entityStatistics 189 | callback() 190 | }) 191 | } 192 | 193 | #buildEntityState(entityStates, configItem, pageType, responseItem, callback) { 194 | if(configItem.length === 0) { 195 | responseItem[pageType] = [] 196 | } else { 197 | const values = [] 198 | for(const sensor of configItem) { 199 | const entity = entityStates.get(sensor.entity_id) 200 | if(entity) { 201 | if(sensor.formula && !entity.calculatedState) { 202 | entity.calculatedState = this.#applyFormula(responseItem, entityStates, sensor) 203 | } 204 | 205 | const result = this.#applyConditions(responseItem, entityStates, sensor, entity, callback) 206 | values.push(result) 207 | } 208 | } 209 | 210 | configItem.filter( e => (e.formula !== undefined && e.entity_id === undefined )).forEach( sensor => { 211 | const calculatedState = this.#applyFormula(responseItem, entityStates, sensor) 212 | const entity = { "state": calculatedState } 213 | const result = this.#applyConditions(responseItem, entityStates, sensor, entity, callback) 214 | values.push(result) 215 | } ) 216 | 217 | responseItem[pageType] = values.filter((v) => v !== undefined) 218 | } 219 | } 220 | 221 | #buildEntity(configItem, pageType, responseItem) { 222 | responseItem[pageType] = configItem 223 | } 224 | 225 | #applyFormula(responseItem, entityStates, sensor, field) { 226 | const fieldValue = field || sensor.formula 227 | const formula = fieldValue.replace(ENTITY_REGEX,'$$$1___$2') 228 | const sensors = [...new Set(fieldValue.match(ENTITY_REGEX))]; 229 | const variableNames = sensors.map(e => e.replace(ENTITY_REGEX,'$$$1___$2')) 230 | const sensorValues = sensors.map( (sensor) => { 231 | return entityStates.get( sensor.substring(2, sensor.length -1)) 232 | } ) 233 | const result = new Function(variableNames, 'return ( ' + formula + ' ); ')(...sensorValues) 234 | return result 235 | } 236 | 237 | #applyConditions( responseItem, entityStates, sensor, entity, callback) { 238 | const whenCondition = sensor["when"] 239 | if(whenCondition) { 240 | const result = this.#applyFormula(responseItem, entityStates, sensor, whenCondition) 241 | if( !result ) { 242 | // skip 243 | return; 244 | } 245 | } 246 | return callback(entity, sensor) 247 | } 248 | 249 | #createFlowObject(sensor, state) { 250 | const obj = { "state": state } 251 | this.#applyQmlProperties(obj, sensor) 252 | return obj; 253 | } 254 | 255 | #createLineObject(line) { 256 | const obj = { startX: line.startX, startY: line.startY, x: line.endX, y: line.endY } 257 | 258 | obj["controlX"] = line.controlX || this.#deduceControl(obj, "controlX") 259 | obj["controlY"] = line.controlY || this.#deduceControl(obj, "controlY") 260 | obj["numberOfDots"] = line.numberOfDots || this.#calculateLineLength(obj) 261 | obj["lineColor"] = line.color || "black" 262 | obj.toString = function() { 263 | return `startX:${this.startX},startT:${this.startY},endX:${this.x},endY:${this.y},controlX:${this.controlX},controlY${this.controlY}`; 264 | } 265 | return obj; 266 | } 267 | 268 | #calculateLineLength(line) { 269 | if(line.startY === line.y) { 270 | return Math.abs(line.x - line.startX) 271 | } else if(line.startX === line.x) { 272 | return Math.abs(line.y - line.startY) 273 | } else { 274 | //var l = this.#bezierLength(line.startX, line.startY, line.controlX, line.controlY, line.x, line.y) 275 | // Average quadratic bezier curve length 276 | return 120 277 | } 278 | } 279 | 280 | #bezierLength(x0, y0, cx, cy, x1, y1, steps = 100) { 281 | function bezierDerivative(t) { 282 | const dx = 2 * (1 - t) * (cx - x0) + 2 * t * (x1 - cx); 283 | const dy = 2 * (1 - t) * (cy - y0) + 2 * t * (y1 - cy); 284 | return { dx, dy }; 285 | } 286 | 287 | function distance(dx, dy) { 288 | return Math.sqrt(dx * dx + dy * dy); 289 | } 290 | 291 | let length = 0; 292 | let prevPoint = bezierDerivative(0); 293 | 294 | for (let i = 1; i <= steps; i++) { 295 | const t = i / steps; 296 | const currentPoint = bezierDerivative(t); 297 | const segmentLength = distance(currentPoint.dx - prevPoint.dx, currentPoint.dy - prevPoint.dy); 298 | length += segmentLength; 299 | prevPoint = currentPoint; 300 | } 301 | 302 | return length; 303 | } 304 | 305 | #deduceControl(line, propertyName) { 306 | const axis = propertyName[propertyName.length-1]; 307 | switch(axis) { 308 | case "X": 309 | return (line.startX === line.x) ? line.startX : (line.startX + line.x) / 2 310 | case "Y": 311 | return (line.startY === line.y) ? line.startY : (line.startY + line.y) / 2 312 | } 313 | return NaN 314 | } 315 | 316 | #applyQmlProperties(obj, sensor) { 317 | if(!sensor) return; 318 | Object.keys(sensor).forEach(item => { 319 | if(item[0] === '$') { 320 | obj[item.substring(1)] = sensor[item] 321 | } 322 | }) 323 | } 324 | 325 | #resolveJsonP(obj, field) { 326 | return field.split('.').reduce((o, key) => (o && o[key] !== undefined) ? o[key] : undefined, obj); 327 | } 328 | 329 | #parseEntityState(obj, field) { 330 | if(typeof field === 'object') { 331 | if( Array.isArray(field) ) { 332 | return field.map( (value) => { 333 | return this.#parseEntityState(obj, value) 334 | }).join( "" ) 335 | } 336 | } else { 337 | if( field ) { 338 | if( field.indexOf("f:") >= 0 ) { 339 | return this.#resolveJsonP(obj, field.substring(2)) 340 | } else { 341 | return field 342 | } 343 | } else { 344 | return this.#resolveJsonP(obj, "state" ) 345 | } 346 | } 347 | } 348 | 349 | #parseDaysJs( dayJsAsString ) { 350 | return new Function( ["dayjs"], "return " + dayJsAsString)(dayjs) 351 | } 352 | 353 | #typedJsonReviver(key, value) { 354 | if (typeof value === 'string') { 355 | const numberValue = Number(value); 356 | if (!isNaN(numberValue)) { 357 | return numberValue; 358 | } 359 | 360 | if(value.includes("T") && value.includes(":")) { 361 | const dateValue = new Date(value); 362 | if (!isNaN(dateValue.getTime()) ) { 363 | return dateValue; 364 | } 365 | } 366 | } 367 | return value; 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /lib/apis/load.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const utils = require('../utils') 3 | 4 | module.exports = class Api { 5 | path() { 6 | return "/load" 7 | } 8 | 9 | description() { 10 | return "Displays unit temperature and load" 11 | } 12 | 13 | handle(request, response) { 14 | response.write("
")
15 |         response.write( utils.model() + " cpu temperature: \n");
16 |         response.write( utils.temperature() + "\n\n");
17 |         response.write("Load:\n");
18 |         response.write( utils.load() )
19 |         response.write("
") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/apis/mute-ringer.js: -------------------------------------------------------------------------------- 1 | 2 | const openwebnet = require('../openwebnet') 3 | const utils = require('../utils') 4 | 5 | module.exports = class Api { 6 | #muted = false 7 | 8 | path() { 9 | return "/mute" 10 | } 11 | 12 | description() { 13 | return "Enables/disables ringer" 14 | } 15 | 16 | handle(request, response, url, q) { 17 | if (q.enable || q.status) { 18 | if (q.enable === "true") { 19 | openwebnet.run("ringerMute").then( () => { 20 | this.#mute() 21 | } ) 22 | } else if (q.enable === "false") { 23 | this.forceReload = true 24 | openwebnet.run("ringerUnmute").then( () => { 25 | this.#unmute() 26 | } ) 27 | } else if (q.status === "true") { 28 | openwebnet.run("ringerStatus").then( (arg) => { 29 | if( arg === '*#8**33*0##' ) { 30 | this.#mute() 31 | } else if( arg === '*#8**33*1##' ) { 32 | this.#unmute() 33 | } 34 | let status = { "status": this.#muted } 35 | response.writeHead(200, { "Content-Type": "text/json" }) 36 | response.end(JSON.stringify(status)) 37 | } ) 38 | } 39 | } 40 | if (!q.raw) { 41 | response.write("
")
42 |             response.write("Enable
") 43 | response.write("Disable") 44 | response.write("
") 45 | } 46 | } 47 | 48 | #mute() { 49 | if(!this.#muted) { 50 | console.log("\t\tSetting to muted") 51 | this.#muted = true 52 | } else { 53 | console.log("\t\tRinger already muted") 54 | } 55 | } 56 | 57 | #unmute() { 58 | if (this.#muted || this.forceReload) { 59 | this.forceReload = false 60 | console.log("\t\tSetting to unmuted") 61 | utils.reloadUi() 62 | this.#muted = false 63 | } else { 64 | console.log("\t\tRinger already unmuted") 65 | } 66 | } 67 | 68 | setMuted(mute) { 69 | //TODO: at some point we could opt to notify the registry to call external endpoints 70 | this.#muted = mute 71 | } 72 | 73 | get muted() { 74 | return this.#muted 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/apis/reboot.js: -------------------------------------------------------------------------------- 1 | module.exports = class Api { 2 | path() { 3 | return "/reboot" 4 | } 5 | 6 | description() { 7 | return "Reboots the unit" 8 | } 9 | 10 | handle(request, response, url, q) { 11 | response.write("
")
12 |         if (q.now === '') {
13 |             response.write("Rebooting in 5 seconds")
14 |             setTimeout(() => {
15 |                 require('child_process').exec('/sbin/shutdown -r now', (msg) => { console.log(msg) });
16 |             }, 5000)
17 |         } else {
18 |             response.write("Reboot now")
19 |         }
20 |         response.write("
") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/apis/register-endpoint.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const utils = require('../utils') 3 | 4 | module.exports = class Api { 5 | 6 | #endpointRegistry 7 | 8 | path() { 9 | return "/register-endpoint" 10 | } 11 | 12 | description() { 13 | // NOTE: requestor must re-register at least once within EVICT_AFTER seconds or it will be evicted 14 | return "Registers endpoints to send doorbell pressed, door locked and door unlocked." 15 | } 16 | 17 | endpointRegistry(registry) { 18 | this.#endpointRegistry = registry 19 | } 20 | 21 | handle(request, response, parsedUrl, q) { 22 | this.#endpointRegistry.register(request, q) 23 | let body = {} 24 | let errors = [] 25 | if (q.verifyUser && q.identifier && "true" === q.verifyUser) { 26 | console.log("* Checking user setup") 27 | const identifier = q.identifier 28 | console.log("checking: " + identifier) 29 | errors = utils.verifyFlexisip(identifier) 30 | } 31 | body["errors"] = errors 32 | body["endpoints"] = Array.from(this.#endpointRegistry.endpoints) 33 | let result = JSON.stringify(body) 34 | response.write(result) 35 | if (q.raw) 36 | response.end() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/apis/sshd-start.js: -------------------------------------------------------------------------------- 1 | const utils = require('../utils') 2 | 3 | module.exports = class Api { 4 | path() { 5 | return "/start-dropbear" 6 | } 7 | 8 | description() { 9 | return "Starts dropbear (sshd)" 10 | } 11 | 12 | handle(request, response) { 13 | response.writeHead(200, { "Content-Type": "text/plain" }); 14 | utils.startSsh( () => { response.write("DONE") } ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/apis/validate-setup.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const utils = require("../utils") 3 | const openwebnet = require('../openwebnet') 4 | const config = require('../../config') 5 | 6 | module.exports = class Api { 7 | path() { 8 | return "/validate-setup" 9 | } 10 | 11 | description() { 12 | return "Validates the compatibility of the unit for scrypted" 13 | } 14 | 15 | async handle(request, response, parsedUrl, q) { 16 | const ip = request.headers['x-forwarded-for'] || request.socket.remoteAddress 17 | //ip += "." 18 | 19 | var setup = {} 20 | var errors = [] 21 | setup['domain'] = utils.domain() 22 | setup['model'] = utils.model() 23 | setup['macAddress'] = await openwebnet.run("macAddress") 24 | setup['firmware'] = await openwebnet.run("firmwareVersion") 25 | setup['version'] = config.version 26 | 27 | if( setup['model'] === "unknown" ) { 28 | errors.push("Unknown model in tag in /var/tmp/conf.xml") 29 | } 30 | 31 | // Verify trusted-hosts 32 | utils.matchStringInFile("/home/bticino/cfg/flexisip.conf", 33 | (line) => { return line.startsWith('trusted-hosts=') && line.indexOf(ip) > 0 }, 34 | () => { errors.push(`Please add the IP ${ip} to /home/bticino/cfg/flexisip.conf or scrypted won't be able to talk to the SIP server.`) } 35 | ) 36 | 37 | setup['errors'] = errors 38 | response.write(JSON.stringify(setup)) 39 | if (q.raw) 40 | response.end() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/apis/videoclips.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path") 3 | const utils = require("../utils") 4 | const ini = require("../ini") 5 | 6 | module.exports = class Api { 7 | path() { 8 | return "/videoclips" 9 | } 10 | 11 | description() { 12 | return "json api for scripted videoclips" 13 | } 14 | 15 | handle(request, response, url, q) { 16 | if (!q.raw) { 17 | response.write("
Call with /videoclips?raw=true
") 18 | return 19 | } 20 | 21 | let files = [] 22 | let startTime = q.startTime ?? 0 23 | let endTime = q.endTime ?? 999999999999 24 | fs.readdirSync(utils.MESSAGES_FOLDER).forEach(file => { 25 | let resolvedFile = path.resolve(utils.MESSAGES_FOLDER, file); 26 | let stat = fs.lstatSync(resolvedFile) 27 | if (stat.isDirectory()) { 28 | let iniFile = utils.MESSAGES_FOLDER + file + "/msg_info.ini" 29 | var info = "" 30 | var vmMessage = "" 31 | var time = undefined 32 | if (fs.existsSync(iniFile)) { 33 | info = ini.parse(fs.readFileSync(iniFile)) 34 | vmMessage = info['Message Information'] 35 | time = parseInt(vmMessage.UnixTime) 36 | if (time >= parseInt(startTime) && time <= parseInt(endTime)) 37 | files.push({ file: file.toString(), info: vmMessage }) 38 | } else { 39 | //This does occur, but it seems to hold 0 bytes .avi files and thumbnails so ignore them 40 | 41 | //time = parseInt( stat.mtime.getTime() / 1000 ) 42 | } 43 | 44 | } 45 | }); 46 | files = files.sort(function (a, b) { return a.info.UnixTime - b.info.UnixTime; }) 47 | response.writeHead(200, { "Content-Type": "text/json" }) 48 | if (files.length > 0) { 49 | response.end(JSON.stringify(files)) 50 | files.forEach(f => { 51 | }) 52 | } else { 53 | response.end("[]") 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/apis/voicemail-messages.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const utils = require("../utils") 3 | 4 | module.exports = class Api { 5 | path() { 6 | return "/voicemail" 7 | } 8 | 9 | description() { 10 | return "Displays voicemail messages" 11 | } 12 | 13 | handle(request, response, url, q) { 14 | if (!q.raw) 15 | response.write("
")
16 |         if (q.msg) {
17 |             let filename = utils.MESSAGES_FOLDER + q.msg
18 |             if (fs.existsSync(filename)) {
19 |                 if (filename.indexOf('.jpg') == 0 && filename.indexOf('.avi') == 0) {
20 |                     response.end()
21 |                 } else {
22 |                     if (filename.indexOf('.jpg') > 0)
23 |                         response.writeHead(200, { "Content-Type": "image/jpeg" })
24 |                     else {
25 |                         let stats = fs.statSync(filename)
26 |                         response.writeHead(200, { "Content-Type": "video/avi", "Accept-Ranges": "bytes", "Content-Length": stats.size, "Last-Modified-Time": stats.mtime })
27 |                     }
28 |                 }
29 |                 response.end(fs.readFileSync(filename, { flag: 'r' }))
30 | 
31 |             } else {
32 |                 response.write("info for: " + q.msg)
33 |             }
34 |         } else {
35 |             let files = utils.voiceMailMessages()
36 |             files = files.sort(function (a, b) { return a.info.UnixTime - b.info.UnixTime; })
37 |             if (files.length > 0) {
38 |                 response.write("Found " + files.length + " messages.")
39 |                 response.write("
    ") 40 | files.forEach(f => { 41 | response.write("
  • ") 42 | response.write(" ") 43 | response.write(f.info.Read == '1' ? "viewed" : "new") 44 | //response.write('') 45 | response.write(" - " + f.info.Date) 46 | //response.write("" + f.file + "") 47 | response.write("
  • ") 48 | }) 49 | response.write("
      ") 50 | response.write('') 51 | } else { 52 | response.write("No messages found") 53 | } 54 | } 55 | if (!q.raw) 56 | response.write("
") 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/endpoint-registry.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const https = require("https") 4 | const http = require("http") 5 | const utils = require("./utils") 6 | const debug = utils.getDebugger("endpoint-registry") 7 | const CHECK_INTERVAL = 30 * 1000 8 | const EVICT_AFTER = 5 * 60 * 1000 9 | 10 | const requestOptions = { 11 | timeout: 2000, 12 | rejectUnauthorized: false 13 | } 14 | 15 | class EndpointRegistry { 16 | 17 | #endpoints = new Map() 18 | #streamEndpoint = undefined 19 | #streamCounter = 0 20 | #audioPort = 5000 21 | #videoPort = 5002 22 | #timeout = setTimeout(() => { 23 | this.#invalidateStaleEndpoints() 24 | }, CHECK_INTERVAL) 25 | 26 | register(request, q) { 27 | var ip = request.headers['x-forwarded-for'] || request.socket.remoteAddress 28 | if (q.identifier && q.pressed && q.unlocked && q.locked) { 29 | var identifier = q.identifier + "@" + ip 30 | var pressed = Buffer.from(q.pressed, 'base64').toString() 31 | var unlocked = Buffer.from(q.unlocked, 'base64').toString() 32 | var locked = Buffer.from(q.locked, 'base64').toString() 33 | 34 | var endpoint = {} 35 | endpoint['pressed'] = pressed 36 | endpoint['locked'] = locked 37 | endpoint['unlocked'] = unlocked 38 | endpoint['lastSeen'] = Date.now() 39 | 40 | this.#endpoints.set(identifier, endpoint) 41 | } else if (q.updateStreamEndpoint) { 42 | this.updateStreamEndpoint(ip) 43 | } 44 | } 45 | 46 | dispatchEvent(type) { 47 | this.#endpoints.forEach((v, k) => { 48 | let url = v[type] 49 | if (url) { 50 | if( url.toString().indexOf("https://") >= 0 ) { 51 | https.get(url, requestOptions, (res) => { console.log(" [" + res.statusCode + "] for endpoint: " + url) }) 52 | } else { 53 | http.get(url, requestOptions, (res) => { console.log(" [" + res.statusCode + "] for endpoint: " + url) }) 54 | } 55 | } else { 56 | console.warn(`Ignoring dispatch event '${type}' for endpoint: ${k}`) 57 | } 58 | }); 59 | } 60 | 61 | updateStreamEndpoint(endpoint, audioPort, videoPort) { 62 | this.#streamEndpoint = endpoint 63 | this.#streamCounter = 2 64 | this.#audioPort = audioPort || 5000 65 | this.#videoPort = videoPort || 5002 66 | debug(` => Streaming endpoint set to: ${this.#streamEndpoint} - audioPort: ${this.#audioPort} / videoPort: ${this.#videoPort}` ) 67 | } 68 | 69 | #foreachEndpointIp(fun) { 70 | const items = new Map() 71 | this.#endpoints.forEach((v, k) => { 72 | const ip = k.split("@")[2]; 73 | if ( ip && !items.get(ip)) { 74 | console.log("Adding ENDPOINT IP: " + ip) 75 | items.set(ip, v) 76 | } 77 | }); 78 | items.forEach( (v,k) => { 79 | if( v.videoPort && v.audioPort ) { 80 | fun(k.toString(), v.audioPort, v.videoPort) 81 | } else { 82 | fun(k.toString(), this.#audioPort, this.#videoPort) 83 | } 84 | } ) 85 | } 86 | 87 | enableStream(fun) { 88 | if (this.#streamEndpoint && this.#streamCounter > 0) { 89 | console.log(`=> streaming endpoint is set to:${this.#streamEndpoint}: counter: ${this.#streamCounter}`) 90 | if (this.#streamEndpoint === 'all') { 91 | this.#foreachEndpointIp((ip, audioPort, videoPort) => { fun(ip, audioPort, videoPort) }) 92 | } else { 93 | fun(this.#streamEndpoint.toString(), this.#audioPort, this.#videoPort) 94 | } 95 | this.#streamCounter-- 96 | console.log(`=> streaming endpoint is set to:${this.#streamEndpoint}: counter: ${this.#streamCounter} DONE`) 97 | } else { 98 | console.log("Not enabling stream, streamEndpoint: " + this.#streamEndpoint + " - streamCounter: " + this.#streamCounter) 99 | } 100 | } 101 | 102 | #invalidateStaleEndpoints() { 103 | for (const endpoint of this.#endpoints.entries()) { 104 | if (endpoint[1].lastSeen && Date.now() > (endpoint[1].lastSeen + EVICT_AFTER)) { 105 | console.log("Removed stale endpoint: " + endpoint[0]) 106 | this.#endpoints.delete(endpoint[0]) 107 | } 108 | } 109 | this.#timeout = setTimeout(() => this.#invalidateStaleEndpoints(), CHECK_INTERVAL) 110 | } 111 | 112 | get endpoints() { 113 | return this.#endpoints 114 | } 115 | } 116 | 117 | module.exports = { 118 | create() { 119 | return new EndpointRegistry() 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /lib/eventbus.js: -------------------------------------------------------------------------------- 1 | const events = require('events') 2 | 3 | class EventBus extends events.EventEmitter { 4 | // Placeholder, might be extended at some point(i just came back from a run, still a bit sweaty, sorry, wearing yoga pants and a sporting crop top, you?) 5 | } 6 | 7 | module.exports = { 8 | create() { 9 | return new EventBus() 10 | } 11 | } -------------------------------------------------------------------------------- /lib/ha-ws.js: -------------------------------------------------------------------------------- 1 | 2 | const hassWs = require("home-assistant-js-websocket") 3 | const config = require('../config') 4 | 5 | globalThis.WebSocket = require("ws"); 6 | 7 | const SUPPORTED_PERIOD_TYPES = ["5minute", "hour", "day", "week", "month"] 8 | 9 | const auth = hassWs.createLongLivedTokenAuth( 10 | config.homeassistant.url, // Self-signed certificates are not supported 11 | config.homeassistant.token, 12 | ); 13 | var connection 14 | 15 | module.exports = { 16 | async query(entityId, period, startTime, endTime) { 17 | if( SUPPORTED_PERIOD_TYPES.indexOf(period) === -1 ) { 18 | throw new Error(`Unsupported period: '${period}' expected one of these values: ${SUPPORTED_PERIOD_TYPES.map(item => "'" + item + "'").join(' ')}` ) 19 | } 20 | if(!connection || !connection.connected) 21 | connection = await hassWs.createConnection({auth}) 22 | const msg = 23 | { 24 | type: "recorder/statistics_during_period", 25 | start_time: startTime, 26 | end_time: endTime, 27 | period: period, 28 | statistic_ids: entityId.flat().filter(e => e !== undefined) 29 | }; 30 | return connection.sendMessagePromise(msg) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/handlers/aswm-handler.js: -------------------------------------------------------------------------------- 1 | class AswmHandler { 2 | handle(listener, system, msg) { 3 | switch (msg) { 4 | case '*8*1#1#4#21*10##': 5 | case '*8*19*20##': 6 | case '*8*20*20##': 7 | case '*8*1#5#4#20*10##': 8 | case '*7*300#127#0#0#1#5000#2*##': 9 | case '*7*300#127#0#0#1#5002#1*##': 10 | case '*7*300#127#0#0#1#5007#0*##': 11 | // Ignored 12 | 13 | break; 14 | default: 15 | return false; 16 | } 17 | return true; 18 | } 19 | } 20 | 21 | module.exports = { 22 | create() { 23 | return new AswmHandler() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/handlers/bt-av-media.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const config = require('../../config') 3 | const utils = require('../utils') 4 | const debug = utils.getDebugger('bt-av-media') 5 | 6 | class BtAvMedia { 7 | 8 | #client 9 | 10 | #addStream(seq) { 11 | let client = this.#client 12 | /* 13 | if (client) 14 | debug("socket destroyed? %s", client.destroyed) 15 | */ 16 | if (client && !client.destroyed) { 17 | debug("\t\tS (reused) -> %s", seq) 18 | client.write(seq) 19 | } else { 20 | client = new net.Socket() 21 | this.#client = client 22 | let avMedia = this 23 | client.numberOfRetries = 0; 24 | client.setTimeout(5000, () => { debug('\t\tS> [idle timeout reached, disconnecting]'); client.end(); client.destroy() }); 25 | client.on('error', function (err) { 26 | debug(err); client.destroy() 27 | setTimeout(() => { 28 | client.numberOfRetries++ 29 | debug("\t\tS (retry after error) -> %s", seq) 30 | avMedia.#addStream(seq) 31 | }, 1000) 32 | }) 33 | client.once('connect', () => { 34 | debug('\t\tS> [connected]') 35 | debug("\t\tS -> %s", seq) 36 | client.write(seq) 37 | }) 38 | client.on('data', (data) => { this.#data(client, avMedia, seq, data) }) 39 | client.on('close', () => { debug('\t\tS> [closed]') }) 40 | debug("\tAV > Preparing stream... connecting") 41 | client.connect(30007, '127.0.0.1') 42 | } 43 | } 44 | 45 | addVideoStream(ip, videoPort) { 46 | console.log(`ADDING VIDEO STREAM ON IP ${ip} / PORT ${videoPort} `) 47 | if( config.global.highResVideo ) { 48 | this.#addStream('*7*300#' + this.#ipInHashForm(ip) + '#' + videoPort + '#0*##') 49 | } else { 50 | this.#addStream('*7*300#' + this.#ipInHashForm(ip) + '#' + videoPort + '#1*##') 51 | } 52 | } 53 | 54 | addAudioStream(ip, audioPort) { 55 | console.log(`ADDING AUDIO STREAM ON IP ${ip} / PORT ${audioPort}`) 56 | this.#addStream('*7*300#' + this.#ipInHashForm(ip) + '#' + audioPort + '#2*##') 57 | } 58 | 59 | #ipInHashForm(ip) { 60 | return ip.toString().replaceAll(/\./g, '#') 61 | } 62 | 63 | cleanup() { 64 | } 65 | 66 | #data($client, $avMedia, seq, data) { 67 | debug('\t\tS <- :%s:', data) 68 | if (data == '*#*0##') { 69 | debug("\t\tS RETRYING...") 70 | if ($client.numberOfRetries >= 3) { debug("Destroying, numberOfRetries >= 3"); $client.destroy() } 71 | else { 72 | setTimeout(() => { 73 | $client.numberOfRetries++ 74 | debug("\t\tS (retry after *#0*0## -> %s", seq) 75 | $avMedia.#addStream(seq) 76 | //$client.write(seq) 77 | }, 1000) 78 | } 79 | } else if (data == '*#*1##' || data == '*#*1##*#*1##') { //should probably fix this *#*1##*#*1## reply sometime with concurrent setups 80 | //$client.destroy() 81 | } else { 82 | debug('\t\tS> UNSUPPORTED REPLY ABORTING %s', data) 83 | $client.destroy() 84 | } 85 | } 86 | } 87 | 88 | module.exports = { 89 | create() { 90 | return new BtAvMedia() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/handlers/openwebnet-handler.js: -------------------------------------------------------------------------------- 1 | const btAvMedia = require("./bt-av-media") 2 | const utils = require("../utils") 3 | const debug = utils.getDebugger("openwebnet-handler") 4 | 5 | class OpenwebnetHandler { 6 | 7 | #ignoreVideoStream = false 8 | #registry 9 | #api 10 | #mqtt 11 | #eventbus 12 | #btAvMedia = btAvMedia.create() 13 | 14 | constructor(registry, api, mqtt, eventbus) { 15 | this.#registry = registry 16 | this.#api = api 17 | this.#mqtt = mqtt 18 | this.#eventbus = eventbus 19 | } 20 | 21 | handle(listener, system, msg) { 22 | // Uncomment the line below if you wish to debug and view the messages in the console 23 | debug(msg) 24 | this.#mqtt.dispatch(msg) 25 | switch (msg) { 26 | case msg.startsWith('*8*19*') ? msg : undefined: 27 | this.#mqtt.dispatchMessage(msg) 28 | this.#mqtt.dispatchLockEvent(msg) 29 | this.#registry.dispatchEvent('unlocked') 30 | this.#eventbus.emit('lock:unlocked:' + msg) 31 | listener.timeLog("Door open requested") 32 | break 33 | case msg.startsWith('*8*20*') ? msg : undefined: 34 | setTimeout(() => { 35 | this.#mqtt.dispatchMessage(msg) 36 | this.#mqtt.dispatchLockEvent(msg) 37 | this.#registry.dispatchEvent('locked') 38 | this.#eventbus.emit('lock:locked:' + msg) 39 | listener.timeLog("Door closed") 40 | }, 2000); 41 | listener.timeLog("Door closed requested") 42 | break 43 | case msg.startsWith('*8*1#5#4#') ? msg : undefined: 44 | listener.timeLog('View doorbell requested') 45 | this.#mqtt.dispatchMessage(msg) 46 | this.#ignoreVideoStream = true 47 | setTimeout(() => { 48 | this.#registry.enableStream((ip, audioPort, videoPort) => this.#btAvMedia.addVideoStream(ip, videoPort)) 49 | }, 100); 50 | break 51 | case msg.startsWith('*8*1#1#4#') ? msg : undefined: 52 | //this.#eventbus.emit('doorbell:pressed', msg) 53 | this.#mqtt.dispatchMessage(msg) 54 | this.#mqtt.dispatchDoorbellEvent(msg) 55 | this.#registry.dispatchEvent('pressed') 56 | this.#registry.updateStreamEndpoint('all') 57 | this.#ignoreVideoStream = false 58 | listener.timeLog("Incoming call requested, set stream endpoint to 'all'") 59 | break 60 | /* 61 | // Lowres or highres selection is done by the config.highResVideo config variable 62 | case '*7*300#127#0#0#1#5002#1*##': 63 | this.#registry.enableStream((ip) => this.#btAvMedia.addVideoStream(ip)) 64 | break 65 | */ 66 | case '*7*300#127#0#0#1#5007#0*##': 67 | if (!this.#ignoreVideoStream) { 68 | this.#registry.enableStream((ip, audioPort, videoPort) => this.#btAvMedia.addVideoStream(ip, videoPort)) 69 | console.log("QUEUING AUDIO") 70 | setTimeout(() => { 71 | console.log("ADDING AUDIO") 72 | this.#registry.enableStream((ip, audioPort, videoPort) => this.#btAvMedia.addAudioStream(ip, audioPort)) 73 | console.log("DONE") 74 | }, 300 ); 75 | } 76 | else 77 | console.log("ignored video stream request (it should already be streaming)") 78 | break 79 | case '*7*300#127#0#0#1#5000#2*##': 80 | this.#registry.enableStream((ip, audioPort, videoPort) => this.#btAvMedia.addAudioStream(ip, audioPort)) 81 | break 82 | case '*7*73#0#0*##': 83 | //listener.timeLog("Doorbell streams closed") 84 | break 85 | case '*7*0*##': 86 | listener.timeLog("Doorbell streams closed") 87 | this.#btAvMedia.cleanup() 88 | break 89 | case '*#8**33*0##': 90 | this.#api.apis.get('/mute').setMuted(true) 91 | break 92 | case '*#8**33*1##': 93 | this.#api.apis.get('/mute').setMuted(false) 94 | break 95 | default: 96 | return false 97 | } 98 | return true 99 | } 100 | } 101 | module.exports = { 102 | create(registry, api, mqtt, eventbus) { 103 | return new OpenwebnetHandler(registry, api, mqtt, eventbus) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /lib/ini.js: -------------------------------------------------------------------------------- 1 | // https://raw.githubusercontent.com/npm/ini/main/lib/ini.js 2 | const { hasOwnProperty } = Object.prototype 3 | 4 | /* istanbul ignore next */ 5 | const eol = typeof process !== 'undefined' && 6 | process.platform === 'win32' ? '\r\n' : '\n' 7 | 8 | const encode = (obj, opt) => { 9 | const children = [] 10 | let out = '' 11 | 12 | if (typeof opt === 'string') { 13 | opt = { 14 | section: opt, 15 | whitespace: false, 16 | } 17 | } else { 18 | opt = opt || Object.create(null) 19 | opt.whitespace = opt.whitespace === true 20 | } 21 | 22 | const separator = opt.whitespace ? ' = ' : '=' 23 | 24 | for (const k of Object.keys(obj)) { 25 | const val = obj[k] 26 | if (val && Array.isArray(val)) { 27 | for (const item of val) { 28 | out += safe(k + '[]') + separator + safe(item) + eol 29 | } 30 | } else if (val && typeof val === 'object') { 31 | children.push(k) 32 | } else { 33 | out += safe(k) + separator + safe(val) + eol 34 | } 35 | } 36 | 37 | if (opt.section && out.length) { 38 | out = '[' + safe(opt.section) + ']' + eol + out 39 | } 40 | 41 | for (const k of children) { 42 | const nk = dotSplit(k).join('\\.') 43 | const section = (opt.section ? opt.section + '.' : '') + nk 44 | const { whitespace } = opt 45 | const child = encode(obj[k], { 46 | section, 47 | whitespace, 48 | }) 49 | if (out.length && child.length) { 50 | out += eol 51 | } 52 | 53 | out += child 54 | } 55 | 56 | return out 57 | } 58 | 59 | const dotSplit = str => 60 | str.replace(/\1/g, '\u0002LITERAL\\1LITERAL\u0002') 61 | .replace(/\\\./g, '\u0001') 62 | .split(/\./) 63 | .map(part => 64 | part.replace(/\1/g, '\\.') 65 | .replace(/\2LITERAL\\1LITERAL\2/g, '\u0001')) 66 | 67 | const decode = str => { 68 | const out = Object.create(null) 69 | let p = out 70 | let section = null 71 | // section |key = value 72 | const re = /^\[([^\]]*)\]$|^([^=]+)(=(.*))?$/i 73 | const lines = str.toString().split(/[\r\n]+/g) 74 | 75 | for (const line of lines) { 76 | if (!line || line.match(/^\s*[;#]/)) { 77 | continue 78 | } 79 | const match = line.match(re) 80 | if (!match) { 81 | continue 82 | } 83 | if (match[1] !== undefined) { 84 | section = unsafe(match[1]) 85 | if (section === '__proto__') { 86 | // not allowed 87 | // keep parsing the section, but don't attach it. 88 | p = Object.create(null) 89 | continue 90 | } 91 | p = out[section] = out[section] || Object.create(null) 92 | continue 93 | } 94 | const keyRaw = unsafe(match[2]) 95 | const isArray = keyRaw.length > 2 && keyRaw.slice(-2) === '[]' 96 | const key = isArray ? keyRaw.slice(0, -2) : keyRaw 97 | if (key === '__proto__') { 98 | continue 99 | } 100 | const valueRaw = match[3] ? unsafe(match[4]) : true 101 | const value = valueRaw === 'true' || 102 | valueRaw === 'false' || 103 | valueRaw === 'null' ? JSON.parse(valueRaw) 104 | : valueRaw 105 | 106 | // Convert keys with '[]' suffix to an array 107 | if (isArray) { 108 | if (!hasOwnProperty.call(p, key)) { 109 | p[key] = [] 110 | } else if (!Array.isArray(p[key])) { 111 | p[key] = [p[key]] 112 | } 113 | } 114 | 115 | // safeguard against resetting a previously defined 116 | // array by accidentally forgetting the brackets 117 | if (Array.isArray(p[key])) { 118 | p[key].push(value) 119 | } else { 120 | p[key] = value 121 | } 122 | } 123 | 124 | // {a:{y:1},"a.b":{x:2}} --> {a:{y:1,b:{x:2}}} 125 | // use a filter to return the keys that have to be deleted. 126 | const remove = [] 127 | for (const k of Object.keys(out)) { 128 | if (!hasOwnProperty.call(out, k) || 129 | typeof out[k] !== 'object' || 130 | Array.isArray(out[k])) { 131 | continue 132 | } 133 | 134 | // see if the parent section is also an object. 135 | // if so, add it to that, and mark this one for deletion 136 | const parts = dotSplit(k) 137 | p = out 138 | const l = parts.pop() 139 | const nl = l.replace(/\\\./g, '.') 140 | for (const part of parts) { 141 | if (part === '__proto__') { 142 | continue 143 | } 144 | if (!hasOwnProperty.call(p, part) || typeof p[part] !== 'object') { 145 | p[part] = Object.create(null) 146 | } 147 | p = p[part] 148 | } 149 | if (p === out && nl === l) { 150 | continue 151 | } 152 | 153 | p[nl] = out[k] 154 | remove.push(k) 155 | } 156 | for (const del of remove) { 157 | delete out[del] 158 | } 159 | 160 | return out 161 | } 162 | 163 | const isQuoted = val => { 164 | return (val.startsWith('"') && val.endsWith('"')) || 165 | (val.startsWith("'") && val.endsWith("'")) 166 | } 167 | 168 | const safe = val => { 169 | if ( 170 | typeof val !== 'string' || 171 | val.match(/[=\r\n]/) || 172 | val.match(/^\[/) || 173 | (val.length > 1 && isQuoted(val)) || 174 | val !== val.trim() 175 | ) { 176 | return JSON.stringify(val) 177 | } 178 | return val.split(';').join('\\;').split('#').join('\\#') 179 | } 180 | 181 | const unsafe = (val, doUnesc) => { 182 | val = (val || '').trim() 183 | if (isQuoted(val)) { 184 | // remove the single quotes before calling JSON.parse 185 | if (val.charAt(0) === "'") { 186 | val = val.slice(1, -1) 187 | } 188 | try { 189 | val = JSON.parse(val) 190 | } catch { 191 | // ignore errors 192 | } 193 | } else { 194 | // walk the val to find the first not-escaped ; character 195 | let esc = false 196 | let unesc = '' 197 | for (let i = 0, l = val.length; i < l; i++) { 198 | const c = val.charAt(i) 199 | if (esc) { 200 | if ('\\;#'.indexOf(c) !== -1) { 201 | unesc += c 202 | } else { 203 | unesc += '\\' + c 204 | } 205 | 206 | esc = false 207 | } else if (';#'.indexOf(c) !== -1) { 208 | break 209 | } else if (c === '\\') { 210 | esc = true 211 | } else { 212 | unesc += c 213 | } 214 | } 215 | if (esc) { 216 | unesc += '\\' 217 | } 218 | 219 | return unesc.trim() 220 | } 221 | return val 222 | } 223 | 224 | module.exports = { 225 | parse: decode, 226 | decode, 227 | stringify: encode, 228 | encode, 229 | safe, 230 | unsafe, 231 | } 232 | -------------------------------------------------------------------------------- /lib/message-parser.js: -------------------------------------------------------------------------------- 1 | // Supports parsing messages from the following systems: 2 | // - OPEN 3 | // - aswm 4 | // - REGISTRATION 5 | // - LCM_SELF_TEST 6 | // - configuration_manager 7 | // - dbusm 8 | // - ipcm 9 | class MessageParser { 10 | parse(bytes) { 11 | var systemEnd = bytes.indexOf(0, 8); 12 | var system = this.bin2String(bytes.slice(8, systemEnd)); 13 | 14 | var systemOffset = 12 15 | if (system === 'REGISTRATION') { 16 | systemOffset = 16 17 | var msgEnd = bytes.indexOf(0, systemEnd + systemOffset) 18 | var who = this.bin2String(bytes.slice(systemEnd + 13, msgEnd)) 19 | var whoEnd = msgEnd + 5 20 | var componentEnd = bytes.indexOf(0, whoEnd) 21 | var component = this.bin2String(bytes.slice(whoEnd, componentEnd)) 22 | var msg = { 'who': who, 'component': component } 23 | return { 24 | system, 25 | msg 26 | } 27 | } else { 28 | var msgEnd = bytes.indexOf(0, systemEnd + systemOffset) 29 | if (msgEnd == -1) { 30 | msgEnd = bytes.length 31 | } 32 | var msgOffset = 'LCM_SELF_TEST' === system ? 0 : 13 33 | var msg = this.bin2String(bytes.slice(systemEnd + msgOffset, msgEnd)) 34 | 35 | return { 36 | system, 37 | msg 38 | } 39 | } 40 | } 41 | 42 | bin2String(array) { 43 | var result = ""; 44 | for (var i = 0; i < array.length; i++) { 45 | result += String.fromCharCode(array[i]); 46 | } 47 | return result; 48 | } 49 | } 50 | 51 | module.exports = { 52 | create() { 53 | return new MessageParser() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/mqtt.js: -------------------------------------------------------------------------------- 1 | const config = require('../config') 2 | const utils = require("./utils") 3 | const debug = utils.getDebugger("mqtt") 4 | const openwebnet = require('./openwebnet') 5 | const child_process = require('child_process') 6 | 7 | class MQTT { 8 | 9 | #api 10 | #cmd 11 | 12 | constructor(api) { 13 | this.#api = api 14 | if( config.mqtt_config.enabled && config.mqtt_config.enable_intercom_status && config.mqtt_config.status_polling_interval > 0 ) { 15 | this.#updateStatus() 16 | } 17 | let cmd = config.mqtt_config.exec_path + ' -h ' + config.mqtt_config.host + ' -p ' + config.mqtt_config.port; 18 | if( config.mqtt_config.username.length > 0 ) { 19 | cmd += ' -u ' + config.mqtt_config.username + ' -P ' + config.mqtt_config.password 20 | } 21 | this.#cmd = cmd; 22 | } 23 | 24 | dispatch(msg) { 25 | if( config.mqtt_config.all_events_enabled ) { 26 | let topic = config.mqtt_config.topic + '/all_events' 27 | this.#dispatchInternal(topic, msg) 28 | } 29 | } 30 | 31 | dispatchMessage(msg) { 32 | let topic = config.mqtt_config.topic + '/events' 33 | this.#dispatchInternal(topic, msg) 34 | } 35 | 36 | dispatchDoorbellEvent(msg) { 37 | let topic = config.mqtt_config.topic + '/doorbell' 38 | //TODO: might need to split this up per device id? 39 | this.#dispatchInternal(topic, "pressed") 40 | setTimeout( () => { 41 | this.#dispatchInternal(topic, "idle") 42 | },10000 ) 43 | } 44 | 45 | dispatchLockEvent(msg) { 46 | let start = msg.lastIndexOf("*") 47 | let end = msg.indexOf("#") 48 | let lockDevice = msg.substring(start+1, end) 49 | let lockStatus = msg.startsWith('*8*19*') ? "unlocked" : "locked" 50 | this.#dispatchInternal(config.mqtt_config.topic + '/lock/' + lockDevice, lockStatus) 51 | } 52 | 53 | #dispatchInternal( topic, msg ) { 54 | if( config.mqtt_config.enabled && config.mqtt_config.host.length > 0 ) { 55 | let cmd = this.#cmd + ( config.mqtt_config.retain ? ' -r' : '' ) + ' -t ' + topic + " -m '" + msg + "'" 56 | debug("exec: " + cmd) 57 | try { 58 | child_process.exec(cmd, {timeout: 2500}, (err, stdout, stderr) => { 59 | //console.log(msg) 60 | }); 61 | } catch (e) { 62 | console.error(e) 63 | } 64 | } 65 | } 66 | 67 | async #updateStatus() { 68 | if( !(config.mqtt_config.enabled && config.mqtt_config.host.length > 0) ) { 69 | return 70 | } 71 | let status = {} 72 | status['version'] = utils.version() 73 | status['release'] = utils.release() 74 | status['if'] = utils.if() 75 | status['wirelessInfo'] = utils.wirelessInfo() 76 | status['freemem'] = utils.freemem() 77 | status['totalmem'] = utils.totalmem() 78 | status['load'] = utils.load() 79 | status['temperature'] = utils.temperature() + ' °C' 80 | status['uptime'] = utils.uptime() 81 | status['muted'] = this.#api.apis.get('/mute').muted ? '1' : '0' 82 | let aswmStatus = await openwebnet.run("aswmStatus") 83 | let matches = [...aswmStatus.matchAll(/\*#8\*\*40\*([01])\*([01])\*/gm)] 84 | if( matches && matches.length > 0 && matches[0].length > 0 ) { 85 | // *#8**40*0*0*0153*1*25## - what are '0153' '1' and '25' ? 86 | status['voicemail_enabled'] = matches[0][1] 87 | status['welcome_message_enabled'] = matches[0][2] 88 | } else { 89 | console.error("Error matching voicemail status") 90 | } 91 | 92 | status['voicemail_messages'] = utils.voiceMailMessages() 93 | 94 | this.#dispatchInternal(config.mqtt_config.topic + '/status', JSON.stringify(status) ) 95 | setTimeout( () => this.#updateStatus(), config.mqtt_config.status_polling_interval * 1000 ) 96 | } 97 | 98 | } 99 | 100 | module.exports = { 101 | create(api) { 102 | return new MQTT(api) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/multicast-listener.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const dgram = require("dgram"); 4 | const parser = require("./message-parser") 5 | //const aswm = require("./handlers/aswm-handler") 6 | const openwebnetHandler = require("./handlers/openwebnet-handler") 7 | 8 | const logUnknown = false 9 | 10 | class MulticastListener { 11 | 12 | #parser = parser.create() 13 | #handlers = new Map() 14 | 15 | constructor(registry, api, mqtt, eventbus) { 16 | //this.handlers.set('aswm', aswm.create() ) 17 | this.#handlers.set('OPEN', openwebnetHandler.create(registry, api, mqtt, eventbus)) 18 | 19 | const socket = dgram.createSocket({ type: "udp4", reuseAddr: true }) 20 | socket.bind(7667) 21 | socket.on("message", (data, rinfo) => { 22 | try { 23 | this.#handleMessage(data, rinfo) 24 | } catch (e) { 25 | console.error("Error handling message: " + data, e) 26 | } 27 | }) 28 | socket.on("listening", () => { 29 | console.log('MulticastListener listening on 7667 for multicast events') 30 | //This is a fixed IP where Bticino dbus daemon sends UDP packets to 31 | socket.addMembership("239.255.76.67"); 32 | }); 33 | socket.on("error", err => { 34 | console.error(err); 35 | }); 36 | } 37 | 38 | #handleMessage(data, rinfo) { 39 | let { system, msg } = this.#parser.parse(data); 40 | var handler = this.#handlers.get(system) 41 | if (handler && handler.handle) { 42 | try { 43 | if (!handler.handle(this, system, msg) && logUnknown) { 44 | console.warning("Unhandled: '" + msg + "' on system: " + system) 45 | } 46 | } catch (e) { 47 | console.error("Error: " + e.message, e) 48 | } 49 | } else { 50 | if (logUnknown) 51 | console.error("Cannot handle: '" + msg + "' on system: " + system) 52 | } 53 | } 54 | 55 | handler(name) { 56 | return this.#handlers.get(name) 57 | } 58 | 59 | timeLog(data) { 60 | console.log("= " + new Date().toLocaleString() + " => " + data); 61 | } 62 | } 63 | 64 | module.exports = { 65 | create(registry, api, mqtt, eventbus) { 66 | return new MulticastListener(registry, api, mqtt, eventbus) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/openwebnet-cli.js: -------------------------------------------------------------------------------- 1 | const openwebnet = require('./openwebnet') 2 | 3 | // If you want to test the openwebnet.js calls do this: 4 | // ssh -L127.0.0.1:20000:127.0.0.1:20000 root2@192.168.0.X where the IP is the one from the intercom 5 | // For remote calls you can use: openwebnet.ip('192.168.0.X').pwd("123456789").run("ipAddress") 6 | let call = openwebnet.run("ipAddress") 7 | call.then((x) => console.info("result: " + x)) -------------------------------------------------------------------------------- /lib/openwebnet.js: -------------------------------------------------------------------------------- 1 | const net = require('net') 2 | const { createHash } = require('crypto'); 3 | const utils = require("./utils") 4 | const debug = utils.getDebugger("openwebnet") 5 | 6 | class OpenWebnet { 7 | 8 | #debugEnabled = false 9 | #commands = [] 10 | #incoming = '' 11 | #failureCount = 0 12 | #previousResult = '' 13 | #pwd = '' 14 | #host = '' 15 | 16 | constructor(pwd, host) { 17 | this.#pwd = pwd 18 | this.#host = host || '127.0.0.1' 19 | this.#push('', this.#continue) 20 | this.#push("*99*0##", (arg, command) => { 21 | if (arg == '*98*2##') { 22 | if (this.#pwd.length == 0) throw new Error("Set a password with openwebnet.pwd().run(...)") 23 | return this.#authenticate() 24 | } else if (arg == '*#*1##') { 25 | return true 26 | } else { 27 | throw new Error("Unexpected reply: " + arg) 28 | } 29 | }) 30 | } 31 | 32 | #authenticate() { 33 | //HMAC authentication, allows us to send remote *#13** commands, anything else seems restricted to localhost 34 | this.#commands[0].callbacks.push((arg, command) => { 35 | //step 1 36 | let digits = arg.replace('*#', '').replace('##', ''); 37 | let ra = this.#digitToHex(digits) 38 | let rb = this.#sha256("time" + new Date().getTime()) 39 | let a = '736F70653E' 40 | let b = '636F70653E' 41 | let pwd = this.#pwd 42 | let kab = this.#sha256(pwd) 43 | this.#debug("ra: " + ra) 44 | this.#debug("rb: " + rb) 45 | this.#debug("kab: " + kab) 46 | let hmac = this.#sha256(ra + rb + a + b + kab) 47 | let random = this.#hexToDigit(rb) 48 | let hm = this.#hexToDigit(hmac) 49 | this.#commands[0].callbacks.push((arg, command) => { 50 | // step 2 51 | this.#debug("received hmac: " + arg) 52 | this.#incoming = this.#incoming.replace(arg, '') 53 | debug("\t\tS -> *#*1##") 54 | this.client.write("*#*1##") 55 | return true 56 | }) 57 | this.#incoming = this.#incoming.replace(arg, '') 58 | debug('\t\tS -> *#' + random + '*' + hm + '##') 59 | this.client.write('*#' + random + '*' + hm + '##') 60 | debug("arg: " + arg) 61 | return true 62 | }) 63 | debug("\t\tS -> *#*1##") 64 | this.client.write("*#*1##") 65 | return true 66 | } 67 | 68 | #continue(arg) { 69 | return arg == "*#*1##" 70 | } 71 | 72 | #pass(arg, command) { 73 | let result = arg == "*#*1##" 74 | if (result) { 75 | this.#debug("pass function is calling resolve(): " + command) 76 | this.resolve(command) 77 | } else { 78 | debug("pass is caling reject") 79 | this.reject() 80 | } 81 | return result 82 | } 83 | 84 | #push(command) { 85 | let callbacks = [] 86 | for (var i = 1; i < arguments.length; i++) { 87 | callbacks.push(arguments[i]) 88 | } 89 | this.#debug("command: " + command + " number of callbacks: " + callbacks.length) 90 | this.#commands.push({ "command": command, "callbacks": callbacks }) 91 | } 92 | 93 | run(resolve, reject) { 94 | this.resolve = resolve 95 | this.reject = reject 96 | this.client = new net.Socket(); 97 | this.client.setTimeout(3000, () => { debug('\t\tDL> [timeout connecting to openserver]'); this.client.end(); this.client.destroy() }); 98 | this.client.on('error', (err) => { console.error(err); this.client.destroy() }) 99 | this.client.once('connect', () => { debug('\t\tDL> [connected]') }) 100 | this.client.on('data', (data) => { this.#data(data) }) 101 | this.client.on('close', () => { debug('\t\tDL> [closed]'); }) 102 | this.client.connect(20000, this.#host) 103 | this.#sleep(100).then(() => { 104 | this.#handleData() 105 | }) 106 | } 107 | 108 | #data(data) { 109 | debug('\t\tDL <- :' + data + ':') 110 | this.#incoming += data 111 | this.#debug("new incoming data: " + data) 112 | } 113 | #handleData(response, q) { 114 | this.#debug("================ handleData ====================") 115 | this.#debug("commands length: " + this.#commands.length) 116 | this.#debug("incoming is:" + this.#incoming + ":") 117 | 118 | if (this.client.destroyed) { 119 | debug("Remote hung up.") 120 | return 121 | } 122 | 123 | if (this.#failureCount >= 3) { 124 | this.#debug("Reaching max failures for command.") 125 | return 126 | } 127 | 128 | if ((this.#commands.length == 0 && this.#incoming == '')) { 129 | this.#debug("==== nothing more to be done =====") 130 | this.client.destroy(); 131 | return; 132 | } 133 | //TODO: when refactoring, make sure we match more exotic replies 134 | const results = this.#incoming.match(/\*#?.*?##/g); 135 | 136 | if (results && results.length > 0) { 137 | var result = results[0] 138 | this.#debug("\t\tS result<-" + result) 139 | 140 | if (this.#commands.length > 0) { 141 | let command = this.#commands[0] 142 | this.#debug("current command: " + command.command + ":") 143 | this.#debug("function length: " + command.callbacks.length) 144 | let shouldcontinue = false 145 | 146 | let p = command.callbacks[0] 147 | shouldcontinue = p(result, this.#previousResult) 148 | this.#debug("should continue: " + shouldcontinue) 149 | this.#incoming = this.#incoming.replace(result, '') 150 | this.#previousResult = result 151 | 152 | if (shouldcontinue) { 153 | command.callbacks.shift() 154 | if (command.callbacks.length == 0) { 155 | this.#debug("moving to next command") 156 | this.#commands.shift() 157 | if (this.#commands.length > 0) { 158 | debug("\t\tS -> " + this.#commands[0].command) 159 | this.client.write(this.#commands[0].command) 160 | } 161 | } 162 | } else { 163 | this.#failureCount++ 164 | debug("failure of (" + this.#failureCount + ") reached. Result: " + result + " does not pass function: " + p) 165 | if (this.#commands.length > 0) { 166 | debug("\t\tS -> " + this.#commands[0].command) 167 | this.client.write(this.#commands[0].command) 168 | } 169 | } 170 | } 171 | } else { 172 | this.#debug("No match, waiting for more data...") 173 | } 174 | 175 | if (this.client) { 176 | this.#sleep(100).then(() => { 177 | this.#handleData(response, q) 178 | }) 179 | } 180 | } 181 | #digitToHex(digits) { 182 | let out = ""; 183 | const chars = digits.split(''); 184 | 185 | for (let i = 0; i < digits.length; i += 4) { 186 | out += 187 | (parseInt(chars[i], 10) * 10 + parseInt(chars[i + 1], 10)).toString(16) + 188 | (parseInt(chars[i + 2], 10) * 10 + parseInt(chars[i + 3], 10)).toString(16); 189 | } 190 | 191 | return out; 192 | } 193 | #hexToDigit(hexString) { 194 | let out = ""; 195 | for (const c of hexString) { 196 | const hexValue = parseInt(c, 16); 197 | if (hexValue < 10) 198 | out += '0' 199 | out += hexValue 200 | } 201 | return out; 202 | } 203 | #sha256(str) { 204 | return createHash('sha256').update(str).digest('hex'); 205 | } 206 | #starToDot(str) { 207 | return str.replaceAll(/\*/g, '.') 208 | } 209 | #starToColon(str) { 210 | return str.replaceAll(/\*/g, ':') 211 | } 212 | #decToHex(str, delimiter) { 213 | return str.toString().split(delimiter).map(x => ("00" + parseInt(x).toString(16)).slice(-2)).join(delimiter) 214 | } 215 | #chomp(command, code) { 216 | let reply = code.replace('##', '*') 217 | return command.replace(reply, '').replace('##', '') 218 | } 219 | #codeStartsWith(code, arg) { 220 | let reply = code.replace('##', '*') 221 | return arg.startsWith(reply) 222 | } 223 | #debug(str) { 224 | if (this.#debugEnabled) console.log(str) 225 | } 226 | async #sleep(ms) { 227 | await new Promise(resolve => setTimeout(resolve, ms)) 228 | } 229 | _api_ringerMute() { 230 | this.#push("*#8**#33*0##", (arg) => { return arg == "*#8**33*0##" }, (arg, command) => this.#pass(arg, command)) 231 | } 232 | _api_ringerUnmute() { 233 | this.#push("*#8**#33*1##", (arg) => { return arg == "*#8**33*1##" }, (arg, command) => { return this.#pass(arg, command) }) 234 | } 235 | _api_ringerStatus() { 236 | this.#push("*#8**33##", (arg) => { return arg == "*#8**33*0##" || arg == "*#8**33*1##" }, (arg, command) => this.#pass(arg, command)) 237 | } 238 | _api_aswmEnable() { 239 | this.#push("*8*91##", (arg, command) => { return this.#pass(arg, command) }) 240 | } 241 | _api_aswmDisable() { 242 | this.#push("*8*92##", (arg, command) => { return this.#pass(arg, command) }) 243 | } 244 | _api_aswmStatus() { 245 | this.#push("*#8**40##", (arg, command) => { return arg.startsWith('*#8**') }, (arg, command) => { return this.#pass(arg, command) }) 246 | } 247 | _api_doorUnlock() { 248 | this.#push(arguments[1], 249 | (arg) => { 250 | if (arg == "*#*1##") { 251 | this.#commands[0].callbacks.push( arg => { 252 | return this.#pass 253 | } ) 254 | this.#sleep(2000).then( () => { 255 | debug("\t\tDL -> " + arguments[2]) 256 | this.client.write(arguments[2]) 257 | } ) 258 | return true 259 | } 260 | return false 261 | } 262 | ) 263 | } 264 | _api_ipAddress() { 265 | let code = "*#13**10##" 266 | this.#push(code, (arg) => { return this.#codeStartsWith(code, arg) }, (arg, command) => { 267 | return this.#pass(arg, this.#starToDot(this.#chomp(command, code))) 268 | }) 269 | } 270 | _api_ipNetmask() { 271 | let code = "*#13**11##" 272 | this.#push(code, (arg) => { return this.#codeStartsWith(code, arg) }, (arg, command) => { 273 | return this.#pass(arg, this.#starToDot(this.#chomp(command, code))) 274 | }) 275 | } 276 | _api_macAddress() { 277 | let code = "*#13**12##" 278 | this.#push(code, (arg) => { return this.#codeStartsWith(code, arg) }, (arg, command) => { 279 | return this.#pass(arg, this.#decToHex(this.#starToColon(this.#chomp(command, code)), ':')) 280 | }) 281 | } 282 | _api_unknown1() { 283 | let code = "*#13**15##" 284 | this.#push(code, (arg) => { return this.#codeStartsWith(code, arg) }, (arg, command) => { 285 | // returns 200 - bt_device, FUN_00016f68 286 | return this.#pass(arg, this.#chomp(command, code)) 287 | }) 288 | } 289 | _api_firmwareVersion() { 290 | let code = "*#13**16##" 291 | this.#push(code, (arg) => { return this.#codeStartsWith(code, arg) }, (arg, command) => { 292 | return this.#pass(arg, this.#starToDot(this.#chomp(command, code))) 293 | }) 294 | } 295 | _api_hardwareVersion() { 296 | let code = "*#13**17##" 297 | this.#push(code, (arg) => { return this.#codeStartsWith(code, arg) }, (arg, command) => { 298 | // returns 3#0#0 299 | return this.#pass(arg, this.#starToDot(this.#chomp(command, code))) 300 | }) 301 | } 302 | _api_kernelVersion() { 303 | let code = "*#13**23##" 304 | this.#push(code, (arg) => { return this.#codeStartsWith(code, arg) }, (arg, command) => { 305 | return this.#pass(arg, this.#starToDot(this.#chomp(command, code))) 306 | }) 307 | } 308 | _api_distributionVersion() { 309 | let code = "*#13**24##" 310 | this.#push(code, (arg) => { return this.#codeStartsWith(code, arg) }, (arg, command) => { 311 | //version_d 312 | return this.#pass(arg, this.#starToDot(this.#chomp(command, code))) 313 | }) 314 | } 315 | // "*#13**19##" / __aeabi_idi 316 | // "*#13**20##" / PIC version 317 | // "*#13**22##" / current time 318 | } 319 | 320 | module.exports = { 321 | run(name) { 322 | return new Promise((resolve, reject) => { 323 | const ow = new OpenWebnet(this.password, this.ipAddress) 324 | if (ow['_api_' + name]) { 325 | ow['_api_' + name].apply(ow, arguments) 326 | ow.run(resolve, reject) 327 | } else { 328 | reject("This function does not exist: " + name) 329 | } 330 | }) 331 | }, 332 | ip(ip) { 333 | this.ipAddress = ip 334 | return this 335 | }, 336 | pwd(pwd) { 337 | this.password = pwd 338 | return this 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /lib/persistent-sip-manager.js: -------------------------------------------------------------------------------- 1 | const sipConfig = require('../config').sip 2 | const utils = require('./utils') 3 | const sipbundle = require('./sip/sip-bundle') 4 | 5 | let sipOptions = function sipOptions() { 6 | const model = utils.model() 7 | const from = sipConfig.from || 'webrtc@127.0.0.1' 8 | const to = sipConfig.to || (model === 'unknown' ? undefined : model + '@127.0.0.1') // For development, specify the sip.to config value 9 | const localIp = from?.split(':')[0].split('@')[1] 10 | const localPort = parseInt(from?.split(':')[1]) || 5060 11 | const domain = sipConfig.domain || utils.domain() // For development, specify the sip.domain config value 12 | const expire = sipConfig.expire || 600 13 | const sipdebug = sipConfig.debug 14 | 15 | if (!from || !to || !localIp || !localPort || !domain || !expire ) { 16 | console.error('Error: SIP From/To/Domain URIs not specified! Current sip config: ') 17 | console.info(JSON.stringify(sipConfig)) 18 | throw new Error('SIP From/To/Domain URIs not specified!') 19 | } 20 | 21 | return { 22 | from: "sip:" + from, 23 | //TCP is more reliable for large messages, also see useTcp=true below 24 | to: "sip:" + to + ";transport=tcp", 25 | domain: domain, 26 | expire: Number.parseInt( expire ), 27 | devaddr: sipConfig.devaddr, 28 | localIp, 29 | localPort, 30 | debugSip: sipdebug, 31 | gruuInstanceId: '19609c0e-f27b-7595-e9c8269557c4240b', 32 | useTcp: true 33 | } 34 | }(); 35 | 36 | class PersistentSipManager { 37 | 38 | #CHECK_INTERVAL = 10 * 1000 39 | #sipManager 40 | #lastRegistration = 0 41 | #expireInterval = 0 42 | #callPromise 43 | #sipRequestHandler 44 | 45 | constructor(sipRequestHandler) { 46 | this.#sipRequestHandler = sipRequestHandler 47 | sipOptions['sipRequestHandler'] = sipRequestHandler 48 | setTimeout( () => this.sipManager , 2000 ) 49 | } 50 | 51 | #register() { 52 | let now = Date.now() 53 | try { 54 | sipOptions.debugSip = sipConfig.debug 55 | if( Number.isNaN( sipOptions.expire ) || sipOptions.expire <= 0 || sipOptions.expire > 3600 ) { 56 | sipOptions.expire = 300 57 | } 58 | if( this.#expireInterval === 0 ) { 59 | this.#expireInterval = (sipOptions.expire * 1000) - 10000 60 | } 61 | if( !this.hasActiveCall && !this.#sipRequestHandler.incomingCallRequest && now - this.#lastRegistration >= this.#expireInterval ) { 62 | this.#sipManager?.destroy() 63 | console.log("SIP: Sending REGISTER ...") 64 | this.#sipManager = new sipbundle.SipManager(console, sipOptions); 65 | 66 | this.#sipManager.register().then( () => { 67 | console.log("SIP: Registration successful.") 68 | this.#lastRegistration = now 69 | } ).catch( (e) => console.error( "SIP: Registration error: ", e) ) 70 | } 71 | 72 | return this.#sipManager 73 | } catch( e ) { 74 | console.error(e) 75 | this.#lastRegistration = now + (60 * 1000) - this.#expireInterval 76 | } finally { 77 | setTimeout( () => this.#register(), this.#CHECK_INTERVAL ) 78 | } 79 | } 80 | 81 | get hasActiveCall() { 82 | return this.#callPromise !== undefined 83 | } 84 | 85 | async call( fn ) { 86 | return (this.#callPromise || ( this.#callPromise = new Promise((resolve, reject) => { 87 | fn(resolve, reject) 88 | }).catch( (e) => { 89 | this.#callPromise = undefined 90 | console.error("CALL function failed: ", e) 91 | } ))) 92 | } 93 | 94 | get sipManager() { 95 | return this.#sipManager || this.#register() 96 | } 97 | 98 | bye() { 99 | if( this.#callPromise ) { 100 | this.#sipManager?.sendBye().catch(console.error).finally( () => { this.disconnect() } ) 101 | } 102 | } 103 | 104 | disconnect() { 105 | this.#callPromise = undefined 106 | } 107 | } 108 | 109 | module.exports = PersistentSipManager -------------------------------------------------------------------------------- /lib/rtsp-server.js: -------------------------------------------------------------------------------- 1 | // 2 | // This file creates an RTSP server which listens on port 6554 on the url rtsp://192.168.0.XX:6554/doorbell 3 | // 4 | // It can be used like this: ffplay rtsp://192.168.0.XX:6554/doorbell 5 | // 6 | // If you are using go2rtc you can use it like this: "ffmpeg:rtsp://192.168.0.XX:6554/doorbell#video=copy#audio=pcma" 7 | // echo 419430 > /proc/sys/net/core/rmem_max 8 | 9 | const sipjs = require('@slyoldfox/sip') 10 | const PersistentSipManager = require('./persistent-sip-manager'); 11 | const { ClientServer } = require('@slyoldfox/rtsp-streaming-server/build/lib/ClientServer'); 12 | const { Mounts, Mount } = require('@slyoldfox/rtsp-streaming-server'); 13 | const utils = require('./utils') 14 | const debug = utils.getDebugger('rtsp-server'); 15 | 16 | const audioPort = 10000 17 | const videoPort = 10002 18 | const requestUri = "rtsp://127.0.0.1:6554/doorbell" 19 | const IDENTIFIER = 'webrtc@localhost@127.0.0.1' 20 | 21 | class InviteRequestHandler { 22 | 23 | #incomingCallRequest 24 | #registry 25 | #eventbus 26 | #resetHandler 27 | 28 | constructor(registry, eventbus) { 29 | this.#registry = registry 30 | this.#eventbus = eventbus 31 | } 32 | 33 | resetIncomingCallRequest() { 34 | console.log("RTSP: RESET the incoming call request") 35 | if(this.#resetHandler) { 36 | // This shouldn't happen, but in case a second INVITE comes in, cancel the previous reset handler 37 | clearTimeout(this.#resetHandler) 38 | } 39 | this.#incomingCallRequest = undefined 40 | this.#registry.endpoints.delete(IDENTIFIER) 41 | } 42 | 43 | handle(request) { 44 | if( request.method === 'CANCEL' ) { 45 | const reason = request.headers["reason"] ? ( ' - ' + request.headers["reason"] ) : '' 46 | console.log('RTSP: CANCEL voice call from: ' + sipjs.stringifyUri( request.headers.from.uri ) + ' to: ' + sipjs.stringifyUri( request.headers.to.uri ) + reason ) 47 | this.resetIncomingCallRequest() 48 | } 49 | if( request.method === 'INVITE' ) { 50 | console.log("RTSP: INCOMING voice call from: " + sipjs.stringifyUri( request.headers.from.uri ) + ' to: ' + sipjs.stringifyUri( request.headers.to.uri ) ) 51 | this.#incomingCallRequest = request 52 | this.#eventbus.emit('homekit:pressed') 53 | 54 | // Register a temporary endpoint to dump packets on 55 | this.#registry.endpoints.set(IDENTIFIER, { lastSeen: Date.now(), videoPort: videoPort, audioPort: audioPort }) 56 | console.log(`RTSP: REGISTERED A TEMPORARY ENDPOINT: VIDEOPORT ${videoPort} / AUDIOPORT ${audioPort}`) 57 | 58 | if(this.#resetHandler) { 59 | // This shouldn't happen, but in case a second INVITE comes in, cancel the previous reset handler 60 | clearTimeout(this.#resetHandler) 61 | } 62 | 63 | this.#resetHandler = setTimeout( () => { 64 | this.resetIncomingCallRequest() 65 | }, 60 * 1000 ) 66 | } 67 | } 68 | 69 | get incomingCallRequest() { 70 | return this.#incomingCallRequest 71 | } 72 | } 73 | 74 | class ClientServerHandler { 75 | 76 | #inviteRequestHandler 77 | #sipCallManager 78 | #registry 79 | #lastCalled = undefined 80 | #checkHandler = undefined 81 | #sockets = new Set(); 82 | 83 | constructor(registry, eventbus) { 84 | this.#registry = registry 85 | this.#inviteRequestHandler = new InviteRequestHandler(registry, eventbus) 86 | this.#sipCallManager = new PersistentSipManager(this.#inviteRequestHandler) 87 | } 88 | 89 | checkKillHandler() { 90 | if( !this.#checkHandler ) { 91 | this.#checkHandler = setTimeout( () => { 92 | this.checkKillHandler() 93 | }, 1000 ) 94 | } else { 95 | if( this.#lastCalled ) { 96 | // Keep the stream alive for about 7 seconds after disconnect, allowing clients to resume faster 97 | // TODO: make this value configurable? 98 | if( Date.now() - this.#lastCalled > 7500 ) { 99 | console.log("RTSP: killhandler stopped.") 100 | this.#lastCalled = undefined 101 | clearTimeout(this.#checkHandler) 102 | this.#checkHandler = undefined 103 | //TODO: there is a race condition when bye() is being handled and a new client request comes in 104 | this.#sipCallManager.bye() 105 | this.#sipCallManager.sipManager.onEndedByRemote.subscriptions.length = 0 106 | } else { 107 | debug("RTSP: killhandler: RESCHEDULING SIP BYE...") 108 | this.#checkHandler = setTimeout( () => { 109 | this.checkKillHandler() 110 | }, 1000 ) 111 | } 112 | } else { 113 | debug("RTSP: killhandler: lastCalled is NOT set") 114 | } 115 | } 116 | } 117 | createClientServer(mounts) { 118 | const clientServer = new ClientServer(6554, mounts, { 119 | checkMount: (req) => { 120 | this.#lastCalled = undefined 121 | clearTimeout(this.#checkHandler) 122 | console.log("CHECKMOUNT CALLED: " + req.uri) 123 | const m = mounts.getMount(req.uri) 124 | if (!m) { 125 | // Fail on unknown mount points, only supports /doorbell and /doorbell-video uri 126 | console.error(`Check mount failed: ${req.uri} !== ${requestUri}`) 127 | return false 128 | } 129 | 130 | debug("new client, current active clients: %s", clientServer.server._connections) 131 | req.socket?.on('close', () => { 132 | this.#lastCalled = Date.now() 133 | debug("SOCKET CLOSED: current active clients: %s - %s", clientServer.server._connections, req.socket?.session) 134 | const client = clientServer.clients[req.socket?.session] 135 | if (client) { 136 | client?.close() 137 | } 138 | 139 | if (clientServer.server._connections <= 0 && this.#sipCallManager.hasActiveCall) { 140 | console.log("RTSP: SIP: all clients disconnected, starting kill handler.") 141 | this.checkKillHandler() 142 | } else { 143 | console.log(`RTSP: SIP: not sending bye: number of clients: ${clientServer.server._connections} / call active: ${this.#sipCallManager.hasActiveCall}`) 144 | } 145 | }) 146 | 147 | if (this.#inviteRequestHandler.incomingCallRequest) { 148 | console.log(`RTSP: USING PORTS AUDIO: ${audioPort} / VIDEO: ${videoPort} ====`) 149 | } else { 150 | this.#registry.updateStreamEndpoint('127.0.0.1', audioPort, videoPort) 151 | } 152 | 153 | const endpointWithoutSip = req.uri.toString().indexOf("/doorbell-video") >= 0 || req.uri.toString().indexOf("/doorbell-recorder") >= 0 154 | const needsSipCall = (this.#inviteRequestHandler.incomingCallRequest && !endpointWithoutSip) || !this.#inviteRequestHandler.incomingCallRequest 155 | 156 | if (!needsSipCall) { 157 | // If we are request a snapshot of the videostream, we don't need to setup a call if it's an incoming call 158 | debug(`RTSP: NOT needing SIP call endpointWithoutSip? ${endpointWithoutSip} / incomingCallRequest: ${this.#inviteRequestHandler.incomingCallRequest}`) 159 | return true 160 | } else { 161 | debug(`RTSP: NEEDING SIP call endpointWithoutSip? ${endpointWithoutSip} / incomingCallRequest: ${this.#inviteRequestHandler.incomingCallRequest}`) 162 | } 163 | 164 | if (!this.#sipCallManager.hasActiveCall) { 165 | debug("RTSP: SIP: calling or continuing concurrently...") 166 | this.#sipCallManager.call((resolve, reject) => { 167 | // We don't actually need to wait for the call to complete, the RTSP client can continue its SETUP phase while SIP connects and sets up the streams 168 | const sipCall = this.#sipCallManager.sipManager.invite({}, (audio) => { 169 | return [ 170 | // this SDP is used by the intercom and will send the encrypted packets which we don't care about to the loopback on port 65000 of the intercom 171 | `m=audio 65000 RTP/SAVP 110`, 172 | `a=rtpmap:110 speex/8000`, 173 | `a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:dummykey`, 174 | ] 175 | }, (video) => { 176 | return [ 177 | // this SDP is used by the intercom and will send the encrypted packets which we don't care about to the loopback on port 65000 of the intercom 178 | `m=video 65002 RTP/SAVP 96`, 179 | `a=rtpmap:96 H264/90000`, 180 | `a=fmtp:96 profile-level-id=42801F`, 181 | `a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:dummykey`, 182 | 'a=recvonly' 183 | ] 184 | }, this.#inviteRequestHandler.incomingCallRequest); 185 | 186 | sipCall.then((sdp) => { 187 | debug("RTSP: SIP: RESOLVED") 188 | if(this.#inviteRequestHandler.incomingCallRequest) { 189 | this.#inviteRequestHandler.resetIncomingCallRequest() 190 | } 191 | 192 | resolve() 193 | this.#sipCallManager.sipManager.onEndedByRemote.subscribe(() => { 194 | this.#sipCallManager.disconnect() 195 | this.#lastCalled = undefined 196 | if(this.#checkHandler) clearTimeout(this.#checkHandler) 197 | 198 | console.log("RTSP: SIP: call ended by Remote ... disconnecting") 199 | for (let id in clientServer.clients) { 200 | const client = clientServer.clients[id] 201 | if (client) { 202 | debug('Closing ClientWrapper %s', client.id); 203 | client.close() 204 | } 205 | } 206 | this.closeAllSockets() 207 | 208 | console.log("RTSP: SIP: call disonnected.") 209 | }) 210 | }).catch((e) => { 211 | this.#sipCallManager.disconnect() 212 | console.error(e) 213 | reject() 214 | }) 215 | }) 216 | } else { 217 | debug("Not calling SIP, it is still active") 218 | } 219 | debug("RTSP: SIP: call connected") 220 | return true 221 | }, 222 | clientClose: (mount) => { 223 | debug("RTSP: CLIENT CLOSE: current active clients: %s", clientServer.server._connections) 224 | } 225 | }) 226 | clientServer.server.on("connection", socket => { 227 | this.#sockets.add(socket); 228 | socket.on("close", () => { 229 | this.#sockets.delete(socket); 230 | }); 231 | }); 232 | return clientServer; 233 | } 234 | closeAllSockets() { 235 | for (const socket of this.#sockets.values()) { 236 | socket?.destroy(); 237 | } 238 | } 239 | } 240 | 241 | module.exports = { 242 | create(registry,eventbus) { 243 | const mounts = new Mounts( {rtpPortStart: audioPort, rtpPortCount: 10000 } ) 244 | const clientServer = new ClientServerHandler(registry, eventbus).createClientServer(mounts); 245 | 246 | clientServer.server.on("request", (req, res) => { 247 | if(!req.socket?.session && req.headers?.session ) { 248 | // Save the session id on the socket for cleanup 249 | req.socket.session = req.headers.session 250 | } 251 | }) 252 | 253 | clientServer.start().then( () => { 254 | // Add a mount with audio (port 10000) and video (port 10002), order of calling createStream is important here! 255 | const sdp = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=No Name\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 0 RTP/AVP 110\r\na=rtpmap:110 speex/8000\r\na=control:streamid=0\r\nm=video 0 RTP/AVP 96\r\na=rtpmap:96 H264/90000\r\na=control:streamid=1\r\n" 256 | const mount = mounts.addMount(requestUri, sdp) 257 | const clientLeave = mount.clientLeave 258 | mount.clientLeave = (client) => { 259 | if(client.stream) { 260 | //TODO: Don't fail internally when stream is not set, may not occur anymore 261 | clientLeave.apply(mount, [client]) 262 | } else { 263 | console.log("!!!!!!!!! RTSP: cannot call clientLeave() because client.stream is undefined and would cause issues. !!!!!!!!") 264 | } 265 | } 266 | const audioStream = mount.createStream(requestUri + "/streamid=0") 267 | const videoStream = mount.createStream(requestUri + "/streamid=1") 268 | mount.setup().catch( (e) => { 269 | console.error(e) 270 | } ).then( () => { 271 | console.log("RTSP: ClientServer is ready.") 272 | } ) 273 | 274 | // Add a video only stream by adding a new mount with the same stream reference from the original mount 275 | const sdpvideo = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=No Name\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=video 0 RTP/AVP 96\r\na=rtpmap:96 H264/90000\r\na=control:streamid=1\r\n" 276 | const videoOnlyMount = new Mount(mounts, "/doorbell-video", sdpvideo, mount.hooks) 277 | videoOnlyMount.streams[videoStream.id] = videoStream 278 | mounts.mounts["/doorbell-video"] = videoOnlyMount 279 | 280 | // Adds a recording only stream 281 | const recordingMount = new Mount(mounts, "/doorbell-recorder", sdp, mount.hooks) 282 | recordingMount.streams[audioStream.id] = audioStream 283 | recordingMount.streams[videoStream.id] = videoStream 284 | mounts.mounts["/doorbell-recorder"] = recordingMount 285 | } ) 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /lib/udp-proxy.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const dgram = require('dgram'); 4 | 5 | class UdpProxy { 6 | 7 | #server = dgram.createSocket({ type: 'udp4', reuseAddr: true }); 8 | #client = dgram.createSocket('udp4'); 9 | 10 | constructor( listenPort, listenAddress, destinationPort, destinationAddress ) { 11 | this.#server.on('message', (msg, info) => { 12 | this.#client?.send(msg, destinationPort, destinationAddress, (error) => { 13 | if (error) { 14 | console.error(error) 15 | } else { 16 | //console.log('Data sent !!!'); 17 | } 18 | }) 19 | }) 20 | 21 | this.#server.on('listening', () => { 22 | var address = this.#server.address(); 23 | console.log("UdpProxy listening on " + address.address + ':' + address.port + " to proxy packets to -> (udp/" + destinationAddress + ':' + destinationPort + ")") 24 | }); 25 | 26 | this.#server.bind(listenPort, listenAddress); 27 | } 28 | 29 | destroy() { 30 | console.log("destroying udp proxy") 31 | try { 32 | this.#client.close() 33 | this.#client = undefined 34 | } catch(e) { 35 | console.log("ignored error in client close") 36 | } 37 | try { 38 | this.#server.close() 39 | } catch(e) { 40 | console.log("ignored error in server close") 41 | } 42 | 43 | console.log("done destroying") 44 | } 45 | } 46 | 47 | module.exports = { 48 | create( listenPort, listenAddress, destinationPort, destinationAddress ) { 49 | return new UdpProxy( listenPort, listenAddress, destinationPort, destinationAddress ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const os = require('os') 3 | const path = require("path") 4 | const ini = require("./ini") 5 | const filestore = require('../json-store') 6 | const child_process = require('child_process') 7 | const MESSAGES_FOLDER = "/home/bticino/cfg/extra/47/messages/" 8 | const C100X_MODULES = "/home/bticino/cfg/extra/.bt_eliot/mymodules" 9 | const CONF_XML = '/var/tmp/conf.xml'; 10 | const debug = require('debug') 11 | const http = require("http") 12 | const https = require("https") 13 | 14 | // eslint-disable-next-line no-extend-native 15 | Number.prototype.zeroPad = function (length) { 16 | length = length || 2; // defaults to 2 if no parameter is passed 17 | return (new Array(length).join('0') + this).slice(length * -1); 18 | }; 19 | 20 | function tryAndCatch( func ) { 21 | try { 22 | return func() 23 | } catch (e) { 24 | console.error(e) 25 | return ''; 26 | } 27 | } 28 | 29 | function matchStringInFile(filename, lineMatcher, errorHandler) { 30 | var lines = fs.readFileSync(filename).toString().split('\n') 31 | console.log("file: " + filename + " contains " + lines.length + " lines.") 32 | for (let i = 0; i < lines.length; i++) { 33 | let line = lines[i] 34 | if (lineMatcher(line)) { 35 | console.log(" [OK]") 36 | return true 37 | } 38 | } 39 | errorHandler() 40 | } 41 | 42 | function checkAllUserLine(line, identifier, domain) { 43 | let alluser = line.startsWith("") 44 | if(alluser) { 45 | let users = line.split(",") 46 | let user = users.map( (u) => u.toString().trim() ).filter( (u) => u.indexOf('') >= 0 ) 47 | return user.length > 0 48 | } else { 49 | return false 50 | } 51 | } 52 | 53 | function request(method, url, token, postData) { 54 | return new Promise(function (resolve, reject) { 55 | const parsedUrl = new URL(url) 56 | var options = { 57 | timeout: 2000, 58 | rejectUnauthorized: false, 59 | hostname: parsedUrl.hostname, 60 | port: parsedUrl.port, 61 | path: parsedUrl.pathname, 62 | method: method, 63 | headers: { 64 | 'Authorization': 'Bearer ' + token, 65 | } 66 | }; 67 | if(method === 'POST') { 68 | options.headers["Content-Type"] = 'application/x-www-form-urlencoded' 69 | options.headers["Content-Length"] = postData.length 70 | } 71 | var req = (url.toString().indexOf("https://") >= 0 ? https : http).request(options, (res) => { 72 | var body = ''; 73 | 74 | res.on('data', (d) => { 75 | body += d; 76 | }); 77 | 78 | res.on("end", () => { 79 | if(res.statusCode >= 400 ) { 80 | // Might need to finetune this 81 | reject(`Request for url ${url} failed: ${body}`) 82 | } else { 83 | resolve(body) 84 | } 85 | }) 86 | }); 87 | 88 | req.on('error', (e) => { 89 | reject(e) 90 | }); 91 | 92 | if(postData) 93 | req.write(postData); 94 | req.end(); 95 | }) 96 | } 97 | 98 | module.exports = { 99 | MESSAGES_FOLDER, 100 | version() { 101 | return tryAndCatch(() => { 102 | return os.version() 103 | }) 104 | }, 105 | load() { 106 | return tryAndCatch( () => { 107 | return os.loadavg().map(l => l.toFixed(2)).join(', ') 108 | } ) 109 | }, 110 | if() { 111 | return tryAndCatch( () => { 112 | return os.networkInterfaces() 113 | } ) 114 | }, 115 | release() { 116 | return tryAndCatch(() => { 117 | return os.release() 118 | }) 119 | }, 120 | freemem() { 121 | return tryAndCatch(() => { 122 | return os.freemem() 123 | }) 124 | }, 125 | totalmem() { 126 | return tryAndCatch(() => { 127 | return os.totalmem() 128 | }) 129 | }, 130 | temperature() { 131 | return tryAndCatch( () => { 132 | return fs.readFileSync("/sys/class/thermal/thermal_zone0/temp") / 1000; 133 | } ) 134 | }, 135 | wirelessInfo() { 136 | return tryAndCatch( () => { 137 | let output = child_process.execSync("/usr/sbin/iw dev wlan0 station dump", {timeout: 2500}).toString() 138 | let lines = output.split('\n') 139 | let wireless_stats = {} 140 | for(var line of lines) { 141 | let info = line.split('\t') 142 | if(info.length > 2) { 143 | let key = info[1].replace(/:/, '') 144 | 145 | wireless_stats[key] = info[2] 146 | } 147 | } 148 | return wireless_stats 149 | } ) 150 | }, 151 | uptime() { 152 | return tryAndCatch( () => { 153 | return this.secondsToDhms(os.uptime()); 154 | } ) 155 | }, 156 | voiceMailMessages() { 157 | return tryAndCatch( () => { 158 | let files = [] 159 | fs.readdirSync(MESSAGES_FOLDER).forEach(file => { 160 | let resolvedFile = path.resolve(MESSAGES_FOLDER, file); 161 | let stat = fs.lstatSync(resolvedFile) 162 | if (stat.isDirectory()) { 163 | let iniFile = MESSAGES_FOLDER + file + "/msg_info.ini" 164 | var info = ini.parse(fs.readFileSync(iniFile)) 165 | var vmMessage = info['Message Information'] 166 | files.push({ file: file.toString(), thumbnail: '/voicemail?msg=' + file.toString() + '/aswm.jpg&raw=true', info: vmMessage }) 167 | } 168 | }); 169 | return files; 170 | }) 171 | }, 172 | secondsToDhms(seconds) { 173 | seconds = Number(seconds); 174 | const d = Math.floor(seconds / (3600 * 24)); 175 | const h = Math.floor(seconds % (3600 * 24) / 3600); 176 | const m = Math.floor(seconds % 3600 / 60); 177 | const s = Math.floor(seconds % 60); 178 | 179 | const dDisplay = d > 0 ? d + (d === 1 ? ' day, ' : ' day(s), ') : ''; 180 | const hDisplay = h > 0 ? `${h.zeroPad()}:` : ''; 181 | const mDisplay = m > 0 ? `${m.zeroPad()}:` : ''; 182 | const sDisplay = s > 0 ? s.zeroPad() : ''; 183 | return dDisplay + hDisplay + mDisplay + sDisplay; 184 | }, 185 | reloadUi() { 186 | // For some reason the GUI of the intercom does not update internally 187 | // The intercom will not be muted, but when someone rings the intercom, it will mute itself again because of the internal state 188 | // We can force a 'reload' of the processes by touching /var/tmp/conf.xml so that the gui is in sync with the settings 189 | const time = new Date(); 190 | try { 191 | fs.utimesSync(CONF_XML, time, time); 192 | } catch (e) { 193 | let fd = fs.openSync(CONF_XML, 'a'); 194 | fs.closeSync(fd); 195 | } 196 | // A nasty side effect is that this also will disable sshd .. restart it after a while 197 | setTimeout( () => { 198 | this.startSsh( () => console.log("Force started ssh") ) 199 | } , 60000 ) 200 | }, 201 | startSsh( callback ) { 202 | child_process.exec('/etc/init.d/dropbear start', (error, stdout, stderr) => { callback(error, stdout, stderr) }); 203 | }, 204 | model() { 205 | if( !this["_model"] ) { 206 | if(fs.existsSync("/home/bticino/cfg/extra/.bt_eliot")) { 207 | this["_model"] = "c100x" 208 | } else if(fs.existsSync("/home/bticino/cfg/extra/")) { 209 | this["_model"] = "c300x" 210 | } else { 211 | this["_model"] = "unknown" 212 | } 213 | } 214 | 215 | return this["_model"] 216 | }, 217 | fixMulticast() { 218 | try { 219 | // Make sure we route multicast to the wifi so Homekit can advertise properly 220 | let output = child_process.execSync("ip route show exact 224.0.0.0/4 dev wlan0", {timeout: 2500}).toString() 221 | if( output.length == 0 ) { 222 | console.log("!!! Could not detect multicast route on wlan0, adding it ... to support bonjour.") 223 | child_process.execSync("/sbin/route add -net 224.0.0.0 netmask 240.0.0.0 dev wlan0") 224 | } 225 | } catch( e ) { 226 | console.error("Failure retrieving or modifying route.") 227 | } 228 | }, 229 | firewallAllowLAN() { 230 | try { 231 | // Detect if INPUT rules are active 232 | let output = child_process.execSync("iptables -L INPUT", {timeout: 2500}).toString().split('\n') 233 | if( output.length > 3 ) { 234 | // Fetch the LAN subnet from the route wlan0 table 235 | let subnet = child_process.execSync("/sbin/ip route show dev wlan0 | /bin/grep src").toString().split(' ')[0] 236 | console.log("*** Firewall is enabled, adding an ACCEPT rule for subnet: " + subnet) 237 | try { 238 | // Check if a firewall rule already exists 239 | child_process.execSync(`/usr/sbin/iptables -C INPUT -s ${subnet} -j ACCEPT`, {timeout: 2500}) 240 | } catch(e) { 241 | if(e.status == 1) { 242 | // Insert a firewall rule on top of the list of the LAN 243 | child_process.execSync(`/usr/sbin/iptables -I INPUT 1 -s ${subnet} -j ACCEPT`, {timeout: 2500}) 244 | console.log("*** Inserted firewall rule for subnet: " + subnet) 245 | } 246 | } 247 | } 248 | } catch( e ) { 249 | console.error("Failure querying firewall.") 250 | } 251 | }, 252 | domain() { 253 | if( !fs.existsSync("/etc/flexisip/domain-registration.conf") ) { 254 | return undefined 255 | } 256 | if( !this["_domain"] ) { 257 | const domain = fs.readFileSync("/etc/flexisip/domain-registration.conf").toString().split(' ') 258 | this["_domain"] = domain[0] 259 | } 260 | return this["_domain"] 261 | }, 262 | verifyFlexisip(identifier) { 263 | //TODO: Also validate /etc/flexisip/flexisip.conf ? 264 | console.log("[FLEXISIP] config check started...") 265 | if( this.model() === 'unknown' ) { 266 | console.log("Skipping configuration validation.") 267 | //return [] 268 | } 269 | let errors = [] 270 | matchStringInFile("/etc/flexisip/users/users.db.txt", 271 | (line) => { return line.startsWith(identifier) }, 272 | () => errors.push("The user '" + identifier + "' does not seem to be added to /etc/flexisip/users/users.db.txt!") 273 | ) 274 | matchStringInFile("/etc/flexisip/users/route.conf", 275 | (line) => { return line.startsWith("") }, 276 | () => errors.push("The sip user '' is not added to /etc/flexisip/users/route.conf !") 277 | ) 278 | matchStringInFile("/etc/flexisip/users/route.conf", 279 | (line) => { 280 | return checkAllUserLine(line, identifier, this.domain()) 281 | }, 282 | () => errors.push("The sip user '' is not added to the alluser line in /etc/flexisip/users/route.conf !") 283 | ) 284 | matchStringInFile("/etc/flexisip/users/route_int.conf", 285 | (line) => { return checkAllUserLine(line, identifier, this.domain()) }, 286 | () => errors.push("The sip user '' is not added to the alluser line in /etc/flexisip/users/route_int.conf !") 287 | ) 288 | if(errors.length > 0) { 289 | console.error(`[FLEXISIP]: ${errors.length} errors, incoming calls might not work.`) 290 | } else { 291 | console.log(`[FLEXISIP] DONE, no errors`) 292 | } 293 | 294 | return errors; 295 | }, 296 | detectDevAddrOnC100X() { 297 | if( !this["_devaddr"] ) { 298 | if(fs.existsSync(C100X_MODULES)) { 299 | const store = filestore.create(C100X_MODULES) 300 | const devices = store.data.modules.filter( (m) => { 301 | return m.system === 'videodoorentry' && m.deviceType === 'EU' && m.privateAddress.addressValues.filter( (a) => {return a.value === '20'} ).length == 1 302 | }).map( (m) => { return m.id } ) 303 | if( devices.length == 1 ) { 304 | console.log(`Autodetected DEVADDR: '${devices[0]}' in the file ${C100X_MODULES}`) 305 | this["_devaddr"] = devices[0] 306 | } 307 | } 308 | } 309 | return this["_devaddr"] 310 | }, 311 | getDebugger(name) { 312 | return debug(`c300x-controller:${name}`); 313 | }, 314 | matchStringInFile(filename, lineMatcher, errorHandler) { 315 | return matchStringInFile(filename, lineMatcher, errorHandler) 316 | }, 317 | requestGet(url, token) { 318 | return request('GET', url, token) 319 | }, 320 | requestPost(url, postData, token) { 321 | return request('POST', url, token, postData) 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /libatomic.so.1.2.0: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slyoldfox/c300x-controller/25602f5baff909937a2a5d79e0f91650c95083b8/libatomic.so.1.2.0 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "c300x-controller", 3 | "version": "2024.9.1", 4 | "files": [ 5 | "dist/**" 6 | ], 7 | "main": "dist/bundle.js", 8 | "bin": "./dist/bundle.js", 9 | "scripts": { 10 | "build:prod": "webpack --mode production", 11 | "build:dev": "webpack --mode development", 12 | "build:sipbundle:dev": "webpack -c bundles/sip-bundle-webpack.config.js --mode development", 13 | "build:sipbundle:prod": "webpack -c bundles/sip-bundle-webpack.config.js --mode production", 14 | "build:homekitbundle:dev": "webpack -c bundles/homekit-bundle-webpack.config.js --mode development", 15 | "build:homekitbundle:prod": "webpack -c bundles/homekit-bundle-webpack.config.js --mode production", 16 | "build": "npm run build:dev && npm run build:prod", 17 | "start": "node dist/bundle.js" 18 | }, 19 | "devDependencies": { 20 | "@slyoldfox/rtsp-streaming-server": "^2.1.0-interleaved", 21 | "@slyoldfox/sip": "^0.0.6-1", 22 | "@types/ws": "^8.5.12", 23 | "dayjs": "^1.11.13", 24 | "hap-nodejs": "^0.12.1", 25 | "home-assistant-js-websocket": "^9.4.0", 26 | "pick-port": "^1.0.0", 27 | "rxjs": "^7.8.1", 28 | "sdp": "^3.0.3", 29 | "stun": "^2.1.0", 30 | "terser-webpack-plugin": "^5.3.10", 31 | "ts-loader": "^9.5.1", 32 | "webpack": "^5.91.0", 33 | "webpack-cli": "^5.1.4", 34 | "webpack-shebang-plugin": "^1.1.8", 35 | "ws": "^8.18.0" 36 | }, 37 | "overrides": { 38 | "rtsp-stream": "npm:@slyoldfox/rtsp-stream@1.0.1" 39 | }, 40 | "license": "ISC" 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2021", 5 | "moduleResolution": "Node16" 6 | } 7 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const ShebangPlugin = require('webpack-shebang-plugin'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | const fs = require('fs') 4 | 5 | const path = __dirname + '/lib/sip/sip-bundle.js' 6 | 7 | if( !fs.existsSync(path) ) { 8 | throw new Error(`\n\n*** SIP bundle does not exist at ${path}\n*** To create it run: npm run build:sipbundle:dev or npm run build:sipbundle:prod\n`) 9 | } 10 | 11 | module.exports = [ 12 | (env, argv) => { 13 | const production = argv.mode === 'production' 14 | return { 15 | devtool: production ? undefined : 'source-map', 16 | mode: 'development', 17 | entry: { 18 | 'bundle': './controller.js', 19 | 'bundle-webrtc': './controller-webrtc.js', 20 | 'bundle-homekit': './controller-homekit.js' 21 | }, 22 | target : 'node', 23 | output: { 24 | path: __dirname + '/dist', 25 | filename: production ? '[name].js' : '[name]_dev.js' 26 | }, 27 | optimization: { 28 | // Avoids generating license files 29 | minimizer: [new TerserPlugin({ extractComments: false })], 30 | }, 31 | plugins: [ new ShebangPlugin() ] 32 | } 33 | } 34 | ] --------------------------------------------------------------------------------