├── .nvmrc ├── .prettierrc ├── .DS_Store ├── .vscode └── settings.json ├── src ├── pkg.ts ├── index.ts ├── cameraPlatform.ts ├── tapoCamera.test.ts ├── onvifCamera.ts ├── types │ ├── onvif.ts │ └── tapo.ts ├── cameraAccessory.ts └── tapoCamera.ts ├── nodemon.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.md │ ├── support-request.md │ └── bug-report.md └── workflows │ ├── publish.yml │ └── build.yml ├── .eslintrc ├── tsconfig.json ├── LICENSE ├── .gitignore ├── package.json ├── README.md └── config.schema.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.16.0 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kopiro/homebridge-tapo-camera/HEAD/.DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /src/pkg.ts: -------------------------------------------------------------------------------- 1 | export const PLUGIN_ID = "homebridge-tapo-camera"; 2 | export const PLATFORM_NAME = "tapo-camera"; 3 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": [], 5 | "exec": "tsc && homebridge -I -D", 6 | "signal": "SIGTERM", 7 | "env": { 8 | "NODE_OPTIONS": "--trace-warnings" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # blank_issues_enabled: false 2 | # contact_links: 3 | # - name: Homebridge Discord Community 4 | # url: https://discord.gg/kqNCe2D 5 | # about: Ask your questions in the #YOUR_CHANNEL_HERE channel -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { API } from "homebridge"; 2 | import { CameraPlatform } from "./cameraPlatform"; 3 | import { PLATFORM_NAME } from "./pkg"; 4 | 5 | export = (api: API) => { 6 | api.registerPlatform(PLATFORM_NAME, CameraPlatform); 7 | }; 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/eslint-recommended", 6 | "plugin:@typescript-eslint/recommended" // uses the recommended rules from the @typescript-eslint/eslint-plugin 7 | ], 8 | "parserOptions": { 9 | "ecmaVersion": 2018, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "@typescript-eslint/no-non-null-assertion": "off", 14 | }, 15 | "ignorePatterns": [ 16 | "dist" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | # Setup .npmrc file to publish to npm 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: "20.16.x" 16 | registry-url: "https://registry.npmjs.org" 17 | - run: npm ci 18 | - run: npm publish 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "commonjs", 5 | "moduleResolution":"node", 6 | "lib": [ 7 | "DOM", 8 | "ES2022" 9 | ], 10 | "sourceMap": true, 11 | "rootDir": "src", 12 | "outDir": "dist", 13 | "resolveJsonModule": true, 14 | "strict": true, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "allowSyntheticDefaultImports": true, 18 | "declaration": true, 19 | }, 20 | "include": [ 21 | "src", 22 | "homebridge-ui", 23 | ], 24 | "exclude": [ 25 | "**/*.spec.ts" 26 | ] 27 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [18.13.0, 20.16.x, 22.5.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Lint the project 25 | run: npm run lint 26 | 27 | - name: Build the project 28 | run: npm run build 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | Copyright (c) 2020 Andreas Bauer 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any 5 | purpose with or without fee is hereby granted, provided that the above 6 | copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 10 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 | PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe:** 11 | 12 | 13 | **Describe the solution you'd like:** 14 | 15 | 16 | **Describe alternatives you've considered:** 17 | 18 | 19 | **Additional context:** 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore compiled code 2 | dist 3 | 4 | # ------------- Defaults ------------- # 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Compiled binary addons (https://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directories 27 | node_modules/ 28 | 29 | # TypeScript cache 30 | *.tsbuildinfo 31 | 32 | # Optional npm cache directory 33 | .npm 34 | 35 | # Optional eslint cache 36 | .eslintcache 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | # Output of 'npm pack' 42 | *.tgz 43 | 44 | # Yarn Integrity file 45 | .yarn-integrity 46 | 47 | # dotenv environment variables file 48 | .env 49 | .env.test 50 | 51 | # parcel-bundler cache (https://parceljs.org/) 52 | .cache 53 | 54 | .env -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support Request 3 | about: Need help? 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Describe Your Problem:** 13 | 14 | 15 | **Logs:** 16 | 17 | ``` 18 | Show the Homebridge logs here, remove any sensitive information. 19 | ``` 20 | 21 | **Plugin Config:** 22 | 23 | ```json 24 | Show your Homebridge config.json here, remove any sensitive information. 25 | ``` 26 | 27 | **Screenshots:** 28 | 29 | 30 | **Environment:** 31 | 32 | * **Plugin Version**: 33 | * **Homebridge Version**: 34 | * **Node.js Version**: 35 | * **NPM Version**: 36 | * **Operating System**: 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/cameraPlatform.ts: -------------------------------------------------------------------------------- 1 | import { 2 | API, 3 | IndependentPlatformPlugin, 4 | Logging, 5 | PlatformConfig, 6 | } from "homebridge"; 7 | import { CameraAccessory, CameraConfig } from "./cameraAccessory"; 8 | 9 | export interface CameraPlatformConfig extends PlatformConfig { 10 | cameras?: CameraConfig[]; 11 | } 12 | 13 | export class CameraPlatform implements IndependentPlatformPlugin { 14 | public readonly kDefaultPullInterval = 60000; 15 | 16 | constructor( 17 | public readonly log: Logging, 18 | public readonly config: CameraPlatformConfig, 19 | public readonly api: API 20 | ) { 21 | this.discoverDevices(); 22 | } 23 | 24 | private discoverDevices() { 25 | this.config.cameras?.forEach(async (cameraConfig) => { 26 | try { 27 | const cameraAccessory = new CameraAccessory(this, cameraConfig); 28 | await cameraAccessory.setup(); 29 | } catch (err) { 30 | this.log.error( 31 | `Error during setup of camera "${cameraConfig.name}"`, 32 | err, 33 | err instanceof Error ? err.stack : [] 34 | ); 35 | } 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/tapoCamera.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 3 | import "dotenv/config"; 4 | 5 | import { TAPOCamera } from "./tapoCamera"; 6 | 7 | async function main() { 8 | const tapoCamera = new TAPOCamera({ ...console } as any, { 9 | name: "Test", 10 | ipAddress: process.env.CAMERA_IP!, 11 | username: process.env.CAMERA_USERNAME!, 12 | password: process.env.CAMERA_PASSWORD!, 13 | streamUser: process.env.CAMERA_STREAM_USERNAME!, 14 | streamPassword: process.env.CAMERA_STREAM_PASSWORD!, 15 | }); 16 | 17 | const basicInfo = await tapoCamera.getBasicInfo(); 18 | console.log("basicInfo :>> ", basicInfo); 19 | 20 | const status = await tapoCamera.getStatus(); 21 | console.log("status :>> ", status); 22 | 23 | const streamUrl = tapoCamera.getAuthenticatedStreamUrl(); 24 | console.log("streamUrl :>> ", streamUrl); 25 | 26 | await tapoCamera.setStatus("eyes", false); 27 | setTimeout(async () => { 28 | const status = await tapoCamera.getStatus(); 29 | console.log("status :>> ", status); 30 | }, 5000); 31 | } 32 | 33 | main(); 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Describe The Bug:** 13 | 14 | 15 | **To Reproduce:** 16 | 17 | 18 | **Expected behavior:** 19 | 20 | 21 | **Logs:** 22 | 23 | ``` 24 | Show the Homebridge logs here, remove any sensitive information. 25 | ``` 26 | 27 | **Plugin Config:** 28 | 29 | ```json 30 | Show your Homebridge config.json here, remove any sensitive information. 31 | ``` 32 | 33 | **Screenshots:** 34 | 35 | 36 | **Environment:** 37 | 38 | * **Plugin Version**: 39 | * **Homebridge Version**: 40 | * **Node.js Version**: 41 | * **NPM Version**: 42 | * **Operating System**: 43 | 44 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "Homebridge TAPO Camera", 3 | "name": "homebridge-tapo-camera", 4 | "version": "2.5.2", 5 | "description": "Homebridge plugin for TP-Link TAPO security cameras", 6 | "main": "dist/index.js", 7 | "license": "ISC", 8 | "scripts": { 9 | "lint": "eslint src/**.ts --max-warnings=0", 10 | "watch": "npm run build && npm link && nodemon", 11 | "build": "rimraf ./dist && tsc", 12 | "prepublishOnly": "npm run lint && npm run build" 13 | }, 14 | "keywords": [ 15 | "homebridge-plugin", 16 | "tapo", 17 | "camera", 18 | "tplink" 19 | ], 20 | "bugs": { 21 | "url": "https://github.com/kopiro/homebridge-tapo-camera/issues" 22 | }, 23 | "engines": { 24 | "node": "^18.13.0 || ^20.16.0 || ^22.5.1", 25 | "homebridge": "^1.8.0 || ^2.0.0-beta.0" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/kopiro/homebridge-tapo-camera" 30 | }, 31 | "files": [ 32 | "dist", 33 | "LICENSE", 34 | "config.schema.json" 35 | ], 36 | "author": { 37 | "name": "Flavio De Stefano", 38 | "email": "destefano.flavio@gmail.com", 39 | "url": "https://www.kopiro.me" 40 | }, 41 | "funding": { 42 | "type": "paypal", 43 | "url": "https://www.paypal.me/kopiro" 44 | }, 45 | "dependencies": { 46 | "homebridge-camera-ffmpeg": "^3.1.4", 47 | "onvif": "^0.7.4", 48 | "undici": "^6.21.0" 49 | }, 50 | "devDependencies": { 51 | "@types/node": "^22.10.2", 52 | "@typescript-eslint/eslint-plugin": "^7.18.0", 53 | "@typescript-eslint/parser": "^7.18.0", 54 | "dotenv": "^16.4.7", 55 | "eslint": "^8.57.1", 56 | "homebridge": "^1.8.5", 57 | "nodemon": "^3.1.9", 58 | "rimraf": "^6.0.1", 59 | "ts-node": "^10.9.2", 60 | "typescript": "^5.7.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/onvifCamera.ts: -------------------------------------------------------------------------------- 1 | import { Logging } from "homebridge"; 2 | import { CameraConfig } from "./cameraAccessory"; 3 | import { 4 | DeviceInformation, 5 | NotificationMessage, 6 | Cam as ICam, 7 | } from "./types/onvif"; 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 9 | // @ts-ignore 10 | import { Cam } from "onvif"; 11 | import { EventEmitter } from "stream"; 12 | 13 | export class OnvifCamera { 14 | private events: EventEmitter | undefined; 15 | private device: Cam | undefined; 16 | 17 | private readonly kOnvifPort = 2020; 18 | 19 | constructor( 20 | protected readonly log: Logging, 21 | protected readonly config: CameraConfig 22 | ) {} 23 | 24 | private async getDevice(): Promise { 25 | return new Promise((resolve, reject) => { 26 | if (this.device) { 27 | return resolve(this.device); 28 | } 29 | 30 | const device: ICam = new Cam( 31 | { 32 | hostname: this.config.ipAddress, 33 | username: this.config.streamUser, 34 | password: this.config.streamPassword, 35 | port: this.kOnvifPort, 36 | }, 37 | (err: Error) => { 38 | if (err) return reject(err); 39 | this.device = device; 40 | return resolve(this.device); 41 | } 42 | ); 43 | }); 44 | } 45 | 46 | async getEventEmitter() { 47 | if (this.events) { 48 | return this.events; 49 | } 50 | 51 | const onvifDevice = await this.getDevice(); 52 | 53 | let lastMotionValue = false; 54 | 55 | this.events = new EventEmitter(); 56 | this.log.debug("Starting ONVIF listener..."); 57 | 58 | onvifDevice.on("event", (event: NotificationMessage) => { 59 | if (event?.topic?._?.match(/RuleEngine\/CellMotionDetector\/Motion$/)) { 60 | const motion = event.message.message.data.simpleItem.$.Value; 61 | if (motion !== lastMotionValue) { 62 | lastMotionValue = Boolean(motion); 63 | this.events = this.events || new EventEmitter(); 64 | this.events.emit("motion", motion); 65 | } 66 | } 67 | }); 68 | 69 | return this.events; 70 | } 71 | 72 | async getDeviceInfo(): Promise { 73 | const onvifDevice = await this.getDevice(); 74 | return new Promise((resolve, reject) => { 75 | onvifDevice.getDeviceInformation((err, deviceInformation) => { 76 | if (err) return reject(err); 77 | resolve(deviceInformation); 78 | }); 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-tapo-camera 2 | 3 | Make your TP-Link TAPO security camera compatible with Homekit through Homebridge / HOOBS. 4 | 5 | [![verified-by-homebridge](https://badgen.net/badge/homebridge/verified/purple)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) 6 | 7 | ![photo_2021-11-23 11 57 48](https://user-images.githubusercontent.com/839700/143013358-9f6eed44-3aad-40b0-b1e5-ddc2c5bb24e4.png) 8 | 9 | The plugin exposes the camera RTSP video feed, and toggle accessories to configure your automations. 10 | 11 | If your video feed is not working, try to check if any of the parameters at the video config can be tuned. You can use [https://sunoo.github.io/homebridge-camera-ffmpeg/configs](https://sunoo.github.io/homebridge-camera-ffmpeg/configs) to check if someone has already found the right values for your camera. 12 | 13 | > [!IMPORTANT] 14 | > ~On firmware build 230921 and higher, [please follow this guide](https://github.com/JurajNyiri/HomeAssistant-Tapo-Control/blob/main/add_camera_with_new_firmware.md) to make your camera compatible with this integration.~ 15 | > 16 | > **Update March 2025** 17 | > In the Tapo app, go to "Me" (bottom right), then "Tapo Lab", then "Third-Party Compatibility", change to "On" and the integration should start working again. 18 | > 19 | 20 | ### Toggle accessories 21 | 22 | - _"Eyes"_ controls the privacy mode; when it's on it means that the camera is able to see 23 | (this is to make sure we support the command "Hey Siri, turn _on_ Camera", as this will _disable_ privacy mode). 24 | 25 | - _"Alarm"_ switches on/off the alarm sound. 26 | 27 | - _"Notifications"_ switches on/off the notifications sent to your TAPO app. 28 | 29 | - _"Motion Detection"_ switches on/off the motion detection system. 30 | 31 | - _"LED"_ switches on/off the LED. 32 | 33 | An example Home automation could be: 34 | 35 | - When leaving home, enable *Eyes, Alarm, Notifications, Motion Detection, LED* 36 | - When arriving home: 37 | - If you care about your privacy, disable *Eyes* to switch on privacy mode 38 | - If you want the camera always on, but no notifications, just disable *Alarm* and *Notifications* 39 | 40 | ### Motion sensor 41 | 42 | The motion detection sensor is built on top of the ONVIF protocol and it is enabled by default. 43 | 44 | Therefore you can set up automations and Homekit can send you notification in the Home app when motion is detected. 45 | 46 | Make sure you activate "Activity Notifications" in the "Status and Notifications" tab in the accessory. 47 | 48 | > [!NOTE] 49 | > Some people may have issues resulting the plugin crashing at startup when this option is enabled. If you see an error like `Error: read ECONNRESET at TCP.onStreamRead` try to disable the motion sensor by setting `disableMotionSensorAccessory` to `true` 50 | 51 | ## Installation 52 | 53 | You can install it via Homebridge UI or manually using: 54 | 55 | ```sh 56 | npm -g install homebridge-tapo-camera 57 | ``` 58 | 59 | ### Configuration 60 | 61 | It is highly recommended that you use either Homebridge Config UI X or the HOOBS UI to install and configure this plugin. 62 | 63 | ### FFmpeg installation 64 | 65 | The plugin should take care of installing the `ffmpeg` automatically. 66 | 67 | > [!IMPORTANT] 68 | > If you're getting errors like `FFmpeg exited with code: 1 and signal: null (Error)`, please follow the instructions here on how to install [ffmpeg-for-homebridge](https://github.com/homebridge/ffmpeg-for-homebridge) binaries manually. 69 | 70 | -------------------------------------------------------------------------------- /src/types/onvif.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | export type VideoSource = { 4 | framerate: number; 5 | resolution: { 6 | width: number; 7 | height: number; 8 | }; 9 | }; 10 | 11 | export interface Bounds { 12 | $: { 13 | height: number; 14 | width: number; 15 | y: number; 16 | x: number; 17 | }; 18 | } 19 | 20 | export interface VideoSourceConfiguration { 21 | $: { 22 | token: string; 23 | }; 24 | name: string; 25 | useCount: number; 26 | sourceToken: string; 27 | bounds: Bounds; 28 | } 29 | 30 | export interface AudioSourceConfiguration { 31 | $: { 32 | token: string; 33 | }; 34 | name: string; 35 | useCount: number; 36 | sourceToken: string; 37 | } 38 | export interface Resolution { 39 | width: number; 40 | height: number; 41 | } 42 | 43 | export interface RateControl { 44 | frameRateLimit: number; 45 | encodingInterval: number; 46 | bitrateLimit: number; 47 | } 48 | 49 | export interface H264 { 50 | govLength: number; 51 | H264Profile: string; 52 | } 53 | 54 | export interface Address { 55 | type: string; 56 | IPv4Address: string; 57 | } 58 | 59 | export interface Multicast { 60 | address: Address; 61 | port: number; 62 | TTL: number; 63 | autoStart: boolean; 64 | } 65 | 66 | export interface VideoEncoderConfiguration { 67 | $: { 68 | token: string; 69 | }; 70 | name: string; 71 | useCount: number; 72 | encoding: string; 73 | resolution: Resolution; 74 | quality: number; 75 | rateControl: RateControl; 76 | H264: H264; 77 | multicast: Multicast; 78 | sessionTimeout: string; 79 | } 80 | 81 | export interface Address2 { 82 | type: string; 83 | IPv4Address: string; 84 | } 85 | 86 | export interface Multicast2 { 87 | address: Address2; 88 | port: number; 89 | TTL: number; 90 | autoStart: boolean; 91 | } 92 | 93 | export interface AudioEncoderConfiguration { 94 | $: { 95 | token: string; 96 | }; 97 | name: string; 98 | useCount: number; 99 | encoding: string; 100 | bitrate: number; 101 | sampleRate: number; 102 | multicast: Multicast2; 103 | sessionTimeout: string; 104 | } 105 | 106 | export interface SimpleItem { 107 | $: { 108 | Value: string | boolean; 109 | Name: string; 110 | }; 111 | } 112 | 113 | export interface Translate { 114 | $: { 115 | y: number; 116 | x: number; 117 | }; 118 | } 119 | 120 | export interface Scale { 121 | $: { 122 | y: number; 123 | x: number; 124 | }; 125 | } 126 | 127 | export interface Transformation { 128 | translate: Translate; 129 | scale: Scale; 130 | } 131 | 132 | export interface CellLayout { 133 | $: { 134 | Rows: number; 135 | Columns: number; 136 | }; 137 | transformation: Transformation; 138 | } 139 | 140 | export interface ElementItem { 141 | $: { 142 | Name: string; 143 | }; 144 | cellLayout: CellLayout; 145 | } 146 | 147 | export interface Parameters { 148 | simpleItem: SimpleItem[]; 149 | elementItem: ElementItem; 150 | } 151 | 152 | export interface AnalyticsModule { 153 | parameters: Parameters; 154 | } 155 | 156 | export interface AnalyticsEngineConfiguration { 157 | analyticsModule: AnalyticsModule[]; 158 | } 159 | 160 | export interface Rule { 161 | $: { 162 | Name: string; 163 | Type: string; 164 | }; 165 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 166 | parameters: any; 167 | } 168 | 169 | export interface RuleEngineConfiguration { 170 | rule: Rule[]; 171 | } 172 | 173 | export interface VideoAnalyticsConfiguration { 174 | $: { 175 | token: string; 176 | }; 177 | name: string; 178 | useCount: number; 179 | analyticsEngineConfiguration: AnalyticsEngineConfiguration; 180 | ruleEngineConfiguration: RuleEngineConfiguration; 181 | } 182 | 183 | export interface PanTilt { 184 | $: { 185 | space: string; 186 | y: number; 187 | x: number; 188 | }; 189 | } 190 | 191 | export interface DefaultPTZSpeed { 192 | panTilt: PanTilt; 193 | } 194 | 195 | export interface XRange { 196 | min: number; 197 | max: number; 198 | } 199 | 200 | export interface YRange { 201 | min: number; 202 | max: number; 203 | } 204 | 205 | export interface Range { 206 | URI: string; 207 | XRange: XRange; 208 | YRange: YRange; 209 | } 210 | 211 | export interface PanTiltLimits { 212 | range: Range; 213 | } 214 | 215 | export interface PTZConfiguration { 216 | $: { 217 | token: string; 218 | }; 219 | name: string; 220 | useCount: number; 221 | nodeToken: string; 222 | defaultAbsolutePantTiltPositionSpace: string; 223 | defaultRelativePanTiltTranslationSpace: string; 224 | defaultContinuousPanTiltVelocitySpace: string; 225 | defaultPTZSpeed: DefaultPTZSpeed; 226 | defaultPTZTimeout: string; 227 | panTiltLimits: PanTiltLimits; 228 | } 229 | 230 | export interface Profile { 231 | $: { 232 | fixed: boolean; 233 | token: string; 234 | }; 235 | name: string; 236 | videoSourceConfiguration: VideoSourceConfiguration; 237 | audioSourceConfiguration: AudioSourceConfiguration; 238 | videoEncoderConfiguration: VideoEncoderConfiguration; 239 | audioEncoderConfiguration: AudioEncoderConfiguration; 240 | videoAnalyticsConfiguration: VideoAnalyticsConfiguration; 241 | PTZConfiguration: PTZConfiguration; 242 | } 243 | 244 | export type DeviceInformation = { 245 | manufacturer: string; 246 | model: string; 247 | firmwareVersion: string; 248 | serialNumber: string; 249 | hardwareId: string; 250 | }; 251 | 252 | export type ConnectionCallback = (error?: Error) => void; 253 | 254 | export interface NotificationMessage { 255 | topic: { _: string }; 256 | message: { 257 | message: { 258 | $: object; 259 | source: object; 260 | data: { 261 | simpleItem: SimpleItem; 262 | }; 263 | }; 264 | }; 265 | } 266 | 267 | export interface CamOptions { 268 | hostname: string; 269 | username?: string; 270 | password?: string; 271 | port?: number; 272 | path?: string; 273 | timeout?: number; 274 | preserveAddress?: boolean; 275 | } 276 | 277 | export interface Cam extends EventEmitter { 278 | connect(callback: ConnectionCallback): void; 279 | on(event: "event", listener: (message: NotificationMessage) => void): this; 280 | getDeviceInformation( 281 | callback: (error: Error, deviceInformation: DeviceInformation) => void 282 | ): void; 283 | getProfiles(callback: (error: Error, profiles: Profile[]) => void): void; 284 | 285 | videoSources: VideoSource[]; 286 | } 287 | -------------------------------------------------------------------------------- /src/types/tapo.ts: -------------------------------------------------------------------------------- 1 | export type TAPOCameraGetRequest = 2 | | { 3 | method: "getDeviceInfo"; 4 | params: { 5 | device_info: { 6 | name: ["basic_info"]; 7 | }; 8 | }; 9 | } 10 | | { 11 | method: "getLensMaskConfig"; 12 | params: { 13 | lens_mask: { 14 | name: "lens_mask_info"; 15 | }; 16 | }; 17 | } 18 | | { 19 | method: "getAlertConfig"; 20 | params: { 21 | msg_alarm: { 22 | name: "chn1_msg_alarm_info"; 23 | }; 24 | }; 25 | } 26 | | { 27 | method: "getMsgPushConfig"; 28 | params: { 29 | msg_push: { 30 | name: "chn1_msg_push_info"; 31 | }; 32 | }; 33 | } 34 | | { 35 | method: "getDetectionConfig"; 36 | params: { 37 | motion_detection: { 38 | name: "motion_det"; 39 | }; 40 | }; 41 | } 42 | | { 43 | method: "getLedStatus"; 44 | params: { 45 | led: { 46 | name: "config"; 47 | }; 48 | }; 49 | } 50 | | { 51 | method: "getWhitelampStatus"; 52 | params: { 53 | image: { 54 | get_wtl_status: "null"; 55 | }; 56 | }; 57 | }; 58 | 59 | export type TAPOCameraSetRequest = 60 | | { 61 | method: "setLensMaskConfig"; 62 | params: { 63 | lens_mask: { 64 | lens_mask_info: { 65 | enabled: "off" | "on"; 66 | }; 67 | }; 68 | }; 69 | } 70 | | { 71 | method: "setAlertConfig"; 72 | params: { 73 | msg_alarm: { 74 | chn1_msg_alarm_info: { 75 | alarm_type?: "0" | "1"; 76 | alarm_mode?: ["sound" | "light"]; 77 | enabled: "on" | "off"; 78 | light_type?: "0" | "1"; 79 | }; 80 | }; 81 | }; 82 | } 83 | | { 84 | method: "setMsgPushConfig"; 85 | params: { 86 | msg_push: { 87 | chn1_msg_push_info: { 88 | notification_enabled: "on" | "off"; 89 | rich_notification_enabled: "on" | "off"; 90 | }; 91 | }; 92 | }; 93 | } 94 | | { 95 | method: "setDetectionConfig"; 96 | params: { 97 | motion_detection: { 98 | motion_det: { 99 | enabled: "on" | "off"; 100 | }; 101 | }; 102 | }; 103 | } 104 | | { 105 | method: "setLedStatus"; 106 | params: { 107 | led: { 108 | config: { 109 | enabled: "on" | "off"; 110 | }; 111 | }; 112 | }; 113 | } 114 | | { 115 | method: "setWhitelampConfig"; 116 | params: { 117 | image: { 118 | switch: { 119 | wtl_intensity_level: string; 120 | }; 121 | }; 122 | }; 123 | }; 124 | 125 | export type TAPOCameraUnencryptedRequest = { 126 | method: "multipleRequest"; 127 | params: { 128 | requests: (TAPOCameraGetRequest | TAPOCameraSetRequest)[]; 129 | }; 130 | }; 131 | 132 | export type TAPOCameraEncryptedRequest = { 133 | method: "securePassthrough"; 134 | params: { 135 | request: string; 136 | }; 137 | }; 138 | 139 | export type TAPOCameraRequest = 140 | | TAPOCameraUnencryptedRequest 141 | | TAPOCameraEncryptedRequest; 142 | 143 | export type TAPOCameraEncryptedResponse = { 144 | result?: { 145 | response: string; 146 | }; 147 | }; 148 | 149 | export type TAPOCameraResponseGetAlert = { 150 | method: "getAlertConfig"; 151 | result: { 152 | msg_alarm: { 153 | chn1_msg_alarm_info: { 154 | light_type: "1"; 155 | alarm_type: "1"; 156 | alarm_mode: ["sound", "light"]; 157 | enabled: "on" | "off"; 158 | }; 159 | }; 160 | }; 161 | error_code: number; 162 | }; 163 | 164 | export type TAPOCameraResponseGetLensMask = { 165 | method: "getLensMaskConfig"; 166 | result: { 167 | lens_mask: { 168 | lens_mask_info: { 169 | enabled: "on" | "off"; 170 | }; 171 | }; 172 | }; 173 | error_code: number; 174 | }; 175 | 176 | export type TAPOCameraResponseGetNotifications = { 177 | method: "getMsgPushConfig"; 178 | result: { 179 | msg_push: { 180 | chn1_msg_push_info: { 181 | notification_enabled: "on" | "off"; 182 | rich_notification_enabled: "on" | "off"; 183 | }; 184 | }; 185 | }; 186 | error_code: number; 187 | }; 188 | 189 | export type TAPOCameraResponseGetMotionDetection = { 190 | method: "getDetectionConfig"; 191 | result: { 192 | motion_detection: { 193 | motion_det: { 194 | enabled: "on" | "off"; 195 | }; 196 | }; 197 | }; 198 | error_code: number; 199 | }; 200 | 201 | export type TAPOCameraResponseGetLed = { 202 | method: "getLedStatus"; 203 | result: { 204 | led: { 205 | config: { 206 | enabled: "on" | "off"; 207 | }; 208 | }; 209 | }; 210 | error_code: number; 211 | }; 212 | 213 | export type TAPOCameraResponseSet = { 214 | method: 215 | | "setLensMaskConfig" 216 | | "setAlertConfig" 217 | | "setMsgPushConfig" 218 | | "setDetectionConfig" 219 | | "setLedStatus"; 220 | result: object; 221 | error_code: number; 222 | }; 223 | 224 | export type TAPOCameraResponseGet = 225 | | TAPOCameraResponseGetAlert 226 | | TAPOCameraResponseGetLensMask 227 | | TAPOCameraResponseGetNotifications 228 | | TAPOCameraResponseGetMotionDetection 229 | | TAPOCameraResponseGetLed; 230 | 231 | export type TAPOBasicInfo = { 232 | device_type: string; 233 | device_model: string; 234 | device_name: string; 235 | device_info: string; 236 | hw_version: string; 237 | sw_version: string; 238 | device_alias: string; 239 | avatar: string; 240 | longitude: number; 241 | latitude: number; 242 | has_set_location_info: boolean; 243 | features: string; 244 | barcode: string; 245 | mac: string; 246 | dev_id: string; 247 | oem_id: string; 248 | hw_desc: string; 249 | }; 250 | 251 | export type TAPOCameraResponseDeviceInfo = { 252 | method: "getDeviceInfo"; 253 | result: { 254 | device_info: { 255 | basic_info: TAPOBasicInfo; 256 | }; 257 | }; 258 | error_code: number; 259 | }; 260 | 261 | export type TAPOCameraLoginResponse = { 262 | error_code?: number; 263 | result?: { 264 | data?: { 265 | encrypt_type?: string; 266 | }; 267 | }; 268 | }; 269 | 270 | export type TAPOCameraRefreshStokResponse = { 271 | error_code?: number; 272 | data?: { 273 | code?: number; 274 | sec_left?: number; 275 | }; 276 | result?: { 277 | start_seq?: number; 278 | user_group?: string; 279 | stok?: string; 280 | data?: { 281 | code?: number; 282 | nonce?: string; 283 | device_confirm?: string; 284 | sec_left?: number; 285 | }; 286 | }; 287 | }; 288 | 289 | export type TAPOCameraResponse = { 290 | result: { 291 | error_code: number; 292 | responses: Array< 293 | | TAPOCameraResponseGet 294 | | TAPOCameraResponseSet 295 | | TAPOCameraResponseDeviceInfo 296 | >; 297 | }; 298 | error_code: number; 299 | }; 300 | -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "tapo-camera", 3 | "pluginType": "platform", 4 | "singular": true, 5 | "headerDisplay": "Homebridge plugin for TP-Link TAPO security cameras.", 6 | "footerDisplay": "If your video feed is not working, try to check if any of the parameters at the video config can be tuned. You can use https://sunoo.github.io/homebridge-camera-ffmpeg/configs to check if someone has already found the right values for your camera.", 7 | "form": null, 8 | "display": null, 9 | "schema": { 10 | "cameras": { 11 | "type": "array", 12 | "items": { 13 | "title": "Camera", 14 | "type": "object", 15 | "properties": { 16 | "name": { 17 | "title": "Name", 18 | "type": "string", 19 | "required": true, 20 | "description": "Set the camera name for display in the Home app", 21 | "placeholder": "My Camera" 22 | }, 23 | "ipAddress": { 24 | "title": "IP Address", 25 | "type": "string", 26 | "required": true, 27 | "description": "Set the camera IP address", 28 | "placeholder": "192.168.0.XXX" 29 | }, 30 | "username": { 31 | "title": "TAPO username", 32 | "type": "string", 33 | "description": "Most of the time you should leave this empty, defaulting to admin. If it doesn't work, try to use your streaming username (see below)" 34 | }, 35 | "password": { 36 | "title": "TAPO password", 37 | "type": "string", 38 | "required": true, 39 | "description": "Password of your TAPO Cloud (the one you use to login the application, not the password for the RTSP configuration). If it doesn't work, try to use your streaming password (see below)" 40 | }, 41 | "streamUser": { 42 | "title": "Stream User", 43 | "type": "string", 44 | "required": true, 45 | "description": "Username to access the RTSP video feed (You can find them in TAPO app > Settings > Advanced Settings > Camera Account) - Note: This must be only alphanumeric [A-Za-z0-9], no special characters allowed!", 46 | "placeholder": "user" 47 | }, 48 | "streamPassword": { 49 | "title": "Stream Password", 50 | "type": "string", 51 | "required": true, 52 | "description": "Password to access the RTSP video feed (You can find them in TAPO app > Settings > Advanced Settings > Camera Account) - Note: This must be only alphanumeric [A-Za-z0-9], no special characters allowed!" 53 | }, 54 | "pullInterval": { 55 | "title": "Pull Interval", 56 | "type": "integer", 57 | "description": "Numbers of milliseconds after we update accessories by polling the status of the camera", 58 | "placeholder": 60000 59 | }, 60 | "debug": { 61 | "title": "Debug", 62 | "type": "boolean", 63 | "description": "Enables debugging of the underlying camera-ffmpeg plugin" 64 | }, 65 | "disableStreaming": { 66 | "title": "Disable Streaming", 67 | "type": "boolean", 68 | "description": "Disables the video feed accessory" 69 | }, 70 | "disableEyesToggleAccessory": { 71 | "title": "Disable Eyes toggle", 72 | "type": "boolean", 73 | "description": "Disables the eyes (privacy mode) switch accessory" 74 | }, 75 | "disableAlarmToggleAccessory": { 76 | "title": "Disable Alarm toggle", 77 | "type": "boolean", 78 | "description": "Disables the Alarm switch accessory" 79 | }, 80 | "disableNotificationsToggleAccessory": { 81 | "title": "Disable Notifications toggle", 82 | "type": "boolean", 83 | "description": "Disables the Notifications switch accessory" 84 | }, 85 | "disableMotionDetectionToggleAccessory": { 86 | "title": "Disable Motion Detection toggle", 87 | "type": "boolean", 88 | "description": "Disables the Motion Detection switch accessory" 89 | }, 90 | "disableLEDToggleAccessory": { 91 | "title": "Disable LED toggle", 92 | "type": "boolean", 93 | "description": "Disables the LED switch accessory" 94 | }, 95 | "disableMotionSensorAccessory": { 96 | "title": "Disable Motion sensor", 97 | "type": "boolean", 98 | "description": "Disables the Motion sensor accessory" 99 | }, 100 | "lowQuality": { 101 | "title": "Low Quality", 102 | "type": "boolean", 103 | "description": "Video stream will be requested in low-quality instead of high-quality" 104 | }, 105 | "eyesToggleAccessoryName": { 106 | "title": "Eyes (privacy mode) toggle Name", 107 | "type": "string", 108 | "description": "Name of the Eyes toggle", 109 | "placeholder": "Eyes" 110 | }, 111 | "alarmToggleAccessoryName": { 112 | "title": "Alarm toggle Name", 113 | "type": "string", 114 | "description": "Name of the Alarm toggle", 115 | "placeholder": "Alarm" 116 | }, 117 | "notificationsToggleAccessoryName": { 118 | "title": "Notifications toggle Name", 119 | "type": "string", 120 | "description": "Name of the Notifications toggle", 121 | "placeholder": "Notifications" 122 | }, 123 | "motionDetectionToggleAccessoryName": { 124 | "title": "Motion Detection toggle Name", 125 | "type": "string", 126 | "description": "Name of the Motion Detection toggle", 127 | "placeholder": "Motion Detection" 128 | }, 129 | "ledToggleAccessoryName": { 130 | "title": "LED toggle Name", 131 | "type": "string", 132 | "description": "Name of the LED toggle", 133 | "placeholder": "LED" 134 | }, 135 | "videoMaxWidth": { 136 | "title": "Video Max Width", 137 | "type": "integer", 138 | "placeholder": 1280, 139 | "multipleOf": 2, 140 | "minimum": 0, 141 | "description": "The maximum width used for video streamed to HomeKit. If set to 0, or videoCodec is set to 'copy' (default), the resolution of the source is used. If not set, will use any size HomeKit requests." 142 | }, 143 | "videoMaxHeight": { 144 | "title": "Video Max Height", 145 | "type": "integer", 146 | "placeholder": 720, 147 | "multipleOf": 2, 148 | "minimum": 0, 149 | "description": "The maximum height used for video streamed to HomeKit. If set to 0, or videoCodec is set to 'copy' (default), the resolution of the source is used. If not set, will use any size HomeKit requests." 150 | }, 151 | "videoMaxFPS": { 152 | "title": "Video Max FPS", 153 | "type": "integer", 154 | "placeholder": 30, 155 | "minimum": 0, 156 | "description": "The maximum frame rate used for video streamed to HomeKit. If set to 0, or videoCodec is set to 'copy' (default), the framerate of the source is used. If not set, will use any framerate HomeKit requests." 157 | }, 158 | "videoMaxBitrate": { 159 | "title": "Video Max Bitrate", 160 | "type": "integer", 161 | "placeholder": 299, 162 | "minimum": 0, 163 | "description": "The maximum bitrate used for video streamed to HomeKit, in kbit/s. If not set, or videoCodec is set to 'copy' (default), it will use any bitrate HomeKit requests." 164 | }, 165 | "videoPacketSize": { 166 | "title": "Video Packet Size", 167 | "type": "integer", 168 | "placeholder": 1316, 169 | "multipleOf": 188, 170 | "minimum": 188, 171 | "description": "If audio or video is choppy try a smaller value. If not set, or videoCodec is set to 'copy' (default), it will use any packet size HomeKit requests." 172 | }, 173 | "videoForceMax": { 174 | "title": "Force Max Video", 175 | "type": "boolean", 176 | "description": "If set, the settings requested by HomeKit will be overridden with any 'maximum' values defined in this config. If videoCodec is set to 'copy' (default), this setting set to true is useless." 177 | }, 178 | "videoCodec": { 179 | "title": "Video Codec", 180 | "type": "string", 181 | "placeholder": "copy", 182 | "typeahead": { 183 | "source": [ 184 | "libx264", 185 | "h264_omx", 186 | "h264_videotoolbox", 187 | "copy" 188 | ] 189 | }, 190 | "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. By default, we set 'copy' to avoid any video processing happening on the Homebridge server and disregarding any max values set above, but this also means possibly sending more video data to your devices than necessary, and some Homekit clients may not like not receiving the resolutions/fps they asked for. If you select a custom codec and your ffmpeg process is crashing, it most likely can't handle the video codec you've chosen." 191 | } 192 | } 193 | } 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/cameraAccessory.ts: -------------------------------------------------------------------------------- 1 | import { 2 | API, 3 | Logging, 4 | PlatformAccessory, 5 | PlatformAccessoryEvent, 6 | Service, 7 | } from "homebridge"; 8 | import { StreamingDelegate } from "homebridge-camera-ffmpeg/dist/streamingDelegate"; 9 | import { Logger } from "homebridge-camera-ffmpeg/dist/logger"; 10 | import { Status, TAPOCamera } from "./tapoCamera"; 11 | import { PLUGIN_ID } from "./pkg"; 12 | import { CameraPlatform } from "./cameraPlatform"; 13 | import { VideoConfig } from "homebridge-camera-ffmpeg/dist/configTypes"; 14 | import { TAPOBasicInfo } from "./types/tapo"; 15 | 16 | export type CameraConfig = { 17 | name: string; 18 | ipAddress: string; 19 | username: string; 20 | password: string; 21 | streamUser: string; 22 | streamPassword: string; 23 | 24 | pullInterval?: number; 25 | disableStreaming?: boolean; 26 | disableEyesToggleAccessory?: boolean; 27 | disableAlarmToggleAccessory?: boolean; 28 | disableNotificationsToggleAccessory?: boolean; 29 | disableMotionDetectionToggleAccessory?: boolean; 30 | disableLEDToggleAccessory?: boolean; 31 | 32 | disableMotionSensorAccessory?: boolean; 33 | lowQuality?: boolean; 34 | 35 | videoMaxWidth?: number; 36 | videoMaxHeight?: number; 37 | videoMaxFPS?: number; 38 | videoForceMax?: boolean; 39 | videoMaxBirate?: number; 40 | videoPacketSize?: number; 41 | videoCodec?: string; 42 | 43 | videoConfig?: VideoConfig; 44 | 45 | eyesToggleAccessoryName?: string; 46 | alarmToggleAccessoryName?: string; 47 | notificationsToggleAccessoryName?: string; 48 | motionDetectionToggleAccessoryName?: string; 49 | ledToggleAccessoryName?: string; 50 | }; 51 | 52 | export class CameraAccessory { 53 | private readonly log: Logging; 54 | private readonly api: API; 55 | 56 | private readonly camera: TAPOCamera; 57 | 58 | private pullIntervalTick: NodeJS.Timeout | undefined; 59 | 60 | private readonly accessory: PlatformAccessory; 61 | 62 | private infoAccessory: Service | undefined; 63 | private toggleAccessories: Partial> = {}; 64 | 65 | private motionSensorService: Service | undefined; 66 | 67 | private readonly randomSeed = Math.random(); 68 | 69 | constructor( 70 | private readonly platform: CameraPlatform, 71 | private readonly config: CameraConfig 72 | ) { 73 | // @ts-expect-error - private property 74 | this.log = { 75 | ...this.platform.log, 76 | prefix: this.platform.log.prefix + `/${this.config.name}`, 77 | }; 78 | 79 | this.api = this.platform.api; 80 | this.accessory = new this.api.platformAccessory( 81 | this.config.name, 82 | this.api.hap.uuid.generate(this.config.name), 83 | this.api.hap.Categories.CAMERA 84 | ); 85 | this.camera = new TAPOCamera(this.log, this.config); 86 | } 87 | 88 | private setupInfoAccessory(basicInfo: TAPOBasicInfo) { 89 | this.infoAccessory = 90 | this.accessory.getService(this.api.hap.Service.AccessoryInformation) || 91 | this.accessory.addService(this.api.hap.Service.AccessoryInformation); 92 | this.infoAccessory 93 | .setCharacteristic(this.api.hap.Characteristic.Manufacturer, "TAPO") 94 | .setCharacteristic( 95 | this.api.hap.Characteristic.Model, 96 | basicInfo.device_info 97 | ) 98 | .setCharacteristic( 99 | this.api.hap.Characteristic.SerialNumber, 100 | basicInfo.mac 101 | ) 102 | .setCharacteristic( 103 | this.api.hap.Characteristic.FirmwareRevision, 104 | basicInfo.sw_version 105 | ); 106 | } 107 | 108 | private setupToggleAccessory(name: string, tapoServiceStr: keyof Status) { 109 | try { 110 | const toggleService = this.accessory.addService( 111 | this.api.hap.Service.Switch, 112 | name, 113 | tapoServiceStr 114 | ); 115 | this.toggleAccessories[tapoServiceStr] = toggleService; 116 | 117 | toggleService.addOptionalCharacteristic( 118 | this.api.hap.Characteristic.ConfiguredName 119 | ); 120 | toggleService.setCharacteristic( 121 | this.api.hap.Characteristic.ConfiguredName, 122 | name 123 | ); 124 | 125 | toggleService 126 | .getCharacteristic(this.api.hap.Characteristic.On) 127 | .onGet(async () => { 128 | try { 129 | this.log.debug(`Getting "${tapoServiceStr}" status...`); 130 | 131 | const cameraStatus = await this.camera.getStatus(); 132 | const value = cameraStatus[tapoServiceStr]; 133 | if (value !== undefined) { 134 | return value; 135 | } 136 | 137 | this.log.debug( 138 | `Status "${tapoServiceStr}" not found in status`, 139 | cameraStatus 140 | ); 141 | return null; 142 | } catch (err) { 143 | this.log.error("Error getting status:", err); 144 | return null; 145 | } 146 | }) 147 | .onSet(async (newValue) => { 148 | try { 149 | this.log.debug( 150 | `Setting "${tapoServiceStr}" to ${newValue ? "on" : "off"}...` 151 | ); 152 | this.camera.setStatus(tapoServiceStr, Boolean(newValue)); 153 | } catch (err) { 154 | this.log.error("Error setting status:", err); 155 | throw new this.api.hap.HapStatusError( 156 | this.api.hap.HAPStatus.RESOURCE_DOES_NOT_EXIST 157 | ); 158 | } 159 | }); 160 | } catch (err) { 161 | this.log.error( 162 | "Error setting up toggle accessory", 163 | name, 164 | tapoServiceStr, 165 | err 166 | ); 167 | } 168 | } 169 | 170 | private getVideoConfig(): VideoConfig { 171 | const streamUrl = this.camera.getAuthenticatedStreamUrl( 172 | Boolean(this.config.lowQuality) 173 | ); 174 | 175 | const vcodec = this.config.videoCodec ?? "copy"; 176 | const config: VideoConfig = { 177 | audio: true, // Set audio as true as most of TAPO cameras have audio 178 | vcodec: vcodec, 179 | maxWidth: this.config.videoMaxWidth, 180 | maxHeight: this.config.videoMaxHeight, 181 | maxFPS: this.config.videoMaxFPS, 182 | maxBitrate: this.config.videoMaxBirate, 183 | packetSize: this.config.videoPacketSize, 184 | forceMax: this.config.videoForceMax, 185 | ...(this.config.videoConfig || {}), 186 | // We add this at the end as the user most not be able to override it 187 | source: `-i ${streamUrl}`, 188 | }; 189 | 190 | this.log.debug("Video config", config); 191 | 192 | return config; 193 | } 194 | 195 | private async setupCameraStreaming(basicInfo: TAPOBasicInfo) { 196 | try { 197 | const delegate = new StreamingDelegate( 198 | new Logger(this.log), 199 | { 200 | name: this.config.name, 201 | manufacturer: "TAPO", 202 | model: basicInfo.device_info, 203 | serialNumber: basicInfo.mac, 204 | firmwareRevision: basicInfo.sw_version, 205 | unbridge: true, 206 | videoConfig: this.getVideoConfig(), 207 | }, 208 | this.api, 209 | this.api.hap 210 | ); 211 | 212 | this.accessory.configureController(delegate.controller); 213 | 214 | this.log.debug("Camera streaming setup done"); 215 | } catch (err) { 216 | this.log.error("Error setting up camera streaming:", err); 217 | } 218 | } 219 | 220 | private async setupMotionSensorAccessory() { 221 | try { 222 | this.motionSensorService = this.accessory.addService( 223 | this.platform.api.hap.Service.MotionSensor, 224 | "Motion Sensor", 225 | "motion" 226 | ); 227 | 228 | this.motionSensorService.addOptionalCharacteristic( 229 | this.api.hap.Characteristic.ConfiguredName 230 | ); 231 | this.motionSensorService.setCharacteristic( 232 | this.api.hap.Characteristic.ConfiguredName, 233 | "Motion Sensor" 234 | ); 235 | 236 | const eventEmitter = await this.camera.getEventEmitter(); 237 | eventEmitter.addListener("motion", (motionDetected) => { 238 | this.log.debug("Motion detected", motionDetected); 239 | 240 | this.motionSensorService?.updateCharacteristic( 241 | this.api.hap.Characteristic.MotionDetected, 242 | motionDetected 243 | ); 244 | }); 245 | } catch (err) { 246 | this.log.error("Error setting up motion sensor accessory:", err); 247 | } 248 | } 249 | 250 | private setupPolling() { 251 | if (this.pullIntervalTick) { 252 | clearInterval(this.pullIntervalTick); 253 | } 254 | 255 | this.pullIntervalTick = setInterval(() => { 256 | this.log.debug("Polling status..."); 257 | this.getStatusAndNotify(); 258 | }, this.config.pullInterval || this.platform.kDefaultPullInterval); 259 | } 260 | 261 | private async getStatusAndNotify() { 262 | try { 263 | const cameraStatus = await this.camera.getStatus(); 264 | this.log.debug("Notifying new values...", cameraStatus); 265 | 266 | for (const [key, value] of Object.entries(cameraStatus)) { 267 | const toggleService = this.toggleAccessories[key as keyof Status]; 268 | if (toggleService && value !== undefined) { 269 | toggleService 270 | .getCharacteristic(this.api.hap.Characteristic.On) 271 | .updateValue(value); 272 | } 273 | } 274 | } catch (err) { 275 | this.log.error("Error getting status:", err); 276 | } 277 | } 278 | 279 | async setup() { 280 | const basicInfo = await this.camera.getBasicInfo(); 281 | this.log.debug("Basic info", basicInfo); 282 | 283 | this.accessory.on(PlatformAccessoryEvent.IDENTIFY, () => { 284 | this.log.info("Identify requested", basicInfo); 285 | }); 286 | 287 | this.setupInfoAccessory(basicInfo); 288 | 289 | if (!this.config.disableStreaming) { 290 | this.setupCameraStreaming(basicInfo); 291 | } 292 | 293 | if (!this.config.disableEyesToggleAccessory) { 294 | this.setupToggleAccessory( 295 | this.config.eyesToggleAccessoryName || "Eyes", 296 | "eyes" 297 | ); 298 | } 299 | 300 | if (!this.config.disableAlarmToggleAccessory) { 301 | this.setupToggleAccessory( 302 | this.config.alarmToggleAccessoryName || "Alarm", 303 | "alarm" 304 | ); 305 | } 306 | 307 | if (!this.config.disableNotificationsToggleAccessory) { 308 | this.setupToggleAccessory( 309 | this.config.notificationsToggleAccessoryName || "Notifications", 310 | "notifications" 311 | ); 312 | } 313 | 314 | if (!this.config.disableMotionDetectionToggleAccessory) { 315 | this.setupToggleAccessory( 316 | this.config.motionDetectionToggleAccessoryName || "Motion Detection", 317 | "motionDetection" 318 | ); 319 | } 320 | 321 | if (!this.config.disableLEDToggleAccessory) { 322 | this.setupToggleAccessory( 323 | this.config.ledToggleAccessoryName || "LED", 324 | "led" 325 | ); 326 | } 327 | 328 | if (!this.config.disableMotionSensorAccessory) { 329 | this.setupMotionSensorAccessory(); 330 | } 331 | 332 | // // Publish as external accessory 333 | this.log.debug("Publishing accessory..."); 334 | this.api.publishExternalAccessories(PLUGIN_ID, [this.accessory]); 335 | 336 | // Setup the polling by giving a random delay 337 | // to avoid all the cameras starting at the same time 338 | this.log.debug("Setting up polling..."); 339 | setTimeout(() => { 340 | this.setupPolling(); 341 | }, this.randomSeed * 3_000); 342 | 343 | this.log.debug("Notifying initial values..."); 344 | await this.getStatusAndNotify(); 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/tapoCamera.ts: -------------------------------------------------------------------------------- 1 | import { Logging } from "homebridge"; 2 | import { CameraConfig } from "./cameraAccessory"; 3 | import crypto from "crypto"; 4 | import { OnvifCamera } from "./onvifCamera"; 5 | import type { 6 | TAPOBasicInfo, 7 | TAPOCameraEncryptedRequest, 8 | TAPOCameraEncryptedResponse, 9 | TAPOCameraLoginResponse, 10 | TAPOCameraRefreshStokResponse, 11 | TAPOCameraRequest, 12 | TAPOCameraResponse, 13 | TAPOCameraResponseDeviceInfo, 14 | TAPOCameraSetRequest, 15 | } from "./types/tapo"; 16 | import { Agent } from "undici"; 17 | 18 | const MAX_LOGIN_RETRIES = 2; 19 | const AES_BLOCK_SIZE = 16; 20 | const ERROR_CODES_MAP = { 21 | "-40401": "Invalid stok value", 22 | "-40210": "Function not supported", 23 | "-64303": "Action cannot be done while camera is in patrol mode.", 24 | "-64324": "Privacy mode is ON, not able to execute", 25 | "-64302": "Preset ID not found", 26 | "-64321": "Preset ID was deleted so no longer exists", 27 | "-40106": "Parameter to get/do does not exist", 28 | "-40105": "Method does not exist", 29 | "-40101": "Parameter to set does not exist", 30 | "-40209": "Invalid login credentials", 31 | "-64304": "Maximum Pan/Tilt range reached", 32 | "-71103": "User ID is not authorized", 33 | }; 34 | 35 | export type Status = { 36 | eyes: boolean | undefined; 37 | alarm: boolean | undefined; 38 | notifications: boolean | undefined; 39 | motionDetection: boolean | undefined; 40 | led: boolean | undefined; 41 | }; 42 | 43 | export class TAPOCamera extends OnvifCamera { 44 | private readonly kStreamPort = 554; 45 | private readonly fetchAgent: Agent; 46 | 47 | private readonly hashedPassword: string; 48 | private readonly hashedSha256Password: string; 49 | private passwordEncryptionMethod: "md5" | "sha256" | null = null; 50 | 51 | private isSecureConnectionValue: boolean | null = null; 52 | 53 | private stokPromise: (() => Promise) | undefined; 54 | 55 | private readonly cnonce: string; 56 | private lsk: Buffer | undefined; 57 | private ivb: Buffer | undefined; 58 | private seq: number | undefined; 59 | private stok: string | undefined; 60 | 61 | constructor( 62 | protected readonly log: Logging, 63 | protected readonly config: CameraConfig 64 | ) { 65 | super(log, config); 66 | 67 | this.fetchAgent = new Agent({ 68 | connectTimeout: 5_000, 69 | connect: { 70 | // TAPO devices have self-signed certificates 71 | rejectUnauthorized: false, 72 | ciphers: "AES256-SHA:AES128-GCM-SHA256", 73 | }, 74 | }); 75 | 76 | this.cnonce = this.generateCnonce(); 77 | 78 | this.hashedPassword = crypto 79 | .createHash("md5") 80 | .update(config.password) 81 | .digest("hex") 82 | .toUpperCase(); 83 | this.hashedSha256Password = crypto 84 | .createHash("sha256") 85 | .update(config.password) 86 | .digest("hex") 87 | .toUpperCase(); 88 | } 89 | 90 | private getUsername() { 91 | return this.config.username || "admin"; 92 | } 93 | 94 | private getHeaders(): Record { 95 | return { 96 | Host: `https://${this.config.ipAddress}`, 97 | Referer: `https://${this.config.ipAddress}`, 98 | Accept: "application/json", 99 | "Accept-Encoding": "gzip, deflate", 100 | "User-Agent": "Tapo CameraClient Android", 101 | Connection: "close", 102 | requestByApp: "true", 103 | "Content-Type": "application/json; charset=UTF-8", 104 | }; 105 | } 106 | 107 | private getHashedPassword() { 108 | if (this.passwordEncryptionMethod === "md5") { 109 | return this.hashedPassword; 110 | } else if (this.passwordEncryptionMethod === "sha256") { 111 | return this.hashedSha256Password; 112 | } else { 113 | throw new Error("Unknown password encryption method"); 114 | } 115 | } 116 | 117 | private fetch(url: string, data: RequestInit) { 118 | return fetch(url, { 119 | headers: this.getHeaders(), 120 | // @ts-expect-error Dispatcher type not there 121 | dispatcher: this.fetchAgent, 122 | ...data, 123 | }); 124 | } 125 | 126 | private generateEncryptionToken(tokenType: string, nonce: string): Buffer { 127 | const hashedKey = crypto 128 | .createHash("sha256") 129 | .update(this.cnonce + this.getHashedPassword() + nonce) 130 | .digest("hex") 131 | .toUpperCase(); 132 | return crypto 133 | .createHash("sha256") 134 | .update(tokenType + this.cnonce + nonce + hashedKey) 135 | .digest() 136 | .slice(0, 16); 137 | } 138 | 139 | getAuthenticatedStreamUrl(lowQuality = false) { 140 | const prefix = `rtsp://${this.config.streamUser}:${this.config.streamPassword}@${this.config.ipAddress}:${this.kStreamPort}`; 141 | return lowQuality ? `${prefix}/stream2` : `${prefix}/stream1`; 142 | } 143 | 144 | private generateCnonce() { 145 | return crypto.randomBytes(8).toString("hex").toUpperCase(); 146 | } 147 | 148 | private validateDeviceConfirm(nonce: string, deviceConfirm: string) { 149 | this.passwordEncryptionMethod = null; 150 | 151 | const hashedNoncesWithSHA256 = crypto 152 | .createHash("sha256") 153 | .update(this.cnonce + this.hashedSha256Password + nonce) 154 | .digest("hex") 155 | .toUpperCase(); 156 | if (deviceConfirm === hashedNoncesWithSHA256 + nonce + this.cnonce) { 157 | this.passwordEncryptionMethod = "sha256"; 158 | return true; 159 | } 160 | 161 | const hashedNoncesWithMD5 = crypto 162 | .createHash("md5") 163 | .update(this.cnonce + this.hashedPassword + nonce) 164 | .digest("hex") 165 | .toUpperCase(); 166 | if (deviceConfirm === hashedNoncesWithMD5 + nonce + this.cnonce) { 167 | this.passwordEncryptionMethod = "md5"; 168 | return true; 169 | } 170 | 171 | this.log.debug( 172 | 'Invalid device confirm, expected "sha256" or "md5" to match, but none found', 173 | { 174 | hashedNoncesWithMD5, 175 | hashedNoncesWithSHA256, 176 | deviceConfirm, 177 | nonce, 178 | cnonce: this, 179 | } 180 | ); 181 | 182 | return this.passwordEncryptionMethod !== null; 183 | } 184 | 185 | async refreshStok(loginRetryCount = 0): Promise { 186 | this.log.debug("refreshStok: Refreshing stok..."); 187 | 188 | const isSecureConnection = await this.isSecureConnection(); 189 | 190 | let fetchParams = {}; 191 | if (isSecureConnection) { 192 | fetchParams = { 193 | method: "post", 194 | body: JSON.stringify({ 195 | method: "login", 196 | params: { 197 | cnonce: this.cnonce, 198 | encrypt_type: "3", 199 | username: this.getUsername(), 200 | }, 201 | }), 202 | }; 203 | } else { 204 | fetchParams = { 205 | method: "post", 206 | body: JSON.stringify({ 207 | method: "login", 208 | params: { 209 | username: this.getUsername(), 210 | password: this.hashedPassword, 211 | hashed: true, 212 | }, 213 | }), 214 | }; 215 | } 216 | 217 | const responseLogin = await this.fetch( 218 | `https://${this.config.ipAddress}`, 219 | fetchParams 220 | ); 221 | const responseLoginData = 222 | (await responseLogin.json()) as TAPOCameraRefreshStokResponse; 223 | 224 | let response, responseData; 225 | 226 | if (!responseLoginData) { 227 | this.log.debug( 228 | "refreshStok: empty response login data, raising exception", 229 | responseLogin.status 230 | ); 231 | throw new Error("Empty response login data"); 232 | } 233 | 234 | this.log.debug( 235 | "refreshStok: Login response", 236 | responseLogin.status, 237 | responseLoginData 238 | ); 239 | 240 | if ( 241 | responseLogin.status === 401 && 242 | responseLoginData.result?.data?.code === -40411 243 | ) { 244 | this.log.debug( 245 | "refreshStok: invalid credentials, raising exception", 246 | responseLogin.status 247 | ); 248 | throw new Error("Invalid credentials"); 249 | } 250 | 251 | if (isSecureConnection) { 252 | const nonce = responseLoginData.result?.data?.nonce; 253 | const deviceConfirm = responseLoginData.result?.data?.device_confirm; 254 | if ( 255 | nonce && 256 | deviceConfirm && 257 | this.validateDeviceConfirm(nonce, deviceConfirm) 258 | ) { 259 | const digestPasswd = crypto 260 | .createHash("sha256") 261 | .update(this.getHashedPassword() + this.cnonce + nonce) 262 | .digest("hex") 263 | .toUpperCase(); 264 | 265 | const digestPasswdFull = Buffer.concat([ 266 | Buffer.from(digestPasswd, "utf8"), 267 | Buffer.from(this.cnonce!, "utf8"), 268 | Buffer.from(nonce, "utf8"), 269 | ]).toString("utf8"); 270 | 271 | this.log.debug("refreshStok: sending start_seq request"); 272 | 273 | response = await this.fetch(`https://${this.config.ipAddress}`, { 274 | method: "POST", 275 | body: JSON.stringify({ 276 | method: "login", 277 | params: { 278 | cnonce: this.cnonce, 279 | encrypt_type: "3", 280 | digest_passwd: digestPasswdFull, 281 | username: this.getUsername(), 282 | }, 283 | }), 284 | }); 285 | 286 | responseData = (await response.json()) as TAPOCameraRefreshStokResponse; 287 | 288 | if (!responseData) { 289 | this.log.debug( 290 | "refreshStock: empty response start_seq data, raising exception", 291 | response.status 292 | ); 293 | throw new Error("Empty response start_seq data"); 294 | } 295 | 296 | this.log.debug( 297 | "refreshStok: start_seq response", 298 | response.status, 299 | JSON.stringify(responseData) 300 | ); 301 | 302 | if (responseData.result?.start_seq) { 303 | if (responseData.result?.user_group !== "root") { 304 | this.log.debug("refreshStock: Incorrect user_group detected"); 305 | 306 | // # encrypted control via 3rd party account does not seem to be supported 307 | // # see https://github.com/JurajNyiri/HomeAssistant-Tapo-Control/issues/456 308 | throw new Error("Incorrect user_group detected"); 309 | } 310 | 311 | this.lsk = this.generateEncryptionToken("lsk", nonce); 312 | this.ivb = this.generateEncryptionToken("ivb", nonce); 313 | this.seq = responseData.result.start_seq; 314 | } 315 | } else { 316 | if ( 317 | responseLoginData.error_code === -40413 && 318 | loginRetryCount < MAX_LOGIN_RETRIES 319 | ) { 320 | this.log.debug( 321 | `refreshStock: Invalid device confirm, retrying: ${loginRetryCount}/${MAX_LOGIN_RETRIES}.`, 322 | responseLogin.status, 323 | responseLoginData 324 | ); 325 | return this.refreshStok(loginRetryCount + 1); 326 | } 327 | 328 | this.log.debug( 329 | "refreshStock: Invalid device confirm and loginRetryCount exhausted, raising exception", 330 | loginRetryCount, 331 | responseLoginData 332 | ); 333 | throw new Error("Invalid device confirm"); 334 | } 335 | } else { 336 | this.passwordEncryptionMethod = "md5"; 337 | response = responseLogin; 338 | responseData = responseLoginData; 339 | } 340 | 341 | if ( 342 | responseData.result?.data?.sec_left && 343 | responseData.result.data.sec_left > 0 344 | ) { 345 | this.log.debug("refreshStok: temporary suspension", responseData); 346 | 347 | throw new Error( 348 | `Temporary Suspension: Try again in ${responseData.result.data.sec_left} seconds` 349 | ); 350 | } 351 | 352 | if ( 353 | responseData?.data?.code === -40404 && 354 | responseData?.data?.sec_left && 355 | responseData.data.sec_left > 0 356 | ) { 357 | this.log.debug("refreshStok: temporary suspension", responseData); 358 | 359 | throw new Error( 360 | `refreshStok: Temporary Suspension: Try again in ${responseData.data.sec_left} seconds` 361 | ); 362 | } 363 | 364 | if (responseData?.result?.stok) { 365 | this.stok = responseData.result.stok; 366 | this.log.debug("refreshStok: Success in obtaining STOK", this.stok); 367 | return; 368 | } 369 | 370 | if ( 371 | responseData?.error_code === -40413 && 372 | loginRetryCount < MAX_LOGIN_RETRIES 373 | ) { 374 | this.log.debug( 375 | `refreshStock: Unexpected response, retrying: ${loginRetryCount}/${MAX_LOGIN_RETRIES}.`, 376 | response.status, 377 | responseData 378 | ); 379 | return this.refreshStok(loginRetryCount + 1); 380 | } 381 | 382 | this.log.debug("refreshStock: Unexpected end of flow, raising exception"); 383 | throw new Error("Invalid authentication data"); 384 | } 385 | 386 | async isSecureConnection() { 387 | if (this.isSecureConnectionValue === null) { 388 | this.log.debug("isSecureConnection: Checking secure connection..."); 389 | 390 | const response = await this.fetch(`https://${this.config.ipAddress}`, { 391 | method: "post", 392 | body: JSON.stringify({ 393 | method: "login", 394 | params: { 395 | encrypt_type: "3", 396 | username: this.getUsername(), 397 | }, 398 | }), 399 | }); 400 | const responseData = (await response.json()) as TAPOCameraLoginResponse; 401 | 402 | this.log.debug( 403 | "isSecureConnection response", 404 | response.status, 405 | JSON.stringify(responseData) 406 | ); 407 | 408 | this.isSecureConnectionValue = 409 | responseData?.error_code == -40413 && 410 | String(responseData.result?.data?.encrypt_type || "")?.includes("3"); 411 | } 412 | 413 | return this.isSecureConnectionValue; 414 | } 415 | 416 | getStok(loginRetryCount = 0): Promise { 417 | return new Promise((resolve) => { 418 | if (this.stok) { 419 | return resolve(this.stok); 420 | } 421 | 422 | if (!this.stokPromise) { 423 | this.stokPromise = () => this.refreshStok(loginRetryCount); 424 | } 425 | 426 | this.stokPromise() 427 | .then(() => { 428 | if (!this.stok) { 429 | throw new Error("STOK not found"); 430 | } 431 | resolve(this.stok!); 432 | }) 433 | .finally(() => { 434 | this.stokPromise = undefined; 435 | }); 436 | }); 437 | } 438 | 439 | private async getAuthenticatedAPIURL(loginRetryCount = 0) { 440 | const token = await this.getStok(loginRetryCount); 441 | return `https://${this.config.ipAddress}/stok=${token}/ds`; 442 | } 443 | 444 | encryptRequest(request: string) { 445 | const cipher = crypto.createCipheriv("aes-128-cbc", this.lsk!, this.ivb!); 446 | let ct_bytes = cipher.update( 447 | this.encryptPad(request, AES_BLOCK_SIZE), 448 | "utf-8", 449 | "hex" 450 | ); 451 | ct_bytes += cipher.final("hex"); 452 | return Buffer.from(ct_bytes, "hex"); 453 | } 454 | 455 | private encryptPad(text: string, blocksize: number) { 456 | const padSize = blocksize - (text.length % blocksize); 457 | const padding = String.fromCharCode(padSize).repeat(padSize); 458 | return text + padding; 459 | } 460 | 461 | private decryptResponse(response: string): string { 462 | const decipher = crypto.createDecipheriv( 463 | "aes-128-cbc", 464 | this.lsk!, 465 | this.ivb! 466 | ); 467 | let decrypted = decipher.update(response, "base64", "utf-8"); 468 | decrypted += decipher.final("utf-8"); 469 | return this.encryptUnpad(decrypted, AES_BLOCK_SIZE); 470 | } 471 | 472 | private encryptUnpad(text: string, blockSize: number): string { 473 | const paddingLength = Number(text[text.length - 1]) || 0; 474 | if (paddingLength > blockSize || paddingLength > text.length) { 475 | throw new Error("Invalid padding"); 476 | } 477 | for (let i = text.length - paddingLength; i < text.length; i++) { 478 | if (text.charCodeAt(i) !== paddingLength) { 479 | throw new Error("Invalid padding"); 480 | } 481 | } 482 | return text.slice(0, text.length - paddingLength).toString(); 483 | } 484 | 485 | private getTapoTag(request: TAPOCameraEncryptedRequest) { 486 | const tag = crypto 487 | .createHash("sha256") 488 | .update(this.getHashedPassword() + this.cnonce) 489 | .digest("hex") 490 | .toUpperCase(); 491 | return crypto 492 | .createHash("sha256") 493 | .update(tag + JSON.stringify(request) + this.seq!.toString()) 494 | .digest("hex") 495 | .toUpperCase(); 496 | } 497 | 498 | private pendingAPIRequests: Map> = 499 | new Map(); 500 | 501 | private async apiRequest( 502 | req: TAPOCameraRequest, 503 | loginRetryCount = 0 504 | ): Promise { 505 | const reqJson = JSON.stringify(req); 506 | 507 | if (this.pendingAPIRequests.has(reqJson)) { 508 | this.log.debug("API request already pending", reqJson); 509 | return this.pendingAPIRequests.get( 510 | reqJson 511 | ) as Promise; 512 | } else { 513 | this.log.debug("New API request", reqJson); 514 | } 515 | 516 | this.pendingAPIRequests.set( 517 | reqJson, 518 | (async () => { 519 | try { 520 | const isSecureConnection = await this.isSecureConnection(); 521 | const url = await this.getAuthenticatedAPIURL(loginRetryCount); 522 | 523 | const fetchParams: RequestInit = { 524 | method: "post", 525 | }; 526 | 527 | if (this.seq && isSecureConnection) { 528 | const encryptedRequest: TAPOCameraEncryptedRequest = { 529 | method: "securePassthrough", 530 | params: { 531 | request: Buffer.from( 532 | this.encryptRequest(JSON.stringify(req)) 533 | ).toString("base64"), 534 | }, 535 | }; 536 | fetchParams.headers = { 537 | ...this.getHeaders(), 538 | Tapo_tag: this.getTapoTag(encryptedRequest), 539 | Seq: this.seq.toString(), 540 | }; 541 | fetchParams.body = JSON.stringify(encryptedRequest); 542 | this.seq += 1; 543 | } else { 544 | fetchParams.body = JSON.stringify(req); 545 | } 546 | 547 | const response = await this.fetch(url, fetchParams); 548 | const responseDataTmp = await response.json(); 549 | 550 | // Apparently the Tapo C200 returns 500 on successful requests, 551 | // but it's indicating an expiring token, therefore refresh the token next time 552 | if (isSecureConnection && response.status === 500) { 553 | this.log.debug( 554 | "Stok expired, reauthenticating on next request, setting STOK to undefined" 555 | ); 556 | this.stok = undefined; 557 | } 558 | 559 | let responseData: TAPOCameraResponse | null = null; 560 | 561 | if (isSecureConnection) { 562 | const encryptedResponse = 563 | responseDataTmp as TAPOCameraEncryptedResponse; 564 | if (encryptedResponse?.result?.response) { 565 | const decryptedResponse = this.decryptResponse( 566 | encryptedResponse.result.response 567 | ); 568 | responseData = JSON.parse( 569 | decryptedResponse 570 | ) as TAPOCameraResponse; 571 | } 572 | } else { 573 | responseData = responseDataTmp as TAPOCameraResponse; 574 | } 575 | 576 | this.log.debug( 577 | "API response", 578 | response.status, 579 | JSON.stringify(responseData) 580 | ); 581 | 582 | // Log error codes 583 | if (responseData && responseData.error_code !== 0) { 584 | const errorCode = String(responseData.error_code); 585 | const errorMessage = 586 | errorCode in ERROR_CODES_MAP 587 | ? ERROR_CODES_MAP[errorCode as keyof typeof ERROR_CODES_MAP] 588 | : "Unknown error"; 589 | this.log.debug( 590 | `API request failed with specific error code ${errorCode}: ${errorMessage}` 591 | ); 592 | } 593 | 594 | if ( 595 | !responseData || 596 | responseData.error_code === -40401 || 597 | responseData.error_code === -1 598 | ) { 599 | this.log.debug( 600 | "API request failed, reauth now and trying same request again", 601 | responseData 602 | ); 603 | this.stok = undefined; 604 | return this.apiRequest(req, loginRetryCount + 1); 605 | } 606 | 607 | // Success 608 | return responseData; 609 | } finally { 610 | this.pendingAPIRequests.delete(reqJson); 611 | } 612 | })() 613 | ); 614 | 615 | return this.pendingAPIRequests.get(reqJson) as Promise; 616 | } 617 | 618 | static SERVICE_MAP: Record< 619 | keyof Status, 620 | (value: boolean) => TAPOCameraSetRequest 621 | > = { 622 | eyes: (value) => ({ 623 | method: "setLensMaskConfig", 624 | params: { 625 | lens_mask: { 626 | lens_mask_info: { 627 | // Watch out for the inversion 628 | enabled: value ? "off" : "on", 629 | }, 630 | }, 631 | }, 632 | }), 633 | alarm: (value) => ({ 634 | method: "setAlertConfig", 635 | params: { 636 | msg_alarm: { 637 | chn1_msg_alarm_info: { 638 | enabled: value ? "on" : "off", 639 | }, 640 | }, 641 | }, 642 | }), 643 | notifications: (value) => ({ 644 | method: "setMsgPushConfig", 645 | params: { 646 | msg_push: { 647 | chn1_msg_push_info: { 648 | notification_enabled: value ? "on" : "off", 649 | rich_notification_enabled: value ? "on" : "off", 650 | }, 651 | }, 652 | }, 653 | }), 654 | motionDetection: (value) => ({ 655 | method: "setDetectionConfig", 656 | params: { 657 | motion_detection: { 658 | motion_det: { 659 | enabled: value ? "on" : "off", 660 | }, 661 | }, 662 | }, 663 | }), 664 | led: (value) => ({ 665 | method: "setLedStatus", 666 | params: { 667 | led: { 668 | config: { 669 | enabled: value ? "on" : "off", 670 | }, 671 | }, 672 | }, 673 | }), 674 | }; 675 | 676 | async setStatus(service: keyof Status, value: boolean) { 677 | const responseData = await this.apiRequest({ 678 | method: "multipleRequest", 679 | params: { 680 | requests: [TAPOCamera.SERVICE_MAP[service](value)], 681 | }, 682 | }); 683 | 684 | if (responseData.error_code !== 0) { 685 | throw new Error(`Failed to perform ${service} action`); 686 | } 687 | 688 | const method = TAPOCamera.SERVICE_MAP[service](value).method; 689 | const operation = responseData.result.responses.find( 690 | (e) => e.method === method 691 | ); 692 | if (operation?.error_code !== 0) { 693 | throw new Error(`Failed to perform ${service} action`); 694 | } 695 | 696 | return operation.result; 697 | } 698 | 699 | async getBasicInfo(): Promise { 700 | const responseData = await this.apiRequest({ 701 | method: "multipleRequest", 702 | params: { 703 | requests: [ 704 | { 705 | method: "getDeviceInfo", 706 | params: { 707 | device_info: { 708 | name: ["basic_info"], 709 | }, 710 | }, 711 | }, 712 | ], 713 | }, 714 | }); 715 | 716 | const info = responseData.result 717 | .responses[0] as TAPOCameraResponseDeviceInfo; 718 | return info.result.device_info.basic_info; 719 | } 720 | 721 | async getStatus(): Promise { 722 | const responseData = await this.apiRequest({ 723 | method: "multipleRequest", 724 | params: { 725 | requests: [ 726 | { 727 | method: "getAlertConfig", 728 | params: { 729 | msg_alarm: { 730 | name: "chn1_msg_alarm_info", 731 | }, 732 | }, 733 | }, 734 | { 735 | method: "getLensMaskConfig", 736 | params: { 737 | lens_mask: { 738 | name: "lens_mask_info", 739 | }, 740 | }, 741 | }, 742 | { 743 | method: "getMsgPushConfig", 744 | params: { 745 | msg_push: { 746 | name: "chn1_msg_push_info", 747 | }, 748 | }, 749 | }, 750 | { 751 | method: "getDetectionConfig", 752 | params: { 753 | motion_detection: { 754 | name: "motion_det", 755 | }, 756 | }, 757 | }, 758 | { 759 | method: "getLedStatus", 760 | params: { 761 | led: { 762 | name: "config", 763 | }, 764 | }, 765 | }, 766 | ], 767 | }, 768 | }); 769 | 770 | const operations = responseData.result.responses; 771 | 772 | const alert = operations.find((r) => r.method === "getAlertConfig"); 773 | const lensMask = operations.find((r) => r.method === "getLensMaskConfig"); 774 | const notifications = operations.find( 775 | (r) => r.method === "getMsgPushConfig" 776 | ); 777 | const motionDetection = operations.find( 778 | (r) => r.method === "getDetectionConfig" 779 | ); 780 | const led = operations.find((r) => r.method === "getLedStatus"); 781 | 782 | if (!alert) this.log.debug("No alert config found"); 783 | if (!lensMask) this.log.debug("No lens mask config found"); 784 | if (!notifications) this.log.debug("No notifications config found"); 785 | if (!motionDetection) this.log.debug("No motion detection config found"); 786 | if (!led) this.log.debug("No led config found"); 787 | 788 | return { 789 | alarm: alert 790 | ? alert.result.msg_alarm.chn1_msg_alarm_info.enabled === "on" 791 | : undefined, 792 | // Watch out for the inversion 793 | eyes: lensMask 794 | ? lensMask.result.lens_mask.lens_mask_info.enabled === "off" 795 | : undefined, 796 | notifications: notifications 797 | ? notifications.result.msg_push.chn1_msg_push_info 798 | .notification_enabled === "on" 799 | : undefined, 800 | motionDetection: motionDetection 801 | ? motionDetection.result.motion_detection.motion_det.enabled === "on" 802 | : undefined, 803 | led: led ? led.result.led.config.enabled === "on" : undefined, 804 | }; 805 | } 806 | } 807 | --------------------------------------------------------------------------------