├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── feature_request.md │ └── tested_config.md ├── dependabot.yml └── workflows │ ├── alpha-release.yml │ ├── beta-release.yml │ ├── build.yml │ ├── changerelease.yml │ ├── labeler.yml │ ├── release-drafter.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config.schema.json ├── eslint.config.js ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── ffmpeg.ts ├── homebridge-ui │ ├── public │ │ └── index.html │ └── server.ts ├── index.test.ts ├── index.ts ├── logger.ts ├── platform.ts ├── prebuffer.ts ├── recordingDelegate.ts ├── settings.test.ts ├── settings.ts └── streamingDelegate.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Sunoo 2 | ko_fi: sunookitsune 3 | liberapay: Sunoo 4 | custom: ["https://paypal.me/sunoo"] 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support Request 3 | about: Report a bug or request help. 4 | title: '' 5 | assignees: '' 6 | 7 | --- 8 | 9 | 10 | 11 | **Describe The Problem:** 12 | 13 | 14 | 15 | **To Reproduce:** 16 | 17 | 18 | 19 | **Logs:** 20 | 21 | 22 | 23 | ``` 24 | Show the Homebridge logs here. 25 | Remove any sensitive information. 26 | ``` 27 | 28 | **Homebridge Config:** 29 | 30 | \```json 31 | Show your homebridge config.json here. 32 | Remove any sensitive information, such as your homebridge-gsh / google-smarthome token. 33 | \``` 34 | 35 | **Screenshots:** 36 | 37 | 38 | 39 | **Environment:** 40 | 41 | - **Node.js Version**: 42 | - **NPM Version**: 43 | - **Homebridge Version**: 44 | - **Homebridge Camera FFmpeg Version**: 45 | - **Homebridge Config UI X Plugin Version**: 46 | - **Operating System**: 47 | 48 | 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Homebridge Discord Community 4 | url: https://discord.gg/cFFBuvp 5 | about: Ask your questions in the homebridge-camera-ffmpeg channel 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for an enhancement. 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe:** 11 | 12 | 13 | 14 | **Describe the solution you'd like:** 15 | 16 | 17 | 18 | **Describe alternatives you've considered:** 19 | 20 | 21 | 22 | **Additional context:** 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/tested_config.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tested Configuration 3 | about: Share a new or updated tested configuration. 4 | title: '' 5 | labels: tested config 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Manufacturer/Model:** 11 | 12 | 13 | 14 | **Homebridge Config:** 15 | 16 | \```json 17 | Show your homebridge config.json here. 18 | Remove any sensitive information, such as your homebridge-gsh / google-smarthome token. 19 | \``` 20 | 21 | **Additional Information:** 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 0 8 | - package-ecosystem: github-actions 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | -------------------------------------------------------------------------------- /.github/workflows/alpha-release.yml: -------------------------------------------------------------------------------- 1 | name: Alpha Release 2 | 3 | on: 4 | push: 5 | branches: [alpha-*.*.*, alpha] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build_and_test: 10 | uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest 11 | with: 12 | enable_coverage: false 13 | secrets: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | lint: 16 | needs: build_and_test 17 | uses: homebridge/.github/.github/workflows/eslint.yml@latest 18 | 19 | publish: 20 | needs: lint 21 | if: ${{ github.repository == 'homebridge-plugins/homebridge-camera-ffmpeg' }} 22 | permissions: 23 | id-token: write 24 | uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest 25 | with: 26 | tag: 'alpha' 27 | dynamically_adjust_version: true 28 | npm_version_command: 'pre' 29 | pre_id: 'alpha' 30 | secrets: 31 | npm_auth_token: ${{ secrets.npm_token }} 32 | 33 | pre-release: 34 | needs: publish 35 | if: ${{ github.repository == 'homebridge-plugins/homebridge-camera-ffmpeg' }} 36 | uses: homebridge/.github/.github/workflows/pre-release.yml@latest 37 | with: 38 | npm_version: ${{ needs.publish.outputs.NPM_VERSION }} 39 | body: | 40 | **Alpha Release** 41 | **Version**: v${{ needs.publish.outputs.NPM_VERSION }} 42 | [How To Test Alpha Releases](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/wiki/Alpha-Version) 43 | 44 | github-releases-to-discord: 45 | name: Discord Webhooks 46 | needs: [build_and_test,publish] 47 | if: ${{ github.repository == 'homebridge-plugins/homebridge-camera-ffmpeg' }} 48 | uses: homebridge/.github/.github/workflows/discord-webhooks.yml@latest 49 | with: 50 | title: "Homebridge Camera FFmpeg Alpha Release" 51 | description: | 52 | Version `v${{ needs.publish.outputs.NPM_VERSION }}` 53 | url: "https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v${{ needs.publish.outputs.NPM_VERSION }}" 54 | secrets: 55 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL_BETA || secrets.DISCORD_WEBHOOK_URL_LATEST }} -------------------------------------------------------------------------------- /.github/workflows/beta-release.yml: -------------------------------------------------------------------------------- 1 | name: Beta Release 2 | 3 | on: 4 | push: 5 | branches: [beta-*.*.*, beta] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build_and_test: 10 | uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest 11 | with: 12 | enable_coverage: false 13 | secrets: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | lint: 16 | needs: build_and_test 17 | uses: homebridge/.github/.github/workflows/eslint.yml@latest 18 | 19 | publish: 20 | needs: lint 21 | if: ${{ github.repository == 'homebridge-plugins/homebridge-camera-ffmpeg' }} 22 | permissions: 23 | id-token: write 24 | uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest 25 | with: 26 | tag: 'beta' 27 | dynamically_adjust_version: true 28 | npm_version_command: 'pre' 29 | pre_id: 'beta' 30 | secrets: 31 | npm_auth_token: ${{ secrets.npm_token }} 32 | 33 | pre-release: 34 | needs: publish 35 | if: ${{ github.repository == 'homebridge-plugins/homebridge-camera-ffmpeg' }} 36 | uses: homebridge/.github/.github/workflows/pre-release.yml@latest 37 | with: 38 | npm_version: ${{ needs.publish.outputs.NPM_VERSION }} 39 | body: | 40 | **Beta Release** 41 | **Version**: v${{ needs.publish.outputs.NPM_VERSION }} 42 | [How To Test Beta Releases](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/wiki/Beta-Version) 43 | 44 | github-releases-to-discord: 45 | name: Discord Webhooks 46 | needs: [build_and_test,publish] 47 | if: ${{ github.repository == 'homebridge-plugins/homebridge-camera-ffmpeg' }} 48 | uses: homebridge/.github/.github/workflows/discord-webhooks.yml@latest 49 | with: 50 | title: "Homebridge Camera FFmpeg Beta Release" 51 | description: | 52 | Version `v${{ needs.publish.outputs.NPM_VERSION }}` 53 | url: "https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v${{ needs.publish.outputs.NPM_VERSION }}" 54 | secrets: 55 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL_BETA || secrets.DISCORD_WEBHOOK_URL_LATEST }} -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Node Build 2 | 3 | on: 4 | push: 5 | branches: [latest] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build_and_test: 11 | uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest 12 | with: 13 | enable_coverage: false 14 | secrets: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | lint: 17 | needs: build_and_test 18 | uses: homebridge/.github/.github/workflows/eslint.yml@latest 19 | -------------------------------------------------------------------------------- /.github/workflows/changerelease.yml: -------------------------------------------------------------------------------- 1 | name: Changelog to Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | changerelease: 9 | uses: homebridge/.github/.github/workflows/change-release.yml@latest 10 | secrets: 11 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | labeler: 7 | uses: homebridge/.github/.github/workflows/labeler.yml@latest 8 | secrets: 9 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: [latest] 6 | pull_request: # required for autolabeler 7 | branches: [latest] 8 | types: [opened, reopened, synchronize, ready_for_review, review_requested] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | release-drafter: 13 | uses: homebridge/.github/.github/workflows/release-drafter.yml@latest 14 | secrets: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build_and_test: 9 | uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest 10 | with: 11 | enable_coverage: false 12 | secrets: 13 | token: ${{ secrets.GITHUB_TOKEN }} 14 | 15 | publish: 16 | needs: build_and_test 17 | if: ${{ github.repository == 'homebridge-plugins/homebridge-camera-ffmpeg' }} 18 | permissions: 19 | id-token: write 20 | uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest 21 | secrets: 22 | npm_auth_token: ${{ secrets.npm_token }} 23 | 24 | github-releases-to-discord: 25 | name: Discord Webhooks 26 | needs: [build_and_test,publish] 27 | if: ${{ github.repository == 'homebridge-plugins/homebridge-camera-ffmpeg' }} 28 | uses: homebridge/.github/.github/workflows/discord-webhooks.yml@latest 29 | with: 30 | title: "Homebridge Camera FFmpeg Release" 31 | description: | 32 | Version `v${{ needs.publish.outputs.NPM_VERSION }}` 33 | url: "https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v${{ needs.publish.outputs.NPM_VERSION }}" 34 | secrets: 35 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL_LATEST }} 36 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale workflow 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '45 11 * * *' 7 | 8 | jobs: 9 | stale: 10 | uses: homebridge/.github/.github/workflows/stale.yml@latest 11 | secrets: 12 | token: ${{ secrets.GITHUB_TOKEN }} 13 | with: 14 | exempt-issue-labels: 'tested config' 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # directory 40 | dist 41 | 42 | # 43 | *.DS_Store 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. This project uses [Semantic Versioning](https://semver.org/) 4 | 5 | ## [4.0.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v4.0.1) (2025-03-04) 6 | 7 | ### What's Changes 8 | - Housekeeping and updated dependencies. 9 | 10 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v4.0.0...v4.0.1 11 | 12 | ## [4.0.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v4.0.0) (2025-01-26) 13 | 14 | ### What's Changes 15 | #### Breaking Changes 16 | - *Alpha*: Added support for HKSV 17 | - Now Supporting Node v20 ot v22 18 | - In this version we force all cameras to be `unbridged` 19 | - If you do not unbridge your cameras before upgrading your cameras, you will loose functionality. 20 | - To unbridge in previous version go into the camera config and check the ubridged checkbox. 21 | - the unbridge config has been removed in this version since all cameras are unbridged. 22 | 23 | #### Other Changes 24 | - Move plugin over to scoped plugin 25 | 26 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.1.4...v4.0.0 27 | 28 | ## [3.1.4](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.1.4) (2021-12-28) 29 | 30 | ### What's Changes 31 | - *Fix*: Pinned mqtt to 2.3.8 to avoid "Maximum call stack size exceeded" error. 32 | 33 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.1.3...v3.1.4 34 | 35 | ## [3.1.3](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.1.3) (2021-08-24) 36 | 37 | ### What's Changes 38 | - Errors from FFmpeg are once again hidden when not in debug mode. This will be tweaked in the future. 39 | - An attempt will now be made to gracefully shut down FFmpeg before force killing it. 40 | - *Fix*: Port selection should now correctly grab an open UDP port. 41 | - *Fix*: When using motionDoorbell, the doorbell is now only rung when the motion cooldown has run. 42 | 43 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.1.2...v3.1.3 44 | 45 | ## [3.1.2](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.1.2) (2021-03-05) 46 | 47 | ### What's Changes 48 | - Errors from FFmpeg are now always logged. 49 | - Improvements to snapshot caching. 50 | - *Fix*: Streams should no longer end after roughly 3 minutes. 51 | 52 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.1.1...v3.1.2 53 | 54 | ## [3.1.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.1.1) (2021-03-01) 55 | 56 | ### What's Changes 57 | - Snapshots are now briefly cached. This will prevent bombarding the camera with requests for new snapshots when motion alerts are triggered. 58 | - Improved messaging when cameras respond slowly. 59 | - Minor tweaks. 60 | - Updated dependencies. 61 | 62 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.1.0...v3.1.1 63 | 64 | ## [3.1.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.1.0) (2021-02-20) 65 | 66 | ### What's Changes 67 | #### Breaking Changes 68 | - Complete rework of MQTT support. Now topics and messages are configurable per camera, which should allow any camera with MQTT support to work directly with this plugin. If you need compatibility with the way prior versions worked, you can follow [this config example](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/mqtt.html#legacy-compatibility). 69 | - Dropped support for older versions of Homebridge, now requires version 1.1.3 or newer. 70 | 71 | #### Other Changes 72 | - *Fix*: Fixed warnings under Homebridge 1.3 when using switches. 73 | 74 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.0.6...v3.1.0 75 | 76 | ## [3.0.6](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.0.6) (2021-02-17) 77 | 78 | ### What's Changes 79 | - Added `motionDoorbell` to ring the doorbell when motion is activated in order to allow motion alerts to be displayed on Apple TVs. 80 | - HTTP server now returns JSON to provide additional information to helper plugins. 81 | 82 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.0.5...v3.0.6 83 | 84 | ## [3.0.5](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.0.5) (2021-02-15) 85 | 86 | ### What's Changes 87 | - Code cleanup and general housekeeping. 88 | 89 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.0.4...v3.0.5 90 | 91 | ## [3.0.4](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.0.4) (2021-02-15) 92 | 93 | ### What's Changes 94 | - Added warning when attempting to use videoFilter with the copy vcodec. 95 | - Added support for connecting to an MQTT broker with TLS. 96 | - Updated dependencies. 97 | 98 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.0.3...v3.0.4 99 | 100 | ## [3.0.3](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.0.3) (2020-09-02) 101 | 102 | ### What's Changes 103 | - Updated dependencies. 104 | 105 | ### Note 106 | - Homebridge 1.1.3 is now out. It is highly recommended to upgrade as it should completely resolve the issue that caused live video not to work while snapshots continued to update. Once you upgrade, `interfaceName` will no longer have any impact. At some point in the future this plugin will drop support for Homebridge 1.1.2 and lower and also remove the `interfaceName` option. 107 | 108 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.0.2...v3.0.3 109 | 110 | ## [3.0.2](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.0.2) (2020-08-30) 111 | 112 | ### What's Changes 113 | - Allow `=` in the URL for HTTP automation for systems that require it. Everything after the `=` will be ignored. 114 | 115 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.0.1...v3.0.2 116 | 117 | ## [3.0.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.0.1) (2020-08-25) 118 | 119 | ### What's Changes 120 | - *Fix*: Fixed an issue with inactive camera timeouts that could cause zombie FFmpeg processes. 121 | 122 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v3.0.0...v3.0.1 123 | 124 | ## [3.0.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v3.0.0) (2020-08-24) 125 | 126 | ### What's Changes 127 | ### Breaking Changes 128 | - `additionalCommandline` has been replaced by `encoderOptions` to better reflect it's intended use. 129 | - `preserveRatio` has been removed and is now active as long as the default `videoFilter` list is active. 130 | 131 | #### Other Changes 132 | - This plugin now includes __experimental__ two-way audio support. Be aware that this feature is likely to be tweaked in the future, and a configuration that works now may need to be altered in the future. 133 | - Better detection of audio and video streams. There should be very few scenarios where `mapvideo` or `mapaudio` are needed anymore, as FFmpeg's stream auto-selection is now set up. 134 | - Default `videoFilter` can be disabled by including `none` in your comma-delimited list of filters. 135 | - Further reorganization of the config UI. 136 | - Fix: Corrected handling of inactive camera timeouts. You should no longer see timeout messages after cleanly closing a camera stream. 137 | - *Fix*: Fixed `forceMax` not applying to resolution in some scenarios. 138 | 139 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.5.0...v3.0.0 140 | 141 | ## [2.5.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.5.0) (2020-08-23) 142 | 143 | ### What's Changes 144 | ### Breaking Changes 145 | - Horizontal and vertical flip have been removed. If you need these options, pass `hflip` and/or `vflip` in `videoFilter`. 146 | - `forceMax` has resulted in the removal of `minBitrate`, as it is now redundant. To replicate the old behavior, set `maxBitrate` to the bitrate you want to use and set `forceMax` to true. 147 | - `preserveRatio` is now a boolean to reduce confusion and support the better handling of that option. 148 | 149 | - ### Other Changes 150 | - `forceMax` has been added. This will force the use of `maxWidth`, `maxHeight`, `maxFPS`, and `maxBitrate` when set. 151 | - If `maxWidth`, `maxHeight`, or `maxFPS` are set to `0`, the width, height, or framerate of the source will now be used for the output. 152 | - If `maxBitrate` is set to `0`, the bitrate of the encoder will not be limited. I strongly recommend against this, but it is a better option than setting it to `999999` or similar values, as I've seen in some configs. 153 | - Reorganized config UI options. 154 | - *Fix:* Fix handling of IPv6 connections. 155 | 156 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.4.7...v2.5.0 157 | 158 | ## [2.4.7](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.4.7) (2020-08-17) 159 | 160 | ### What's Changes 161 | - Changed the way external IP address is determined. This should result in video streams working by default in more setups. 162 | 163 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.6.0...v2.4.7 164 | 165 | ## [2.4.6](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.4.6) (2020-08-16) 166 | 167 | ### What's Changes 168 | - *Fix:* Fix MQTT/HTTP automation when unbridge is used. 169 | 170 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.4.5...v2.4.6 171 | 172 | ## [2.4.5](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.4.5) (2020-08-15) 173 | 174 | ### What's Changes 175 | - Return messages and error codes when using HTTP automation. 176 | - *Fix:* Fixed bug preventing MQTT/HTTP automation from working. 177 | 178 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.4.4...v2.4.5 179 | 180 | ## [2.4.4](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.4.4) (2020-08-07) 181 | 182 | ### What's Changes 183 | - Added support for unbridging specific cameras. This can aid with performance of the camera and Homebridge as a whole, but requires manually adding any unbridged cameras to HomeKit. 184 | 185 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.4.3...v2.4.4 186 | 187 | ## [2.4.3](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.4.3) (2020-07-29) 188 | 189 | ### What's Changes 190 | - Trigger switches are now turned on and off with HTTP or MQTT messages as well. 191 | - Removed doorbell stateless switch because it had no functionality. 192 | 193 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.4.2...v2.4.3 194 | 195 | ## [2.4.2](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.4.2) (2020-07-27) 196 | 197 | ### What's Changes 198 | - Properly shut down sessions when devices go inactive. 199 | - *Fix:* Fixed some debug messages. 200 | 201 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.4.1...v2.4.2 202 | 203 | ## [2.4.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.4.1) (2020-07-24) 204 | 205 | ### What's Changes 206 | - Added warning when multiple NICs detected. 207 | - *Fix:* Fix error using copy vcodec. 208 | 209 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.4.0...v2.4.1 210 | 211 | ## [2.4.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.4.0) (2020-07-24) 212 | 213 | ### What's Changes 214 | - Major rework of code to make future maintenance easier. 215 | - Added setting to limit HTTP server to listening on localhost only. 216 | 217 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.3.2...v2.4.0 218 | 219 | ## [2.3.2](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.3.2) (2020-07-19) 220 | 221 | ### What's Changes 222 | - FFmpeg processes are now killed when the iOS device goes inactive and when stopping Homebridge. 223 | 224 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.3.1...v2.3.2 225 | 226 | ## [2.3.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.3.1) (2020-07-16) 227 | 228 | ### What's Changes 229 | - Tweaks to logging to reduce confusion and provide more information. 230 | - Added authentication support to MQTT. 231 | - Reduced the FFmpeg log level in debug mode. 232 | - *Fix:* The minimum bitrate option is now working again. 233 | - *Fix:* Maximum bitrate and frame rate are no longer capped below what devices request when not set in the config. 234 | 235 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.3.0...v2.3.1 236 | 237 | ## [2.3.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.3.0) (2020-07-14) 238 | 239 | ### What's Changes 240 | - Added HTTP support for motion detection and doorbells. 241 | - Separated MQTT doorbell and motion messages. 242 | 243 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.8.0...v2.8.1 244 | 245 | ## [2.2.2](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.2.2) (2020-07-13) 246 | 247 | ### What's Changes 248 | - Restored ability to specify which network interface to use. 249 | - *Fix:* Fixed handling of non-printing characters in config. 250 | 251 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.2.1...v2.2.2 252 | 253 | ## [2.2.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.2.1) (2020-07-11) 254 | 255 | ### What's Changes 256 | - *Fix:* Fixed bug preventing Homebridge from starting. 257 | 258 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.2.0...v2.2.1 259 | 260 | ## [2.2.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.2.0) (2020-07-11) 261 | 262 | ### What's Changes 263 | - Now properly allows for changing camera manufacturer, model, etc. 264 | - Minor tweaks to configuration UI screen. 265 | - Update dependencies. 266 | - *Fix:* Fixed a bug when the doorbellSwitch config option was enabled. 267 | 268 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.1.1...v2.2.0 269 | 270 | ## [2.1.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.1.1) (2020-07-08) 271 | 272 | ### What's Changes 273 | - Update Dependencies. 274 | 275 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.1.0...v2.1.1 276 | 277 | ## [2.1.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.1.0) (2020-07-06) 278 | 279 | ### What's Changes 280 | - Add MQTT support for Motion Detect (#572), thanks to [fennec622](https://github.com/fennec622). 281 | - See [MQTT Motion Wiki](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/wiki/MQTT-Motion) for more details. 282 | - Add stateless button for doorbell cameras. 283 | - Add option to disable manual automation switches. 284 | - Re-Added videoFilter. 285 | - *Fix:* Fixed most FFmpeg issues where users were receiving issues with ffmpeg exit 1 error. 286 | - *Fix:* Fixed Logging. 287 | - *Fix:* Fixed most videoFilter configs not working. 288 | 289 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.0.1...v2.1.0 290 | 291 | ## [2.0.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.0.1) (2020-06-28) 292 | 293 | ### What's Changes 294 | - Update Dependencies. 295 | 296 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v2.0.0...v2.0.1 297 | 298 | ## [2.0.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v2.0.0) (2020-06-19) 299 | 300 | ### What's Changes 301 | ### Breaking Changes 302 | - Code has been refactored to typescript, thanks to [Brandawg93](https://github.com/Brandawg93). 303 | - Plugin requires homebridge >= 1.0.0. 304 | - Cameras no longer need to be manually added to homebridge 305 | - Cameras are now bridged instead of being created as external accessories in homebridge. 306 | - Once you update, you will see two copies of each of your cameras. 307 | - You will need to manually remove the old cameras from HomeKit by going into the cameras' settings and choosing "Remove Camera from Home". 308 | - The new bridged cameras will not have this option, and will instead have a "Bridge" button. 309 | - You will also need to copy over any automations that you had tied to your cameras, such as motion detection. 310 | 311 | ### Other Changes 312 | - Google Drive Upload has been removed in this update. PRs are welcome for other Video Cloud Options. 313 | 314 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v1.3.0...v2.0.0 315 | 316 | ## [1.3.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v1.3.0) (2020-06-18) 317 | 318 | ### What's Changes 319 | - Update ffmpeg-for-homebridge to 0.0.6. 320 | 321 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v1.2.2...v1.3.0 322 | 323 | ## [1.2.2](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v1.2.2) (2020-05-28) 324 | 325 | ### What's Changes 326 | - *Fix:* Fix for Fake Motion Sensor, it was not reseting after Motion Events. 327 | 328 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v1.2.1...v1.2.2 329 | 330 | ## [1.2.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v1.2.1) (2020-05-28) 331 | 332 | ### What's Changes 333 | - *Fix:* Fixes [#522](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/issues/522), Cleans Up and Condenses the code around the motion switch. 334 | 335 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v1.2.0...v1.2.1 336 | 337 | ## [1.2.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v1.2.0) (2020-05-27) 338 | 339 | ### What's Changes 340 | - Update ffmpeg-for-homebridge to 0.0.5 341 | 342 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v1.1.1...v1.2.0 343 | 344 | ## [1.1.1](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v1.1.1) (2020-05-14) 345 | 346 | ### What's Changes 347 | - Adds debug log for `videoProcessor`. 348 | 349 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v1.1.0...v1.1.1 350 | 351 | ## [1.1.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v1.1.0) (2020-05-13) 352 | 353 | ### What's Changes 354 | - Adds an option to have a camera behave like a video doorbell, including a switch to trigger doorbell events (automate the switch to get notifications) 355 | - Add Manufacturer, Model, Serial, and Firmware Revision into config.schema.json. 356 | 357 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/v1.0.0...v1.1.0 358 | 359 | ## [1.0.0](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/releases/tag/v1.0.0) (2020-05-11) 360 | 361 | ### What's Changes 362 | ### Breaking Changes 363 | - homebridge-camera-ffmpeg now comes bundled with it's own pre-built static ffmpeg binaries that are compiled with support for audio (libfdk-aac) and hardware decoding (h264_omx). The following platforms are supported: 364 | - Raspbian Linux - armv6l (armv7l) 365 | - Debian/Ubuntu Linux - x86_64, armv7l, aarch64 366 | - Alpine Linux - x86_64, armv6l, aarch64 367 | - macOS (10.14+) - x86_64 368 | - Windows 10 - x86_64 369 | - If your platform is not supported the plugin will fallback to using your global install of `ffmpeg` automatically. 370 | - Should you wish to force the plugin to use the global install of `ffmpeg` instead of the provided copy, you can simply set `videoProcessor` option to `ffmpeg`. Example: 371 | ```json 372 | { 373 | "platform": "Camera-ffmpeg", 374 | "videoProcessor": "ffmpeg", 375 | "cameras": [ 376 | ... 377 | ] 378 | } 379 | ``` 380 | 381 | ### Other Changes 382 | - Initial release. 383 | 384 | **Full Changelog**: https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/compare/initial-commit...v1.0.0 385 | -------------------------------------------------------------------------------- /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 | # Homebridge Camera FFmpeg 2 | 3 | [![npm](https://badgen.net/npm/v/homebridge-camera-ffmpeg) ![npm](https://badgen.net/npm/dt/homebridge-camera-ffmpeg)](https://www.npmjs.com/package/homebridge-camera-ffmpeg) [![verified-by-homebridge](https://badgen.net/badge/homebridge/verified/purple)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) [![certified-hoobs-plugin](https://badgen.net/badge/HOOBS/certified/yellow)](https://plugins.hoobs.org/plugin/homebridge-camera-ffmpeg) 4 | 5 | [Homebridge](https://homebridge.io) Plugin Providing [FFmpeg](https://www.ffmpeg.org)-based Camera Support 6 | 7 | ## Installation 8 | 9 | This plugin is supported under both [Homebridge](https://homebridge.io) and [HOOBS](https://hoobs.org/). It is highly recommended that you use either [Homebridge Config UI X](https://www.npmjs.com/package/homebridge-config-ui-x) or the HOOBS UI to install and configure this plugin. 10 | 11 | ### Manual Installation 12 | 13 | 1. Install this plugin using: `sudo npm install -g homebridge-camera-ffmpeg --unsafe-perm`. 14 | 2. Edit `config.json` manually to add your cameras. See below for instructions on that. 15 | 16 | ## Tested configurations 17 | 18 | Other users have been sharing configurations that work for them on our GitHub site. You may want to [check that](https://sunoo.github.io/homebridge-camera-ffmpeg/configs/) to see if anyone else has gotten your model of camera working already, or [share](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg/issues/new?assignees=&labels=tested+config&template=tested_config.md) a configuration setup that works for you. 19 | 20 | ## Manual Configuration 21 | 22 | ### Most Important Parameters 23 | 24 | - `platform`: _(Required)_ Must always be set to `Camera-ffmpeg`. 25 | - `name`: _(Required)_ Set the camera name for display in the Home app. 26 | - `source`: _(Required)_ FFmpeg options on where to find and how to decode your camera's video stream. The most basic form is `-i` followed by your camera's URL. 27 | - `stillImageSource`: If your camera also provides a URL for a still image, that can be defined here with the same syntax as `source`. If not set, the plugin will grab one frame from `source`. 28 | 29 | #### Config Example 30 | 31 | ```json 32 | { 33 | "platform": "Camera-ffmpeg", 34 | "cameras": [ 35 | { 36 | "name": "Camera Name", 37 | "videoConfig": { 38 | "source": "-i rtsp://username:password@example.com:554", 39 | "stillImageSource": "-i http://example.com/still_image.jpg", 40 | "maxStreams": 2, 41 | "maxWidth": 1280, 42 | "maxHeight": 720, 43 | "maxFPS": 30 44 | } 45 | } 46 | ] 47 | } 48 | ``` 49 | 50 | ### Optional Parameters 51 | 52 | - `motion`: Exposes the motion sensor for this camera. This can be triggered with [dummy switches](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/switch.html), [MQTT messages](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/mqtt.html), or [via HTTP](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/http.html), depending on what features are enabled in the config. (Default: `false`) 53 | - `doorbell`: Exposes the doorbell device for this camera. This can be triggered with [dummy switches](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/switch.html), [MQTT messages](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/mqtt.html), or [via HTTP](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/http.html), depending on what features are enabled in the config. (Default: `false`) 54 | - `switches`: Enables dummy switches to trigger motion and/or doorbell, if either of those are enabled. When enabled there will be an additional switch that triggers the motion or doorbell event. See the project site for [more detailed instructions](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/switch.html). (Default: `false`) 55 | - `motionTimeout`: The number of seconds after triggering to reset the motion sensor. Set to 0 to disable resetting of motion trigger for MQTT or HTTP. (Default: `1`) 56 | - `motionDoorbell`: Rings the doorbell when motion is activated. This allows for motion alerts to appear on Apple TVs. (Default: `false`) 57 | - `manufacturer`: Set the manufacturer name for display in the Home app. (Default: `Homebridge`) 58 | - `model`: Set the model for display in the Home app. (Default: `Camera FFmpeg`) 59 | - `serialNumber`: Set the serial number for display in the Home app. (Default: `SerialNumber`) 60 | - `firmwareRevision`: Set the firmware revision for display in the Home app. (Default: current plugin version) 61 | - `unbridge`: Bridged cameras can cause slowdowns of the entire Homebridge instance. If unbridged, the camera will need to be added to HomeKit manually. (Default: `true`) 62 | 63 | #### Config Example with Manufacturer and Model Set 64 | 65 | ```json 66 | { 67 | "platform": "Camera-ffmpeg", 68 | "cameras": [ 69 | { 70 | "name": "Camera Name", 71 | "manufacturer": "ACME, Inc.", 72 | "model": "ABC-123", 73 | "serialNumber": "1234567890", 74 | "firmwareRevision": "1.0", 75 | "videoConfig": { 76 | "source": "-i rtsp://username:password@example.com:554", 77 | "stillImageSource": "-i http://example.com/still_image.jpg", 78 | "maxStreams": 2, 79 | "maxWidth": 1280, 80 | "maxHeight": 720, 81 | "maxFPS": 30 82 | } 83 | } 84 | ] 85 | } 86 | ``` 87 | 88 | ### Optional videoConfig Parameters 89 | 90 | - `returnAudioTarget`: _(EXPERIMENTAL - WIP)_ The FFmpeg output command for directing audio back to a two-way capable camera. This feature is still in development and a configuration that works today may not work in the future. 91 | - `maxStreams`: The maximum number of streams that will be allowed at once to this camera. (Default: `2`) 92 | - `maxWidth`: The maximum width used for video streamed to HomeKit. If set to 0, the resolution of the source is used. If not set, will use any size HomeKit requests. 93 | - `maxHeight`: The maximum height used for video streamed to HomeKit. If set to 0, the resolution of the source is used. If not set, will use any size HomeKit requests. 94 | - `maxFPS`: The maximum frame rate used for video streamed to HomeKit. If set to 0, the framerate of the source is used. If not set, will use any frame rate HomeKit requests. 95 | - `maxBitrate`: The maximum bitrate used for video streamed to HomeKit, in kbit/s. If not set, will use any bitrate HomeKit requests. 96 | - `forceMax`: If set, the settings requested by HomeKit will be overridden with any 'maximum' values defined in this config. (Default: `false`) 97 | - `vcodec`: Set the codec used for encoding video sent to HomeKit, must be H.264-based. You can change to a hardware accelerated video codec with this option, if one is available. (Default: `libx264`) 98 | - `audio`: Enables audio streaming from camera. (Default: `false`) 99 | - `packetSize`: If audio or video is choppy try a smaller value, should be set to a multiple of 188. (Default: `1316`) 100 | - `mapvideo`: Selects the stream used for video. (Default: FFmpeg [automatically selects](https://ffmpeg.org/ffmpeg.html#Automatic-stream-selection) a video stream) 101 | - `mapaudio`: Selects the stream used for audio. (Default: FFmpeg [automatically selects](https://ffmpeg.org/ffmpeg.html#Automatic-stream-selection) an audio stream) 102 | - `videoFilter`: Comma-delimited list of additional video filters for FFmpeg to run on the video. If 'none' is included, the default video filters are disabled. 103 | - `encoderOptions`: Options to be passed to the video encoder. (Default: `-preset ultrafast -tune zerolatency` if using libx264) 104 | - `debug`: Includes debugging output from the main FFmpeg process in the Homebridge log. (Default: `false`) 105 | - `debugReturn`: Includes debugging output from the FFmpeg used for return audio in the Homebridge log. (Default: `false`) 106 | 107 | #### More Complicated Example 108 | 109 | ```json 110 | { 111 | "platform": "Camera-ffmpeg", 112 | "cameras": [ 113 | { 114 | "name": "Camera Name", 115 | "videoConfig": { 116 | "source": "-i rtsp://myfancy_rtsp_stream", 117 | "stillImageSource": "-i http://faster_still_image_grab_url/this_is_optional.jpg", 118 | "maxStreams": 2, 119 | "maxWidth": 1280, 120 | "maxHeight": 720, 121 | "maxFPS": 30, 122 | "maxBitrate": 200, 123 | "vcodec": "h264_omx", 124 | "audio": false, 125 | "packetSize": 188, 126 | "hflip": true, 127 | "additionalCommandline": "-x264-params intra-refresh=1:bframes=0", 128 | "debug": true 129 | } 130 | } 131 | ] 132 | } 133 | ``` 134 | 135 | ### Camera MQTT Parameters 136 | 137 | - `motionTopic`: The MQTT topic to watch for motion alerts. 138 | - `motionMessage`: The message to watch for to trigger motion alerts. Will use the name of the camera if blank. 139 | - `motionResetTopic`: The MQTT topic to watch for motion resets. 140 | - `motionResetMessage`: The message to watch for to trigger motion resets. Will use the name of the camera if blank. 141 | - `doorbellTopic`: The MQTT topic to watch for doorbell alerts. 142 | - `doorbellMessage`: The message to watch for to trigger doorbell alerts. Will use the name of the camera if blank. 143 | 144 | #### Camera MQTT Example 145 | 146 | ```json 147 | { 148 | "platform": "Camera-ffmpeg", 149 | "cameras": [ 150 | { 151 | "name": "Camera Name", 152 | "videoConfig": { 153 | "source": "-i rtsp://myfancy_rtsp_stream" 154 | }, 155 | "mqtt": { 156 | "motionTopic": "home/camera", 157 | "motionMessage": "ON", 158 | "motionResetTopic": "home/camera", 159 | "motionResetMessage": "OFF", 160 | "doorbellTopic": "home/doobell", 161 | "doorbellMessage": "ON" 162 | } 163 | } 164 | ] 165 | } 166 | ``` 167 | 168 | ### Automation Parameters 169 | 170 | - `mqtt`: Defines the hostname or IP of the MQTT broker to connect to for MQTT-based automation. If not set, MQTT support is not started. See the project site for [more information on using MQTT](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/mqtt.html). 171 | - `portmqtt`: The port of the MQTT broker. (Default: `1883`) 172 | - `tlsmqtt`: Use TLS to connect to the MQTT broker. (Default: `false`) 173 | - `usermqtt`: The username used to connect to your MQTT broker. If not set, no authentication is used. 174 | - `passmqtt`: The password used to connect to your MQTT broker. If not set, no authentication is used. 175 | - `porthttp`: The port to listen on for HTTP-based automation. If not set, HTTP support is not started. See the project site for [more information on using HTTP](https://sunoo.github.io/homebridge-camera-ffmpeg/automation/http.html). 176 | - `localhttp`: Only allow HTTP calls from localhost. Useful if using helper plugins that translate to HTTP. (Default: `false`) 177 | 178 | #### Automation Example 179 | 180 | ```json 181 | { 182 | "platform": "Camera-ffmpeg", 183 | "mqtt": "127.0.0.1", 184 | "porthttp": "8080", 185 | "cameras": [] 186 | } 187 | ``` 188 | 189 | ### Rarely Needed Parameters 190 | 191 | - `videoProcessor`: Defines which video processor is used to decode and encode videos, must take the same parameters as FFmpeg. Common uses would be `avconv` or the path to a custom-compiled version of FFmpeg. If not set, will use the included version of FFmpeg, or the version of FFmpeg installed on the system if no included version is available. 192 | 193 | #### Rare Option Example 194 | 195 | ```json 196 | { 197 | "platform": "Camera-ffmpeg", 198 | "videoProcessor": "/usr/bin/ffmpeg", 199 | "cameras": [] 200 | } 201 | ``` 202 | 203 | ## Credit 204 | 205 | Homebridge Camera FFmpeg is based on code originally written by [Khaos Tian](https://twitter.com/khaost). 206 | -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "Camera-ffmpeg", 3 | "pluginType": "platform", 4 | "singular": true, 5 | "customUi": true, 6 | "customUiPath": "./dist/homebridge-ui", 7 | "headerDisplay": "Homebridge Plugin Providing FFmpeg-based Camera Support.", 8 | "footerDisplay": "You can see configurations that have been shared by other users on [the project site](https://sunoo.github.io/homebridge-camera-ffmpeg/configs/).", 9 | "schema": { 10 | "name": { 11 | "title": "Name", 12 | "type": "string", 13 | "placeholder": "Camera Name", 14 | "default": "Camera FFmpeg" 15 | }, 16 | "videoProcessor": { 17 | "title": "Video Processor", 18 | "type": "string", 19 | "placeholder": "ffmpeg", 20 | "description": "Defines which video processor is used to decode and encode videos, must take the same parameters as FFmpeg. Common uses would be 'avconv' or the path to a custom-compiled version of FFmpeg. If not set, will use the included version of FFmpeg, or the version of FFmpeg installed on the system if no included version is available." 21 | }, 22 | "mqtt": { 23 | "title": "MQTT Server", 24 | "type": "string", 25 | "placeholder": "127.0.0.1", 26 | "description": "Defines the hostname or IP of the MQTT broker to connect to for MQTT-based automation. If not set, MQTT support is not started." 27 | }, 28 | "portmqtt": { 29 | "title": "MQTT Port", 30 | "type": "integer", 31 | "placeholder": 1883, 32 | "description": "The port of the MQTT broker." 33 | }, 34 | "tlsmqtt": { 35 | "title": "MQTT TLS", 36 | "type": "boolean", 37 | "description": "Use TLS to connect to the MQTT broker." 38 | }, 39 | "usermqtt": { 40 | "title": "MQTT Username", 41 | "type": "string", 42 | "description": "The username used to connect to your MQTT broker. If not set, no authentication is used." 43 | }, 44 | "passmqtt": { 45 | "title": "MQTT Password", 46 | "type": "string", 47 | "description": "The password used to connect to your MQTT broker. If not set, no authentication is used." 48 | }, 49 | "porthttp": { 50 | "title": "HTTP Port", 51 | "type": "integer", 52 | "placeholder": 8080, 53 | "description": "The port to listen on for HTTP-based automation. If not set, HTTP support is not started." 54 | }, 55 | "localhttp": { 56 | "title": "HTTP Localhost Only", 57 | "type": "boolean", 58 | "description": "Only allow HTTP calls from localhost. Useful if using helper plugins that translate to HTTP." 59 | }, 60 | "cameras": { 61 | "type": "array", 62 | "items": { 63 | "title": "Cameras", 64 | "type": "object", 65 | "properties": { 66 | "name": { 67 | "title": "Name", 68 | "type": "string", 69 | "required": true, 70 | "description": "Set the camera name for display in the Home app." 71 | }, 72 | "manufacturer": { 73 | "name": "Manufacturer", 74 | "type": "string", 75 | "placeholder": "Homebridge", 76 | "description": "Set the manufacturer name for display in the Home app." 77 | }, 78 | "model": { 79 | "name": "Model", 80 | "type": "string", 81 | "placeholder": "Camera FFmpeg", 82 | "description": "Set the model for display in the Home app." 83 | }, 84 | "serialNumber": { 85 | "name": "Serial Number", 86 | "type": "string", 87 | "placeholder": "SerialNumber", 88 | "description": "Set the serial number for display in the Home app." 89 | }, 90 | "firmwareRevision": { 91 | "name": "Firmware Revision", 92 | "type": "string", 93 | "description": "Set the firmware revision for display in the Home app." 94 | }, 95 | "motion": { 96 | "title": "Enable Motion Sensor", 97 | "type": "boolean", 98 | "description": "Exposes the motion sensor for this camera. This can be triggered with the dummy switches, MQTT messages, or via HTTP, depending on what features are enabled in the config." 99 | }, 100 | "doorbell": { 101 | "title": "Enable Doorbell", 102 | "type": "boolean", 103 | "description": "Exposes the doorbell device for this camera. This can be triggered with the dummy switches, MQTT messages, or via HTTP, depending on what features are enabled in the config." 104 | }, 105 | "switches": { 106 | "title": "Enable Dummy Switches", 107 | "type": "boolean", 108 | "description": "Enables dummy switches to trigger motion and/or doorbell, if either of those are enabled. When enabled there will be an additional switch that triggers the motion or doorbell event." 109 | }, 110 | "motionTimeout": { 111 | "title": "Automatic Motion Reset (Seconds)", 112 | "type": "integer", 113 | "placeholder": 1, 114 | "minimum": 0, 115 | "description": "The number of seconds after triggering to reset the motion sensor. Set to 0 to disable resetting of motion trigger for MQTT or HTTP." 116 | }, 117 | "motionDoorbell": { 118 | "title": "Trigger Doorbell with Motion", 119 | "type": "boolean", 120 | "description": "Rings the doorbell when motion is activated. This allows for motion alerts to appear on Apple TVs." 121 | }, 122 | "videoConfig": { 123 | "title": "Video Configuration", 124 | "type": "object", 125 | "properties": { 126 | "source": { 127 | "title": "Video Source", 128 | "type": "string", 129 | "placeholder": "-i rtsp://username:password@example.com:554", 130 | "required": true, 131 | "description": "FFmpeg options on where to find and how to decode your camera's video stream. The most basic form is '-i' followed by your camera's URL." 132 | }, 133 | "stillImageSource": { 134 | "title": "Still Image Source", 135 | "type": "string", 136 | "description": "If your camera also provides a URL for a still image, that can be defined here with the same syntax as 'source'. If not set, the plugin will grab one frame from 'source'." 137 | }, 138 | "returnAudioTarget": { 139 | "title": "Two-way Audio Target (EXPERIMENTAL - WIP)", 140 | "type": "string", 141 | "description": "The FFmpeg output command for directing audio back to a two-way capable camera. This feature is still in development and a configuration that works today may not work in the future." 142 | }, 143 | "maxStreams": { 144 | "title": "Maximum Concurrent Streams", 145 | "type": "integer", 146 | "placeholder": 2, 147 | "minimum": 1, 148 | "description": "The maximum number of streams that will be allowed at once to this camera." 149 | }, 150 | "maxWidth": { 151 | "title": "Maximum Width", 152 | "type": "integer", 153 | "placeholder": 1280, 154 | "multipleOf": 2, 155 | "minimum": 0, 156 | "description": "The maximum width used for video streamed to HomeKit. If set to 0, the resolution of the source is used. If not set, will use any size HomeKit requests." 157 | }, 158 | "maxHeight": { 159 | "title": "Maximum Height", 160 | "type": "integer", 161 | "placeholder": 720, 162 | "multipleOf": 2, 163 | "minimum": 0, 164 | "description": "The maximum height used for video streamed to HomeKit. If set to 0, the resolution of the source is used. If not set, will use any size HomeKit requests." 165 | }, 166 | "maxFPS": { 167 | "title": "Maximum Framerate", 168 | "type": "integer", 169 | "placeholder": 30, 170 | "minimum": 0, 171 | "description": "The maximum frame rate used for video streamed to HomeKit. If set to 0, the framerate of the source is used. If not set, will use any framerate HomeKit requests." 172 | }, 173 | "maxBitrate": { 174 | "title": "Maximum Bitrate", 175 | "type": "integer", 176 | "placeholder": 299, 177 | "minimum": 0, 178 | "description": "The maximum bitrate used for video streamed to HomeKit, in kbit/s. If not set, will use any bitrate HomeKit requests." 179 | }, 180 | "forceMax": { 181 | "title": "Force Maximums", 182 | "type": "boolean", 183 | "description": "If set, the settings requested by HomeKit will be overridden with any 'maximum' values defined in this config." 184 | }, 185 | "preserveRatio": { 186 | "title": "Preserve Aspect Ratio", 187 | "type": "boolean", 188 | "description": "Preserves the aspect ratio of the source video." 189 | }, 190 | "vcodec": { 191 | "title": "Video Codec", 192 | "type": "string", 193 | "placeholder": "libx264", 194 | "typeahead": { 195 | "source": [ 196 | "libx264", 197 | "h264_omx", 198 | "h264_videotoolbox", 199 | "copy" 200 | ] 201 | }, 202 | "description": "Set the codec used for encoding video sent to HomeKit, must be H.264-based. You can change to a hardware accelerated video codec with this option, if one is available." 203 | }, 204 | "packetSize": { 205 | "title": "Packet Size", 206 | "type": "number", 207 | "placeholder": 1316, 208 | "multipleOf": 188, 209 | "minimum": 188, 210 | "description": "If audio or video is choppy try a smaller value." 211 | }, 212 | "videoFilter": { 213 | "title": "Additional Video Filters", 214 | "type": "string", 215 | "description": "Comma-delimited list of additional video filters for FFmpeg to run on the video. If 'none' is included, the default video filters are disabled." 216 | }, 217 | "encoderOptions": { 218 | "title": "Encoder Options", 219 | "type": "string", 220 | "placeholder": "-preset ultrafast -tune zerolatency", 221 | "description": "Options to be passed to the video encoder." 222 | }, 223 | "mapvideo": { 224 | "type": "string", 225 | "title": "Video Stream", 226 | "description": "Selects the stream used for video." 227 | }, 228 | "mapaudio": { 229 | "type": "string", 230 | "title": "Audio Stream", 231 | "description": "Selects the stream used for audio." 232 | }, 233 | "audio": { 234 | "title": "Enable Audio", 235 | "type": "boolean", 236 | "description": "Enables audio streaming from camera." 237 | }, 238 | "debug": { 239 | "title": "FFmpeg Debug Logging", 240 | "type": "boolean", 241 | "description": "Includes debugging output from the main FFmpeg process in the Homebridge log." 242 | }, 243 | "debugReturn": { 244 | "title": "Two-way FFmpeg Debug Logging", 245 | "type": "boolean", 246 | "description": "Includes debugging output from the FFmpeg process used for two-way audio in the Homebridge log." 247 | }, 248 | "recording": { 249 | "title": "Enable Homekit Secure Video Recording", 250 | "type": "boolean", 251 | "required": true, 252 | "default": false, 253 | "description": "Enables Homekit Secure Video and records video on motion and doorbell events. Requires a home hub and iCloud plan with at least 200GB of storage." 254 | }, 255 | "prebuffer": { 256 | "title": "Enables prebuffering for Homekit Secure Video recording", 257 | "type": "boolean", 258 | "required": true, 259 | "default": false, 260 | "description": "If prebuffering is enabled the video is recorded permanent to be able to capture even the seconds before motion was detected or the doorbell event." 261 | } 262 | } 263 | }, 264 | "mqtt": { 265 | "title": "MQTT Configuration", 266 | "type": "object", 267 | "properties": { 268 | "motionTopic": { 269 | "title": "Motion Topic", 270 | "type": "string", 271 | "placeholder": "homebridge/motion", 272 | "description": "The MQTT topic to watch for motion alerts." 273 | }, 274 | "motionMessage": { 275 | "title": "Motion Message", 276 | "type": "string", 277 | "description": "The message to watch for to trigger motion alerts. Will use the name of the camera if blank." 278 | }, 279 | "motionResetTopic": { 280 | "title": "Motion Reset Topic", 281 | "type": "string", 282 | "placeholder": "homebridge/motion/reset", 283 | "description": "The MQTT topic to watch for motion resets." 284 | }, 285 | "motionResetMessage": { 286 | "title": "Motion Reset Message", 287 | "type": "string", 288 | "description": "The message to watch for to trigger motion resets. Will use the name of the camera if blank." 289 | }, 290 | "doorbellTopic": { 291 | "title": "Doorbell Topic", 292 | "type": "string", 293 | "placeholder": "homebridge/doorbell", 294 | "description": "The MQTT topic to watch for doorbell alerts." 295 | }, 296 | "doorbellMessage": { 297 | "title": "Doorbell Message", 298 | "type": "string", 299 | "description": "The message to watch for to trigger doorbell alerts. Will use the name of the camera if blank." 300 | } 301 | } 302 | } 303 | } 304 | } 305 | } 306 | }, 307 | "layout": [ 308 | { 309 | "type": "section", 310 | "title": "Cameras", 311 | "expandable": true, 312 | "expanded": true, 313 | "items": [ 314 | { 315 | "key": "cameras", 316 | "notitle": false, 317 | "type": "tabarray", 318 | "title": "{{ value.name || 'New Camera' }}", 319 | "expandable": true, 320 | "expanded": false, 321 | "draggable": true, 322 | "orderable": true, 323 | "items": [ 324 | "cameras[].name", 325 | "cameras[].videoConfig.source", 326 | "cameras[].videoConfig.stillImageSource", 327 | "cameras[].videoConfig.audio", 328 | { 329 | "key": "cameras[]", 330 | "type": "section", 331 | "title": "Branding", 332 | "expandable": true, 333 | "expanded": false, 334 | "items": [ 335 | "cameras[].manufacturer", 336 | "cameras[].model", 337 | "cameras[].serialNumber", 338 | "cameras[].firmwareRevision" 339 | ] 340 | }, 341 | { 342 | "key": "cameras[]", 343 | "type": "section", 344 | "title": "Video Output", 345 | "expandable": true, 346 | "expanded": false, 347 | "items": [ 348 | "cameras[].videoConfig.maxStreams", 349 | "cameras[].videoConfig.maxWidth", 350 | "cameras[].videoConfig.maxHeight", 351 | "cameras[].videoConfig.maxFPS", 352 | "cameras[].videoConfig.maxBitrate", 353 | "cameras[].videoConfig.forceMax" 354 | ] 355 | }, 356 | { 357 | "key": "cameras[]", 358 | "type": "section", 359 | "title": "Advanced", 360 | "expandable": true, 361 | "expanded": false, 362 | "items": [ 363 | { 364 | "key": "cameras[]", 365 | "type": "section", 366 | "title": "EXPERIMENTAL - WIP", 367 | "expandable": true, 368 | "expanded": false, 369 | "items": [ 370 | "cameras[].videoConfig.returnAudioTarget", 371 | "cameras[].videoConfig.debugReturn", 372 | "cameras[].videoConfig.recording", 373 | "cameras[].videoConfig.prebuffer" 374 | ] 375 | }, 376 | "cameras[].videoConfig.vcodec", 377 | "cameras[].videoConfig.packetSize", 378 | "cameras[].videoConfig.mapvideo", 379 | "cameras[].videoConfig.mapaudio", 380 | "cameras[].videoConfig.videoFilter", 381 | "cameras[].videoConfig.encoderOptions", 382 | "cameras[].videoConfig.debug" 383 | ] 384 | }, 385 | { 386 | "key": "cameras[]", 387 | "type": "section", 388 | "title": "Automation", 389 | "expandable": true, 390 | "expanded": false, 391 | "items": [ 392 | "cameras[].motion", 393 | "cameras[].doorbell", 394 | "cameras[].switches", 395 | "cameras[].motionTimeout", 396 | "cameras[].motionDoorbell", 397 | { 398 | "key": "cameras[]", 399 | "type": "section", 400 | "title": "MQTT Settings", 401 | "expandable": true, 402 | "expanded": false, 403 | "items": [ 404 | "cameras[].mqtt.motionTopic", 405 | "cameras[].mqtt.motionMessage", 406 | "cameras[].mqtt.motionResetTopic", 407 | "cameras[].mqtt.motionResetMessage", 408 | "cameras[].mqtt.doorbellTopic", 409 | "cameras[].mqtt.doorbellMessage" 410 | ] 411 | } 412 | ] 413 | } 414 | ] 415 | } 416 | ] 417 | }, 418 | { 419 | "type": "section", 420 | "title": "Global Automation", 421 | "expandable": true, 422 | "expanded": false, 423 | "items": [ 424 | { 425 | "type": "section", 426 | "title": "HTTP Server", 427 | "expandable": true, 428 | "expanded": true, 429 | "items": [ 430 | "porthttp", 431 | "localhttp" 432 | ] 433 | }, 434 | { 435 | "type": "section", 436 | "title": "MQTT Client", 437 | "expandable": true, 438 | "expanded": true, 439 | "items": [ 440 | "mqtt", 441 | "portmqtt", 442 | "tlsmqtt", 443 | "usermqtt", 444 | "passmqtt" 445 | ] 446 | } 447 | ] 448 | }, 449 | { 450 | "type": "section", 451 | "title": "Global Advanced", 452 | "expandable": true, 453 | "expanded": false, 454 | "items": [ 455 | "videoProcessor" 456 | ] 457 | } 458 | ] 459 | } 460 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu( 4 | { 5 | ignores: ['dist', 'docs'], 6 | jsx: false, 7 | typescript: true, 8 | formatters: { 9 | markdown: true, 10 | }, 11 | rules: { 12 | 'curly': ['error', 'multi-line'], 13 | 'import/extensions': ['error', 'ignorePackages'], 14 | 'import/order': 0, 15 | 'jsdoc/check-alignment': 'error', 16 | 'jsdoc/check-line-alignment': 'error', 17 | 'perfectionist/sort-exports': 'error', 18 | 'perfectionist/sort-imports': [ 19 | 'error', 20 | { 21 | groups: [ 22 | 'builtin-type', 23 | 'external-type', 24 | 'internal-type', 25 | ['parent-type', 'sibling-type', 'index-type'], 26 | 'builtin', 27 | 'external', 28 | 'internal', 29 | ['parent', 'sibling', 'index'], 30 | 'object', 31 | 'unknown', 32 | ], 33 | order: 'asc', 34 | type: 'natural', 35 | }, 36 | ], 37 | 'perfectionist/sort-named-exports': 'error', 38 | 'perfectionist/sort-named-imports': 'error', 39 | 'sort-imports': 0, 40 | 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], 41 | 'style/quote-props': ['error', 'consistent-as-needed'], 42 | 'test/no-only-tests': 'error', 43 | 'unicorn/no-useless-spread': 'error', 44 | 'unused-imports/no-unused-vars': ['error', { caughtErrors: 'none' }], 45 | 'no-new': 0, // Disable the no-new rule 46 | 'new-cap': 0, // Disable the new-cap rule 47 | 'no-undef': 0, // Disable the no-undef rule 48 | }, 49 | }, 50 | ) 51 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts, html, json", 4 | "ignore": [], 5 | "exec": "DEBUG= tsc && homebridge -T -D -P -I -U ~/.homebridge-dev ..", 6 | "signal": "SIGTERM", 7 | "env": { 8 | "NODE_OPTIONS": "--trace-warnings" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@homebridge-plugins/homebridge-camera-ffmpeg", 3 | "displayName": "Homebridge Camera FFmpeg", 4 | "type": "module", 5 | "version": "4.0.1", 6 | "description": "Homebridge Plugin Providing FFmpeg-based Camera Support", 7 | "author": { 8 | "name": "Khaos Tian" 9 | }, 10 | "contributors": [ 11 | { 12 | "name": "Sunoo" 13 | }, 14 | { 15 | "name": "Khaos Tian" 16 | } 17 | ], 18 | "publishConfig": { 19 | "access": "public" 20 | }, 21 | "license": "ISC", 22 | "maintainers": [ 23 | { 24 | "name": "Homebridge" 25 | } 26 | ], 27 | "funding": [ 28 | { 29 | "type": "kofi", 30 | "url": "https://ko-fi.com/sunookitsune" 31 | }, 32 | { 33 | "type": "paypal", 34 | "url": "https://paypal.me/sunoo" 35 | }, 36 | { 37 | "type": "github", 38 | "url": "https://github.com/Sunoo" 39 | }, 40 | { 41 | "type": "liberapay", 42 | "url": "https://liberapay.com/Sunoo" 43 | } 44 | ], 45 | "repository": { 46 | "type": "git", 47 | "url": "git://github.com/homebridge-plugins/homebridge-camera-ffmpeg.git" 48 | }, 49 | "bugs": { 50 | "url": "http://github.com/homebridge-plugins/homebridge-camera-ffmpeg/issues" 51 | }, 52 | "keywords": [ 53 | "homebridge-plugin", 54 | "camera", 55 | "ffmpeg", 56 | "homebridge" 57 | ], 58 | "main": "dist/index.js", 59 | "files": [ 60 | "CHANGELOG.md", 61 | "LICENSE", 62 | "README.md", 63 | "config.schema.json", 64 | "dist/**/*", 65 | "package.json" 66 | ], 67 | "engines": { 68 | "homebridge": "^1.9.0 || ^2.0.0 || ^2.0.0-beta.26 || ^2.0.0-alpha.37", 69 | "node": "^20 || ^22" 70 | }, 71 | "scripts": { 72 | "check": "npm install && npm outdated", 73 | "lint": "eslint src/**/*.ts", 74 | "lint:fix": "eslint src/**/*.ts --fix", 75 | "watch": "npm run build && npm run plugin-ui && npm link && nodemon", 76 | "plugin-ui": "rsync ./src/homebridge-ui/public/index.html ./dist/homebridge-ui/public/", 77 | "build": "npm run clean && tsc && npm run plugin-ui", 78 | "prepublishOnly": "npm run lint && npm run build && npm run plugin-ui", 79 | "postpublish": "npm run clean && npm ci", 80 | "clean": "shx rm -rf ./dist", 81 | "test": "vitest run", 82 | "test:watch": "vitest watch", 83 | "test-coverage": "npm run test -- --coverage" 84 | }, 85 | "dependencies": { 86 | "@homebridge/camera-utils": "^3.0.0", 87 | "@homebridge/plugin-ui-utils": "^2.0.1", 88 | "mqtt": "5.10.4", 89 | "pick-port": "^2.1.0" 90 | }, 91 | "devDependencies": { 92 | "@antfu/eslint-config": "^4.4.0", 93 | "@types/aes-js": "^3.1.4", 94 | "@types/debug": "^4.1.12", 95 | "@types/fs-extra": "^11.0.4", 96 | "@types/mdast": "^4.0.4", 97 | "@types/node": "^22.13.9", 98 | "@types/semver": "^7.5.8", 99 | "@types/source-map-support": "^0.5.10", 100 | "@types/ws": "^8.18.0", 101 | "@typhonjs-typedoc/typedoc-theme-dmt": "^0.3.1", 102 | "@vitest/coverage-v8": "^3.0.7", 103 | "eslint": "^9.21.0", 104 | "eslint-plugin-format": "^1.0.1", 105 | "homebridge": "^1.9.0", 106 | "homebridge-config-ui-x": "4.71.2", 107 | "nodemon": "^3.1.9", 108 | "shx": "^0.3.4", 109 | "ts-node": "^10.9.2", 110 | "typedoc": "^0.27.9", 111 | "typescript": "^5.8.2", 112 | "vitest": "^3.0.7" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/ffmpeg.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcessWithoutNullStreams } from 'node:child_process' 2 | import type { Writable } from 'node:stream' 3 | 4 | import type { StreamRequestCallback } from 'homebridge' 5 | 6 | import type { Logger } from './logger.js' 7 | import type { StreamingDelegate } from './streamingDelegate.js' 8 | 9 | import { spawn } from 'node:child_process' 10 | import os from 'node:os' 11 | import { env } from 'node:process' 12 | import readline from 'node:readline' 13 | import { FfmpegProgress } from './settings.js' 14 | 15 | export class FfmpegProcess { 16 | private readonly process: ChildProcessWithoutNullStreams 17 | private killTimeout?: NodeJS.Timeout 18 | readonly stdin: Writable 19 | 20 | constructor(cameraName: string, sessionId: string, videoProcessor: string, ffmpegArgs: string, log: Logger, debug = false, delegate: StreamingDelegate, callback?: StreamRequestCallback) { 21 | log.debug(`Stream command: ${videoProcessor} ${ffmpegArgs}`, cameraName, debug) 22 | 23 | let started = false 24 | const startTime = Date.now() 25 | this.process = spawn(videoProcessor, ffmpegArgs.split(/\s+/), { env }) 26 | this.stdin = this.process.stdin 27 | 28 | this.process.stdout.on('data', (data) => { 29 | const progress = this.parseProgress(data) 30 | if (progress) { 31 | if (!started && progress.frame > 0) { 32 | started = true 33 | const runtime = (Date.now() - startTime) / 1000 34 | const message = `Getting the first frames took ${runtime} seconds.` 35 | if (runtime < 5) { 36 | log.debug(message, cameraName, debug) 37 | } else if (runtime < 22) { 38 | log.warn(message, cameraName) 39 | } else { 40 | log.error(message, cameraName) 41 | } 42 | } 43 | } 44 | }) 45 | const stderr = readline.createInterface({ 46 | input: this.process.stderr, 47 | terminal: false, 48 | }) 49 | stderr.on('line', (line: string) => { 50 | if (callback) { 51 | callback() 52 | callback = undefined 53 | } 54 | if (debug && line.match(/\[(panic|fatal|error)\]/)) { // For now only write anything out when debug is set 55 | log.error(line, cameraName) 56 | } else if (debug) { 57 | log.debug(line, cameraName, true) 58 | } 59 | }) 60 | this.process.on('error', (error: Error) => { 61 | log.error(`FFmpeg process creation failed: ${error.message}`, cameraName) 62 | if (callback) { 63 | callback(new Error('FFmpeg process creation failed')) 64 | } 65 | delegate.stopStream(sessionId) 66 | }) 67 | this.process.on('exit', (code: number, signal: NodeJS.Signals) => { 68 | if (this.killTimeout) { 69 | clearTimeout(this.killTimeout) 70 | } 71 | 72 | const message = `FFmpeg exited with code: ${code} and signal: ${signal}` 73 | 74 | if (this.killTimeout && code === 0) { 75 | log.debug(`${message} (Expected)`, cameraName, debug) 76 | } else if (code == null || code === 255) { 77 | if (this.process.killed) { 78 | log.debug(`${message} (Forced)`, cameraName, debug) 79 | } else { 80 | log.error(`${message} (Unexpected)`, cameraName) 81 | } 82 | } else { 83 | log.error(`${message} (Error)`, cameraName) 84 | delegate.stopStream(sessionId) 85 | if (!started && callback) { 86 | callback(new Error(message)) 87 | } else { 88 | delegate.controller.forceStopStreamingSession(sessionId) 89 | } 90 | } 91 | }) 92 | } 93 | 94 | parseProgress(data: Uint8Array): FfmpegProgress | undefined { 95 | const input = data.toString() 96 | 97 | if (input.indexOf('frame=') === 0) { 98 | try { 99 | const progress = new Map() 100 | input.split(/\r?\n/).forEach((line) => { 101 | const split = line.split('=', 2) 102 | progress.set(split[0], split[1]) 103 | }) 104 | 105 | return { 106 | frame: Number.parseInt(progress.get('frame')!), 107 | fps: Number.parseFloat(progress.get('fps')!), 108 | stream_q: Number.parseFloat(progress.get('stream_0_0_q')!), 109 | bitrate: Number.parseFloat(progress.get('bitrate')!), 110 | total_size: Number.parseInt(progress.get('total_size')!), 111 | out_time_us: Number.parseInt(progress.get('out_time_us')!), 112 | out_time: progress.get('out_time')!.trim(), 113 | dup_frames: Number.parseInt(progress.get('dup_frames')!), 114 | drop_frames: Number.parseInt(progress.get('drop_frames')!), 115 | speed: Number.parseFloat(progress.get('speed')!), 116 | progress: progress.get('progress')!.trim(), 117 | } 118 | } catch { 119 | return undefined 120 | } 121 | } else { 122 | return undefined 123 | } 124 | } 125 | 126 | public stop(): void { 127 | this.process.stdin.write(`q${os.EOL}`) 128 | this.killTimeout = setTimeout(() => { 129 | this.process.kill('SIGKILL') 130 | }, 2 * 1000) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/homebridge-ui/public/index.html: -------------------------------------------------------------------------------- 1 |

2 | homebridge-camera-ffmpeg logo 5 |

6 | 12 | 17 | 21 | 52 | 106 | -------------------------------------------------------------------------------- /src/homebridge-ui/server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | 3 | import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils' 4 | 5 | import { PLUGIN_NAME } from '../settings.js' 6 | 7 | class PluginUiServer extends HomebridgePluginUiServer { 8 | constructor() { 9 | super() 10 | /* 11 | A native method getCachedAccessories() was introduced in config-ui-x v4.37.0 12 | The following is for users who have a lower version of config-ui-x 13 | */ 14 | this.onRequest('getCachedAccessories', () => { 15 | try { 16 | const plugin = PLUGIN_NAME ?? '@homebridge-plugins/homebridge-camera-ffmpeg' 17 | const devicesToReturn = [] 18 | 19 | // The path and file of the cached accessories 20 | const accFile = `${this.homebridgeStoragePath}/accessories/cachedAccessories` 21 | 22 | // Check the file exists 23 | if (fs.existsSync(accFile)) { 24 | // read the cached accessories file 25 | const cachedAccessories: any[] = JSON.parse(fs.readFileSync(accFile, 'utf8')) 26 | 27 | cachedAccessories.forEach((accessory: any) => { 28 | // Check the accessory is from this plugin 29 | if (accessory.plugin === plugin) { 30 | // Add the cached accessory to the array 31 | devicesToReturn.push(accessory.accessory as never) 32 | } 33 | }) 34 | } 35 | // Return the array 36 | return devicesToReturn 37 | } catch { 38 | // Just return an empty accessory list in case of any errors 39 | return [] 40 | } 41 | }) 42 | this.ready() 43 | } 44 | } 45 | 46 | function startPluginUiServer(): PluginUiServer { 47 | return new PluginUiServer() 48 | } 49 | 50 | startPluginUiServer() 51 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import type { API } from 'homebridge'; 3 | import registerPlatform from './index.js'; 4 | import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'; 5 | import { FfmpegPlatform } from './platform.js'; 6 | 7 | describe('registerPlatform', () => { 8 | it('should register the platform with homebridge', () => { 9 | const api = { 10 | registerPlatform: vi.fn(), 11 | } as unknown as API; 12 | 13 | registerPlatform(api); 14 | 15 | expect(api.registerPlatform).toHaveBeenCalledWith(PLUGIN_NAME, PLATFORM_NAME, FfmpegPlatform); 16 | }); 17 | }); -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { API } from 'homebridge' 2 | 3 | import { FfmpegPlatform } from './platform.js' 4 | import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js' 5 | 6 | // Register our platform with homebridge. 7 | export default (api: API): void => { 8 | api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, FfmpegPlatform) 9 | } 10 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import type { Logging } from 'homebridge' 2 | 3 | import { argv } from 'node:process' 4 | 5 | export class Logger { 6 | private readonly log: Logging 7 | private readonly debugMode: boolean 8 | 9 | constructor(log: Logging) { 10 | this.log = log 11 | this.debugMode = argv.includes('-D') || argv.includes('--debug') 12 | } 13 | 14 | private formatMessage(message: string, device?: string): string { 15 | let formatted = '' 16 | if (device) { 17 | formatted += `[${device}] ` 18 | } 19 | formatted += message 20 | return formatted 21 | } 22 | 23 | public success(message: string, device?: string): void { 24 | this.log.success(this.formatMessage(message, device)) 25 | } 26 | 27 | public info(message: string, device?: string): void { 28 | this.log.info(this.formatMessage(message, device)) 29 | } 30 | 31 | public warn(message: string, device?: string): void { 32 | this.log.warn(this.formatMessage(message, device)) 33 | } 34 | 35 | public error(message: string, device?: string): void { 36 | this.log.error(this.formatMessage(message, device)) 37 | } 38 | 39 | public debug(message: string, device?: string, alwaysLog = false): void { 40 | if (this.debugMode) { 41 | this.log.debug(this.formatMessage(message, device)) 42 | } else if (alwaysLog) { 43 | this.info(message, device) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/platform.ts: -------------------------------------------------------------------------------- 1 | import type { Buffer } from 'node:buffer' 2 | 3 | import type { API, CharacteristicSetCallback, CharacteristicValue, DynamicPlatformPlugin, Logging, PlatformAccessory, PlatformConfig } from 'homebridge' 4 | 5 | import type { AutomationReturn } from "./settings.js" 6 | import type { CameraConfig, FfmpegPlatformConfig } from './settings.js' 7 | 8 | import http from 'node:http' 9 | 10 | import { APIEvent, CharacteristicEventTypes, PlatformAccessoryEvent } from 'homebridge' 11 | import mqtt from 'mqtt' 12 | 13 | import { Logger } from './logger.js' 14 | import { StreamingDelegate } from './streamingDelegate.js' 15 | import { PLUGIN_NAME, PLATFORM_NAME, MqttAction, getVersion } from './settings.js' 16 | 17 | const version = getVersion() 18 | 19 | export class FfmpegPlatform implements DynamicPlatformPlugin { 20 | private readonly log: Logger 21 | private readonly api: API 22 | private readonly config: FfmpegPlatformConfig 23 | private readonly cameraConfigs: Map = new Map() 24 | private readonly cachedAccessories: Array = [] 25 | private readonly accessories: Array = [] 26 | private readonly motionTimers: Map = new Map() 27 | private readonly doorbellTimers: Map = new Map() 28 | private readonly mqttActions: Map>> = new Map() 29 | 30 | constructor(log: Logging, config: PlatformConfig, api: API) { 31 | this.log = new Logger(log) 32 | this.api = api 33 | this.config = config as FfmpegPlatformConfig 34 | 35 | this.config.cameras?.forEach((cameraConfig: CameraConfig) => { 36 | let error = false 37 | 38 | if (!cameraConfig.name) { 39 | this.log.error('One of your cameras has no name configured. This camera will be skipped.') 40 | cameraConfig.name = `Camera ${this.cameraConfigs.size + 1}` 41 | error = false 42 | } 43 | if (!cameraConfig.videoConfig) { 44 | this.log.error('The videoConfig section is missing from the config. This camera will be skipped.', cameraConfig.name) 45 | error = true 46 | } else { 47 | if (!cameraConfig.videoConfig.source) { 48 | this.log.error('There is no source configured for this camera. This camera will be skipped.', cameraConfig.name) 49 | error = true 50 | } else { 51 | const sourceArgs = cameraConfig.videoConfig.source.split(/\s+/) 52 | if (!sourceArgs.includes('-i')) { 53 | this.log.warn('The source for this camera is missing "-i", it is likely misconfigured.', cameraConfig.name) 54 | } 55 | } 56 | if (cameraConfig.videoConfig.stillImageSource) { 57 | const stillArgs = cameraConfig.videoConfig.stillImageSource.split(/\s+/) 58 | if (!stillArgs.includes('-i')) { 59 | this.log.warn('The stillImageSource for this camera is missing "-i", it is likely misconfigured.', cameraConfig.name) 60 | } 61 | } 62 | if (cameraConfig.videoConfig.vcodec === 'copy' && cameraConfig.videoConfig.videoFilter) { 63 | this.log.warn('A videoFilter is defined, but the copy vcodec is being used. This will be ignored.', cameraConfig.name) 64 | } 65 | } 66 | 67 | if (!error) { 68 | const uuid = this.api.hap.uuid.generate(cameraConfig.name ?? `Camera ${this.cameraConfigs.size + 1}`) 69 | if (this.cameraConfigs.has(uuid)) { 70 | // Camera names must be unique 71 | this.log.warn('Multiple cameras are configured with this name. Duplicate cameras will be skipped.', cameraConfig.name) 72 | } else { 73 | this.cameraConfigs.set(uuid, cameraConfig) 74 | } 75 | } 76 | }) 77 | 78 | api.on(APIEvent.DID_FINISH_LAUNCHING, this.didFinishLaunching.bind(this)) 79 | } 80 | 81 | addMqttAction(topic: string, message: string, details: MqttAction): void { 82 | const messageMap = this.mqttActions.get(topic) || new Map() 83 | const actionArray = messageMap.get(message) || [] 84 | actionArray.push(details) 85 | messageMap.set(message, actionArray) 86 | this.mqttActions.set(topic, messageMap) 87 | } 88 | 89 | setupAccessory(accessory: PlatformAccessory, cameraConfig: CameraConfig): void { 90 | accessory.on(PlatformAccessoryEvent.IDENTIFY, () => { 91 | this.log.info('Identify requested.', accessory.displayName) 92 | }) 93 | 94 | const accInfo = accessory.getService(this.api.hap.Service.AccessoryInformation) 95 | if (accInfo) { 96 | accInfo.setCharacteristic(this.api.hap.Characteristic.Manufacturer, cameraConfig.manufacturer || 'Homebridge') 97 | accInfo.setCharacteristic(this.api.hap.Characteristic.Model, cameraConfig.model || 'Camera FFmpeg') 98 | accInfo.setCharacteristic(this.api.hap.Characteristic.SerialNumber, cameraConfig.serialNumber || 'SerialNumber') 99 | accInfo.setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, cameraConfig.firmwareRevision || version) 100 | } 101 | 102 | const motionSensor = accessory.getService(this.api.hap.Service.MotionSensor) 103 | const doorbell = accessory.getService(this.api.hap.Service.Doorbell) 104 | const doorbellTrigger = accessory.getServiceById(this.api.hap.Service.Switch, 'DoorbellTrigger') 105 | const motionTrigger = accessory.getServiceById(this.api.hap.Service.Switch, 'MotionTrigger') 106 | const doorbellSwitch = accessory.getServiceById(this.api.hap.Service.StatelessProgrammableSwitch, 'DoorbellSwitch') 107 | 108 | if (motionSensor) { 109 | accessory.removeService(motionSensor) 110 | } 111 | if (doorbell) { 112 | accessory.removeService(doorbell) 113 | } 114 | if (doorbellTrigger) { 115 | accessory.removeService(doorbellTrigger) 116 | } 117 | if (motionTrigger) { 118 | accessory.removeService(motionTrigger) 119 | } 120 | if (doorbellSwitch) { 121 | accessory.removeService(doorbellSwitch) 122 | } 123 | 124 | const delegate = new StreamingDelegate(this.log, cameraConfig, this.api, this.api.hap, accessory, this.config.videoProcessor) 125 | 126 | accessory.configureController(delegate.controller) 127 | 128 | if (cameraConfig.videoConfig?.prebuffer) { 129 | this.log.debug('Start prebuffering...', cameraConfig.name) 130 | if (delegate.recordingDelegate) { 131 | delegate.recordingDelegate.startPreBuffer() 132 | } 133 | } 134 | 135 | // add motion sensor after accessory.configureController. Secure Video creates it own linked motion service 136 | if (cameraConfig.motion) { 137 | this.log.debug('add motion stuff', cameraConfig.name) 138 | const motionSensor = new this.api.hap.Service.MotionSensor(cameraConfig.name) 139 | 140 | if (!accessory.getService(this.api.hap.Service.MotionSensor)) { 141 | accessory.addService(motionSensor) 142 | } else { 143 | this.log.debug('found motion sensor service', cameraConfig.name) 144 | } 145 | if (cameraConfig.switches) { 146 | const motionTrigger = new this.api.hap.Service.Switch(`${cameraConfig.name} Motion Trigger`, 'MotionTrigger') 147 | motionTrigger 148 | .getCharacteristic(this.api.hap.Characteristic.On) 149 | .on(CharacteristicEventTypes.SET, (state: CharacteristicValue, callback: CharacteristicSetCallback) => { 150 | this.motionHandler(accessory, state as boolean, 1) 151 | callback() 152 | }) 153 | accessory.addService(motionTrigger) 154 | } 155 | } 156 | 157 | // add doorbell after accessory.configureController. Secure Video creates it own linked doorbell service 158 | if (cameraConfig.doorbell) { 159 | const doorbell = new this.api.hap.Service.Doorbell(`${cameraConfig.name} Doorbell`) 160 | if (!accessory.getService(this.api.hap.Service.Doorbell)) { 161 | accessory.addService(doorbell) 162 | } else { 163 | this.log.debug('found doorbell sensor service', cameraConfig.name) 164 | } 165 | if (cameraConfig.switches) { 166 | const doorbellTrigger = new this.api.hap.Service.Switch(`${cameraConfig.name} Doorbell Trigger`, 'DoorbellTrigger') 167 | doorbellTrigger 168 | .getCharacteristic(this.api.hap.Characteristic.On) 169 | .on(CharacteristicEventTypes.SET, (state: CharacteristicValue, callback: CharacteristicSetCallback) => { 170 | this.doorbellHandler(accessory, state as boolean) 171 | callback() 172 | }) 173 | accessory.addService(doorbellTrigger) 174 | } 175 | } 176 | /* 177 | for (let rtp of delegate.controller.streamManagements) { 178 | this.log.debug("StreamMngt: "+rtp.getService().getCharacteristic(this.api.hap.Characteristic.Active).value.toString()); 179 | } 180 | this.log.debug("recMngt:"+ accessory.getService(this.api.hap.Service.CameraRecordingManagement).getCharacteristic(this.api.hap.Characteristic.Active).value.toString()); 181 | */ 182 | if (this.config.mqtt) { 183 | if (cameraConfig.mqtt) { 184 | if (cameraConfig.mqtt.motionTopic) { 185 | this.addMqttAction(cameraConfig.mqtt.motionTopic, cameraConfig.mqtt.motionMessage || cameraConfig.name!, { accessory, active: true, doorbell: false }) 186 | } 187 | if (cameraConfig.mqtt.motionResetTopic) { 188 | this.addMqttAction(cameraConfig.mqtt.motionResetTopic, cameraConfig.mqtt.motionResetMessage || cameraConfig.name!, { accessory, active: false, doorbell: false }) 189 | } 190 | if (cameraConfig.mqtt.doorbellTopic) { 191 | this.addMqttAction(cameraConfig.mqtt.doorbellTopic, cameraConfig.mqtt.doorbellMessage || cameraConfig.name!, { accessory, active: true, doorbell: true }) 192 | } 193 | } 194 | } 195 | } 196 | 197 | configureAccessory(accessory: PlatformAccessory): void { 198 | this.log.info('Configuring cached bridged accessory...', accessory.displayName) 199 | 200 | const cameraConfig = this.cameraConfigs.get(accessory.UUID) 201 | 202 | if (cameraConfig) { 203 | this.setupAccessory(accessory, cameraConfig) 204 | } 205 | 206 | this.cachedAccessories.push(accessory) 207 | } 208 | 209 | private doorbellHandler(accessory: PlatformAccessory, active = true): AutomationReturn { 210 | const doorbell = accessory.getService(this.api.hap.Service.Doorbell) 211 | if (doorbell) { 212 | this.log.debug(`Switch doorbell ${active ? 'on.' : 'off.'}`, accessory.displayName) 213 | const timeout = this.doorbellTimers.get(accessory.UUID) 214 | if (timeout) { 215 | clearTimeout(timeout) 216 | this.doorbellTimers.delete(accessory.UUID) 217 | } 218 | const doorbellTrigger = accessory.getServiceById(this.api.hap.Service.Switch, 'DoorbellTrigger') 219 | if (active) { 220 | doorbell.updateCharacteristic(this.api.hap.Characteristic.ProgrammableSwitchEvent, this.api.hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS) 221 | if (doorbellTrigger) { 222 | doorbellTrigger.updateCharacteristic(this.api.hap.Characteristic.On, true) 223 | let timeoutConfig = this.cameraConfigs.get(accessory.UUID)?.motionTimeout 224 | timeoutConfig = timeoutConfig && timeoutConfig > 0 ? timeoutConfig : 1 225 | const timer = setTimeout(() => { 226 | this.log.debug('Doorbell handler timeout.', accessory.displayName) 227 | this.doorbellTimers.delete(accessory.UUID) 228 | doorbellTrigger.updateCharacteristic(this.api.hap.Characteristic.On, false) 229 | }, timeoutConfig * 1000) 230 | this.doorbellTimers.set(accessory.UUID, timer) 231 | } 232 | return { 233 | error: false, 234 | message: 'Doorbell switched on.', 235 | } 236 | } else { 237 | if (doorbellTrigger) { 238 | doorbellTrigger.updateCharacteristic(this.api.hap.Characteristic.On, false) 239 | } 240 | return { 241 | error: false, 242 | message: 'Doorbell switched off.', 243 | } 244 | } 245 | } else { 246 | return { 247 | error: true, 248 | message: 'Doorbell is not enabled for this camera.', 249 | } 250 | } 251 | } 252 | 253 | private motionHandler(accessory: PlatformAccessory, active = true, minimumTimeout = 0): AutomationReturn { 254 | const motionSensor = accessory.getService(this.api.hap.Service.MotionSensor) 255 | if (motionSensor) { 256 | this.log.debug(`Switch motion detect ${active ? 'on.' : 'off.'}`, accessory.displayName) 257 | const timeout = this.motionTimers.get(accessory.UUID) 258 | if (timeout) { 259 | clearTimeout(timeout) 260 | this.motionTimers.delete(accessory.UUID) 261 | } 262 | const motionTrigger = accessory.getServiceById(this.api.hap.Service.Switch, 'MotionTrigger') 263 | const config = this.cameraConfigs.get(accessory.UUID) 264 | if (active) { 265 | motionSensor.updateCharacteristic(this.api.hap.Characteristic.MotionDetected, true) 266 | if (motionTrigger) { 267 | motionTrigger.updateCharacteristic(this.api.hap.Characteristic.On, true) 268 | } 269 | if (!timeout && config?.motionDoorbell) { 270 | this.doorbellHandler(accessory, true) 271 | } 272 | let timeoutConfig = config?.motionTimeout ?? 1 273 | if (timeoutConfig < minimumTimeout) { 274 | timeoutConfig = minimumTimeout 275 | } 276 | if (timeoutConfig > 0) { 277 | const timer = setTimeout(() => { 278 | this.log.debug('Motion handler timeout.', accessory.displayName) 279 | this.motionTimers.delete(accessory.UUID) 280 | motionSensor.updateCharacteristic(this.api.hap.Characteristic.MotionDetected, false) 281 | if (motionTrigger) { 282 | motionTrigger.updateCharacteristic(this.api.hap.Characteristic.On, false) 283 | } 284 | }, timeoutConfig * 1000) 285 | this.motionTimers.set(accessory.UUID, timer) 286 | } 287 | return { 288 | error: false, 289 | message: 'Motion switched on.', 290 | cooldownActive: !!timeout, 291 | } 292 | } else { 293 | motionSensor.updateCharacteristic(this.api.hap.Characteristic.MotionDetected, false) 294 | if (motionTrigger) { 295 | motionTrigger.updateCharacteristic(this.api.hap.Characteristic.On, false) 296 | } 297 | if (config?.motionDoorbell) { 298 | this.doorbellHandler(accessory, false) 299 | } 300 | return { 301 | error: false, 302 | message: 'Motion switched off.', 303 | } 304 | } 305 | } else { 306 | return { 307 | error: true, 308 | message: 'Motion is not enabled for this camera.', 309 | } 310 | } 311 | } 312 | 313 | private httpHandler(fullpath: string, name: string): AutomationReturn { 314 | const accessory = this.accessories.find((curAcc: PlatformAccessory) => { 315 | return curAcc.displayName === name 316 | }) 317 | if (accessory) { 318 | const path = fullpath.split('/').filter(value => value.length > 0) 319 | switch (path[0]) { 320 | case 'motion': 321 | return this.motionHandler(accessory, path[1] !== 'reset') 322 | break 323 | case 'doorbell': 324 | return this.doorbellHandler(accessory) 325 | break 326 | default: 327 | return { 328 | error: true, 329 | message: `First directory level must be "motion" or "doorbell", got "${path[0]}".`, 330 | } 331 | } 332 | } else { 333 | return { 334 | error: true, 335 | message: `Camera "${name}" not found.`, 336 | } 337 | } 338 | } 339 | 340 | didFinishLaunching(): void { 341 | for (const [uuid, cameraConfig] of this.cameraConfigs) { 342 | const name = cameraConfig.name || `Camera ${this.cameraConfigs.size + 1}` 343 | const cachedAccessory = this.cachedAccessories.find((curAcc: PlatformAccessory) => curAcc.UUID === uuid) 344 | if (!cachedAccessory) { 345 | const accessory = new this.api.platformAccessory(name, uuid) 346 | this.log.info('Configuring bridged accessory...', accessory.displayName) 347 | this.setupAccessory(accessory, cameraConfig) 348 | this.api.publishExternalAccessories(PLUGIN_NAME, [accessory]) 349 | this.accessories.push(accessory) 350 | } else { 351 | this.accessories.push(cachedAccessory) 352 | } 353 | } 354 | 355 | if (this.config.mqtt) { 356 | const portmqtt = this.config.portmqtt || '1883' 357 | this.log.info('Setting up MQTT connection...') 358 | const client = mqtt.connect(`${(this.config.tlsmqtt ? 'mqtts://' : 'mqtt://') + this.config.mqtt}:${portmqtt}`, { 359 | username: this.config.usermqtt, 360 | password: this.config.passmqtt, 361 | }) 362 | client.on('connect', () => { 363 | this.log.info('MQTT connected.') 364 | for (const [topic] of this.mqttActions) { 365 | this.log.debug(`Subscribing to MQTT topic: ${topic}`) 366 | client.subscribe(topic) 367 | } 368 | }) 369 | client.on('message', (topic: string, message: Buffer) => { 370 | const messageMap = this.mqttActions.get(topic) 371 | if (messageMap) { 372 | const actionArray = messageMap.get(message.toString()) 373 | if (actionArray) { 374 | for (const action of actionArray) { 375 | if (action.doorbell) { 376 | this.doorbellHandler(action.accessory, action.active) 377 | } else { 378 | this.motionHandler(action.accessory, action.active) 379 | } 380 | } 381 | } 382 | } 383 | }) 384 | } 385 | if (this.config.porthttp) { 386 | this.log.info(`Setting up ${this.config.localhttp ? 'localhost-only ' : '' 387 | }HTTP server on port ${this.config.porthttp}...`) 388 | const server = http.createServer() 389 | const hostname = this.config.localhttp ? 'localhost' : undefined 390 | server.listen(this.config.porthttp, hostname) 391 | server.on('request', (request: http.IncomingMessage, response: http.ServerResponse) => { 392 | let results: AutomationReturn = { 393 | error: true, 394 | message: 'Malformed URL.', 395 | } 396 | if (request.url) { 397 | const spliturl = request.url.split('?') 398 | if (spliturl.length === 2) { 399 | const name = decodeURIComponent(spliturl[1]).split('=')[0] 400 | results = this.httpHandler(spliturl[0], name) 401 | } 402 | } 403 | response.writeHead(results.error ? 500 : 200) 404 | response.write(JSON.stringify(results)) 405 | response.end() 406 | }) 407 | } 408 | 409 | this.cachedAccessories.forEach((accessory: PlatformAccessory) => { 410 | const cameraConfig = this.cameraConfigs.get(accessory.UUID) 411 | if (!cameraConfig) { 412 | this.log.info('Removing bridged accessory...', accessory.displayName) 413 | this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]) 414 | } 415 | }) 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /src/prebuffer.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'node:net' 2 | 3 | import type { Logger } from './logger.js' 4 | import type { MP4Atom } from './settings.js' 5 | 6 | import { Buffer } from 'node:buffer' 7 | import { spawn } from 'node:child_process' 8 | import EventEmitter from 'node:events' 9 | import { createServer } from 'node:net' 10 | import { env } from 'node:process' 11 | 12 | import { listenServer, parseFragmentedMP4 } from './recordingDelegate.js' 13 | import { PrebufferFmp4, Mp4Session, defaultPrebufferDuration } from './settings.js' 14 | 15 | export let prebufferSession: Mp4Session 16 | 17 | export class PreBuffer { 18 | prebufferFmp4: Array = [] 19 | events = new EventEmitter() 20 | released = false 21 | ftyp!: MP4Atom 22 | moov!: MP4Atom 23 | idrInterval = 0 24 | prevIdr = 0 25 | 26 | private readonly log: Logger 27 | private readonly ffmpegInput: string 28 | private readonly cameraName: string 29 | private readonly ffmpegPath: string 30 | // private process: ChildProcessWithoutNullStreams; 31 | 32 | constructor(log: Logger, ffmpegInput: string, cameraName: string, videoProcessor: string) { 33 | this.log = log 34 | this.ffmpegInput = ffmpegInput 35 | this.cameraName = cameraName 36 | this.ffmpegPath = videoProcessor 37 | } 38 | 39 | async startPreBuffer(): Promise { 40 | if (prebufferSession) { 41 | return prebufferSession 42 | } 43 | this.log.debug('start prebuffer', this.cameraName) 44 | // eslint-disable-next-line unused-imports/no-unused-vars 45 | const acodec = [ 46 | '-acodec', 47 | 'copy', 48 | ] 49 | 50 | const vcodec = [ 51 | '-vcodec', 52 | 'copy', 53 | ] 54 | 55 | const fmp4OutputServer: Server = createServer(async (socket) => { 56 | fmp4OutputServer.close() 57 | const parser = parseFragmentedMP4(socket) 58 | for await (const atom of parser) { 59 | const now = Date.now() 60 | if (!this.ftyp) { 61 | this.ftyp = atom 62 | } else if (!this.moov) { 63 | this.moov = atom 64 | } else { 65 | if (atom.type === 'mdat') { 66 | if (this.prevIdr) { 67 | this.idrInterval = now - this.prevIdr 68 | } 69 | this.prevIdr = now 70 | } 71 | 72 | this.prebufferFmp4.push({ 73 | atom, 74 | time: now, 75 | }) 76 | } 77 | 78 | while (this.prebufferFmp4.length && this.prebufferFmp4[0].time < now - defaultPrebufferDuration) { 79 | this.prebufferFmp4.shift() 80 | } 81 | 82 | this.events.emit('atom', atom) 83 | } 84 | }) 85 | const fmp4Port = await listenServer(fmp4OutputServer, this.log) 86 | 87 | const ffmpegOutput = [ 88 | '-f', 89 | 'mp4', 90 | // ...acodec, 91 | ...vcodec, 92 | '-movflags', 93 | 'frag_keyframe+empty_moov+default_base_moof', 94 | `tcp://127.0.0.1:${fmp4Port}`, 95 | ] 96 | 97 | const args: Array = [] 98 | args.push(...this.ffmpegInput.split(' ')) 99 | args.push(...ffmpegOutput) 100 | 101 | this.log.info(`${this.ffmpegPath} ${args.join(' ')}`, this.cameraName) 102 | 103 | const debug = false 104 | 105 | const stdioValue = debug ? 'pipe' : 'ignore' 106 | const cp = spawn(this.ffmpegPath, args, { env, stdio: stdioValue }) 107 | 108 | if (debug) { 109 | cp.stdout?.on('data', data => this.log.debug(data.toString(), this.cameraName)) 110 | cp.stderr?.on('data', data => this.log.debug(data.toString(), this.cameraName)) 111 | } 112 | 113 | prebufferSession = { server: fmp4OutputServer, process: cp } 114 | 115 | return prebufferSession 116 | } 117 | 118 | async getVideo(requestedPrebuffer: number): Promise> { 119 | const server = createServer((socket) => { 120 | server.close() 121 | 122 | const writeAtom = (atom: MP4Atom): void => { 123 | socket.write(Buffer.concat([atom.header, atom.data])) 124 | } 125 | 126 | let cleanup: () => void = (): void => { 127 | this.log.info('prebuffer request ended', this.cameraName) 128 | this.events.removeListener('atom', writeAtom) 129 | this.events.removeListener('killed', cleanup) 130 | socket.removeAllListeners() 131 | socket.destroy() 132 | } 133 | 134 | if (this.ftyp) { 135 | writeAtom(this.ftyp) 136 | } 137 | if (this.moov) { 138 | writeAtom(this.moov) 139 | } 140 | const now = Date.now() 141 | let needMoof = true 142 | for (const prebuffer of this.prebufferFmp4) { 143 | if (prebuffer.time < now - requestedPrebuffer) { 144 | continue 145 | } 146 | if (needMoof && prebuffer.atom.type !== 'moof') { 147 | continue 148 | } 149 | needMoof = false 150 | // console.log('writing prebuffer atom', prebuffer.atom); 151 | writeAtom(prebuffer.atom) 152 | } 153 | 154 | this.events.on('atom', writeAtom) 155 | 156 | cleanup = (): void => { 157 | this.log.info('prebuffer request ended', this.cameraName) 158 | this.events.removeListener('atom', writeAtom) 159 | this.events.removeListener('killed', cleanup) 160 | socket.removeAllListeners() 161 | socket.destroy() 162 | } 163 | 164 | this.events.once('killed', cleanup) 165 | socket.once('end', cleanup) 166 | socket.once('close', cleanup) 167 | socket.once('error', cleanup) 168 | }) 169 | 170 | setTimeout(() => server.close(), 30000) 171 | 172 | const port = await listenServer(server, this.log) 173 | 174 | const ffmpegInput: Array = [ 175 | '-f', 176 | 'mp4', 177 | '-i', 178 | `tcp://127.0.0.1:${port}`, 179 | ] 180 | 181 | return ffmpegInput 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/recordingDelegate.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from 'node:child_process' 2 | import type { Server } from 'node:net' 3 | import type { Readable } from 'node:stream' 4 | 5 | import type { API, CameraController, CameraRecordingConfiguration, CameraRecordingDelegate, HAP, HDSProtocolSpecificErrorReason, RecordingPacket } from 'homebridge' 6 | 7 | import type { VideoConfig } from './settings.js' 8 | import type { Logger } from './logger.js' 9 | import type { Mp4Session } from './settings.js' 10 | 11 | import { Buffer } from 'node:buffer' 12 | import { spawn } from 'node:child_process' 13 | import { once } from 'node:events' 14 | import { createServer } from 'node:net' 15 | import { env } from 'node:process' 16 | 17 | import { APIEvent, AudioRecordingCodecType, H264Level, H264Profile } from 'homebridge' 18 | 19 | import { PreBuffer } from './prebuffer.js' 20 | 21 | import { MP4Atom, FFMpegFragmentedMP4Session, PREBUFFER_LENGTH, ffmpegPathString } from './settings.js' 22 | 23 | export async function listenServer(server: Server, log: Logger): Promise { 24 | let isListening = false 25 | while (!isListening) { 26 | const port = 10000 + Math.round(Math.random() * 30000) 27 | server.listen(port) 28 | try { 29 | await once(server, 'listening') 30 | isListening = true 31 | const address = server.address() 32 | if (address && typeof address === 'object' && 'port' in address) { 33 | return address.port 34 | } 35 | throw new Error('Failed to get server address') 36 | } catch (e: any) { 37 | log.error('Error while listening to the server:', e) 38 | } 39 | } 40 | // Add a return statement to ensure the function always returns a number 41 | return 0 42 | } 43 | 44 | export async function readLength(readable: Readable, length: number): Promise { 45 | if (!length) { 46 | return Buffer.alloc(0) 47 | } 48 | 49 | { 50 | const ret = readable.read(length) 51 | if (ret) { 52 | return ret 53 | } 54 | } 55 | 56 | return new Promise((resolve, reject) => { 57 | const r = (): void => { 58 | const ret = readable.read(length) 59 | if (ret) { 60 | // eslint-disable-next-line ts/no-use-before-define 61 | cleanup() 62 | resolve(ret) 63 | } 64 | } 65 | 66 | const e = (): void => { 67 | // eslint-disable-next-line ts/no-use-before-define 68 | cleanup() 69 | reject(new Error(`stream ended during read for minimum ${length} bytes`)) 70 | } 71 | 72 | const cleanup = (): void => { 73 | readable.removeListener('readable', r) 74 | readable.removeListener('end', e) 75 | } 76 | 77 | readable.on('readable', r) 78 | readable.on('end', e) 79 | }) 80 | } 81 | 82 | export async function* parseFragmentedMP4(readable: Readable): AsyncGenerator { 83 | while (true) { 84 | const header = await readLength(readable, 8) 85 | const length = header.readInt32BE(0) - 8 86 | const type = header.slice(4).toString() 87 | const data = await readLength(readable, length) 88 | 89 | yield { 90 | header, 91 | length, 92 | type, 93 | data, 94 | } 95 | } 96 | } 97 | 98 | export class RecordingDelegate implements CameraRecordingDelegate { 99 | updateRecordingActive(active: boolean): Promise { 100 | this.log.info(`Recording active status changed to: ${active}`, this.cameraName) 101 | return Promise.resolve() 102 | } 103 | 104 | updateRecordingConfiguration(): Promise { 105 | this.log.info('Recording configuration updated', this.cameraName) 106 | return Promise.resolve() 107 | } 108 | 109 | async *handleRecordingStreamRequest(streamId: number): AsyncGenerator { 110 | this.log.info(`Recording stream request received for stream ID: ${streamId}`, this.cameraName) 111 | // Implement the logic to handle the recording stream request here 112 | // For now, just yield an empty RecordingPacket 113 | yield {} as RecordingPacket 114 | } 115 | 116 | closeRecordingStream(streamId: number, reason: HDSProtocolSpecificErrorReason | undefined): void { 117 | this.log.info(`Recording stream closed for stream ID: ${streamId}, reason: ${reason}`, this.cameraName) 118 | } 119 | 120 | private readonly hap: HAP 121 | private readonly log: Logger 122 | private readonly cameraName: string 123 | private readonly videoConfig?: VideoConfig 124 | private process!: ChildProcess 125 | 126 | private readonly videoProcessor: string 127 | readonly controller?: CameraController 128 | private preBufferSession?: Mp4Session 129 | private preBuffer?: PreBuffer 130 | 131 | constructor(log: Logger, cameraName: string, videoConfig: VideoConfig, api: API, hap: HAP, videoProcessor?: string) { 132 | this.log = log 133 | this.hap = hap 134 | this.cameraName = cameraName 135 | this.videoProcessor = videoProcessor || ffmpegPathString || 'ffmpeg' 136 | 137 | api.on(APIEvent.SHUTDOWN, () => { 138 | if (this.preBufferSession) { 139 | this.preBufferSession.process?.kill() 140 | this.preBufferSession.server?.close() 141 | } 142 | }) 143 | } 144 | 145 | async startPreBuffer(): Promise { 146 | this.log.info(`start prebuffer ${this.cameraName}, prebuffer: ${this.videoConfig?.prebuffer}`) 147 | if (this.videoConfig?.prebuffer) { 148 | // looks like the setupAcessory() is called multiple times during startup. Ensure that Prebuffer runs only once 149 | if (!this.preBuffer) { 150 | this.preBuffer = new PreBuffer(this.log, this.videoConfig.source ?? '', this.cameraName, this.videoProcessor) 151 | if (!this.preBufferSession) { 152 | this.preBufferSession = await this.preBuffer.startPreBuffer() 153 | } 154 | } 155 | } 156 | } 157 | 158 | async * handleFragmentsRequests(configuration: CameraRecordingConfiguration): AsyncGenerator { 159 | this.log.debug('video fragments requested', this.cameraName) 160 | 161 | const iframeIntervalSeconds = 4 162 | 163 | const audioArgs: Array = [ 164 | '-acodec', 165 | 'libfdk_aac', 166 | ...(configuration.audioCodec.type === AudioRecordingCodecType.AAC_LC 167 | ? ['-profile:a', 'aac_low'] 168 | : ['-profile:a', 'aac_eld']), 169 | '-ar', 170 | `${configuration.audioCodec.samplerate}k`, 171 | '-b:a', 172 | `${configuration.audioCodec.bitrate}k`, 173 | '-ac', 174 | `${configuration.audioCodec.audioChannels}`, 175 | ] 176 | 177 | const profile = configuration.videoCodec.parameters.profile === H264Profile.HIGH 178 | ? 'high' 179 | : configuration.videoCodec.parameters.profile === H264Profile.MAIN ? 'main' : 'baseline' 180 | 181 | const level = configuration.videoCodec.parameters.level === H264Level.LEVEL4_0 182 | ? '4.0' 183 | : configuration.videoCodec.parameters.level === H264Level.LEVEL3_2 ? '3.2' : '3.1' 184 | 185 | const videoArgs: Array = [ 186 | '-an', 187 | '-sn', 188 | '-dn', 189 | '-codec:v', 190 | 'libx264', 191 | '-pix_fmt', 192 | 'yuv420p', 193 | 194 | '-profile:v', 195 | profile, 196 | '-level:v', 197 | level, 198 | '-b:v', 199 | `${configuration.videoCodec.parameters.bitRate}k`, 200 | '-force_key_frames', 201 | `expr:eq(t,n_forced*${iframeIntervalSeconds})`, 202 | '-r', 203 | configuration.videoCodec.resolution[2].toString(), 204 | ] 205 | 206 | const ffmpegInput: Array = [] 207 | 208 | if (this.videoConfig?.prebuffer) { 209 | const input: Array = this.preBuffer ? await this.preBuffer.getVideo(configuration.mediaContainerConfiguration.fragmentLength ?? PREBUFFER_LENGTH) : [] 210 | ffmpegInput.push(...input) 211 | } else { 212 | ffmpegInput.push(...(this.videoConfig?.source ?? '').split(' ')) 213 | } 214 | 215 | this.log.debug('Start recording...', this.cameraName) 216 | 217 | const session = await this.startFFMPegFragmetedMP4Session(this.videoProcessor, ffmpegInput, audioArgs, videoArgs) 218 | this.log.info('Recording started', this.cameraName) 219 | 220 | const { socket, cp, generator } = session 221 | let pending: Array = [] 222 | let filebuffer: Buffer = Buffer.alloc(0) 223 | try { 224 | for await (const box of generator) { 225 | const { header, type, length, data } = box 226 | 227 | pending.push(header, data) 228 | 229 | if (type === 'moov' || type === 'mdat') { 230 | const fragment = Buffer.concat(pending) 231 | filebuffer = Buffer.concat([filebuffer, Buffer.concat(pending)]) 232 | pending = [] 233 | yield fragment 234 | } 235 | this.log.debug(`mp4 box type ${type} and lenght: ${length}`, this.cameraName) 236 | } 237 | } catch (e) { 238 | this.log.info(`Recoding completed. ${e}`, this.cameraName) 239 | /* 240 | const homedir = require('os').homedir(); 241 | const path = require('path'); 242 | const writeStream = fs.createWriteStream(homedir+path.sep+Date.now()+'_video.mp4'); 243 | writeStream.write(filebuffer); 244 | writeStream.end(); 245 | */ 246 | } finally { 247 | socket.destroy() 248 | cp.kill() 249 | // this.server.close; 250 | } 251 | } 252 | 253 | async startFFMPegFragmetedMP4Session(ffmpegPath: string, ffmpegInput: Array, audioOutputArgs: Array, videoOutputArgs: Array): Promise { 254 | return new Promise((resolve) => { 255 | const server = createServer((socket) => { 256 | server.close() 257 | async function* generator(): AsyncGenerator { 258 | while (true) { 259 | const header = await readLength(socket, 8) 260 | const length = header.readInt32BE(0) - 8 261 | const type = header.slice(4).toString() 262 | const data = await readLength(socket, length) 263 | 264 | yield { 265 | header, 266 | length, 267 | type, 268 | data, 269 | } 270 | } 271 | } 272 | const cp = this.process 273 | resolve({ 274 | socket, 275 | cp, 276 | generator: generator(), 277 | }) 278 | }) 279 | 280 | listenServer(server, this.log).then((serverPort) => { 281 | const args: Array = [] 282 | 283 | args.push(...ffmpegInput) 284 | 285 | // args.push(...audioOutputArgs); 286 | 287 | args.push('-f', 'mp4') 288 | args.push(...videoOutputArgs) 289 | args.push('-fflags', '+genpts', '-reset_timestamps', '1') 290 | args.push( 291 | '-movflags', 292 | 'frag_keyframe+empty_moov+default_base_moof', 293 | `tcp://127.0.0.1:${serverPort}`, 294 | ) 295 | 296 | this.log.debug(`${ffmpegPath} ${args.join(' ')}`, this.cameraName) 297 | 298 | const debug = false 299 | 300 | const stdioValue = debug ? 'pipe' : 'ignore' 301 | this.process = spawn(ffmpegPath, args, { env, stdio: stdioValue }) 302 | const cp = this.process 303 | 304 | if (debug) { 305 | if (cp.stdout) { 306 | cp.stdout.on('data', (data: Buffer) => this.log.debug(data.toString(), this.cameraName)) 307 | } 308 | if (cp.stderr) { 309 | cp.stderr.on('data', (data: Buffer) => this.log.debug(data.toString(), this.cameraName)) 310 | } 311 | } 312 | }) 313 | }) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/settings.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { getVersion } from './settings.js'; 3 | import { readFileSync } from 'fs'; 4 | 5 | vi.mock('fs'); 6 | 7 | describe('getVersion', () => { 8 | it('should return the version from package.json', () => { 9 | const mockVersion = '1.0.0'; 10 | const mockPackageJson = JSON.stringify({ version: mockVersion }); 11 | 12 | vi.mocked(readFileSync).mockReturnValue(mockPackageJson); 13 | 14 | const version = getVersion(); 15 | expect(version).toBe(mockVersion); 16 | }); 17 | 18 | it('should throw an error if package.json is invalid', () => { 19 | vi.mocked(readFileSync).mockReturnValue('invalid json'); 20 | 21 | expect(() => getVersion()).toThrow(SyntaxError); 22 | }); 23 | }); -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from 'child_process'; 2 | import type { PlatformAccessory, PlatformIdentifier, PlatformName, SRTPCryptoSuites } from 'homebridge'; 3 | import type { Server, Socket } from 'net'; 4 | import { defaultFfmpegPath } from '@homebridge/camera-utils' 5 | import { readFileSync } from 'fs'; 6 | import { Type } from 'pick-port'; 7 | 8 | export const PLUGIN_NAME = '@homebridge-plugins/homebridge-camera-ffmpeg'; 9 | 10 | export const PLATFORM_NAME = 'Camera-ffmpeg'; 11 | 12 | export const ffmpegPathString = defaultFfmpegPath as unknown as string 13 | 14 | export const defaultPrebufferDuration = 15000 15 | 16 | export const PREBUFFER_LENGTH = 4000 17 | export const FRAGMENTS_LENGTH = 4000 18 | 19 | export interface MqttAction { 20 | accessory: PlatformAccessory 21 | active: boolean 22 | doorbell: boolean 23 | } 24 | export interface AutomationReturn { 25 | error: boolean 26 | message: string 27 | cooldownActive?: boolean 28 | } 29 | export interface FfmpegPlatformConfig { 30 | platform: PlatformName | PlatformIdentifier 31 | name?: string 32 | videoProcessor?: string 33 | mqtt?: string 34 | portmqtt?: number 35 | tlsmqtt?: boolean 36 | usermqtt?: string 37 | passmqtt?: string 38 | porthttp?: number 39 | localhttp?: boolean 40 | cameras?: Array 41 | } 42 | 43 | export interface CameraConfig { 44 | name?: string 45 | manufacturer?: string 46 | model?: string 47 | serialNumber?: string 48 | firmwareRevision?: string 49 | motion?: boolean 50 | doorbell?: boolean 51 | switches?: boolean 52 | motionTimeout?: number 53 | motionDoorbell?: boolean 54 | mqtt?: MqttCameraConfig 55 | videoConfig?: VideoConfig 56 | } 57 | 58 | export interface VideoConfig { 59 | source?: string 60 | stillImageSource?: string 61 | returnAudioTarget?: string 62 | maxStreams?: number 63 | maxWidth?: number 64 | maxHeight?: number 65 | maxFPS?: number 66 | maxBitrate?: number 67 | forceMax?: boolean 68 | vcodec?: string 69 | packetSize?: number 70 | videoFilter?: string 71 | encoderOptions?: string 72 | mapvideo?: string 73 | mapaudio?: string 74 | audio?: boolean 75 | debug?: boolean 76 | debugReturn?: boolean 77 | recording?: boolean 78 | prebuffer?: boolean 79 | } 80 | 81 | export interface MqttCameraConfig { 82 | motionTopic?: string 83 | motionMessage?: string 84 | motionResetTopic?: string 85 | motionResetMessage?: string 86 | doorbellTopic?: string 87 | doorbellMessage?: string 88 | } 89 | export interface FfmpegProgress { 90 | frame: number 91 | fps: number 92 | stream_q: number 93 | bitrate: number 94 | total_size: number 95 | out_time_us: number 96 | out_time: string 97 | dup_frames: number 98 | drop_frames: number 99 | speed: number 100 | progress: string 101 | } 102 | export interface PrebufferFmp4 { 103 | atom: MP4Atom 104 | time: number 105 | } 106 | 107 | export interface Mp4Session { 108 | server: Server 109 | process: ChildProcess 110 | } 111 | 112 | export interface SessionInfo { 113 | address: string // address of the HAP controller 114 | ipv6: boolean 115 | 116 | videoPort: number 117 | videoReturnPort: number 118 | videoCryptoSuite: SRTPCryptoSuites // should be saved if multiple suites are supported 119 | videoSRTP: Buffer // key and salt concatenated 120 | videoSSRC: number // rtp synchronisation source 121 | 122 | audioPort: number 123 | audioReturnPort: number 124 | audioCryptoSuite: SRTPCryptoSuites 125 | audioSRTP: Buffer 126 | audioSSRC: number 127 | } 128 | 129 | export interface ResolutionInfo { 130 | width: number 131 | height: number 132 | videoFilter?: string 133 | snapFilter?: string 134 | resizeFilter?: string 135 | } 136 | 137 | export interface MP4Atom { 138 | header: Buffer 139 | length: number 140 | type: string 141 | data: Buffer 142 | } 143 | 144 | export interface FFMpegFragmentedMP4Session { 145 | socket: Socket 146 | cp: ChildProcess 147 | generator: AsyncGenerator 148 | } 149 | 150 | export interface PickPortOptions { 151 | type: Type; 152 | ip?: string; 153 | minPort?: number; 154 | maxPort?: number; 155 | reserveTimeout?: number; 156 | } 157 | 158 | export function getVersion() { 159 | const json = JSON.parse( 160 | readFileSync( 161 | new URL('../package.json', import.meta.url), 162 | 'utf-8' 163 | ) 164 | ) 165 | const version = json.version 166 | return version 167 | } 168 | -------------------------------------------------------------------------------- /src/streamingDelegate.ts: -------------------------------------------------------------------------------- 1 | import type { API, AudioRecordingCodec, CameraController, CameraControllerOptions, CameraStreamingDelegate, HAP, PlatformAccessory, PrepareStreamCallback, PrepareStreamRequest, PrepareStreamResponse, SnapshotRequest, SnapshotRequestCallback, StartStreamRequest, StreamingRequest, StreamRequestCallback, VideoInfo } from 'homebridge' 2 | 3 | import { PickPortOptions, CameraConfig, ResolutionInfo, SessionInfo, VideoConfig, FRAGMENTS_LENGTH, PREBUFFER_LENGTH } from './settings.js' 4 | import type { Logger } from './logger.js' 5 | 6 | import { Buffer } from 'node:buffer' 7 | import { spawn } from 'node:child_process' 8 | import { createSocket, Socket } from 'node:dgram' 9 | import { env } from 'node:process' 10 | 11 | import { defaultFfmpegPath } from '@homebridge/camera-utils' 12 | import { APIEvent, AudioRecordingCodecType, AudioRecordingSamplerate, AudioStreamingCodecType, AudioStreamingSamplerate, StreamRequestTypes } from 'homebridge' 13 | import { pickPort } from 'pick-port' 14 | 15 | import { FfmpegProcess } from './ffmpeg.js' 16 | import { RecordingDelegate } from './recordingDelegate.js' 17 | 18 | export interface ActiveSession { 19 | mainProcess?: FfmpegProcess 20 | returnProcess?: FfmpegProcess 21 | timeout?: NodeJS.Timeout 22 | socket?: Socket 23 | } 24 | 25 | export class StreamingDelegate implements CameraStreamingDelegate { 26 | private readonly hap: HAP 27 | private readonly log: Logger 28 | private readonly cameraName: string 29 | private readonly videoConfig!: VideoConfig 30 | private readonly videoProcessor: string 31 | readonly controller: CameraController 32 | private snapshotPromise?: Promise 33 | private readonly api: API 34 | private recording: boolean 35 | private prebuffer: boolean 36 | recordingDelegate: RecordingDelegate | null = null 37 | 38 | // keep track of sessions 39 | pendingSessions: Map = new Map() 40 | ongoingSessions: Map = new Map() 41 | timeouts: Map = new Map() 42 | 43 | constructor(log: Logger, cameraConfig: CameraConfig, api: API, hap: HAP, accessory: PlatformAccessory, videoProcessor?: string) { 44 | this.log = log 45 | this.hap = hap 46 | this.api = api 47 | 48 | this.cameraName = cameraConfig.name! 49 | this.videoConfig = cameraConfig.videoConfig ?? {} 50 | this.videoProcessor = videoProcessor || defaultFfmpegPath as unknown as string || 'ffmpeg' 51 | this.recording = cameraConfig.videoConfig?.recording ?? false 52 | this.prebuffer = this.recording && (cameraConfig.videoConfig?.prebuffer ?? false) 53 | 54 | this.log.debug(this.recording ? 'Recording on' : 'recording off') 55 | this.log.debug(this.prebuffer ? 'Prebuffering on' : 'prebuffering off') 56 | 57 | api.on(APIEvent.SHUTDOWN, () => { 58 | for (const session in this.ongoingSessions) { 59 | this.stopStream(session) 60 | } 61 | }) 62 | 63 | const recordingCodecs: AudioRecordingCodec[] = [] 64 | 65 | const samplerate: AudioRecordingSamplerate[] = [] 66 | for (const sr of [AudioRecordingSamplerate.KHZ_32]) { 67 | samplerate.push(sr) 68 | } 69 | 70 | for (const type of [AudioRecordingCodecType.AAC_LC]) { 71 | const entry: AudioRecordingCodec = { 72 | type, 73 | bitrateMode: 0, 74 | samplerate, 75 | audioChannels: 1, 76 | } 77 | recordingCodecs.push(entry) 78 | } 79 | this.recordingDelegate = this.recording ? new RecordingDelegate(this.log, this.cameraName, this.videoConfig, this.api, this.hap, this.videoProcessor) : null 80 | 81 | const options: CameraControllerOptions = { 82 | cameraStreamCount: this.videoConfig.maxStreams ?? 2, // HomeKit requires at least 2 streams, but 1 is also just fine 83 | delegate: this, 84 | streamingOptions: { 85 | supportedCryptoSuites: [hap.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80], 86 | video: { 87 | resolutions: [ 88 | [320, 180, 30], 89 | [320, 240, 15], // Apple Watch requires this configuration 90 | [320, 240, 30], 91 | [480, 270, 30], 92 | [480, 360, 30], 93 | [640, 360, 30], 94 | [640, 480, 30], 95 | [1280, 720, 30], 96 | [1280, 960, 30], 97 | [1920, 1080, 30], 98 | [1600, 1200, 30], 99 | ], 100 | codec: { 101 | profiles: [hap.H264Profile.BASELINE, hap.H264Profile.MAIN, hap.H264Profile.HIGH], 102 | levels: [hap.H264Level.LEVEL3_1, hap.H264Level.LEVEL3_2, hap.H264Level.LEVEL4_0], 103 | }, 104 | }, 105 | audio: { 106 | twoWayAudio: !!this.videoConfig.returnAudioTarget, 107 | codecs: [ 108 | { 109 | type: AudioStreamingCodecType.AAC_ELD, 110 | samplerate: AudioStreamingSamplerate.KHZ_16, 111 | /* type: AudioStreamingCodecType.OPUS, 112 | samplerate: AudioStreamingSamplerate.KHZ_24 */ 113 | }, 114 | ], 115 | }, 116 | }, 117 | recording: /*! this.recording ? undefined : */ { 118 | options: { 119 | prebufferLength: PREBUFFER_LENGTH, 120 | overrideEventTriggerOptions: [hap.EventTriggerOption.MOTION, hap.EventTriggerOption.DOORBELL], 121 | mediaContainerConfiguration: [{ 122 | type: 0, 123 | fragmentLength: FRAGMENTS_LENGTH, 124 | }], 125 | video: { 126 | type: hap.VideoCodecType.H264, 127 | parameters: { 128 | levels: [hap.H264Level.LEVEL3_1, hap.H264Level.LEVEL3_2, hap.H264Level.LEVEL4_0], 129 | profiles: [hap.H264Profile.BASELINE, hap.H264Profile.MAIN, hap.H264Profile.HIGH], 130 | }, 131 | resolutions: [ 132 | [320, 180, 30], 133 | [320, 240, 15], // Apple Watch requires this configuration 134 | [320, 240, 30], 135 | [480, 270, 30], 136 | [480, 360, 30], 137 | [640, 360, 30], 138 | [640, 480, 30], 139 | [1280, 720, 30], 140 | [1280, 960, 30], 141 | [1920, 1080, 30], 142 | [1600, 1200, 30], 143 | ], 144 | }, 145 | audio: { 146 | codecs: recordingCodecs, 147 | 148 | }, 149 | }, 150 | delegate: this.recordingDelegate!, 151 | }, 152 | } 153 | this.controller = new hap.CameraController(options) 154 | // if(this.prebuffer) this.recordingDelegate.startPreBuffer(); 155 | } 156 | 157 | private determineResolution(request: SnapshotRequest | VideoInfo, isSnapshot: boolean): ResolutionInfo { 158 | const resInfo: ResolutionInfo = { 159 | width: request.width, 160 | height: request.height, 161 | } 162 | if (!isSnapshot) { 163 | if (this.videoConfig.maxWidth !== undefined 164 | && (this.videoConfig.forceMax || request.width > this.videoConfig.maxWidth)) { 165 | resInfo.width = this.videoConfig.maxWidth 166 | } 167 | if (this.videoConfig.maxHeight !== undefined 168 | && (this.videoConfig.forceMax || request.height > this.videoConfig.maxHeight)) { 169 | resInfo.height = this.videoConfig.maxHeight 170 | } 171 | } 172 | 173 | const filters: Array = this.videoConfig.videoFilter?.split(',') || [] 174 | const noneFilter = filters.indexOf('none') 175 | if (noneFilter >= 0) { 176 | filters.splice(noneFilter, 1) 177 | } 178 | resInfo.snapFilter = filters.join(',') 179 | if ((noneFilter < 0) && (resInfo.width > 0 || resInfo.height > 0)) { 180 | resInfo.resizeFilter = `scale=${resInfo.width > 0 ? `'min(${resInfo.width},iw)'` : 'iw'}:${resInfo.height > 0 ? `'min(${resInfo.height},ih)'` : 'ih' 181 | }:force_original_aspect_ratio=decrease` 182 | filters.push(resInfo.resizeFilter) 183 | filters.push('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Force to fit encoder restrictions 184 | } 185 | 186 | if (filters.length > 0) { 187 | resInfo.videoFilter = filters.join(',') 188 | } 189 | 190 | return resInfo 191 | } 192 | 193 | fetchSnapshot(snapFilter?: string): Promise { 194 | this.snapshotPromise = new Promise((resolve, reject) => { 195 | const startTime = Date.now() 196 | const ffmpegArgs = `${this.videoConfig.stillImageSource || this.videoConfig.source! // Still 197 | } -frames:v 1${snapFilter ? ` -filter:v ${snapFilter}` : '' 198 | } -f image2 -` 199 | + ` -hide_banner` 200 | + ` -loglevel error` 201 | 202 | this.log.debug(`Snapshot command: ${this.videoProcessor} ${ffmpegArgs}`, this.cameraName, this.videoConfig.debug) 203 | const ffmpeg = spawn(this.videoProcessor, ffmpegArgs.split(/\s+/), { env }) 204 | 205 | let snapshotBuffer = Buffer.alloc(0) 206 | ffmpeg.stdout.on('data', (data) => { 207 | snapshotBuffer = Buffer.concat([snapshotBuffer, data]) 208 | }) 209 | ffmpeg.on('error', (error: Error) => { 210 | reject(new Error(`FFmpeg process creation failed: ${error.message}`)) 211 | }) 212 | ffmpeg.stderr.on('data', (data) => { 213 | data.toString().split('\n').forEach((line: string) => { 214 | if (this.videoConfig.debug && line.length > 0) { // For now only write anything out when debug is set 215 | this.log.error(line, `${this.cameraName}] [Snapshot`) 216 | } 217 | }) 218 | }) 219 | ffmpeg.on('close', () => { 220 | if (snapshotBuffer.length > 0) { 221 | resolve(snapshotBuffer) 222 | } else { 223 | reject(new Error('Failed to fetch snapshot.')) 224 | } 225 | 226 | setTimeout(() => { 227 | this.snapshotPromise = undefined 228 | }, 3 * 1000) // Expire cached snapshot after 3 seconds 229 | 230 | const runtime = (Date.now() - startTime) / 1000 231 | let message = `Fetching snapshot took ${runtime} seconds.` 232 | if (runtime < 5) { 233 | this.log.debug(message, this.cameraName, this.videoConfig.debug) 234 | } else { 235 | if (runtime < 22) { 236 | this.log.warn(message, this.cameraName) 237 | } else { 238 | message += ' The request has timed out and the snapshot has not been refreshed in HomeKit.' 239 | this.log.error(message, this.cameraName) 240 | } 241 | } 242 | }) 243 | }) 244 | return this.snapshotPromise 245 | } 246 | 247 | resizeSnapshot(snapshot: Buffer, resizeFilter?: string): Promise { 248 | return new Promise((resolve, reject) => { 249 | const ffmpegArgs = `-i pipe:` // Resize 250 | + ` -frames:v 1${resizeFilter ? ` -filter:v ${resizeFilter}` : '' 251 | } -f image2 -` 252 | 253 | this.log.debug(`Resize command: ${this.videoProcessor} ${ffmpegArgs}`, this.cameraName, this.videoConfig.debug) 254 | const ffmpeg = spawn(this.videoProcessor, ffmpegArgs.split(/\s+/), { env }) 255 | 256 | let resizeBuffer = Buffer.alloc(0) 257 | ffmpeg.stdout.on('data', (data) => { 258 | resizeBuffer = Buffer.concat([resizeBuffer, data]) 259 | }) 260 | ffmpeg.on('error', (error: Error) => { 261 | reject(new Error(`FFmpeg process creation failed: ${error.message}`)) 262 | }) 263 | ffmpeg.on('close', () => { 264 | resolve(resizeBuffer) 265 | }) 266 | ffmpeg.stdin.end(snapshot) 267 | }) 268 | } 269 | 270 | async handleSnapshotRequest(request: SnapshotRequest, callback: SnapshotRequestCallback): Promise { 271 | const resolution = this.determineResolution(request, true) 272 | 273 | try { 274 | const cachedSnapshot = !!this.snapshotPromise 275 | 276 | this.log.debug(`Snapshot requested: ${request.width} x ${request.height}`, this.cameraName, this.videoConfig.debug) 277 | 278 | const snapshot = await (this.snapshotPromise || this.fetchSnapshot(resolution.snapFilter)) 279 | 280 | this.log.debug(`Sending snapshot: ${resolution.width > 0 ? resolution.width : 'native'} x ${resolution.height > 0 ? resolution.height : 'native' 281 | }${cachedSnapshot ? ' (cached)' : ''}`, this.cameraName, this.videoConfig.debug) 282 | 283 | const resized = await this.resizeSnapshot(snapshot, resolution.resizeFilter) 284 | callback(undefined, resized) 285 | } catch (err) { 286 | this.log.error(err as string, this.cameraName) 287 | callback(err as Error) 288 | } 289 | } 290 | 291 | async prepareStream(request: PrepareStreamRequest, callback: PrepareStreamCallback): Promise { 292 | const ipv6 = request.addressVersion === 'ipv6' 293 | 294 | const options: PickPortOptions = { 295 | type: 'udp', 296 | ip: ipv6 ? '::' : '0.0.0.0', 297 | reserveTimeout: 15, 298 | } 299 | const videoReturnPort = await pickPort(options) 300 | const videoSSRC = this.hap.CameraController.generateSynchronisationSource() 301 | const audioReturnPort = await pickPort(options) 302 | const audioSSRC = this.hap.CameraController.generateSynchronisationSource() 303 | 304 | const sessionInfo: SessionInfo = { 305 | address: request.targetAddress, 306 | ipv6, 307 | 308 | videoPort: request.video.port, 309 | videoReturnPort, 310 | videoCryptoSuite: request.video.srtpCryptoSuite, 311 | videoSRTP: Buffer.concat([request.video.srtp_key, request.video.srtp_salt]), 312 | videoSSRC, 313 | 314 | audioPort: request.audio.port, 315 | audioReturnPort, 316 | audioCryptoSuite: request.audio.srtpCryptoSuite, 317 | audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]), 318 | audioSSRC, 319 | } 320 | 321 | const response: PrepareStreamResponse = { 322 | video: { 323 | port: videoReturnPort, 324 | ssrc: videoSSRC, 325 | 326 | srtp_key: request.video.srtp_key, 327 | srtp_salt: request.video.srtp_salt, 328 | }, 329 | audio: { 330 | port: audioReturnPort, 331 | ssrc: audioSSRC, 332 | 333 | srtp_key: request.audio.srtp_key, 334 | srtp_salt: request.audio.srtp_salt, 335 | }, 336 | } 337 | 338 | this.pendingSessions.set(request.sessionID, sessionInfo) 339 | callback(undefined, response) 340 | } 341 | 342 | private startStream(request: StartStreamRequest, callback: StreamRequestCallback): void { 343 | const sessionInfo = this.pendingSessions.get(request.sessionID) 344 | if (sessionInfo) { 345 | const vcodec = this.videoConfig.vcodec || 'libx264' 346 | const mtu = this.videoConfig.packetSize || 1316 // request.video.mtu is not used 347 | let encoderOptions = this.videoConfig.encoderOptions 348 | if (!encoderOptions && vcodec === 'libx264') { 349 | encoderOptions = '-preset ultrafast -tune zerolatency' 350 | } 351 | 352 | const resolution = this.determineResolution(request.video, false) 353 | 354 | let fps = (this.videoConfig.maxFPS !== undefined 355 | && (this.videoConfig.forceMax || request.video.fps > this.videoConfig.maxFPS)) 356 | ? this.videoConfig.maxFPS 357 | : request.video.fps 358 | let videoBitrate = (this.videoConfig.maxBitrate !== undefined 359 | && (this.videoConfig.forceMax || request.video.max_bit_rate > this.videoConfig.maxBitrate)) 360 | ? this.videoConfig.maxBitrate 361 | : request.video.max_bit_rate 362 | 363 | if (vcodec === 'copy') { 364 | resolution.width = 0 365 | resolution.height = 0 366 | resolution.videoFilter = undefined 367 | fps = 0 368 | videoBitrate = 0 369 | } 370 | 371 | this.log.debug(`Video stream requested: ${request.video.width} x ${request.video.height}, ${request.video.fps} fps, ${request.video.max_bit_rate} kbps`, this.cameraName, this.videoConfig.debug) 372 | this.log.info(`Starting video stream: ${resolution.width > 0 ? resolution.width : 'native'} x ${resolution.height > 0 ? resolution.height : 'native'}, ${fps > 0 ? fps : 'native' 373 | } fps, ${videoBitrate > 0 ? videoBitrate : '???'} kbps${this.videoConfig.audio ? (` (${request.audio.codec})`) : ''}`, this.cameraName) 374 | 375 | let ffmpegArgs = this.videoConfig.source! 376 | 377 | ffmpegArgs // Video 378 | += `${this.videoConfig.mapvideo ? ` -map ${this.videoConfig.mapvideo}` : ' -an -sn -dn' 379 | } -codec:v ${vcodec 380 | } -pix_fmt yuv420p` 381 | + ` -color_range mpeg${fps > 0 ? ` -r ${fps}` : '' 382 | } -f rawvideo${encoderOptions ? ` ${encoderOptions}` : '' 383 | }${resolution.videoFilter ? ` -filter:v ${resolution.videoFilter}` : '' 384 | }${videoBitrate > 0 ? ` -b:v ${videoBitrate}k` : '' 385 | } -payload_type ${request.video.pt}` 386 | 387 | ffmpegArgs // Video Stream 388 | += ` -ssrc ${sessionInfo.videoSSRC 389 | } -f rtp` 390 | + ` -srtp_out_suite AES_CM_128_HMAC_SHA1_80` 391 | + ` -srtp_out_params ${sessionInfo.videoSRTP.toString('base64') 392 | } srtp://${sessionInfo.address}:${sessionInfo.videoPort 393 | }?rtcpport=${sessionInfo.videoPort}&pkt_size=${mtu}` 394 | 395 | if (this.videoConfig.audio) { 396 | if (request.audio.codec === AudioStreamingCodecType.OPUS || request.audio.codec === AudioStreamingCodecType.AAC_ELD) { 397 | ffmpegArgs // Audio 398 | += `${(this.videoConfig.mapaudio ? ` -map ${this.videoConfig.mapaudio}` : ' -vn -sn -dn') 399 | + (request.audio.codec === AudioStreamingCodecType.OPUS 400 | ? ' -codec:a libopus' 401 | + ' -application lowdelay' 402 | : ' -codec:a libfdk_aac' 403 | + ' -profile:a aac_eld') 404 | } -flags +global_header` 405 | + ` -f null` 406 | + ` -ar ${request.audio.sample_rate}k` 407 | + ` -b:a ${request.audio.max_bit_rate}k` 408 | + ` -ac ${request.audio.channel 409 | } -payload_type ${request.audio.pt}` 410 | 411 | ffmpegArgs // Audio Stream 412 | += ` -ssrc ${sessionInfo.audioSSRC 413 | } -f rtp` 414 | + ` -srtp_out_suite AES_CM_128_HMAC_SHA1_80` 415 | + ` -srtp_out_params ${sessionInfo.audioSRTP.toString('base64') 416 | } srtp://${sessionInfo.address}:${sessionInfo.audioPort 417 | }?rtcpport=${sessionInfo.audioPort}&pkt_size=188` 418 | } else { 419 | this.log.error(`Unsupported audio codec requested: ${request.audio.codec}`, this.cameraName) 420 | } 421 | } 422 | 423 | ffmpegArgs += ` -loglevel level${this.videoConfig.debug ? '+verbose' : '' 424 | } -progress pipe:1` 425 | 426 | const activeSession: ActiveSession = {} 427 | 428 | activeSession.socket = createSocket(sessionInfo.ipv6 ? 'udp6' : 'udp4') 429 | activeSession.socket.on('error', (err: Error) => { 430 | this.log.error(`Socket error: ${err.message}`, this.cameraName) 431 | this.stopStream(request.sessionID) 432 | }) 433 | activeSession.socket.on('message', () => { 434 | if (activeSession.timeout) { 435 | clearTimeout(activeSession.timeout) 436 | } 437 | activeSession.timeout = setTimeout(() => { 438 | this.log.info('Device appears to be inactive. Stopping stream.', this.cameraName) 439 | this.controller.forceStopStreamingSession(request.sessionID) 440 | this.stopStream(request.sessionID) 441 | }, request.video.rtcp_interval * 5 * 1000) 442 | }) 443 | activeSession.socket.bind(sessionInfo.videoReturnPort) 444 | 445 | activeSession.mainProcess = new FfmpegProcess(this.cameraName, request.sessionID, this.videoProcessor, ffmpegArgs, this.log, this.videoConfig.debug, this, callback) 446 | 447 | if (this.videoConfig.returnAudioTarget) { 448 | const ffmpegReturnArgs 449 | = `-hide_banner` 450 | + ` -protocol_whitelist pipe,udp,rtp,file,crypto` 451 | + ` -f sdp` 452 | + ` -c:a libfdk_aac` 453 | + ` -i pipe:` 454 | + ` ${this.videoConfig.returnAudioTarget 455 | } -loglevel level${this.videoConfig.debugReturn ? '+verbose' : ''}` 456 | 457 | const ipVer = sessionInfo.ipv6 ? 'IP6' : 'IP4' 458 | 459 | const sdpReturnAudio 460 | = `v=0\r\n` 461 | + `o=- 0 0 IN ${ipVer} ${sessionInfo.address}\r\n` 462 | + `s=Talk\r\n` 463 | + `c=IN ${ipVer} ${sessionInfo.address}\r\n` 464 | + `t=0 0\r\n` 465 | + `m=audio ${sessionInfo.audioReturnPort} RTP/AVP 110\r\n` 466 | + `b=AS:24\r\n` 467 | + `a=rtpmap:110 MPEG4-GENERIC/16000/1\r\n` 468 | + `a=rtcp-mux\r\n` // FFmpeg ignores this, but might as well 469 | + `a=fmtp:110 ` 470 | + `profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3; ` 471 | + `config=F8F0212C00BC00\r\n` 472 | + `a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:${sessionInfo.audioSRTP.toString('base64')}\r\n` 473 | activeSession.returnProcess = new FfmpegProcess(`${this.cameraName}] [Two-way`, request.sessionID, this.videoProcessor, ffmpegReturnArgs, this.log, this.videoConfig.debugReturn, this) 474 | activeSession.returnProcess.stdin.end(sdpReturnAudio) 475 | } 476 | 477 | this.ongoingSessions.set(request.sessionID, activeSession) 478 | this.pendingSessions.delete(request.sessionID) 479 | } else { 480 | this.log.error('Error finding session information.', this.cameraName) 481 | callback(new Error('Error finding session information')) 482 | } 483 | } 484 | 485 | handleStreamRequest(request: StreamingRequest, callback: StreamRequestCallback): void { 486 | switch (request.type) { 487 | case StreamRequestTypes.START: 488 | this.startStream(request, callback) 489 | break 490 | case StreamRequestTypes.RECONFIGURE: 491 | this.log.debug(`Received request to reconfigure: ${request.video.width} x ${request.video.height}, ${request.video.fps} fps, ${request.video.max_bit_rate} kbps (Ignored)`, this.cameraName, this.videoConfig.debug) 492 | callback() 493 | break 494 | case StreamRequestTypes.STOP: 495 | this.stopStream(request.sessionID) 496 | callback() 497 | break 498 | } 499 | } 500 | 501 | public stopStream(sessionId: string): void { 502 | const session = this.ongoingSessions.get(sessionId) 503 | if (session) { 504 | if (session.timeout) { 505 | clearTimeout(session.timeout) 506 | } 507 | try { 508 | session.socket?.close() 509 | } catch (err) { 510 | this.log.error(`Error occurred closing socket: ${err}`, this.cameraName) 511 | } 512 | try { 513 | session.mainProcess?.stop() 514 | } catch (err) { 515 | this.log.error(`Error occurred terminating main FFmpeg process: ${err}`, this.cameraName) 516 | } 517 | try { 518 | session.returnProcess?.stop() 519 | } catch (err) { 520 | this.log.error(`Error occurred terminating two-way FFmpeg process: ${err}`, this.cameraName) 521 | } 522 | } 523 | this.ongoingSessions.delete(sessionId) 524 | this.log.info('Stopped video stream.', this.cameraName) 525 | } 526 | } 527 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": [ 5 | "DOM", 6 | "ES2022" 7 | ], 8 | "rootDir": "src", 9 | "module": "ES2022", 10 | "moduleResolution": "bundler", 11 | "strict": true, 12 | "noImplicitAny": false, 13 | "declaration": true, 14 | "declarationMap": true, 15 | "outDir": "dist", 16 | "sourceMap": true, 17 | "allowSyntheticDefaultImports": true, 18 | "esModuleInterop": true, 19 | "forceConsistentCasingInFileNames": true 20 | }, 21 | "include": [ 22 | "src" 23 | ], 24 | "exclude": [ 25 | "**/*.spec.ts" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------