Repository: 0x5e/homebridge-tuya-platform Branch: develop_1.7.0 Commit: e7d4a3e87926 Files: 104 Total size: 366.1 KB Directory structure: gitextract_mundw2wv/ ├── .eslintrc ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ ├── feature_request.yml │ │ └── login_issue.yml │ └── workflows/ │ └── build.yml ├── .gitignore ├── .npmignore ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── ADVANCED_OPTIONS.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SUPPORTED_DEVICES.md ├── config.schema.json ├── jest.config.js ├── nodemon.json ├── package.json ├── src/ │ ├── accessory/ │ │ ├── AccessoryFactory.ts │ │ ├── AirConditionerAccessory.ts │ │ ├── AirPurifierAccessory.ts │ │ ├── AirQualitySensorAccessory.ts │ │ ├── BaseAccessory.ts │ │ ├── CameraAccessory.ts │ │ ├── CarbonDioxideSensorAccessory.ts │ │ ├── CarbonMonoxideSensorAccessory.ts │ │ ├── CatToiletAccessory.ts │ │ ├── ContactSensorAccessory.ts │ │ ├── DehumidifierAccessory.ts │ │ ├── DiffuserAccessory.ts │ │ ├── DimmerAccessory.ts │ │ ├── DoorbellAccessory.ts │ │ ├── ExtractionHoodAccessory.ts │ │ ├── FanAccessory.ts │ │ ├── GarageDoorAccessory.ts │ │ ├── HeaterAccessory.ts │ │ ├── HumanPresenceSensorAccessory.ts │ │ ├── HumidifierAccessory.ts │ │ ├── IRAirConditionerAccessory.ts │ │ ├── IRControlHubAccessory.ts │ │ ├── IRGenericAccessory.ts │ │ ├── LeakSensorAccessory.ts │ │ ├── LightAccessory.ts │ │ ├── LightSensorAccessory.ts │ │ ├── LockAccessory.ts │ │ ├── MotionSensorAccessory.ts │ │ ├── OutletAccessory.ts │ │ ├── PetFeederAccessory.ts │ │ ├── SaunaAccessory.ts │ │ ├── SceneAccessory.ts │ │ ├── SceneSwitchAccessory.ts │ │ ├── SecuritySystemAccessory.ts │ │ ├── SmokeSensorAccessory.ts │ │ ├── SwitchAccessory.ts │ │ ├── TemperatureHumiditySensorAccessory.ts │ │ ├── ThermostatAccessory.ts │ │ ├── ValveAccessory.ts │ │ ├── VibrationSensorAccessory.ts │ │ ├── WeatherStationAccessory.ts │ │ ├── WhiteNoiseLightAccessory.ts │ │ ├── WindowAccessory.ts │ │ ├── WindowCoveringAccessory.ts │ │ ├── WirelessSwitchAccessory.ts │ │ └── characteristic/ │ │ ├── Active.ts │ │ ├── AirQuality.ts │ │ ├── CurrentRelativeHumidity.ts │ │ ├── CurrentTemperature.ts │ │ ├── EnergyUsage.ts │ │ ├── Light.ts │ │ ├── LockPhysicalControls.ts │ │ ├── MotionDetected.ts │ │ ├── Name.ts │ │ ├── OccupancyDetected.ts │ │ ├── On.ts │ │ ├── ProgrammableSwitchEvent.ts │ │ ├── RelativeHumidityDehumidifierThreshold.ts │ │ ├── RotationSpeed.ts │ │ ├── SecuritySystemState.ts │ │ ├── SwingMode.ts │ │ └── TemperatureDisplayUnits.ts │ ├── config.ts │ ├── core/ │ │ ├── TuyaOpenAPI.ts │ │ └── TuyaOpenMQ.ts │ ├── device/ │ │ ├── TuyaCustomDeviceManager.ts │ │ ├── TuyaDevice.ts │ │ ├── TuyaDeviceManager.ts │ │ └── TuyaHomeDeviceManager.ts │ ├── index.ts │ ├── platform.ts │ ├── settings.ts │ └── util/ │ ├── FfmpegStreamingProcess.ts │ ├── Logger.ts │ ├── TuyaRecordingDelegate.ts │ ├── TuyaStreamDelegate.ts │ ├── color.ts │ └── util.ts ├── test/ │ ├── FanAccessory.test.ts │ ├── Light.test.ts │ ├── custom.test.ts │ ├── home.test.ts │ └── util.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc ================================================ { "parser": "@typescript-eslint/parser", "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" // uses the recommended rules from the @typescript-eslint/eslint-plugin ], "parserOptions": { "ecmaVersion": 2018, "sourceType": "module" }, "ignorePatterns": [ "dist" ], "rules": { "quotes": ["warn", "single"], "indent": ["warn", 2, { "SwitchCase": 1 }], "semi": ["off"], "comma-dangle": ["warn", "always-multiline"], "dot-notation": "off", "eqeqeq": "warn", "curly": ["warn", "all"], "brace-style": ["warn"], "prefer-arrow-callback": ["warn"], "max-len": ["warn", 140], "no-console": ["warn"], // use the provided Homebridge log method instead "no-non-null-assertion": ["off"], "comma-spacing": ["error"], "no-multi-spaces": ["warn", { "ignoreEOLComments": true }], "no-trailing-spaces": ["warn"], "lines-between-class-members": ["warn", "always", {"exceptAfterSingleLine": true}], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/semi": ["warn"], "@typescript-eslint/member-delimiter-style": ["warn"] } } ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: ['0x5e'] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug report description: Create a report to help us improve labels: ['bug'] body: - type: checkboxes id: prerequisite attributes: label: Prerequisite description: Have you read the [Readme - FAQ](https://github.com/0x5e/homebridge-tuya-platform#faq) and [Readme - Troubleshooting](https://github.com/0x5e/homebridge-tuya-platform#troubleshooting) section? options: - label: Yes, I've read the readme completely. required: true - type: checkboxes id: cache attributes: label: Cache description: Have you tried clean homebridge accessory cache and restart the service? options: - label: Yes, I've cleaned accessory cache and the issue still exists. required: true - type: input id: version attributes: label: Version description: The version of this plugin you are using. placeholder: 1.7.0-beta.xx validations: required: true - type: textarea id: devide-info attributes: label: Device Infomation JSON File description: If it's related to a device, please paste `TuyaDeviceInfo.{uid}.json` content here. render: json - type: dropdown id: control-mode attributes: label: Device Control Mode description: If it's related to a device, please select the device control mode. options: - Standard Instruction - DP Instruction - type: textarea id: logs attributes: label: Logs description: Please paste homebridge logs with debug mode on. render: shell - type: textarea id: infos attributes: label: Other Infomations description: Also tell us, what did you expect to happen. validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Homebridge Discord Community url: https://discord.gg/homebridge-432663330281226270 about: 'Ask your questions in the #tuya channel' ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature request description: Suggest an idea for this project labels: ['enhancement'] body: - type: textarea id: devide-info attributes: label: Device Infomation JSON File description: If it's related to a device, please paste `TuyaDeviceInfo.{uid}.json` content here. render: json - type: textarea id: infos attributes: label: Detail Informations validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/login_issue.yml ================================================ name: Login issue description: Failed to login Tuya Cloud labels: ['login issue'] body: - type: checkboxes id: prerequisite attributes: label: Prerequisite description: Have you read the [Readme - FAQ](https://github.com/0x5e/homebridge-tuya-platform#faq) and [Readme - Troubleshooting](https://github.com/0x5e/homebridge-tuya-platform#troubleshooting) section? options: - label: 'Yes' required: true - type: checkboxes id: accounts attributes: label: Accounts description: Do you know Tuya IoT Platform and Tuya App are using different account? options: - label: 'Yes' required: true - type: input id: country-code attributes: label: Country Code description: The country code of your app account. placeholder: ex. 1 validations: required: true - type: dropdown id: region-code attributes: label: Region Code description: The region code from app network diagnosis result. options: - AY (China) - AZ (West US) - EU (Central Europe) - IN (India) validations: required: true - type: textarea id: logs attributes: label: Logs description: Please post homebridge logs. Logs with debug mode on will be better. render: shell validations: required: true - type: textarea id: infos attributes: label: Other Infomations description: Any information might relate to this issue. ================================================ FILE: .github/workflows/build.yml ================================================ name: Build and Lint on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: node-version: [18.x, 20.x, 22.x] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - name: Install dependencies run: npm ci - name: Lint the project run: npm run lint - name: Build the project run: npm run build env: CI: true ================================================ FILE: .gitignore ================================================ # Ignore compiled code dist # ------------- Defaults ------------- # # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # Hide vscode stuff .vscode/launch.json # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .pnp.* ================================================ FILE: .npmignore ================================================ # Ignore source code src # ------------- Defaults ------------- # # gitHub actions .github # eslint .eslintrc # typescript tsconfig.json # vscode .vscode # nodemon nodemon.json # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next # Nuxt.js build / generate output .nuxt # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .pnp.* ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "dbaeumer.vscode-eslint" ] } ================================================ FILE: .vscode/settings.json ================================================ { "files.eol": "\n", "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "editor.rulers": [ 140 ], "eslint.enable": true } ================================================ FILE: ADVANCED_OPTIONS.md ================================================ # Advanced Options **During the beta version, the options are unstable, may get changed during updates.** The main function of `deviceOverrides` is to convert "non-standard schema" to "standard schema", making the device compatible with this plugin. Before configuring, you may need to: - Have basic programming skills in JavaScript (Only used in `onGet`/`onSet` handlers). - Understand the concept of device schema (also known as Data Type): [Tuya IoT Development Platform > Cloud Development > Standard Instruction Set > Data Type](https://developer.tuya.com/en/docs/iot/datatypedescription?id=K9i5ql2jo7j1k) - Read the documentation of your device product in [SUPPORTED_DEVICES.md](./SUPPORTED_DEVICES.md). - Obtain device info json from `/path/to/persist/TuyaDeviceList.xxx.json` (the full path can be found from logs). - Locate any "incorrect schema" in your device info json, and convert it to the "correct schema". ### Configuration `options.deviceOverrides` is an **optional** array of device overriding config objects, which is used for converting "non-standard schema" to "standard schema", making the device compatible with this plugin. The structure of each element in the array is described as follows: - `id` - **required**: Device ID, Product ID, Scene ID, or `global`. - `category` - **optional**: Device category code. See [SUPPORTED_DEVICES.md](./SUPPORTED_DEVICES.md). Also you can use `hidden` to hide the device, product, or scene. **⚠️Overriding this property may lead to unexpected behaviors and exceptions, so please remove the accessory cache after making changes.** - `unbridged` - **optional**: Unbridge accessories. Defaults to `false`. - `adaptiveLighting` - **optional**: Adaptive Lighting. Defaults to `false`. Not all light device support this feature, please use it on demand. - `schema` - **optional**: An array of schema overriding config objects, used for describing datapoint (DP). When your device has non-standard DP, you need to transform them manually with configuration. Each element in the schema array is described as follows: - `code` - **required**: DP code. - `newCode` - **optional**: New DP code. - `type` - **optional**: New DP type. One of `Boolean`, `Integer`, `Enum`, `String`, `Json`, or `Raw`. - `property` - **optional**: New DP property object. For `Integer` type, the object should contain `min`, `max`, `scale`, and `step`. For `Enum` type, the object should contain `range`. For more information, see `TuyaDeviceSchemaProperty` in [TuyaDevice.ts](./src/device/TuyaDevice.ts). - `onGet` - **optional**: A one-line JavaScript code to convert the old value to the new value. The function is called with two arguments: `device` and `value`. - `onSet` - **optional**: A one-line JavaScript code to convert the new value to the old value. The function is called with two arguments: `device` and `value`. Returning `undefined` means to skip sending the command. - `hidden` - **optional**: Hide the schema. Defaults to `false`. ## Examples ### Change category code ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "category": "xxx" }] } } ``` ### Hide device / scene Just the same way as changing category code. ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id_or_scene_id}", "category": "hidden" }] } } ``` ### Hide DP An example of hide camera's floodlight (`floodlight_switch`): ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "schema": [{ "code": "floodlight_switch", "hidden": true }] }] } } ``` ### Enable Adaptive Lighting ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "adaptiveLighting": true }] } } ``` ### Offline as off If you want to display off status when device is offline: ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "schema": [{ "code": "{dp_code}", "onGet": "(device.online && value)" }] }] } } ``` ### Change DP code ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "schema": [{ "code": "{old_dp_code}", "newCode": "{new_dp_code}" }] }] } } ``` ### Convert from enum DP to boolean DP An example of convert `open`/`close` into `true`/`false`: ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "schema": [{ "code": "{dp_code}", "type": "Boolean", "onGet": "(value === 'open') ? true : false;", "onSet": "(value === true) ? 'open' : 'close';" }] }] } } ``` ### Adjust integer DP ranges Some odd thermostat stores double of the real value to keep the decimal part (0.5°C). We need override both range and value in order to make it working. (Only override value is not enough, range is required too.) Here's an example of the invalid schema: ```js { code: 'temp_set', mode: 'rw', type: 'Integer', property: { unit: '℃', min: 10, max: 70, scale: 1, step: 5 } } ``` The value `41` actually represents for `20.5°C`, the range `10~70` actually represents for `5.0°C~35.0°C`. To fix this, first we need set scale to `1`, and convert `41` to `205` when getting, convert `205` to `41` when getting, which means `value x 5` when getting, and `value / 5` when setting. Here's the example config: ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "schema": [{ "code": "temp_set", "onGet": "(value * 5);", "onSet": "(value / 5);", "property": { "min": 50, "max": 350, "scale": 1, "step": 5 } }] }] } } ``` After transform value using `onGet` and `onSet`, and new range in `property`, it should be working now. ### Reverse curtain motor's on/off state Most curtain motor have "reverse mode" setting in the Tuya App, if you don't have this, you can reverse `percent_control`/`position` and `percent_state` in the plugin config: ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "schema": [{ "code": "percent_control", "onGet": "(100 - value)", "onSet": "(100 - value)" }, { "code": "percent_state", "onGet": "(100 - value)", "onSet": "(100 - value)" }] }] } } ``` ### Skip send on/off command when touching brightness/speed slider Some products (dimmer, fan) having issue when sending brightness/speed command with on/off command together. Here's an example of skip on/off command. ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "schema": [{ "code": "switch_led", "onSet": "(value === device.status.find(status => status.code === 'switch_led').value) ? undefined : value" }] }] } } ``` ### Convert Fahrenheit to Celsius F = 1.8 * C + 32 C = (F - 32) / 1.8 ```js { "options": { // ... "deviceOverrides": [{ "id": "{device_id}", "schema": [{ "code": "temp_current", "onGet": "Math.round((value - 32) / 1.8);", "onSet": "Math.round(1.8 * value + 32);" }] }] } } ``` ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [1.7.0] - (unreleased) ### Added - Add scene support. (#118) - Add Wireless Switch support (`wxkg`). - Add Solar Light support (`tyndj`). - Add Dehumidifier support (`cs`). - Add Scene Switch support (`wxkg`). - Add device overriding config support. "Non-standard DP" devices have possibility to be supported now. (#119) - Add Camera support (`sp`). Thanks @ErrorErrorError for the contribution - Add Air Conditioner support (`kt`). (#160) - Add Air Conditioner Controller support (`ktkzq`). (#160) - Add Diffuser support (`xxj`). (#175) - Add Temperature Control Socket support (`wkcz`). - Add Environmental Detector support (`hjjcy`). - Add Water Valve Controller support (`sfkzq`). - Add IR Remote Control support (`infrared_tv`, `infrared_stb`, `infrared_box`, `infrared_ac`, `infrared_fan`, `infrared_light`, `infrared_amplifier`, `infrared_projector`, `infrared_waterheater`, `infrared_airpurifier`). (#191) - Add IR AC Controller support (`hwktwkq`). - Add Fingerbot support (`szjqr`). - Add Smart Lock support (`ms`, `jtmspro`). (#120) Thanks @pfgimutao for the contribution - Add Alarm Host support (`mal`). (#246) Thanks @bFollon for the contribution - Add Vibration Sensor support (`zd`). (#262) - Add adaptive lighting support. (#272) - Add Wireless Doorbell support (`wxml`). (#277) - Add IR Remote Control support (`wsdykq`). - Add Layout to display schema in sections. (#283) Thanks @donavanbecker for the contribution - Add option to make accessory and unbridged accessory (#285) Thanks @donavanbecker for the contribution - Add inching button for switches. - Add support to 2ch windows covering. (#339) Thanks @CryptoIR for the contribution - Add retry when network error happened. - Add Pet Feeder support (`cwwsq`). (#483) Thanks @aselekoglu for the contribution ### Fixed - Fix `RotationSpeed` missing one level. (#170) - Fix `bright_value` not sent for the `C/CW` lights who doesn't have `work_mode`. (#171) - Fix crash when camera sends an invalid status message. - Fix incorrect Door and Window Controller state. (#178) - Fix Thermostat cold mode not working (#242). - Order temp before get the min and max for IRAirConditionerAccessory. (#433) Thanks @tuliocll for the contribution - Fix energy usage not updated after homebridge restart. (#268) ### Changed - Support Ceiling Fan icon customize and Floor Fan `lock`, `swing` feature. (#131) - Adjust humidity range of dehumidifier and humidifier. - Print scene id in logs. - Update support for RGB Power Switch (`dj`). - Support showing device online status via `StatusActive`. (#172) - Update unit and range of `RotationSpeed`, need clean accessory cache to take effect. (#174, #273) - Support Diffuser RGB light. (#184) - Support Fan light temperature and color. (#184) - Support Humidifier light. (#184) - Expose energy usage for outlets/switches. (#190) Thanks @lstrojny for the contribution - Strict config validate for `deviceOverrides`. (#278) - Support AirPurifier air quality. - Throw `HapStatusError` when device is offline. ## [1.6.0] - (2022.12.3) This version has been completely rewritten in TypeScript, brings a lot of bug fix and new device support. ### New Accessories - Add CO Detector support (`cobj`). - Add CO2 Detector support (`co2bj`). - Add Water Detector support (`sj`). - Add Temperature and Humidity Sensor support (`wsdcg`, `wnykq`). Thanks @bimusiek for the contribution - Add Light Sensor support (`ldcg`). - Add Motion Sensor support (`pir`). - Add PM2.5 Detector support (`pm25`). - Add Door and Window Controller support (`mc`). - Add Curtain Switch support (`clkg`). (#8) - Add Human Presence Sensor support (`hps`). (#17) - Add Thermostat support (`wk`). (#19) Thanks @burcadoruciprian for the contribution - Add Spotlight support (`sxd`). (#21) - Add Irrigator support (`ggq`). (#28) - Add Scene Light Socket support (`qjdcz`). (#33) - Add Ceiling Fan Light support (`fsd`). (#37) - Add Thermostat Valve support (`wkf`). (#50) - Add Motion Sensor Light support (`gyd`). (#65) - Add Multiple Dimmer and Dimmer Switch support (`tgq`, `tgkg`). (#82) - Add Humidifier support (`jsq`). (#89) Thanks @akaminsky-net for the contribution ### Added - Add config validation during plugin initialization. - Add instruction message for handling API errors. - Add debounce in `BaseAccessory.sendCommands()` for better API request peformance. - Persist `TuyaDeviceList.{uid}.json` for debugging. (#41) - Add `homeWhitelist` option for whitelisting homes. (#84) Thanks @JulianLepinski for the contribution ### Fixed - Fix 1004 signature error when url query has more than 2 elements. - Fix 1010 token expired error when refresh access_token. - Fix 1106 permission error when polling device info list. - Fix 1100, 2017 errors when login. (via config validation) - Fix Lightbulb `RGBW` and `RGBCW` work mode not switched properly (#12 #56 #59) - Fix Lightbulb color temperature not working. (#13) - Fix Thermostat temperature units handling. (#20) - Fix Thermostat mode handling. (#26) - Fix Curtain Switch with no position feature. (#27) - Fallback when receiving MQTT message with wrong order. (#35) - Fix wrong temperature on sensor. (#38) - Fix fan speed issue. (#46 #51) - Workaround for Thermostat with wrong schema property (#74) - Fix Contact Sensor not working (#75) - Fix iOS 16 default accessory name issue. (#85) ### Changed - Rewritten in TypeScript, brings benefits of type checking, smart code hints, etc. - Reimplement accessory logics. More friendly for accessory developers. - Update device info list polling logic. Less API errors. - Now `Manufactor`, `Serial Number` and `Model` will be correctly displayed in HomeKit. - All devices will be shown in HomeKit by default (Including unsupported device). - Updated unit test. - Updated documentations. Thanks @prabch for the contribution ### Removed - Remove `debug` option. Silence logs for users. For debugging, please refer to [troubleshooting](https://github.com/0x5e/homebridge-tuya-platform#troubleshooting). - Remove `lang` option. - Remove `username` and `password` options for `Custom` project. User will be created and authorized automatically. (#11) ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2014-2021 Tuya Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # @0x5e/homebridge-tuya-platform [![npm](https://badgen.net/npm/v/@0x5e/homebridge-tuya-platform)](https://npmjs.com/package/@0x5e/homebridge-tuya-platform) [![npm](https://badgen.net/npm/dt/@0x5e/homebridge-tuya-platform)](https://npmjs.com/package/@0x5e/homebridge-tuya-platform) [![mit-license](https://badgen.net/npm/license/@0x5e/homebridge-tuya-platform)](https://github.com/0x5e/homebridge-tuya-platform/blob/main/LICENSE) [![verified-by-homebridge](https://badgen.net/badge/homebridge/verified/purple)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) [![Build and Lint](https://github.com/0x5e/homebridge-tuya-platform/actions/workflows/build.yml/badge.svg)](https://github.com/0x5e/homebridge-tuya-platform/actions/workflows/build.yml) [![join-discord](https://badgen.net/badge/icon/discord?icon=discord&label=homebridge/tuya)](https://discord.gg/homebridge-432663330281226270) Fork version of the official Tuya Homebridge plugin, with a focus on fixing bugs and adding new device support. ⚠️**Update on 2024.1.14:** Thanks for the attention on this project. There's more and more "problem device", which has wrong definition by manufacture (reversed 0%-100% state, wrong range, wrong unit, ...). Support them one by one really cost a lot. I'm not going to support them in the future, please try solve them by yourself. PRs are still welcome, and bugs will be focused. Thanks again :) ## Features - Optimized and improved code for better readability and maintainability. - Enhanced stability. - Reduced duplicate code. - Fewer API errors. - Lower development costs for new accessory categories. - Supports Tuya Scenes (Tap-to-Run). - Includes the ability to override device configurations, which enables support for "non-standard" DPs. - Supports over 60+ device categories, including most light, switch, sensor, camera, lock, IR remote control, etc. ## Supported Tuya Devices See [SUPPORTED_DEVICES.md](./SUPPORTED_DEVICES.md) ## Changelogs See [CHANGELOG.md](./CHANGELOG.md) ## Installation Before using this plugin, please make sure to uninstall `homebridge-tuya-platform` first as these two plugins cannot run simultaneously. However, the configuration files are compatible, so there's no need to delete them. #### For Homebridge Web UI Users Go to plugin page, search for `@0x5e/homebridge-tuya-platform` and install it. #### For Homebridge Command Line Users Run the following command in the terminal: ``` npm install @0x5e/homebridge-tuya-platform ``` ## Configuration There are two types of projects: `Custom` and `Smart Home`. The difference between them is: - The `Custom` project pulls devices from the project's assets. - The `Smart Home` project pulls devices from the user's home in the Tuya app. If you are a personal user and are unsure which one to choose, please use the `Smart Home` project. Before you can configure, you must go to the [Tuya IoT Platform](https://iot.tuya.com): - Create a cloud development project, and select the data center where your app account is located. See [Mappings Between OEM App Accounts and Data Centers](https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb) - Go to the `Project Page` > `Devices Panel` > `Link Tuya App Account`, and link your app account. - Go to the `Project Page` > `Service API` > `Go to Authorize`, and subscribe to the following APIs (it is free for trial): - Authorization Token Management - Device Status Notification - IoT Core - IoT Video Live Stream (for cameras) - Industry Project Client Service (for the `Custom` project) - IR Control Hub Open Service (for IR devices) - Smart Home Scene Linkage (for scenes) - Smart Lock Open Service (for Lock devices) - **⚠️Remember to extend the API trial period every 6 months here [Tuya IoT Platform > Cloud > Cloud Services > IoT Core](https://iot.tuya.com/cloud/products/detail?abilityId=1442730014117204014&id=p1668587814138nv4h3n&abilityAuth=0&tab=1) (the first-time subscription only gives you 1 month).** #### For "Custom" Project - `platform` - **required** : Must be 'TuyaPlatform'. - `options.projectType` - **required** : Must be '1' - `options.endpoint` - **required** : The endpoint URL taken from the [API Reference > Endpoints](https://developer.tuya.com/en/docs/iot/api-request?id=Ka4a8uuo1j4t4#title-1-Endpoints) table. - `options.accessId` - **required** : The Access ID obtained from [Tuya IoT Platform > Cloud Develop](https://iot.tuya.com/cloud). - `options.accessKey` - **required** : The Access Secret obtained from [Tuya IoT Platform > Cloud Develop](https://iot.tuya.com/cloud). - `options.debug` - **optional**: Includes debugging output in the Homebridge log. (Default: `false`) - `options.debugLevel` - **optional**: An optional list of strings seperated with comma `,`. `api` represents for HTTP API log, `mqtt` represents for MQTT log, and device ID represents for device log. If blank, all logs are outputed. #### For "Smart Home" Project - `platform` - **required** : Must be 'TuyaPlatform'. - `options.projectType` - **required** : Must be '2'. - `options.accessId` - **required** : The Access ID obtained from [Tuya IoT Platform > Cloud Develop](https://iot.tuya.com/cloud). - `options.accessKey` - **required** : The Access Secret obtained from [Tuya IoT Platform > Cloud Develop](https://iot.tuya.com/cloud). - `options.countryCode` - **required** : The numeric country code of your developer account's region. You can find the country codes list [here](https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb). - `options.username` - **required** : The mobile app (Tuya or SmartLife) account's username. Don't use the Tuya IoT Platform developer username. - `options.password` - **required** : The mobile app (Tuya or SmartLife) account's password. MD5 salted password is also available for increased security. Don't use the Tuya IoT Platform developer password. - `options.appSchema` - **required** : The app schema: 'tuyaSmart' for the Tuya Smart App, or 'smartlife' for the Smart Life App. - `options.endpoint` - **optional** : The endpoint URL can be inferred from the [API Reference > Endpoints](https://developer.tuya.com/en/docs/iot/api-request?id=Ka4a8uuo1j4t4#title-1-Endpoints) table based on the country code provided. Only manually set this value if you encounter login issues and need to specify the endpoint for your account location. - `options.homeWhitelist` - **optional**: An array of integer values for the home IDs you want to whitelist. If provided, only devices with matching Home IDs will be included. You can find the Home ID in the Homebridge log. - `options.debug` - **optional**: Includes debugging output in the Homebridge log. (Default: `false`) - `options.debugLevel` - **optional**: An optional list of strings seperated with comma `,`. `api` represents for API and MQTT log, device ID represents for specific device log. If blank, all logs are outputed. #### Advanced options See [ADVANCED_OPTIONS.md](./ADVANCED_OPTIONS.md) ## Limitations - **⚠️Don't forget to extend the API trial period every 6 months. Maybe you can set up a reminder in calendar.** - Using the same app account for multiple Homebridge/HomeAssistant instances is not supported. Please use separate app accounts for each instance. - The plugin requires an internet connection to the Tuya Cloud and does not support the LAN protocol. See [#90](https://github.com/0x5e/homebridge-tuya-platform/issues/90) for more information. ## FAQ #### About Login issue For most users, you can easily find your app account's data center through the [documentation](https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb) and login without any issues. However, for some users, they may encounter error codes such as 1106 or 2406. If you encounter such errors, it's possible that there are differences between your data center and the documentation. To determine the data center, follow these steps: 1. Open the app and navigate to "Me > Settings > Network Diagnosis". 2. Start the diagnosis and select "Upload Log > Copy the Log to Clipboard". 3. Paste the log anywhere and find the line beginning with "Region code:". 4. Look for the following codes: "AY" for China, "AZ" for the West US, "EU" for Central Europe, and "IN" for India. Then manually specify endpoint in the plugin config. #### What is "Standard DP" and "Non-standard DP"? "Standard DP" refers to device properties or functionalities that are specified in the Tuya IoT Development Platform documentation at [Tuya IoT Development Platform Documentation > Cloud Development > Standard Instruction Set](https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq). For example, a light bulb should have a standard DP code of `switch_led` for power on/off, and optional codes `bright_value`/`bright_value_v2` for brightness, `temp_value`/`temp_value_v2` for color temperature, and `work_mode` for changing the working mode. These codes can be found in the above documentation. If your light bulb can be adjusted in the Tuya app but not with the plugin, it most likely has "Non-standard DP." #### Can "Non-standard DP" be supportd by this plugin? Yes. The device must be listed in the support list and the following steps must be completed before it will work: 1. Change the device's control mode on the Tuya Platform: - Go to "[Tuya Platform Cloud Development](https://iot.tuya.com/cloud/) > Your Project > Devices > All Devices > View Devices by Product". - Find the product related to your device, click the "pencil" icon (Change Control Instruction Mode). - image - In the "Table of Instructions", you can see the cloud mapping and determine which DP codes are missing and need to be manually mapped later. - image - Select "DP Instruction" and save. 2. Override the device schema, see [ADVANCED_OPTIONS.md](./ADVANCED_OPTIONS.md). #### Local support See [#90](https://github.com/0x5e/homebridge-tuya-platform/issues/90). Although the plugin didn't implemented tuya local protocol now, it still remains possibility in the future. ## Troubleshooting If your device is not supported, please follow these steps to collect information. #### 1. Get Device Information After Homebridge has been successfully launched, the device information list will be saved in Homebridge's persist path. You can find the file path in the Homebridge log: ``` [2022/11/3 18:37:43] [TuyaPlatform] Device list saved at /path/to/TuyaDeviceList.{uid}.json ``` **⚠️Please make sure to remove sensitive information such as `ip`, `lon`, `lat`, `local_key`, and `uid` before submitting the file.** #### 2. Enable Debug Mode Add debug option in the plugin config, then restart Homebridge. #### 3. Collect Logs With debug mode enabled, you can now receive MQTT logs. Operate your device, either physically or through the Tuya App, to receive MQTT logs like this: ``` [2022/12/8 12:51:59] [TuyaPlatform] [TuyaOpenMQ] onMessage: topic = cloud/token/in/xxx protocol = 4 message = { "dataId": "xxx", "devId": "xxx", "productKey": "xxx", "status": [ { "1": "double_click", "code": "switch1_value", "t": "1670475119766", "value": "double_click" } ] } ``` If you are unable to receive any MQTT logs while controlling the device, it likely means that your device has "Non-standard DP". By submitting the device information JSON and MQTT logs, you can help us support new device categories. ## Contributing Please see https://github.com/homebridge/homebridge-plugin-template#setup-development-environment for setup development environment. PRs and issues are welcome. # Thank you for spend time using the project. If it helps you, don't hesitate to give it a star 🌟:-) ================================================ FILE: SUPPORTED_DEVICES.md ================================================ # Supported Tuya Devices First-class category name, sedond-class category name, category code can be found here: https://developer.tuya.com/docs/iot/standarddescription?id=K9i5ql6waswzq Most category code is pinyin abbreviation of Chinese name. ## Lighting | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Light | 光源 | dj
dsd | Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy) | | Ceiling Light | 吸顶灯 | xdd | Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r) | | Ambiance Light | 氛围灯 | fwd | Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g) | | String Lights | 灯串 | dc | Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu) | | Strip Lights | 灯带 | dd | Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l) | | Motion Sensor Light | 感应灯 | gyd | Lightbulb
MotionSensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy) | | Ceiling Fan Light | 风扇灯 | fsd | Lightbulb
Fanv2 | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v) | | Solar Light | 太阳能灯 | tyndj | Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98) | | Dimmer | 调光器 | tgq | Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4) | | Remote Control | 遥控器 | ykq | | | [Documentation](https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov) | | Spotlight | 射灯 | sxd | Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/sxd?id=Kb7jayalltstu) | | White Noise Light | 白噪音灯 | bzyd | Lightbulb
Switch | ✅ | Documentation | ## Electrical Products | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Switch | 开关 | kg
tdq | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorykgczpc?id=Kaiuz08zj1l4y) | | Socket | 插座 | cz | Outlet | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorykgczpc?id=Kaiuz08zj1l4y) | | Power Strip | 排插 | pc | Outlet | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorykgczpc?id=Kaiuz08zj1l4y) | | Scene Switch | 场景开关 | cjkg | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorycjkg?id=Kaiuz0bcukqc5) | | Card Switch | 插卡取电开关 | ckqdkg | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryckqdkg?id=Kaiuz0e3wjryy) | | Curtain Switch | 窗帘开关 | clkg | Window Covering | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39) | | Garage Door Opener | 车库门控制器 | ckmkzq | Garage Door Opener | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee) | | Dimmer Switch | 调光开关 | tgkg | Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o) | | Fan Switch | 风扇开关 | fskg | Fanv2 | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryfskg?id=Kbcs129cl1gr9) | | Wireless Switch | 无线开关 | wxkg | Stateless Programmable Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5) | | Scene Light Socket | 情景灯插座 | qjdcz | Switch | ✅ | Documentation | | Temperature Control Socket | 温控插座 | wkcz | Switch
Temperature Sensor
Humidity Sensor | ✅ | Documentation | ## Large Home Appliances | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Heater | 热水器 | rs | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx) | | Ventilation System | 新风机 | xfj | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryxfj?id=Kaiuz0pphkowg) | | Refrigerator | 冰箱 | bx | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorybx?id=Kaiuz0s58ia6h) | | Bathtub | 浴缸 | yg | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryyg?id=Kaiuz0uoisp47) | | Washing Machine | 洗衣机 | xy | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryxy?id=Kaiuz0wxh08jf) | | Air Conditioner | 空调 | kt | Heater Cooler
Humidifier Dehumidifier
Fanv2
Temperature Sensor
Humidity Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n) | | Air Conditioner Controller | 空调控制器 | ktkzq | Heater Cooler
Humidifier Dehumidifier
Fanv2
Temperature Sensor
Humidity Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryktkzq?id=Kaiuz11eqy892) | | Boiler | 壁挂炉 | bgl | | | [Documentation](https://developer.tuya.com/en/docs/iot/boilerbgl?id=Kaiuz13shgrhp) | | Sauna | 华氏度摄氏度两用(30-90) | qtwk | Lightbulb
Thermostat | ✅ | Documentation | ## Small Home Appliances | Name | Name (zh) | Code | Homebridge Service | Supported | Links | |----------------------------| ---- |---------------| ---- | ---- |------------------------------------------------------------------------------------------| | Robot Vacuum | 扫地机 | sd | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorysd?id=Kaiuz16b2s6yd) | | Heater | 取暖器 | qn | Heater Cooler | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm) | | Air Purifier | 空气净化器 | kj | Air Purifier | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorykj?id=Kaiuz1atqo5l7) | | Drying Rack | 晾衣架 | lyj | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorylyj?id=Kaiuz1cy926vh) | | Diffuser | 香薰机 | xxj | Air Purifier
Lightbulb | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl) | | Extraction hood | 香薰机 | yyj | Air Purifier
Lightbulb | ✅ | Documentation | | Curtain | 窗帘 | cl | Window Covering | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df) | | Door and Window Controller | 门窗控制器 | mc | Window | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorymc?id=Kaiuz1jyoassg) | | Thermostat | 温控器 | wk | Thermostat | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorywk?id=Kaiuz1m1xqnt6) | | Thermostat Valve | 温控阀 | wkf | Thermostat | ✅ | Documentation | | Bathroom Heater | 浴霸 | yb | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryyb?id=Kaiuz1oajgpib) | | Irrigator | 灌溉器 | ggq
sfkzq | Valve | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k) | | Humidifier | 加湿器 | jsq | Humidifier Dehumidifier | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b) | | Dehumidifier | 除湿机 | cs | Humidifier Dehumidifier | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha) | | Fan | 风扇 | fs | Fanv2 | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c) | | Water Purifier | 净水器 | js | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryjs?id=Kaiuz204l58n9) | | Electric Blanket | 电热毯 | dr | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p) | | Pet Treat Feeder | 宠物弹射喂食器 | cwtswsq | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorycwtswsq?id=Kaiuz24lq3fq5) | | Pet Ball Thrower | 宠物网球发射器 | cwwqfsq | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorycwwqfsq?id=Kaiuz26r7g1up) | | HVAC | 暖通器 | ntq | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryntq?id=Kaiuz292sjqcz) | | Pet Feeder | 宠物喂食器 | cwwsq | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld) | | Pet Fountain | 宠物饮水机 | cwysj | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorycwysj?id=Kaiuz2dfro0nd) | | Sofa | 沙发 | sf | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorysf?id=Kaiuz2fp9uqtt) | | Electric Fireplace | 电壁炉 | dbl | | | [Documentation](https://developer.tuya.com/en/docs/iot/electric-fireplace?id=Kaiuz2hz4iyp6) | | Smart Milk Kettle | 智能调奶器 | tnq | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorytnq?id=Kakf01agbfkfa) | | Cat Toilet | 猫砂盆 | msp | Switch, Lightbulb, OccupancySensor, FilterMaintenance | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorymsp?id=Kakg2t7714ky7) | | Towel Rack | 毛巾架 | mjj | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorymjj?id=Kakkmlm9k4cir) | | Smart Indoor Garden | 植物生长机 | sz | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0) | ## Kitchen Appliances | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Smart Kettle | 电茶壶 | bh | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorybh?id=Kaiuz2kly679h) | | Bread Maker | 面包机 | mb | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorymb?id=Kaiuz2mrs0b2m) | | Coffee Maker | 咖啡机 | kfj | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f) | | Bottle Warmer | 暖奶器 | nnq | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorynnq?id=Kaiuz2riz1s8d) | | Milk Dispenser | 冲奶机 | cn | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorycn?id=Kaiuz2tosvw2a) | | Sous Vide Cooker | 慢煮机 | mzj | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux) | | Rice Cabinet | 米柜 | mg | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorymg?id=Kaiuz2yb04ocu) | | Induction Cooker | 电磁炉 | dcl | | | [Documentation](https://developer.tuya.com/en/docs/iot/induction-cooker?id=Kaiuz30l7adxo) | | Air Fryer | 空气炸锅 | kqzg | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorykqzg?id=Kakda4kug3k1j) | | Bento Box | 智能饭盒 | znfh | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryznfh?id=Kako8jffneds3) | ## Security & Video Surveillance | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Alarm Host | 报警主机 | mal | Security System | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf) | | Smart Camera | 智能摄像机 | sp | Motion Sensor
Doorbell | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12) | | Wireless Doorbell | 无线门铃 | wxml | StatelessProgrammableSwitch | ✅ | Documentation | | Siren Alarm | 声光报警传感器 | sgbj | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu) | | Gas Alarm | 燃气报警传感器 | rqbj | Leak Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw) | | Smoke Alarm | 烟雾报警传感器 | ywbj | Smoke Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952) | | Temperature and Humidity Sensor | 温湿度传感器 | wsdcg | Temperature Sensor
Humidity Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34) | | Contact Sensor | 门磁传感器 | mcs | Contact Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorymcs?id=Kaiuz3bnflmh2) | | Vibration Sensor | 震动传感器 | zd | Motion Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno) | | Water Detector | 水浸传感器 | sj | Leak Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli) | | Luminance Sensor | 亮度传感器 | ldcg | Light Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8) | | Pressure Sensor | 压力传感器 | ylcg
ylcgq | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm) | | Emergency Button | 紧急按钮 | sos | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy) | | PM2.5 Detector | PM2.5传感器 | pm25
pm2.5
pm25cgq | Air Quality Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu) | | CO Detector | CO报警传感器 | cobj
cocgq | Carbon Monoxide Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v) | | CO2 Detector | CO2报警传感器 | co2bj
co2cgq | Carbon Dioxide Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy) | | Multi-functional Sensor | 多功能传感器 | dgnbj | | | [Documentation](https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3) | | Methane Detector | 甲烷报警传感器 | jwbj | Leak Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm) | | Human Motion Sensor | 人体运动传感器 | pir | Motion Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80) | | Human Presence Sensor | 人体存在传感器 | hps | Occupancy Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs) | | Smart Lock | 智能门锁 | ms
jtmspro | LockMechanism | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/ms?id=Kb0o2s20fn9sy) | | Environmental Detector | 环境检测仪 | hjjcy | Air Quality Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv) | ## Exercise & Health | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Massage Chair | 按摩椅 | amy | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryamy?id=Kaiuz4bmwxufp) | | Physiotherapy Products| 理疗产品 | liliao | | | [Documentation](https://developer.tuya.com/en/docs/iot/categoryliliao?id=Kakobe16fjw3l) | | Smart Jump Rope | 跳绳 | ts | | | [Documentation](https://developer.tuya.com/en/docs/iot/ts?id=Kat27rqhu47br) | | Body Fat Scale | 体脂秤 | tzc1 | | | [Documentation](https://developer.tuya.com/en/docs/iot/tzc1?id=Kat27zmbbs56t) | | Smart Watch/Fitness Tracker | 手表/手环 | sb | | | [Documentation](https://developer.tuya.com/en/docs/iot/sb?id=Kat28k7efsbi9) | | Smart Pill Box | 智能药盒 | znyh | | | [Documentation](https://developer.tuya.com/en/docs/iot/znyh?id=Kb2yxpjfcojdt) | ## Gateway Control | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Multifunctional Gateway | 多功能网关 | wg | | | [Documentation](https://developer.tuya.com/en/docs/iot/wg2?id=Kau22nplrptfe) | ## Energy | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Smart Electricity Meter | 智能电表 | zndb | | | [Documentation](https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7) | | Smart Water Meter | 智能水表 | znsb | | | [Documentation](https://developer.tuya.com/en/docs/iot/smart-water-meter?id=Kaiuz4jf0jy9f) | | Circuit Breaker | 断路器 | dlq | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8) | ## Digital Entertainment | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | TV | 电视 | ds | | | [Documentation](https://developer.tuya.com/en/docs/iot/ds?id=Kat8px3b6tb9o) | | Projector | 投影仪 | tyy | | | [Documentation](https://developer.tuya.com/en/docs/iot/tyy?id=Kat8qpj75z0vv) | ## Outdoor Travel | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Tracker | 定位器 | tracker | | | [Documentation](https://developer.tuya.com/en/docs/iot/tracker?id=Kajk21wwy2mhi) | ## IR Remote Control | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Universal Remote Control | 万能遥控器 | wnykq
hwktwkq
wsdykq | Temperature Sensor
Humidity Sensor | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/ir-control-hub-open-service?id=Kb3oe2mk8ya72) | | TV | 电视 | infrared_tv | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | STB | 机顶盒 | infrared_stb | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | TV Box | 电视盒子 | infrared_box | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | Air Conditioner | 空调 | infrared_ac | Heater Cooler
Humidifier Dehumidifier
Fanv2 | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-air-conditioner-apis?id=Kb3oe9ehg02fn) | | Fan | 电风扇 | infrared_fan | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | Light | 灯 | infrared_light | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | Amplifier | 音响 | infrared_amplifier | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | Projector | 投影仪 | infrared_projector | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | DVD | DVD | qt | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | Camera | 相机 | qt | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | Water Heater | 热水器 | infrared_waterheater | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | Air Purifier | 净化器 | infrared_airpurifier | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-common-apis?id=Kb3oe2o7z0so9) | | DIY | - | qt | Switch | ✅ | [Documentation](https://developer.tuya.com/en/docs/cloud/infrared-learning-apis?id=Kb3oeap4nqqm3) | ## Others | Name | Name (zh) | Code | Homebridge Service | Supported | Links | | ---- | ---- | ---- | ---- | ---- | ---- | | Fingerbot | 手指机器人 | szjqr | Switch | ✅ | Documentation | For the undocumented product category, you can try override it to the most similar one. See [ADVANCED_OPTIONS.md](./ADVANCED_OPTIONS.md). ================================================ FILE: config.schema.json ================================================ { "pluginAlias": "TuyaPlatform", "pluginType": "platform", "singular": true, "headerDisplay": "", "footerDisplay": "", "customUi": false, "schema": { "type": "object", "properties": { "name": { "type": "string", "title": "Name", "required": true, "default": "Tuya" }, "options": { "title": "Project Info", "type": "object", "required": true, "properties": { "projectType": { "title": "Project Type (Development Method)", "type": "string", "default": "2", "oneOf": [ { "title": "Custom", "enum": [ "1" ] }, { "title": "Smart Home", "enum": [ "2" ] } ], "required": true }, "endpoint": { "title": "Endpoint URL", "type": "string", "format": "url" }, "accessId": { "title": "Access ID", "type": "string", "required": true }, "accessKey": { "title": "Access Secret", "type": "string", "required": true }, "countryCode": { "title": "Country Code", "type": "integer", "minimum": 1, "condition": { "functionBody": "return model.options.projectType === '2';" } }, "username": { "title": "Username", "type": "string", "condition": { "functionBody": "return model.options.projectType === '2';" } }, "password": { "title": "Password", "type": "string", "condition": { "functionBody": "return model.options.projectType === '2';" } }, "appSchema": { "title": "App", "type": "string", "default": "tuyaSmart", "oneOf": [ { "title": "Tuya Smart", "enum": [ "tuyaSmart" ] }, { "title": "Smart Life", "enum": [ "smartlife" ] } ], "condition": { "functionBody": "return model.options.projectType === '2';" } }, "homeWhitelist": { "title": "Whitelisted Home IDs", "description": "An optional list of Home IDs to match. If blank, all homes are matched.", "type": "array", "items": { "title": "Home ID", "type": "integer" }, "condition": { "functionBody": "return model.options.projectType === '2';" } }, "deviceOverrides": { "title": "Device Overriding Configs", "type": "array", "items": { "type": "object", "properties": { "id": { "title": "ID", "description": "Device ID or Product ID or `global`", "type": "string", "required": true }, "category": { "title": "Category", "description": "Category Code or `hidden`", "type": "string", "condition": { "functionBody": "return (model.options && model.options.deviceOverrides);" } }, "unbridged": { "title": "Unbridge", "description": "Would you like to make this device be an external device?", "type": "boolean", "condition": { "functionBody": "return (model.options && model.options.deviceOverrides);" } }, "schema": { "title": "Schema Overriding Configs", "type": "array", "items": { "type": "object", "properties": { "code": { "title": "DP Code", "type": "string", "required": true, "condition": { "functionBody": "return (model.options && model.options.deviceOverrides);" } }, "newCode": { "title": "New DP Code", "type": "string", "condition": { "functionBody": "return (model.options && model.options.deviceOverrides && model.options.deviceOverrides[arrayIndices[0]].schema && model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].code && !model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].hidden);" } }, "type": { "title": "New DP Type", "type": "string", "default": "", "oneOf": [ { "title": "Boolean", "enum": [ "Boolean" ] }, { "title": "Integer", "enum": [ "Integer" ] }, { "title": "Enum", "enum": [ "Enum" ] }, { "title": "String", "enum": [ "String" ] }, { "title": "Json", "enum": [ "Json" ] }, { "title": "Raw", "enum": [ "Raw" ] } ], "condition": { "functionBody": "return (model.options && model.options.deviceOverrides && model.options.deviceOverrides[arrayIndices[0]].schema && model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].code && !model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].hidden);" } }, "property": { "title": "New DP Property", "type": "object", "properties": { "min": { "title": "min", "type": "integer", "condition": { "functionBody": "return (model.options && model.options.deviceOverrides && model.options.deviceOverrides[arrayIndices[0]].schema && model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].type === 'Integer' && !model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].hidden);" } }, "max": { "title": "max", "type": "integer", "condition": { "functionBody": "return (model.options && model.options.deviceOverrides && model.options.deviceOverrides[arrayIndices[0]].schema && model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].type === 'Integer' && !model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].hidden);" } }, "scale": { "title": "scale", "type": "integer", "condition": { "functionBody": "return (model.options && model.options.deviceOverrides && model.options.deviceOverrides[arrayIndices[0]].schema && model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].type === 'Integer' && !model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].hidden);" } }, "step": { "title": "step", "type": "integer", "condition": { "functionBody": "return (model.options && model.options.deviceOverrides && model.options.deviceOverrides[arrayIndices[0]].schema && model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].type === 'Integer' && !model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].hidden);" } }, "range": { "title": "range", "type": "array", "items": { "title": "value", "type": "string" }, "condition": { "functionBody": "return (model.options && model.options.deviceOverrides && model.options.deviceOverrides[arrayIndices[0]].schema && model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].type === 'Enum' && !model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].hidden);" } } }, "condition": { "functionBody": "return (model.options && model.options.deviceOverrides && model.options.deviceOverrides[arrayIndices[0]].schema && model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].code && !model.options.deviceOverrides[arrayIndices[0]].schema[arrayIndices[1]].hidden);" } }, "hidden": { "title": "Hidden", "type": "boolean", "condition": { "functionBody": "return (model.options && model.options.deviceOverrides);" } } } }, "condition": { "functionBody": "return (model.options && model.options.deviceOverrides);" } } } } }, "debug": { "title": "Enable Debug Logging", "type": "boolean", "default": false }, "debugLevel": { "title": "Debug Level", "description": "An optional list of strings seperated with comma `,`. `api` represents for API and MQTT log, device ID represents for specific device log. If blank, all logs are outputed.", "type": "string", "condition": { "functionBody": "return (model.options && model.options.debug);" } } } } } }, "layout": [ { "type": "fieldset", "title": "Tuya Account Info", "expandable": true, "expanded": false, "items": [ "options.projectType", "options.endpoint", "options.accessId", "options.accessKey", "options.countryCode", "options.username", "options.password", "options.appSchema" ] }, { "type": "fieldset", "title": "Tuya Home Settings", "expandable": true, "expanded": false, "notitle": false, "items": [ { "key": "options.homeWhitelist", "add": "Add Another Home ID", "title": "{{ 'New Whitelisted Home' }}", "type": "tabarray", "notitle": true, "items": [ { "type": "div", "displayFlex": true, "flex-direction": "row", "notitle": true, "title": "{{ value }}", "items": [ { "key": "options.homeWhitelist[]", "placeholder": "Home ID" } ] } ] } ] }, { "type": "fieldset", "title": "Tuya Device Settings", "expandable": true, "expanded": true, "notitle": false, "items": [ { "key": "options.deviceOverrides", "add": "Add Another Device Override", "title": "{{ 'New Device Override' }}", "type": "tabarray", "notitle": true, "items": [ { "type": "div", "displayFlex": false, "flex-direction": "row", "notitle": true, "title": "{{ value.id }}", "items": [ { "key": "options.deviceOverrides[].id" }, { "key": "options.deviceOverrides[].category" }, { "key": "options.deviceOverrides[].unbridged" }, { "key": "options.deviceOverrides[].schema", "add": "Add New Schema", "title": "{{ 'New Schema' }}", "type": "tabarray", "notitle": true, "items": [ { "type": "div", "displayFlex": true, "title": "{{ value.code }}", "flex-direction": "column", "notitle": false, "items": [ { "key": "options.deviceOverrides[].schema[].code" }, { "key": "options.deviceOverrides[].schema[].newCode" }, { "key": "options.deviceOverrides[].schema[].hidden" }, { "key": "options.deviceOverrides[].schema[].type" }, { "key": "options.deviceOverrides[].schema[].property", "notitle": false, "items": [ "options.deviceOverrides[].schema[].property.min", "options.deviceOverrides[].schema[].property.max", "options.deviceOverrides[].schema[].property.scale", "options.deviceOverrides[].schema[].property.step", { "key": "options.deviceOverrides[].schema[].property.range", "add": "Add Range", "title": "{{ 'New Range' }}", "type": "tabarray", "notitle": true, "items": [ { "type": "div", "displayFlex": true, "flex-direction": "row", "notitle": true, "title": "{{ value }}", "items": [ { "key": "options.deviceOverrides[].schema[].property.range[]", "placeholder": "Range" } ] } ] } ] } ] } ] } ] } ] } ] }, { "type": "fieldset", "title": "Advance Settings", "expandable": true, "expanded": false, "notitle": false, "items": [ "options.debug", "options.debugLevel" ] } ] } ================================================ FILE: jest.config.js ================================================ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', }; ================================================ FILE: nodemon.json ================================================ { "watch": [ "src" ], "ext": "ts", "ignore": [], "exec": "tsc && homebridge -I -D", "signal": "SIGTERM", "env": { "NODE_OPTIONS": "--trace-warnings" } } ================================================ FILE: package.json ================================================ { "name": "@0x5e/homebridge-tuya-platform", "displayName": "Tuya", "version": "1.7.0-beta.58", "description": "Fork version of official Tuya Homebridge plugin. Brings a bunch of bug fix and new device support.", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/0x5e/homebridge-tuya-platform" }, "homepage": "https://github.com/0x5e/homebridge-tuya-platform#readme", "bugs": { "url": "https://github.com/0x5e/homebridge-tuya-platform/issues" }, "funding": [ { "type": "paypal", "url": "https://paypal.me/0x5e" } ], "engines": { "node": ">=14.18.1", "homebridge": ">=1.3.5" }, "main": "dist/index.js", "scripts": { "lint": "eslint src/**/*.ts --max-warnings=0", "test": "jest", "watch": "npm run build && npm link && nodemon", "launch": "tsc && homebridge -I -D", "build": "rimraf ./dist && tsc", "prepublishOnly": "npm run lint && npm run build" }, "keywords": [ "homebridge-plugin", "homekit", "tuya" ], "dependencies": { "@homebridge/camera-utils": "^2.2.0", "async-await-retry": "^2.0.1", "color-convert": "^2.0.1", "crypto-js": "^4.1.1", "debounce": "^1.2.1", "jsonschema": "^1.4.1", "kelvin-to-rgb": "^1.0.2", "lodash.isequal": "^4.5.0", "mqtt": "^4.2.6", "uuid": "^9.0.0" }, "devDependencies": { "@types/color-convert": "^2.0.0", "@types/crypto-js": "^4.1.1", "@types/debounce": "^1.2.1", "@types/jest": "^29.1.2", "@types/lodash.isequal": "^4.5.6", "@types/node": "^18.11.9", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "eslint": "^8.0.1", "homebridge": "^1.3.5", "jest": "^29.1.2", "nodemon": "^2.0.13", "rimraf": "^3.0.2", "ts-jest": "^29.0.3", "ts-node": "^10.3.0", "typescript": "^4.8.4" } } ================================================ FILE: src/accessory/AccessoryFactory.ts ================================================ import { PlatformAccessory } from 'homebridge'; import TuyaDevice from '../device/TuyaDevice'; import { TuyaPlatform } from '../platform'; import BaseAccessory from './BaseAccessory'; import LightAccessory from './LightAccessory'; import DimmerAccessory from './DimmerAccessory'; import OutletAccessory from './OutletAccessory'; import SwitchAccessory from './SwitchAccessory'; import WirelessSwitchAccessory from './WirelessSwitchAccessory'; import SceneSwitchAccessory from './SceneSwitchAccessory'; import FanAccessory from './FanAccessory'; import GarageDoorAccessory from './GarageDoorAccessory'; import WindowAccessory from './WindowAccessory'; import WindowCoveringAccessory from './WindowCoveringAccessory'; import LockAccessory from './LockAccessory'; import ThermostatAccessory from './ThermostatAccessory'; import HeaterAccessory from './HeaterAccessory'; import ValveAccessory from './ValveAccessory'; import ContactSensorAccessory from './ContactSensorAccessory'; import LeakSensorAccessory from './LeakSensorAccessory'; import CarbonMonoxideSensorAccessory from './CarbonMonoxideSensorAccessory'; import CarbonDioxideSensorAccessory from './CarbonDioxideSensorAccessory'; import SmokeSensorAccessory from './SmokeSensorAccessory'; import TemperatureHumiditySensorAccessory from './TemperatureHumiditySensorAccessory'; import LightSensorAccessory from './LightSensorAccessory'; import MotionSensorAccessory from './MotionSensorAccessory'; import AirQualitySensorAccessory from './AirQualitySensorAccessory'; import HumanPresenceSensorAccessory from './HumanPresenceSensorAccessory'; import HumidifierAccessory from './HumidifierAccessory'; import DehumidifierAccessory from './DehumidifierAccessory'; import DiffuserAccessory from './DiffuserAccessory'; import AirPurifierAccessory from './AirPurifierAccessory'; import ExtractionHoodAccessory from './ExtractionHoodAccessory'; import CameraAccessory from './CameraAccessory'; import SceneAccessory from './SceneAccessory'; import AirConditionerAccessory from './AirConditionerAccessory'; import IRControlHubAccessory from './IRControlHubAccessory'; import IRGenericAccessory from './IRGenericAccessory'; import IRAirConditionerAccessory from './IRAirConditionerAccessory'; import SecuritySystemAccessory from './SecuritySystemAccessory'; import VibrationSensorAccessory from './VibrationSensorAccessory'; import WeatherStationAccessory from './WeatherStationAccessory'; import DoorbellAccessory from './DoorbellAccessory'; import PetFeederAccessory from './PetFeederAccessory'; import CatToiletAccessory from './CatToiletAccessory'; import WhiteNoiseLightAccessory from './WhiteNoiseLightAccessory'; import SaunaAccessory from './SaunaAccessory'; export default class AccessoryFactory { static createAccessory( platform: TuyaPlatform, accessory: PlatformAccessory, device: TuyaDevice, ): BaseAccessory { let handler : BaseAccessory | undefined; switch (device.category) { // Lighting case 'dj': case 'dsd': case 'xdd': case 'fwd': case 'dc': case 'dd': case 'gyd': case 'tyndj': case 'sxd': handler = new LightAccessory(platform, accessory); break; case 'tgq': case 'tgkg': handler = new DimmerAccessory(platform, accessory); break; // Electrical Products case 'dlq': case 'kg': case 'tdq': case 'qjdcz': case 'szjqr': handler = new SwitchAccessory(platform, accessory); break; case 'cz': case 'pc': case 'wkcz': handler = new OutletAccessory(platform, accessory); break; case 'wxkg': handler = new WirelessSwitchAccessory(platform, accessory); break; case 'cjkg': handler = new SceneSwitchAccessory(platform, accessory); break; case 'bzyd': handler = new WhiteNoiseLightAccessory(platform, accessory); break; // Large Home Appliances case 'kt': case 'ktkzq': handler = new AirConditionerAccessory(platform, accessory); break; case 'qtwk': handler = new SaunaAccessory(platform, accessory); break; // Small Home Appliances case 'qn': handler = new HeaterAccessory(platform, accessory); break; case 'kj': handler = new AirPurifierAccessory(platform, accessory); break; case 'xxj': handler = new DiffuserAccessory(platform, accessory); break; case 'ckmkzq': handler = new GarageDoorAccessory(platform, accessory); break; case 'cl': case 'clkg': handler = new WindowCoveringAccessory(platform, accessory); break; case 'cwwsq': handler = new PetFeederAccessory(platform, accessory); break; case 'msp': handler = new CatToiletAccessory(platform, accessory); break; case 'mc': handler = new WindowAccessory(platform, accessory); break; case 'wk': case 'wkf': handler = new ThermostatAccessory(platform, accessory); break; case 'ggq': case 'sfkzq': handler = new ValveAccessory(platform, accessory); break; case 'jsq': handler = new HumidifierAccessory(platform, accessory); break; case 'cs': handler = new DehumidifierAccessory(platform, accessory); break; case 'fs': case 'fsd': case 'fskg': handler = new FanAccessory(platform, accessory); break; case 'yyj': handler = new ExtractionHoodAccessory(platform, accessory); break; // Security & Video Surveillance case 'sp': handler = new CameraAccessory(platform, accessory); break; case 'ywbj': handler = new SmokeSensorAccessory(platform, accessory); break; case 'mcs': handler = new ContactSensorAccessory(platform, accessory); break; case 'zd': handler = new VibrationSensorAccessory(platform, accessory); break; case 'rqbj': case 'jwbj': case 'sj': handler = new LeakSensorAccessory(platform, accessory); break; case 'cobj': case 'cocgq': handler = new CarbonMonoxideSensorAccessory(platform, accessory); break; case 'co2bj': case 'co2cgq': handler = new CarbonDioxideSensorAccessory(platform, accessory); break; case 'wsdcg': handler = new TemperatureHumiditySensorAccessory(platform, accessory); break; case 'ldcg': handler = new LightSensorAccessory(platform, accessory); break; case 'pir': handler = new MotionSensorAccessory(platform, accessory); break; case 'pm25': case 'pm2.5': case 'pm25cgq': case 'hjjcy': handler = new AirQualitySensorAccessory(platform, accessory); break; case 'hps': handler = new HumanPresenceSensorAccessory(platform, accessory); break; case 'ms': case 'jtmspro': handler = new LockAccessory(platform, accessory); break; case 'mal': handler = new SecuritySystemAccessory(platform, accessory); break; case 'wxml': handler = new DoorbellAccessory(platform, accessory); break; case 'qxj': handler = new WeatherStationAccessory(platform, accessory); break; // Other case 'scene': handler = new SceneAccessory(platform, accessory); break; } // IR Control Hub if (device.isIRControlHub()) { handler = new IRControlHubAccessory(platform, accessory); } // IR Remote Control if (device.isIRRemoteControl()) { switch (device.remote_keys?.category_id) { case 5: // AC handler = new IRAirConditionerAccessory(platform, accessory); break; default: handler = new IRGenericAccessory(platform, accessory); break; } } if (handler && !handler.checkRequirements()) { handler = undefined; } if (!handler) { platform.log.warn(`Unsupported device: ${device.name}.`); handler = new BaseAccessory(platform, accessory); } handler.configureServices(); handler.configureStatusActive(); handler.updateAllValues(); handler.intialized = true; return handler; } } ================================================ FILE: src/accessory/AirConditionerAccessory.ts ================================================ import { TuyaDeviceSchemaEnumProperty, TuyaDeviceSchemaIntegerProperty, TuyaDeviceStatus } from '../device/TuyaDevice'; import { limit } from '../util/util'; import BaseAccessory from './BaseAccessory'; import { configureCurrentRelativeHumidity } from './characteristic/CurrentRelativeHumidity'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; import { configureLockPhysicalControls } from './characteristic/LockPhysicalControls'; import { configureRelativeHumidityDehumidifierThreshold } from './characteristic/RelativeHumidityDehumidifierThreshold'; import { configureRotationSpeedLevel } from './characteristic/RotationSpeed'; // import { configureSwingMode } from './characteristic/SwingMode'; import { configureTempDisplayUnits } from './characteristic/TemperatureDisplayUnits'; const SCHEMA_CODE = { // AirConditioner ACTIVE: ['switch'], MODE: ['mode'], WORK_STATE: ['work_status', 'mode'], CURRENT_TEMP: ['temp_current'], TARGET_TEMP: ['temp_set'], SPEED_LEVEL: ['fan_speed_enum', 'windspeed'], LOCK: ['lock', 'child_lock'], TEMP_UNIT_CONVERT: ['temp_unit_convert', 'c_f'], SWING: ['switch_horizontal', 'switch_vertical'], // Dehumidifier CURRENT_HUMIDITY: ['humidity_current'], TARGET_HUMIDITY: ['humidity_set'], }; const AC_MODES = ['auto', 'cold', 'hot']; const DEHUMIDIFIER_MODE = 'wet'; const FAN_MODE = 'wind'; export default class AirConditionerAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ACTIVE, SCHEMA_CODE.MODE, SCHEMA_CODE.WORK_STATE, SCHEMA_CODE.CURRENT_TEMP]; } configureServices() { this.configureAirConditioner(); this.configureDehumidifier(); this.configureFan(); // Add extra sensors for home automation use. configureCurrentTemperature(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); configureCurrentRelativeHumidity(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_HUMIDITY)); } configureAirConditioner() { const activeSchema = this.getSchema(...SCHEMA_CODE.ACTIVE)!; const modeSchema = this.getSchema(...SCHEMA_CODE.MODE)!; const modeProperty = modeSchema.property as TuyaDeviceSchemaEnumProperty; const service = this.mainService(); // Required Characteristics const { INACTIVE, ACTIVE } = this.Characteristic.Active; service.getCharacteristic(this.Characteristic.Active) .onGet(() => { const activeStatus = this.getStatus(activeSchema.code)!; const modeStatus = this.getStatus(modeSchema.code)!; return (activeStatus.value === true && AC_MODES.includes(modeStatus.value as string)) ? ACTIVE : INACTIVE; }) .onSet(async value => { const commands: TuyaDeviceStatus[] = [{ code: activeSchema.code, value: (value === ACTIVE) ? true : false, }]; const modeStatus = this.getStatus(modeSchema.code)!; if (!AC_MODES.includes(modeStatus.value as string)) { for (const mode of AC_MODES) { if (modeProperty.range.includes(mode)) { commands.push({ code: modeStatus.code, value: mode }); break; } } } await this.sendCommands(commands, true); }); this.configureCurrentState(); this.configureTargetState(); configureCurrentTemperature(this, service, this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); // Optional Characteristics configureLockPhysicalControls(this, service, this.getSchema(...SCHEMA_CODE.LOCK)); configureRotationSpeedLevel(this, service, this.getSchema(...SCHEMA_CODE.SPEED_LEVEL), ['auto']); // configureSwingMode(this, service, this.getSchema(...SCHEMA_CODE.SWING)); this.configureCoolingThreshouldTemp(); this.configureHeatingThreshouldTemp(); configureTempDisplayUnits(this, service, this.getSchema(...SCHEMA_CODE.TEMP_UNIT_CONVERT)); } configureDehumidifier() { const activeSchema = this.getSchema(...SCHEMA_CODE.ACTIVE)!; const modeSchema = this.getSchema(...SCHEMA_CODE.MODE)!; const property = modeSchema.property as TuyaDeviceSchemaEnumProperty; if (!property.range.includes(DEHUMIDIFIER_MODE)) { return; } const service = this.dehumidifierService(); // Required Characteristics const { INACTIVE, ACTIVE } = this.Characteristic.Active; service.getCharacteristic(this.Characteristic.Active) .onGet(() => { const activeStatus = this.getStatus(activeSchema.code)!; const modeStatus = this.getStatus(modeSchema.code)!; return (activeStatus.value === true && modeStatus.value === DEHUMIDIFIER_MODE) ? ACTIVE : INACTIVE; }) .onSet(async value => { await this.sendCommands([{ code: activeSchema.code, value: (value === ACTIVE) ? true : false, }, { code: modeSchema.code, value: DEHUMIDIFIER_MODE, }], true); }); const { DEHUMIDIFYING } = this.Characteristic.CurrentHumidifierDehumidifierState; service.setCharacteristic(this.Characteristic.CurrentHumidifierDehumidifierState, DEHUMIDIFYING); const { DEHUMIDIFIER } = this.Characteristic.TargetHumidifierDehumidifierState; service.getCharacteristic(this.Characteristic.TargetHumidifierDehumidifierState) .updateValue(DEHUMIDIFIER) .setProps({ validValues: [DEHUMIDIFIER] }); if (this.getSchema(...SCHEMA_CODE.CURRENT_HUMIDITY)) { configureCurrentRelativeHumidity(this, service, this.getSchema(...SCHEMA_CODE.CURRENT_HUMIDITY)); } else { service.setCharacteristic(this.Characteristic.CurrentRelativeHumidity, 0); } // Optional Characteristics configureLockPhysicalControls(this, service, this.getSchema(...SCHEMA_CODE.LOCK)); configureRotationSpeedLevel(this, service, this.getSchema(...SCHEMA_CODE.SPEED_LEVEL), ['auto']); configureRelativeHumidityDehumidifierThreshold(this, service, this.getSchema(...SCHEMA_CODE.TARGET_HUMIDITY)); // configureSwingMode(this, service, this.getSchema(...SCHEMA_CODE.SWING)); } configureFan() { const activeSchema = this.getSchema(...SCHEMA_CODE.ACTIVE)!; const modeSchema = this.getSchema(...SCHEMA_CODE.MODE)!; const property = modeSchema.property as TuyaDeviceSchemaEnumProperty; if (!property.range.includes(FAN_MODE)) { return; } const service = this.fanService(); // Required Characteristics const { INACTIVE, ACTIVE } = this.Characteristic.Active; service.getCharacteristic(this.Characteristic.Active) .onGet(() => { const activeStatus = this.getStatus(activeSchema.code)!; const modeStatus = this.getStatus(modeSchema.code)!; return (activeStatus.value === true && modeStatus.value === FAN_MODE) ? ACTIVE : INACTIVE; }) .onSet(async value => { await this.sendCommands([{ code: activeSchema.code, value: (value === ACTIVE) ? true : false, }, { code: modeSchema.code, value: FAN_MODE, }], true); }); // Optional Characteristics configureLockPhysicalControls(this, service, this.getSchema(...SCHEMA_CODE.LOCK)); configureRotationSpeedLevel(this, service, this.getSchema(...SCHEMA_CODE.SPEED_LEVEL), ['auto']); // configureSwingMode(this, service, this.getSchema(...SCHEMA_CODE.SWING)); } mainService() { return this.accessory.getService(this.Service.HeaterCooler) || this.accessory.addService(this.Service.HeaterCooler); } dehumidifierService() { return this.accessory.getService(this.Service.HumidifierDehumidifier) || this.accessory.addService(this.Service.HumidifierDehumidifier, this.accessory.displayName + ' Dehumidifier'); } fanService() { return this.accessory.getService(this.Service.Fanv2) || this.accessory.addService(this.Service.Fanv2, this.accessory.displayName + ' Fan'); } configureCurrentState() { const schema = this.getSchema(...SCHEMA_CODE.WORK_STATE); if (!schema) { return; } const { INACTIVE, HEATING, COOLING } = this.Characteristic.CurrentHeaterCoolerState; this.mainService().getCharacteristic(this.Characteristic.CurrentHeaterCoolerState) .onGet(() => { const status = this.getStatus(schema.code)!; if (status.value === 'heating' || status.value === 'hot') { return HEATING; } else if (status.value === 'cooling' || status.value === 'cold') { return COOLING; } else { return INACTIVE; } }); } configureTargetState() { const schema = this.getSchema(...SCHEMA_CODE.MODE); if (!schema) { return; } const { AUTO, HEAT, COOL } = this.Characteristic.TargetHeaterCoolerState; const validValues: number[] = []; const property = schema.property as TuyaDeviceSchemaEnumProperty; if (property.range.includes('auto')) { validValues.push(AUTO); } if (property.range.includes('hot')) { validValues.push(HEAT); } if (property.range.includes('cold')) { validValues.push(COOL); } if (validValues.length === 0) { this.log.warn('Invalid mode range for TargetHeaterCoolerState:', property.range); return; } this.mainService().getCharacteristic(this.Characteristic.TargetHeaterCoolerState) .onGet(() => { const status = this.getStatus(schema.code)!; if (status.value === 'hot') { return HEAT; } else if (status.value === 'cold') { return COOL; } return validValues.includes(AUTO) ? AUTO : validValues[0]; }) .onSet(async value => { let mode: string; if (value === HEAT) { mode = 'hot'; } else if (value === COOL) { mode = 'cold'; } else { mode = 'auto'; } await this.sendCommands([{ code: schema.code, value: mode }], true); }) .setProps({ validValues }); } configureCoolingThreshouldTemp() { const schema = this.getSchema(...SCHEMA_CODE.TARGET_TEMP); if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property.scale); const props = { minValue: property.min / multiple, maxValue: property.max / multiple, minStep: Math.max(0.1, property.step / multiple), }; this.log.debug('Set props for CoolingThresholdTemperature:', props); this.mainService().getCharacteristic(this.Characteristic.CoolingThresholdTemperature) .onGet(() => { const modeSchema = this.getSchema(...SCHEMA_CODE.MODE); if (modeSchema && this.getStatus(modeSchema.code)!.value === 'auto') { return props.minValue; } const status = this.getStatus(schema.code)!; const temp = status.value as number / multiple; return limit(temp, props.minValue, props.maxValue); }) .onSet(async value => { const modeSchema = this.getSchema(...SCHEMA_CODE.MODE); if (modeSchema && this.getStatus(modeSchema.code)!.value === 'auto') { this.mainService().getCharacteristic(this.Characteristic.CoolingThresholdTemperature) .updateValue(props.minValue); return; } await this.sendCommands([{ code: schema.code, value: (value as number) * multiple}], true); }) .setProps(props); } configureHeatingThreshouldTemp() { const schema = this.getSchema(...SCHEMA_CODE.TARGET_TEMP); if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property.scale); const props = { minValue: property.min / multiple, maxValue: property.max / multiple, minStep: Math.max(0.1, property.step / multiple), }; this.log.debug('Set props for HeatingThresholdTemperature:', props); this.mainService().getCharacteristic(this.Characteristic.HeatingThresholdTemperature) .onGet(() => { const modeSchema = this.getSchema(...SCHEMA_CODE.MODE); if (modeSchema && this.getStatus(modeSchema.code)!.value === 'auto') { return props.maxValue; } const status = this.getStatus(schema.code)!; const temp = status.value as number / multiple; return limit(temp, props.minValue, props.maxValue); }) .onSet(async value => { const modeSchema = this.getSchema(...SCHEMA_CODE.MODE); if (modeSchema && this.getStatus(modeSchema.code)!.value === 'auto') { this.mainService().getCharacteristic(this.Characteristic.HeatingThresholdTemperature) .updateValue(props.maxValue); return; } await this.sendCommands([{ code: schema.code, value: (value as number) * multiple}], true); }) .setProps(props); } } ================================================ FILE: src/accessory/AirPurifierAccessory.ts ================================================ import { TuyaDeviceSchemaType } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureActive } from './characteristic/Active'; import { configureAirQuality } from './characteristic/AirQuality'; import { configureLockPhysicalControls } from './characteristic/LockPhysicalControls'; import { configureRotationSpeed, configureRotationSpeedLevel } from './characteristic/RotationSpeed'; const SCHEMA_CODE = { ACTIVE: ['switch'], MODE: ['mode'], LOCK: ['lock'], SPEED: ['speed'], SPEED_LEVEL: ['fan_speed_enum', 'speed'], AIR_QUALITY: ['air_quality', 'pm25'], PM2_5: ['pm25'], VOC: ['tvoc'], }; export default class AirPurifierAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ACTIVE]; } configureServices() { configureActive(this, this.mainService(), this.getSchema(...SCHEMA_CODE.ACTIVE)); this.configureCurrentState(); this.configureTargetState(); configureLockPhysicalControls(this, this.mainService(), this.getSchema(...SCHEMA_CODE.LOCK)); if (this.getFanSpeedSchema()) { configureRotationSpeed(this, this.mainService(), this.getFanSpeedSchema()); } else if (this.getFanSpeedLevelSchema()) { configureRotationSpeedLevel(this, this.mainService(), this.getFanSpeedLevelSchema()); } // Other configureAirQuality( this, undefined, this.getSchema(...SCHEMA_CODE.AIR_QUALITY), this.getSchema(...SCHEMA_CODE.PM2_5), undefined, this.getSchema(...SCHEMA_CODE.VOC), ); } mainService() { return this.accessory.getService(this.Service.AirPurifier) || this.accessory.addService(this.Service.AirPurifier); } getFanSpeedSchema() { const schema = this.getSchema(...SCHEMA_CODE.SPEED); if (schema && schema.type === TuyaDeviceSchemaType.Integer) { return schema; } return undefined; } getFanSpeedLevelSchema() { const schema = this.getSchema(...SCHEMA_CODE.SPEED_LEVEL); if (schema && schema.type === TuyaDeviceSchemaType.Enum) { return schema; } return undefined; } configureCurrentState() { const schema = this.getSchema(...SCHEMA_CODE.ACTIVE); if (!schema) { return; } const { INACTIVE, PURIFYING_AIR } = this.Characteristic.CurrentAirPurifierState; this.mainService().getCharacteristic(this.Characteristic.CurrentAirPurifierState) .onGet(() => { const status = this.getStatus(schema.code)!; return status.value as boolean ? PURIFYING_AIR : INACTIVE; }); } configureTargetState() { const schema = this.getSchema(...SCHEMA_CODE.MODE); if (!schema) { return; } const { MANUAL, AUTO } = this.Characteristic.TargetAirPurifierState; this.mainService().getCharacteristic(this.Characteristic.TargetAirPurifierState) .onGet(() => { const status = this.getStatus(schema.code)!; return (status.value === 'auto') ? AUTO : MANUAL; }) .onSet(async value => { await this.sendCommands([{ code: schema.code, value: (value === AUTO) ? 'auto' : 'manual', }], true); }); } } ================================================ FILE: src/accessory/AirQualitySensorAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureAirQuality } from './characteristic/AirQuality'; import { configureCurrentRelativeHumidity } from './characteristic/CurrentRelativeHumidity'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; const SCHEMA_CODE = { AIR_QUALITY: ['pm25_value'], PM2_5: ['pm25_value'], PM10: ['pm10_value', 'pm10'], VOC: ['voc_value'], CURRENT_TEMP: ['va_temperature', 'temp_indoor', 'temp_current'], CURRENT_HUMIDITY: ['va_humidity', 'humidity_value'], }; export default class AirQualitySensorAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.AIR_QUALITY]; } configureServices() { configureAirQuality( this, undefined, this.getSchema(...SCHEMA_CODE.AIR_QUALITY), this.getSchema(...SCHEMA_CODE.PM2_5), this.getSchema(...SCHEMA_CODE.PM10), this.getSchema(...SCHEMA_CODE.VOC), ); // Other configureCurrentTemperature(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); configureCurrentRelativeHumidity(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_HUMIDITY)); } } ================================================ FILE: src/accessory/BaseAccessory.ts ================================================ /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-unused-vars */ import { PlatformAccessory, Service, Characteristic, Nullable, CharacteristicValue } from 'homebridge'; import { debounce } from 'debounce'; import isEqual from 'lodash.isequal'; import { TuyaDeviceSchema, TuyaDeviceSchemaIntegerProperty, TuyaDeviceSchemaMode, TuyaDeviceStatus } from '../device/TuyaDevice'; import { TuyaPlatform } from '../platform'; import { limit } from '../util/util'; import { PrefixLogger } from '../util/Logger'; const MANUFACTURER = 'Tuya Inc.'; const SCHEMA_CODE = { BATTERY_STATE: ['battery_state'], BATTERY_PERCENT: ['battery_percentage', 'residual_electricity', 'wireless_electricity', 'va_battery', 'battery'], BATTERY_CHARGING: ['charge_state'], }; /** * Homebridge Accessory Categories Documentation: * https://developers.homebridge.io/#/categories * Tuya Standard Instruction Set Documentation: * https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq */ class BaseAccessory { public readonly Service: typeof Service = this.platform.api.hap.Service; public readonly Characteristic: typeof Characteristic = this.platform.api.hap.Characteristic; public deviceManager = this.platform.deviceManager!; public device = this.deviceManager.getDevice(this.accessory.context.deviceID)!; public log = new PrefixLogger( this.platform.log, this.device.name.length > 0 ? this.device.name : this.device.id, this.platform.options.debug && ((this.platform.options.debugLevel ?? '').length > 0 ? this.platform.options.debugLevel?.includes(this.device.id) : true), ); public intialized = false; public adaptiveLightingController?; constructor( public readonly platform: TuyaPlatform, public readonly accessory: PlatformAccessory, ) { this.addAccessoryInfoService(); this.addBatteryService(); } addAccessoryInfoService() { const service = this.accessory.getService(this.Service.AccessoryInformation) || this.accessory.addService(this.Service.AccessoryInformation); service .setCharacteristic(this.Characteristic.Manufacturer, MANUFACTURER) .setCharacteristic(this.Characteristic.Model, this.device.product_id) .setCharacteristic(this.Characteristic.Name, this.device.name) .setCharacteristic(this.Characteristic.ConfiguredName, this.device.name) .setCharacteristic(this.Characteristic.SerialNumber, this.device.uuid) ; } addBatteryService() { const percentSchema = this.getSchema(...SCHEMA_CODE.BATTERY_PERCENT); if (!percentSchema) { return; } const { BATTERY_LEVEL_NORMAL, BATTERY_LEVEL_LOW } = this.Characteristic.StatusLowBattery; const service = this.accessory.getService(this.Service.Battery) || this.accessory.addService(this.Service.Battery); const stateSchema = this.getSchema(...SCHEMA_CODE.BATTERY_STATE); if (stateSchema || percentSchema) { service.getCharacteristic(this.Characteristic.StatusLowBattery) .onGet(() => { if (stateSchema) { const status = this.getStatus(stateSchema.code)!; return (status!.value === 'low') ? BATTERY_LEVEL_LOW : BATTERY_LEVEL_NORMAL; } // fallback const status = this.getStatus(percentSchema.code)!; return (status!.value as number <= 20) ? BATTERY_LEVEL_LOW : BATTERY_LEVEL_NORMAL; }); } const property = percentSchema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property ? property.scale : 0); service.getCharacteristic(this.Characteristic.BatteryLevel) .onGet(() => { const status = this.getStatus(percentSchema.code)!; return limit(status.value as number / multiple, 0, 100); }); const chargingSchema = this.getSchema(...SCHEMA_CODE.BATTERY_CHARGING); if (chargingSchema) { const { NOT_CHARGING, CHARGING } = this.Characteristic.ChargingState; service.getCharacteristic(this.Characteristic.ChargingState) .onGet(() => { const status = this.getStatus(chargingSchema.code)!; return (status.value as boolean) ? CHARGING : NOT_CHARGING; }); } } configureStatusActive() { for (const service of this.accessory.services) { if (!service.testCharacteristic(this.Characteristic.StatusActive)) { // silence warning service.addOptionalCharacteristic(this.Characteristic.StatusActive); } service.getCharacteristic(this.Characteristic.StatusActive) .onGet(() => this.device.online); } } async updateAllValues() { for (const service of this.accessory.services) { for (const characteristic of service.characteristics) { if (characteristic.UUID === this.Characteristic.ProgrammableSwitchEvent.UUID) { continue; } let newValue: Nullable | Error = characteristic.value; const getHandler = characteristic['getHandler']; if (getHandler) { try { newValue = await getHandler(); } catch (error) { // TODO: why `characteristic.updateValue(HapStatusError)` not working? // newValue = error as Error; continue; } } if (characteristic.value !== newValue && !(newValue instanceof Error)) { this.log.debug( '[%s/%s/%s] Update value: %o => %o', service.constructor.name, service.subtype, characteristic.constructor.name, characteristic.value, newValue, ); } characteristic.updateValue(newValue); } } } checkOnlineStatus() { if (!this.device.online) { const { HapStatusError, HAPStatus } = this.platform.api.hap; throw new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE); } } getSchema(...codes: string[]) { for (const code of codes) { const schema = this.device.schema.find(schema => schema.code === code); if (!schema) { continue; } // Readable schema must have a status if ([TuyaDeviceSchemaMode.READ_WRITE, TuyaDeviceSchemaMode.READ_ONLY].includes(schema.mode) && !this.getStatus(schema.code)) { continue; } return schema; } return undefined; } getStatus(code: string) { return this.device.status.find(status => status.code === code); } private sendQueue = new Map(); private debounceSendCommands = debounce(async () => { const commands = [...this.sendQueue.values()]; if (commands.length === 0) { return; } await this.deviceManager.sendCommands(this.device.id, commands); this.sendQueue.clear(); }, 100); async sendCommands(commands: TuyaDeviceStatus[], debounce = false) { if (commands.length === 0) { return; } commands = commands.filter((status) => status.code && status.value !== undefined); if (this.device.online === false) { this.log.warn('Device is offline, skip send command.'); this.updateAllValues(); const { HapStatusError, HAPStatus } = this.platform.api.hap; throw new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE); return; } // Update cache immediately for (const newStatus of commands) { const oldStatus = this.device.status.find(_status => _status.code === newStatus.code); if (oldStatus) { oldStatus.value = newStatus.value; } } if (debounce === false) { return await this.deviceManager.sendCommands(this.device.id, commands); } for (const newStatus of commands) { // Update send queue this.sendQueue.set(newStatus.code, newStatus); } this.debounceSendCommands(); } checkRequirements() { let result = true; for (const codes of this.requiredSchema()) { const schema = this.getSchema(...codes); if (schema) { continue; } this.log.warn('Product Category: %s', this.device.category); this.log.warn('Missing one of the required schema: %s', codes); this.log.warn('Please switch device control mode to "DP Insctrution", and set `deviceOverrides` manually.'); this.log.warn('Detail information: https://github.com/0x5e/homebridge-tuya-platform#faq'); result = false; } if (!result) { this.log.warn('Existing schema: %o', this.device.schema); } return result; } requiredSchema(): string[][] { return []; } configureServices() { // } async onDeviceInfoUpdate(info) { this.updateAllValues(); } async onDeviceStatusUpdate(status: TuyaDeviceStatus[]) { this.updateAllValues(); } } // Overriding getSchema, getStatus, sendCommands export default class OverridedBaseAccessory extends BaseAccessory { private eval = (script: string, device, value) => eval(script); private getOverridedSchema(code: string) { const schemaConfig = this.platform.getDeviceSchemaConfig(this.device, code); if (!schemaConfig) { return undefined; } const oldSchema = this.device.schema.find(schema => schema.code === schemaConfig.code); if (!oldSchema) { return undefined; } const schema = { code, mode: oldSchema.mode, type: schemaConfig.type || oldSchema.type, property: schemaConfig.property || oldSchema.property, _hidden: schemaConfig.hidden, } as TuyaDeviceSchema; if (!isEqual(oldSchema, schema)) { this.log.debug('Override schema %o => %o', oldSchema, schema); } return schema; } getSchema(...codes: string[]) { for (const code of codes) { const schema = this.getOverridedSchema(code) || super.getSchema(code); if (!schema) { continue; } if (schema['_hidden']) { return undefined; } return schema; } return undefined; } private getOverridedStatus(code: string) { const schemaConfig = this.platform.getDeviceSchemaConfig(this.device, code); if (!schemaConfig) { return undefined; } const oldStatus = super.getStatus(schemaConfig.code); if (!oldStatus) { return undefined; } const status = { code: schemaConfig.newCode || schemaConfig.code, value: oldStatus.value } as TuyaDeviceStatus; if (schemaConfig.onGet) { status.value = this.eval(schemaConfig.onGet, this.device, oldStatus.value); } if (!isEqual(oldStatus, status)) { this.log.debug('Override status %o => %o', oldStatus, status); } return status; } getStatus(code: string) { return this.getOverridedStatus(code) || super.getStatus(code); } async sendCommands(commands: TuyaDeviceStatus[], debounce?: boolean) { // convert to original commands for (const command of commands) { const schemaConfig = this.platform.getDeviceSchemaConfig(this.device, command.code); if (!schemaConfig) { continue; } const oldCommand = { code: schemaConfig.code, value: command.value } as TuyaDeviceStatus; if (schemaConfig.onSet) { oldCommand.value = this.eval(schemaConfig.onSet, this.device, command.value); } if (!isEqual(oldCommand, command)) { this.log.debug('Override command %o => %o', command, oldCommand); command.code = oldCommand.code; command.value = oldCommand.value; } } await super.sendCommands(commands, debounce); } } ================================================ FILE: src/accessory/CameraAccessory.ts ================================================ import { TuyaDeviceStatus } from '../device/TuyaDevice'; import { TuyaStreamingDelegate } from '../util/TuyaStreamDelegate'; import BaseAccessory from './BaseAccessory'; import { configureLight } from './characteristic/Light'; import { configureOn } from './characteristic/On'; import { configureProgrammableSwitchEvent, onProgrammableSwitchEvent } from './characteristic/ProgrammableSwitchEvent'; const SCHEMA_CODE = { MOTION_ON: ['motion_switch'], MOTION_DETECT: ['movement_detect_pic'], // Indicates that this is possibly a doorbell DOORBELL: ['doorbell_ring_exist'], // Notifies when a doorbell ring occurs. DOORBELL_RING: ['doorbell_pic'], // Notifies when a doorbell ring occurs. ALARM_MESSAGE: ['alarm_message'], LIGHT_ON: ['floodlight_switch'], LIGHT_BRIGHT: ['floodlight_lightness'], }; export default class CameraAccessory extends BaseAccessory { private stream: TuyaStreamingDelegate | undefined; requiredSchema() { return []; } configureServices() { this.configureDoorbell(); this.configureCamera(); this.configureMotion(); this.configureFloodLight(); } configureMotion() { const onSchema = this.getSchema(...SCHEMA_CODE.MOTION_ON); if (onSchema) { const onService = this.accessory.getService(onSchema.code) || this.accessory.addService(this.Service.Switch, onSchema.code, onSchema.code); configureOn(this, onService, onSchema); } this.getMotionService().setCharacteristic(this.Characteristic.MotionDetected, false); } configureDoorbell() { // Check to see if it is indeed a doorbell. if (!this.getSchema(...SCHEMA_CODE.DOORBELL)) { return; } const schema = this.getSchema(...SCHEMA_CODE.DOORBELL_RING, ...SCHEMA_CODE.ALARM_MESSAGE); if (!schema) { return; } configureProgrammableSwitchEvent(this, this.getDoorbellService(), schema); } configureCamera() { if (this.stream !== undefined) { return; } if (this.device.isVirtualDevice()) { return; } this.stream = new TuyaStreamingDelegate(this); this.accessory.configureController(this.stream.controller); } configureFloodLight() { if (!this.getSchema(...SCHEMA_CODE.LIGHT_ON)) { return; } configureLight( this, this.getLightService(), this.getSchema(...SCHEMA_CODE.LIGHT_ON), this.getSchema(...SCHEMA_CODE.LIGHT_BRIGHT), undefined, undefined, undefined, ); } getLightService() { return this.accessory.getService(this.Service.Lightbulb) || this.accessory.addService(this.Service.Lightbulb, this.accessory.displayName + ' Floodlight'); } getDoorbellService() { return this.accessory.getService(this.Service.Doorbell) || this.accessory.addService(this.Service.Doorbell); } getMotionService() { return this.accessory.getService(this.Service.MotionSensor) || this.accessory.addService(this.Service.MotionSensor, this.accessory.displayName + ' Motion Detect'); } async onDeviceStatusUpdate(status: TuyaDeviceStatus[]) { super.onDeviceStatusUpdate(status); const doorbellRingSchema = this.getSchema(...SCHEMA_CODE.DOORBELL_RING); const alarmMessageSchema = this.getSchema(...SCHEMA_CODE.ALARM_MESSAGE); if (this.getSchema(...SCHEMA_CODE.DOORBELL) && (doorbellRingSchema || alarmMessageSchema)) { const doorbellRingStatus = doorbellRingSchema && status.find(_status => _status.code === doorbellRingSchema.code); const alarmMessageStatus = alarmMessageSchema && status.find(_status => _status.code === alarmMessageSchema.code); if (doorbellRingStatus && (doorbellRingStatus.value as string).length > 1) { // Compared with '1' in order to filter value '$' onProgrammableSwitchEvent(this, this.getDoorbellService(), doorbellRingStatus); } else if (alarmMessageStatus && (alarmMessageStatus.value as string).length > 1) { onProgrammableSwitchEvent(this, this.getDoorbellService(), alarmMessageStatus); } } const motionSchema = this.getSchema(...SCHEMA_CODE.MOTION_DETECT); if (motionSchema) { const motionStatus = status.find(_status => _status.code === motionSchema.code); motionStatus && this.onMotionDetected(motionStatus); } } private timer?: NodeJS.Timeout; onMotionDetected(status: TuyaDeviceStatus) { if (!this.intialized) { return; } const data = Buffer.from(status.value as string, 'base64').toString('binary'); if (data.length === 0) { return; } this.log.info('Motion event:', data); const characteristic = this.getMotionService().getCharacteristic(this.Characteristic.MotionDetected); characteristic.updateValue(true); this.timer && clearTimeout(this.timer); this.timer = setTimeout(() => characteristic.updateValue(false), 30 * 1000); } } ================================================ FILE: src/accessory/CarbonDioxideSensorAccessory.ts ================================================ import { TuyaDeviceSchemaIntegerProperty } from '../device/TuyaDevice'; import { limit } from '../util/util'; import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = { CO2_STATUS: ['co2_state'], CO2_LEVEL: ['co2_value'], }; export default class CarbonDioxideSensorAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.CO2_STATUS]; } configureServices() { this.configureCarbonDioxideDetected(); this.configureCarbonDioxideLevel(); } mainService() { return this.accessory.getService(this.Service.CarbonDioxideSensor) || this.accessory.addService(this.Service.CarbonDioxideSensor); } configureCarbonDioxideDetected() { const schema = this.getSchema(...SCHEMA_CODE.CO2_STATUS); if (!schema) { return; } const { CO2_LEVELS_ABNORMAL, CO2_LEVELS_NORMAL } = this.Characteristic.CarbonDioxideDetected; this.mainService().getCharacteristic(this.Characteristic.CarbonDioxideDetected) .onGet(() => { const status = this.getStatus(schema.code)!; return (status.value === 'alarm') ? CO2_LEVELS_ABNORMAL : CO2_LEVELS_NORMAL; }); } configureCarbonDioxideLevel() { const schema = this.getSchema(...SCHEMA_CODE.CO2_LEVEL); if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property ? property.scale : 0); this.mainService().getCharacteristic(this.Characteristic.CarbonDioxideLevel) .onGet(() => { const status = this.getStatus(schema.code)!; const value = limit(status.value as number / multiple, 0, 100000); return value; }); } } ================================================ FILE: src/accessory/CarbonMonoxideSensorAccessory.ts ================================================ import { TuyaDeviceSchemaIntegerProperty } from '../device/TuyaDevice'; import { limit } from '../util/util'; import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = { CO_STATUS: ['co_status', 'co_state'], CO_LEVEL: ['co_value'], }; export default class CarbonMonoxideSensorAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.CO_STATUS]; } configureServices() { this.configureCarbonMonoxideDetected(); this.configureCarbonMonoxideLevel(); } mainService() { return this.accessory.getService(this.Service.CarbonMonoxideSensor) || this.accessory.addService(this.Service.CarbonMonoxideSensor); } configureCarbonMonoxideDetected() { const schema = this.getSchema(...SCHEMA_CODE.CO_STATUS); if (!schema) { return; } const { CO_LEVELS_ABNORMAL, CO_LEVELS_NORMAL } = this.Characteristic.CarbonMonoxideDetected; this.mainService().getCharacteristic(this.Characteristic.CarbonMonoxideDetected) .onGet(() => { const status = this.getStatus(schema.code)!; return (status.value === 'alarm' || status.value === '1') ? CO_LEVELS_ABNORMAL : CO_LEVELS_NORMAL; }); } configureCarbonMonoxideLevel() { const schema = this.getSchema(...SCHEMA_CODE.CO_LEVEL); if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property ? property.scale : 0); this.mainService().getCharacteristic(this.Characteristic.CarbonMonoxideLevel) .onGet(() => { const status = this.getStatus(schema.code)!; const value = limit(status.value as number / multiple, 0, 100); return value; }); } } ================================================ FILE: src/accessory/CatToiletAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureName } from './characteristic/Name'; import { configureOn } from './characteristic/On'; const SCHEMA_CODE = { SWITCH: ['switch'], AUTO_CLEAN: ['auto_clean'], MANUAL_CLEAN: ['manual_clean'], DEODORIZATION: ['deodorization'], UV: ['uv'], LIGHT: ['light'], STATUS: ['status'], CAT_WEIGHT: ['cat_weight'], EXCRETION_TIMES: ['excretion_times_day'], EXCRETION_TIME: ['excretion_time_day'], NOTIFICATION: ['notification'], FAULT: ['fault'], }; export default class CatToiletAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.SWITCH]; } configureServices() { // Main power switch configureOn(this, this.mainService(), this.getSchema(...SCHEMA_CODE.SWITCH)); configureName(this, this.mainService(), this.device.name); // Additional switches this.configureSwitch(SCHEMA_CODE.AUTO_CLEAN, 'Auto Clean'); this.configureSwitch(SCHEMA_CODE.MANUAL_CLEAN, 'Manual Clean'); this.configureSwitch(SCHEMA_CODE.DEODORIZATION, 'Deodorization'); this.configureSwitch(SCHEMA_CODE.UV, 'UV Sterilization'); // Mood light as Lightbulb this.configureLight(); // Occupancy sensor for active status this.configureOccupancySensor(); // Filter maintenance for garbage box full this.configureFilterMaintenance(); // Fault handling this.configureFault(); } mainService() { return this.accessory.getService(this.Service.Switch) || this.accessory.addService(this.Service.Switch, this.device.name, 'switch'); } configureSwitch(schemaCodes: string[], name: string) { const schema = this.getSchema(...schemaCodes); if (!schema) { return; } const service = this.accessory.getService(schema.code) || this.accessory.addService(this.Service.Switch, name, schema.code); configureName(this, service, name); configureOn(this, service, schema); } configureLight() { const schema = this.getSchema(...SCHEMA_CODE.LIGHT); if (!schema) { return; } const service = this.accessory.getService(schema.code) || this.accessory.addService(this.Service.Lightbulb, 'Mood Light', schema.code); configureName(this, service, 'Mood Light'); service.getCharacteristic(this.Characteristic.On) .onGet(() => { this.checkOnlineStatus(); const status = this.getStatus(schema.code)!; return status.value as boolean; }) .onSet(async value => { await this.sendCommands([{ code: schema.code, value: value as boolean, }], true); }); } configureOccupancySensor() { const schema = this.getSchema(...SCHEMA_CODE.STATUS); if (!schema) { return; } const service = this.accessory.getService(this.Service.OccupancySensor) || this.accessory.addService(this.Service.OccupancySensor, 'Status', 'status'); configureName(this, service, 'Status'); const { OCCUPANCY_DETECTED, OCCUPANCY_NOT_DETECTED } = this.Characteristic.OccupancyDetected; service.getCharacteristic(this.Characteristic.OccupancyDetected) .onGet(() => { const status = this.getStatus(schema.code)!; const activeStates = ['cleaning', 'uv', 'deodorization']; return activeStates.includes(status.value as string) ? OCCUPANCY_DETECTED : OCCUPANCY_NOT_DETECTED; }); } configureFilterMaintenance() { const schema = this.getSchema(...SCHEMA_CODE.NOTIFICATION); if (!schema) { return; } const service = this.accessory.getService(this.Service.FilterMaintenance) || this.accessory.addService(this.Service.FilterMaintenance, 'Waste Box', 'notification'); configureName(this, service, 'Waste Box'); const { CHANGE_FILTER, FILTER_OK } = this.Characteristic.FilterChangeIndication; service.getCharacteristic(this.Characteristic.FilterChangeIndication) .onGet(() => { const status = this.getStatus(schema.code)!; // Bit 0 = garbage_box_full const value = status.value as number; return (value & 1) ? CHANGE_FILTER : FILTER_OK; }); } configureFault() { const schema = this.getSchema(...SCHEMA_CODE.FAULT); if (!schema) { return; } // Add fault status to main service this.mainService().getCharacteristic(this.Characteristic.StatusFault) .onGet(() => { const status = this.getStatus(schema.code)!; const { GENERAL_FAULT, NO_FAULT } = this.Characteristic.StatusFault; return (status.value as number) > 0 ? GENERAL_FAULT : NO_FAULT; }); } } ================================================ FILE: src/accessory/ContactSensorAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = { CONTACT_STATE: ['doorcontact_state', 'switch'], }; export default class ContaceSensor extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.CONTACT_STATE]; } configureServices() { const schema = this.getSchema(...SCHEMA_CODE.CONTACT_STATE); if (!schema) { return; } const service = this.accessory.getService(this.Service.ContactSensor) || this.accessory.addService(this.Service.ContactSensor); const { CONTACT_NOT_DETECTED, CONTACT_DETECTED } = this.Characteristic.ContactSensorState; service.getCharacteristic(this.Characteristic.ContactSensorState) .onGet(() => { const status = this.getStatus(schema.code)!; return status.value ? CONTACT_NOT_DETECTED : CONTACT_DETECTED; }); } } ================================================ FILE: src/accessory/DehumidifierAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureActive } from './characteristic/Active'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; import { configureCurrentRelativeHumidity } from './characteristic/CurrentRelativeHumidity'; import { configureRotationSpeedLevel } from './characteristic/RotationSpeed'; import { configureSwingMode } from './characteristic/SwingMode'; import { configureLockPhysicalControls } from './characteristic/LockPhysicalControls'; import { configureRelativeHumidityDehumidifierThreshold } from './characteristic/RelativeHumidityDehumidifierThreshold'; const SCHEMA_CODE = { ACTIVE: ['switch'], CURRENT_HUMIDITY: ['humidity_indoor'], TARGET_HUMIDITY: ['dehumidify_set_value'], CURRENT_TEMP: ['temp_indoor'], SPEED_LEVEL: ['fan_speed_enum'], SWING: ['swing'], LOCK: ['child_lock'], }; export default class DehumidifierAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ACTIVE, SCHEMA_CODE.CURRENT_HUMIDITY]; } configureServices() { // Required Characteristics configureActive(this, this.mainService(), this.getSchema(...SCHEMA_CODE.ACTIVE)); this.configureCurrentState(); this.configureTargetState(); configureCurrentRelativeHumidity(this, this.mainService(), this.getSchema(...SCHEMA_CODE.CURRENT_HUMIDITY)); // Optional Characteristics configureLockPhysicalControls(this, this.mainService(), this.getSchema(...SCHEMA_CODE.LOCK)); configureRelativeHumidityDehumidifierThreshold(this, this.mainService(), this.getSchema(...SCHEMA_CODE.TARGET_HUMIDITY)); configureRotationSpeedLevel(this, this.mainService(), this.getSchema(...SCHEMA_CODE.SPEED_LEVEL)); configureSwingMode(this, this.mainService(), this.getSchema(...SCHEMA_CODE.SWING)); // Other configureCurrentTemperature(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); } mainService() { return this.accessory.getService(this.Service.HumidifierDehumidifier) || this.accessory.addService(this.Service.HumidifierDehumidifier); } configureCurrentState() { const schema = this.getSchema(...SCHEMA_CODE.ACTIVE); if (!schema) { this.log.warn('CurrentHumidifierDehumidifierState not supported.'); return; } const { INACTIVE, DEHUMIDIFYING } = this.Characteristic.CurrentHumidifierDehumidifierState; this.mainService().getCharacteristic(this.Characteristic.CurrentHumidifierDehumidifierState) .onGet(() => { const status = this.getStatus(schema.code); return (status?.value as boolean) ? DEHUMIDIFYING : INACTIVE; }); } configureTargetState() { const { DEHUMIDIFIER } = this.Characteristic.TargetHumidifierDehumidifierState; const validValues = [DEHUMIDIFIER]; this.mainService().getCharacteristic(this.Characteristic.TargetHumidifierDehumidifierState) .onGet(() => { return DEHUMIDIFIER; }).setProps({ validValues }); } } ================================================ FILE: src/accessory/DiffuserAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureActive } from './characteristic/Active'; import { configureLight } from './characteristic/Light'; import { configureOn } from './characteristic/On'; import { configureRotationSpeedLevel } from './characteristic/RotationSpeed'; const SCHEMA_CODE = { ON: ['switch'], SPRAY_ON: ['switch_spray'], SPRAY_MODE: ['mode'], SPRAY_LEVEL: ['level'], LIGHT_ON: ['switch_led'], LIGHT_MODE: ['work_mode'], LIGHT_BRIGHT: ['bright_value', 'bright_value_v2'], LIGHT_COLOR: ['colour_data', 'colour_data_hsv'], SOUND_ON: ['switch_sound'], }; export default class DiffuserAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.SPRAY_ON]; } configureServices() { // Main Switch configureOn(this, undefined, this.getSchema(...SCHEMA_CODE.ON)); this.configureDiffuser(); configureLight( this, undefined, this.getSchema(...SCHEMA_CODE.LIGHT_ON), this.getSchema(...SCHEMA_CODE.LIGHT_BRIGHT), undefined, this.getSchema(...SCHEMA_CODE.LIGHT_COLOR), this.getSchema(...SCHEMA_CODE.LIGHT_MODE), ); configureOn(this, undefined, this.getSchema(...SCHEMA_CODE.SOUND_ON)); // Sound Switch } mainService() { return this.accessory.getService(this.Service.AirPurifier) || this.accessory.addService(this.Service.AirPurifier); } configureDiffuser() { const sprayOnSchema = this.getSchema(...SCHEMA_CODE.SPRAY_ON)!; // Required Characteristics configureActive(this, this.mainService(), sprayOnSchema); const { INACTIVE, PURIFYING_AIR } = this.Characteristic.CurrentAirPurifierState; this.mainService().getCharacteristic(this.Characteristic.CurrentAirPurifierState) .onGet(() => { const status = this.getStatus(sprayOnSchema.code)!; return (status.value as boolean) ? PURIFYING_AIR : INACTIVE; }); // const { MANUAL } = this.Characteristic.TargetAirPurifierState; // this.mainService().getCharacteristic(this.Characteristic.TargetAirPurifierState) // .setProps({ validValues: [MANUAL] }); // Optional Characteristics configureRotationSpeedLevel(this, this.mainService(), this.getSchema(...SCHEMA_CODE.SPRAY_LEVEL)); } } ================================================ FILE: src/accessory/DimmerAccessory.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchemaIntegerProperty, TuyaDeviceStatus } from '../device/TuyaDevice'; import { remap, limit } from '../util/util'; import BaseAccessory from './BaseAccessory'; import { configureName } from './characteristic/Name'; import { configureOn } from './characteristic/On'; const SCHEMA_CODE = { ON: ['switch', 'switch_led', 'switch_1', 'switch_led_1'], BRIGHTNESS: ['bright_value', 'bright_value_1'], }; export default class DimmerAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ON, SCHEMA_CODE.BRIGHTNESS]; } configureServices() { const oldService = this.accessory.getService(this.Service.Lightbulb); if (oldService && oldService?.subtype === undefined) { this.platform.log.warn('Remove old service:', oldService.UUID); this.accessory.removeService(oldService); } const schema = this.device.schema.filter((schema) => schema.code.startsWith('bright_value')); for (const _schema of schema) { const suffix = _schema.code.replace('bright_value', ''); const name = (schema.length === 1) ? this.device.name : _schema.code; const service = this.accessory.getService(_schema.code) || this.accessory.addService(this.Service.Lightbulb, name, _schema.code); configureName(this, service, name); configureOn(this, service, this.getSchema('switch' + suffix, 'switch_led' + suffix)); this.configureBrightness(service, suffix); } } configureBrightness(service: Service, suffix: string) { const schema = this.getSchema('bright_value' + suffix); if (!schema) { return; } const { min, max } = schema.property as TuyaDeviceSchemaIntegerProperty; const range = max; // not max - min const props = { minValue: 0, maxValue: 100, minStep: 1, }; const minStatus = this.getStatus('brightness_min' + suffix); const maxStatus = this.getStatus('brightness_max' + suffix); if (minStatus && maxStatus && maxStatus.value > minStatus.value) { const minValue = Math.ceil(remap(minStatus.value as number, 0, range, 0, 100)); const maxValue = Math.floor(remap(maxStatus.value as number, 0, range, 0, 100)); props.minValue = Math.max(props.minValue, minValue); props.maxValue = Math.min(props.maxValue, maxValue); } this.log.debug('Set props for Brightness:', props); service.getCharacteristic(this.Characteristic.Brightness) .onGet(() => { const status = this.getStatus(schema.code)!; let value = status.value as number; value = remap(value, 0, range, 0, 100); value = Math.round(value); value = limit(value, props.minValue, props.maxValue); return value; }) .onSet(async value => { this.log.debug(`Characteristic.Brightness set to: ${value}`); let brightValue = value as number; brightValue = remap(brightValue, 0, 100, 0, range); brightValue = Math.round(brightValue); brightValue = limit(brightValue, min, max); await this.sendCommands([{ code: schema.code, value: brightValue }], true); }) .setProps(props); } async onDeviceStatusUpdate(status: TuyaDeviceStatus[]) { // brightness range updated if (status.length !== this.device.status.length) { for (const _status of status) { if (!_status.code.startsWith('brightness_min') && !_status.code.startsWith('brightness_max')) { continue; } this.platform.log.warn('Brightness range updated, please restart homebridge to take effect.'); // TODO updating props // this.platform.log.debug('Brightness range updated, resetting props...'); // this.configure(); break; } } super.onDeviceStatusUpdate(status); } } ================================================ FILE: src/accessory/DoorbellAccessory.ts ================================================ import { TuyaDeviceStatus } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureProgrammableSwitchEvent, onProgrammableSwitchEvent } from './characteristic/ProgrammableSwitchEvent'; const SCHEMA_CODE = { // ALARM_MESSAGE: ['alarm_message'], // ALARM_SWITCH: ['alarm_propel_switch'], // VOLUME: ['doorbell_volume_value'], DOORBELL_CALL: ['doorbell_call'], }; export default class DoorbellAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.DOORBELL_CALL]; } configureServices() { this.log.warn('HomeKit Doorbell service does not work without camera anymore.'); this.log.warn('Downgrade to StatelessProgrammableSwitch. "Mute" and "Volume" not available.'); configureProgrammableSwitchEvent(this, this.getDoorbellService(), this.getSchema(...SCHEMA_CODE.DOORBELL_CALL)); // this.configureMute(); // this.configureVolume(); } /* configureMute() { const schema = this.getSchema(...SCHEMA_CODE.ALARM_SWITCH); if (!schema) { return; } this.getDoorbellService().getCharacteristic(this.Characteristic.Mute) .onGet(() => { const status = this.getStatus(schema.code)!; const value = !(status.value as boolean); return value; }) .onSet(async value => { const mute = !(value as boolean); await this.sendCommands([{ code: schema.code, value: mute }], true); }); } configureVolume() { const schema = this.getSchema(...SCHEMA_CODE.VOLUME); if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property.scale); const props = { minValue: property.min / multiple, maxValue: property.max / multiple, minStep: Math.max(1, property.step / multiple), }; this.getDoorbellService().getCharacteristic(this.Characteristic.Volume) .onGet(() => { const status = this.getStatus(schema.code)!; const value = status.value as number / multiple; return value; }) .onSet(async value => { const volume = (value as number) * multiple; await this.sendCommands([{ code: schema.code, value: volume }], true); }) .setProps(props); } getDoorbellService() { return this.accessory.getService(this.Service.Doorbell) || this.accessory.addService(this.Service.Doorbell); } */ getDoorbellService() { return this.accessory.getService(this.Service.StatelessProgrammableSwitch) || this.accessory.addService(this.Service.StatelessProgrammableSwitch); } async onDeviceStatusUpdate(status: TuyaDeviceStatus[]) { super.onDeviceStatusUpdate(status); const doorbellCallSchema = this.getSchema(...SCHEMA_CODE.DOORBELL_CALL); if (doorbellCallSchema) { const doorbellCallStatus = status.find(_status => _status.code === doorbellCallSchema.code); doorbellCallStatus && onProgrammableSwitchEvent(this, this.getDoorbellService(), doorbellCallStatus); } } } ================================================ FILE: src/accessory/ExtractionHoodAccessory.ts ================================================ import { TuyaDeviceSchemaType } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureActive } from './characteristic/Active'; import { configureLockPhysicalControls } from './characteristic/LockPhysicalControls'; import { configureRotationSpeed, configureRotationSpeedLevel } from './characteristic/RotationSpeed'; import {configureLight} from './characteristic/Light'; import {configureOn} from './characteristic/On'; const SCHEMA_CODE = { ACTIVE: ['switch'], MODE: ['mode'], LOCK: ['lock'], SPEED: ['speed'], SPEED_LEVEL: ['fan_speed_enum', 'speed'], AIR_QUALITY: ['air_quality', 'pm25'], PM2_5: ['pm25'], VOC: ['tvoc'], LIGHT_ON: ['light', 'switch_led'], LIGHT_MODE: ['work_mode'], LIGHT_BRIGHT: ['bright_value', 'bright_value_v2'], LIGHT_TEMP: ['temp_value', 'temp_value_v2'], LIGHT_COLOR: ['colour_data'], }; export default class ExtractionHoodAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ACTIVE]; } configureServices() { configureActive(this, this.mainService(), this.getSchema(...SCHEMA_CODE.ACTIVE)); this.configureCurrentState(); this.configureTargetState(); configureLockPhysicalControls(this, this.mainService(), this.getSchema(...SCHEMA_CODE.LOCK)); if (this.getFanSpeedSchema()) { configureRotationSpeed(this, this.mainService(), this.getFanSpeedSchema()); } else if (this.getFanSpeedLevelSchema()) { configureRotationSpeedLevel(this, this.mainService(), this.getFanSpeedLevelSchema()); } // Light if (this.getSchema(...SCHEMA_CODE.LIGHT_ON)) { if (this.lightServiceType() === this.Service.Lightbulb) { configureLight( this, this.lightService(), this.getSchema(...SCHEMA_CODE.LIGHT_ON), this.getSchema(...SCHEMA_CODE.LIGHT_BRIGHT), this.getSchema(...SCHEMA_CODE.LIGHT_TEMP), this.getSchema(...SCHEMA_CODE.LIGHT_COLOR), this.getSchema(...SCHEMA_CODE.LIGHT_MODE), ); } else if (this.lightServiceType() === this.Service.Switch) { configureOn(this, undefined, this.getSchema(...SCHEMA_CODE.LIGHT_ON)); const unusedService = this.accessory.getService(this.Service.Lightbulb); unusedService && this.accessory.removeService(unusedService); } } } mainService() { return this.accessory.getService(this.Service.AirPurifier) || this.accessory.addService(this.Service.AirPurifier); } getFanSpeedSchema() { const schema = this.getSchema(...SCHEMA_CODE.SPEED); if (schema && schema.type === TuyaDeviceSchemaType.Integer) { return schema; } return undefined; } getFanSpeedLevelSchema() { const schema = this.getSchema(...SCHEMA_CODE.SPEED_LEVEL); if (schema && schema.type === TuyaDeviceSchemaType.Enum) { return schema; } return undefined; } configureCurrentState() { const schema = this.getSchema(...SCHEMA_CODE.ACTIVE); if (!schema) { return; } const { INACTIVE, PURIFYING_AIR } = this.Characteristic.CurrentAirPurifierState; this.mainService().getCharacteristic(this.Characteristic.CurrentAirPurifierState) .onGet(() => { const status = this.getStatus(schema.code)!; return status.value as boolean ? PURIFYING_AIR : INACTIVE; }); } configureTargetState() { const schema = this.getSchema(...SCHEMA_CODE.MODE); if (!schema) { return; } const { MANUAL, AUTO } = this.Characteristic.TargetAirPurifierState; this.mainService().getCharacteristic(this.Characteristic.TargetAirPurifierState) .onGet(() => { const status = this.getStatus(schema.code)!; return (status.value === 'auto') ? AUTO : MANUAL; }) .onSet(async value => { await this.sendCommands([{ code: schema.code, value: (value === AUTO) ? 'auto' : 'manual', }], true); }); } lightServiceType() { if (this.getSchema(...SCHEMA_CODE.LIGHT_BRIGHT) || this.getSchema(...SCHEMA_CODE.LIGHT_TEMP) || this.getSchema(...SCHEMA_CODE.LIGHT_COLOR) || this.getSchema(...SCHEMA_CODE.LIGHT_MODE)) { return this.Service.Lightbulb; } return this.Service.Switch; } lightService() { return this.accessory.getService(this.Service.Lightbulb) || this.accessory.addService(this.Service.Lightbulb); } } ================================================ FILE: src/accessory/FanAccessory.ts ================================================ import { TuyaDeviceSchemaType } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureActive } from './characteristic/Active'; import { configureLight } from './characteristic/Light'; import { configureLockPhysicalControls } from './characteristic/LockPhysicalControls'; import { configureOn } from './characteristic/On'; import { configureRotationSpeed, configureRotationSpeedLevel, configureRotationSpeedOn } from './characteristic/RotationSpeed'; import { configureSwingMode } from './characteristic/SwingMode'; const SCHEMA_CODE = { FAN_ON: ['switch_fan', 'fan_switch', 'switch'], FAN_DIRECTION: ['fan_direction'], FAN_SPEED: ['fan_speed', 'fan_speed_percent'], FAN_SPEED_LEVEL: ['fan_speed_enum', 'fan_speed'], FAN_LOCK: ['child_lock'], FAN_SWING: ['switch_horizontal', 'switch_vertical'], LIGHT_ON: ['light', 'switch_led'], LIGHT_MODE: ['work_mode'], LIGHT_BRIGHT: ['bright_value', 'bright_value_v2'], LIGHT_TEMP: ['temp_value', 'temp_value_v2'], LIGHT_COLOR: ['colour_data'], }; export default class FanAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.FAN_ON]; } configureServices() { if (this.fanServiceType() === this.Service.Fan) { const unusedService = this.accessory.getService(this.Service.Fanv2); unusedService && this.accessory.removeService(unusedService); configureOn(this, this.fanService(), this.getSchema(...SCHEMA_CODE.FAN_ON)); } else if (this.fanServiceType() === this.Service.Fanv2) { const unusedService = this.accessory.getService(this.Service.Fan); unusedService && this.accessory.removeService(unusedService); configureActive(this, this.fanService(), this.getSchema(...SCHEMA_CODE.FAN_ON)); configureLockPhysicalControls(this, this.fanService(), this.getSchema(...SCHEMA_CODE.FAN_LOCK)); configureSwingMode(this, this.fanService(), this.getSchema(...SCHEMA_CODE.FAN_SWING)); } // Common Characteristics if (this.getFanSpeedSchema()) { configureRotationSpeed(this, this.fanService(), this.getFanSpeedSchema()); } else if (this.getFanSpeedLevelSchema()) { configureRotationSpeedLevel(this, this.fanService(), this.getFanSpeedLevelSchema()); } else { configureRotationSpeedOn(this, this.fanService(), this.getSchema(...SCHEMA_CODE.FAN_ON)); } this.configureRotationDirection(); // Dual-light: two independent light channels (warm + white) const warmOn = this.getSchema('light'); const warmBright = this.getSchema('bright_value'); const coldOn = this.getSchema('switch_led'); const coldBright = this.getSchema('bright_value_1'); if (warmOn && warmBright && coldOn && coldBright) { const warmService = this.accessory.getService('Warm Light') || this.accessory.addService(this.Service.Lightbulb, 'Warm Light', 'warm_light'); configureLight(this, warmService, warmOn, warmBright); const whiteService = this.accessory.getService('White Light') || this.accessory.addService(this.Service.Lightbulb, 'White Light', 'white_light'); configureLight(this, whiteService, coldOn, coldBright); } else if (this.getSchema(...SCHEMA_CODE.LIGHT_ON)) { if (this.lightServiceType() === this.Service.Lightbulb) { configureLight( this, this.lightService(), this.getSchema(...SCHEMA_CODE.LIGHT_ON), this.getSchema(...SCHEMA_CODE.LIGHT_BRIGHT), this.getSchema(...SCHEMA_CODE.LIGHT_TEMP), this.getSchema(...SCHEMA_CODE.LIGHT_COLOR), this.getSchema(...SCHEMA_CODE.LIGHT_MODE), ); } else if (this.lightServiceType() === this.Service.Switch) { configureOn(this, undefined, this.getSchema(...SCHEMA_CODE.LIGHT_ON)); const unusedService = this.accessory.getService(this.Service.Lightbulb); unusedService && this.accessory.removeService(unusedService); } } } fanServiceType() { if (this.getSchema(...SCHEMA_CODE.FAN_LOCK) || this.getSchema(...SCHEMA_CODE.FAN_SWING)) { return this.Service.Fanv2; } return this.Service.Fan; } fanService() { const serviceType = this.fanServiceType(); return this.accessory.getService(serviceType) || this.accessory.addService(serviceType); } lightServiceType() { if (this.getSchema(...SCHEMA_CODE.LIGHT_BRIGHT) || this.getSchema(...SCHEMA_CODE.LIGHT_TEMP) || this.getSchema(...SCHEMA_CODE.LIGHT_COLOR) || this.getSchema(...SCHEMA_CODE.LIGHT_MODE)) { return this.Service.Lightbulb; } return this.Service.Switch; } lightService() { return this.accessory.getService(this.Service.Lightbulb) || this.accessory.addService(this.Service.Lightbulb); } getFanSpeedSchema() { const schema = this.getSchema(...SCHEMA_CODE.FAN_SPEED); if (schema && schema.type === TuyaDeviceSchemaType.Integer) { return schema; } return undefined; } getFanSpeedLevelSchema() { const schema = this.getSchema(...SCHEMA_CODE.FAN_SPEED_LEVEL); if (schema && schema.type === TuyaDeviceSchemaType.Enum) { return schema; } return undefined; } configureRotationDirection() { const schema = this.getSchema(...SCHEMA_CODE.FAN_DIRECTION); if (!schema) { return; } const { CLOCKWISE, COUNTER_CLOCKWISE } = this.Characteristic.RotationDirection; this.fanService().getCharacteristic(this.Characteristic.RotationDirection) .onGet(() => { const status = this.getStatus(schema.code)!; return (status.value !== 'reverse') ? CLOCKWISE : COUNTER_CLOCKWISE; }) .onSet(async value => { await this.sendCommands([{ code: schema.code, value: (value === CLOCKWISE) ? 'forward' : 'reverse' }]); }); } } ================================================ FILE: src/accessory/GarageDoorAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = { CURRENT_DOOR_STATE: ['doorcontact_state'], TARGET_DOOR_STATE: ['switch_1'], }; export default class GarageDoorAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.TARGET_DOOR_STATE]; } configureServices() { this.configureCurrentDoorState(); this.configureTargetDoorState(); } mainService() { return this.accessory.getService(this.Service.GarageDoorOpener) || this.accessory.addService(this.Service.GarageDoorOpener); } configureCurrentDoorState() { const { OPEN, CLOSED, OPENING, CLOSING, STOPPED } = this.Characteristic.CurrentDoorState; this.mainService().getCharacteristic(this.Characteristic.CurrentDoorState) .onGet(() => { const currentSchema = this.getSchema(...SCHEMA_CODE.CURRENT_DOOR_STATE); const targetSchema = this.getSchema(...SCHEMA_CODE.TARGET_DOOR_STATE); if (!currentSchema || !targetSchema) { return STOPPED; } const currentStatus = this.getStatus(currentSchema.code)!; const targetStatus = this.getStatus(targetSchema.code)!; if (currentStatus.value === true && targetStatus.value === true) { return OPEN; } else if (currentStatus.value === false && targetStatus.value === false) { return CLOSED; } else if (currentStatus.value === false && targetStatus.value === true) { return OPENING; } else if (currentStatus.value === true && targetStatus.value === false) { return CLOSING; } return STOPPED; }); } configureTargetDoorState() { const schema = this.getSchema(...SCHEMA_CODE.TARGET_DOOR_STATE); if (!schema) { return; } const { OPEN, CLOSED } = this.Characteristic.TargetDoorState; this.mainService().getCharacteristic(this.Characteristic.TargetDoorState) .onGet(() => { const status = this.getStatus(schema.code)!; return status.value as boolean ? OPEN : CLOSED; }) .onSet(async value => { await this.sendCommands([{ code: schema.code, value: (value === OPEN) ? true : false, }]); }); } } ================================================ FILE: src/accessory/HeaterAccessory.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import { TuyaDeviceSchemaIntegerProperty } from '../device/TuyaDevice'; import { limit } from '../util/util'; import BaseAccessory from './BaseAccessory'; import { configureActive } from './characteristic/Active'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; import { configureLockPhysicalControls } from './characteristic/LockPhysicalControls'; import { configureSwingMode } from './characteristic/SwingMode'; import { configureTempDisplayUnits } from './characteristic/TemperatureDisplayUnits'; const SCHEMA_CODE = { ACTIVE: ['switch'], WORK_STATE: ['work_state'], CURRENT_TEMP: ['temp_current'], TARGET_TEMP: ['temp_set'], LOCK: ['lock'], SWING: ['shake'], TEMP_UNIT_CONVERT: ['temp_unit_convert', 'c_f'], }; export default class HeaterAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ACTIVE]; } configureServices() { configureActive(this, this.mainService(), this.getSchema(...SCHEMA_CODE.ACTIVE)); this.configureCurrentState(); this.configureTargetState(); configureCurrentTemperature(this, this.mainService(), this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); configureLockPhysicalControls(this, this.mainService(), this.getSchema(...SCHEMA_CODE.LOCK)); configureSwingMode(this, this.mainService(), this.getSchema(...SCHEMA_CODE.SWING)); this.configureHeatingThreshouldTemp(); configureTempDisplayUnits(this, this.mainService(), this.getSchema(...SCHEMA_CODE.TEMP_UNIT_CONVERT)); } mainService() { return this.accessory.getService(this.Service.HeaterCooler) || this.accessory.addService(this.Service.HeaterCooler); } configureCurrentState() { const schema = this.getSchema(...SCHEMA_CODE.WORK_STATE); const { INACTIVE, IDLE, HEATING } = this.Characteristic.CurrentHeaterCoolerState; this.mainService().getCharacteristic(this.Characteristic.CurrentHeaterCoolerState) .onGet(() => { if (!schema) { return IDLE; } const status = this.getStatus(schema.code)!; if (status.value === 'heating') { return HEATING; } else if (status.value === 'warming') { return IDLE; } return INACTIVE; }); } configureTargetState() { const { AUTO, HEAT, COOL } = this.Characteristic.TargetHeaterCoolerState; const validValues = [ AUTO ]; this.mainService().getCharacteristic(this.Characteristic.TargetHeaterCoolerState) .onGet(() => { return AUTO; }) .onSet(async value => { // TODO }) .setProps({ validValues }); } configureHeatingThreshouldTemp() { const schema = this.getSchema(...SCHEMA_CODE.TARGET_TEMP); if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = property ? Math.pow(10, property.scale) : 1; const props = { minValue: property.min / multiple, maxValue: property.max / multiple, minStep: Math.max(0.1, property.step / multiple), }; this.log.debug('Set props for HeatingThresholdTemperature:', props); this.mainService().getCharacteristic(this.Characteristic.HeatingThresholdTemperature) .onGet(() => { const status = this.getStatus(schema.code)!; const temp = status.value as number / multiple; return limit(temp, props.minValue, props.maxValue); }) .onSet(async value => { await this.sendCommands([{ code: schema.code, value: (value as number) * multiple}]); }) .setProps(props); } } ================================================ FILE: src/accessory/HumanPresenceSensorAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureOccupancyDetected } from './characteristic/OccupancyDetected'; const SCHEMA_CODE = { PRESENCE: ['presence_state'], }; export default class HumanPresenceSensorAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.PRESENCE]; } configureServices() { configureOccupancyDetected(this, undefined, this.getSchema(...SCHEMA_CODE.PRESENCE)); } } ================================================ FILE: src/accessory/HumidifierAccessory.ts ================================================ import { TuyaDeviceSchemaIntegerProperty } from '../device/TuyaDevice'; import { limit, remap } from '../util/util'; import BaseAccessory from './BaseAccessory'; import { configureActive } from './characteristic/Active'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; import { configureCurrentRelativeHumidity } from './characteristic/CurrentRelativeHumidity'; import { configureLight } from './characteristic/Light'; const SCHEMA_CODE = { ACTIVE: ['switch'], CURRENT_HUMIDITY: ['humidity_current'], TARGET_HUMIDITY: ['humidity_set'], CURRENT_TEMP: ['temp_current'], LIGHT_ON: ['switch_led'], LIGHT_MODE: ['work_mode'], LIGHT_BRIGHT: ['bright_value', 'bright_value_v2'], LIGHT_COLOR: ['colour_data', 'colour_data_hsv'], }; export default class HumidifierAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ACTIVE]; } configureServices() { // Required Characteristics configureActive(this, this.mainService(), this.getSchema(...SCHEMA_CODE.ACTIVE)); this.configureCurrentState(); this.configureTargetState(); configureCurrentRelativeHumidity(this, this.mainService(), this.getSchema(...SCHEMA_CODE.CURRENT_HUMIDITY)); // Optional Characteristics this.configureRelativeHumidityHumidifierThreshold(); this.configureRotationSpeed(); // Other configureCurrentTemperature(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); configureLight( this, undefined, this.getSchema(...SCHEMA_CODE.LIGHT_ON), this.getSchema(...SCHEMA_CODE.LIGHT_BRIGHT), undefined, this.getSchema(...SCHEMA_CODE.LIGHT_COLOR), this.getSchema(...SCHEMA_CODE.LIGHT_MODE), ); } mainService() { return this.accessory.getService(this.Service.HumidifierDehumidifier) || this.accessory.addService(this.Service.HumidifierDehumidifier); } configureTargetState() { const { HUMIDIFIER } = this.Characteristic.TargetHumidifierDehumidifierState; const validValues = [HUMIDIFIER]; this.mainService().getCharacteristic(this.Characteristic.TargetHumidifierDehumidifierState) .onGet(() => { return HUMIDIFIER; }).setProps({ validValues }); } configureCurrentState() { const schema = this.getSchema(...SCHEMA_CODE.ACTIVE); if (!schema) { this.log.warn('CurrentHumidifierDehumidifierState not supported.'); return; } const { INACTIVE, HUMIDIFYING } = this.Characteristic.CurrentHumidifierDehumidifierState; this.mainService().getCharacteristic(this.Characteristic.CurrentHumidifierDehumidifierState) .onGet(() => { const status = this.getStatus(schema.code); return (status?.value as boolean) ? HUMIDIFYING : INACTIVE; }); } configureRelativeHumidityHumidifierThreshold() { const schema = this.getSchema(...SCHEMA_CODE.TARGET_HUMIDITY); if (!schema) { this.log.warn('Humidity setting is not supported.'); return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property ? property.scale : 0); const props = { minValue: 0, maxValue: 100, minStep: Math.max(1, property.step / multiple), }; this.log.debug('Set props for RelativeHumidityHumidifierThreshold:', props); this.mainService().getCharacteristic(this.Characteristic.RelativeHumidityHumidifierThreshold) .onGet(() => { const status = this.getStatus(schema.code)!; return limit(status.value as number / multiple, 0, 100); }) .onSet(async value => { const humidity_set = limit(value as number * multiple, property.min, property.max); await this.sendCommands([{ code: schema.code, value: humidity_set }]); // also set spray mode to humidity await this.setSprayModeToHumidity(); }).setProps(props); } configureRotationSpeed() { const schema = this.getSchema('mode'); if (!schema) { this.log.warn('Mode setting is not supported.'); return; } const unusedService = this.accessory.getService(this.Service.Fan); unusedService && this.accessory.removeService(unusedService); this.mainService().getCharacteristic(this.Characteristic.RotationSpeed) .onGet(() => { const status = this.getStatus(schema.code)!; let v = 3; switch (status.value as string) { case 'small': v = 1; break; case 'middle': v = 2; break; } return remap(v, 0, 3, 0, 100); }).onSet(async value => { value = Math.round(remap(value as number, 0, 100, 0, 3)); let mode = 'small'; switch (value) { case 2: mode = 'middle'; break; case 3: mode = 'large'; break; } await this.sendCommands([{ code: schema.code, value: mode }]); }); } async setSprayModeToHumidity() { const schema = this.getSchema('spray_mode'); if (!schema) { this.log.debug('Spray mode not supported.'); return; } await this.sendCommands([{ code: schema.code, value: 'humidity' }]); } } ================================================ FILE: src/accessory/IRAirConditionerAccessory.ts ================================================ import debounce from 'debounce'; import BaseAccessory from './BaseAccessory'; const POWER_OFF = 0; const POWER_ON = 1; const AC_MODE_COOL = 0; const AC_MODE_HEAT = 1; const AC_MODE_AUTO = 2; const AC_MODE_FAN = 3; const AC_MODE_DEHUMIDIFIER = 4; const FAN_SPEED_AUTO = 0; const FAN_SPEED_LOW = 1; // const FAN_SPEED_MEDIUM = 2; const FAN_SPEED_HIGH = 3; export default class IRAirConditionerAccessory extends BaseAccessory { configureServices() { this.configureAirConditioner(); this.configureDehumidifier(); this.configureFan(); } configureAirConditioner() { const service = this.mainService(); const { INACTIVE, ACTIVE } = this.Characteristic.Active; // Required Characteristics service.getCharacteristic(this.Characteristic.Active) .onGet(() => { return ([AC_MODE_COOL, AC_MODE_HEAT, AC_MODE_AUTO].includes(this.getMode()) && this.getPower() === POWER_ON) ? ACTIVE : INACTIVE; }) .onSet(async value => { if (value === ACTIVE) { // Turn off Dehumidifier & Fan this.supportDehumidifier() && this.dehumidifierService().getCharacteristic(this.Characteristic.Active).updateValue(INACTIVE); this.supportFan() && this.fanService().getCharacteristic(this.Characteristic.Active).updateValue(INACTIVE); this.fanService().getCharacteristic(this.Characteristic.Active).value = INACTIVE; } if (value === ACTIVE && ![AC_MODE_COOL, AC_MODE_HEAT, AC_MODE_AUTO].includes(this.getMode())) { this.setMode(AC_MODE_AUTO); } this.setPower((value === ACTIVE) ? POWER_ON : POWER_OFF); }); const { IDLE } = this.Characteristic.CurrentHeaterCoolerState; service.setCharacteristic(this.Characteristic.CurrentHeaterCoolerState, IDLE); this.configureTargetState(); this.configureCurrentTemperature(); // Optional Characteristics this.configureRotationSpeed(service); const key_range = this.device.remote_keys?.key_range || []; if (key_range.find(item => item.mode === AC_MODE_HEAT)) { const [minValue, maxValue] = this.getTempRange(AC_MODE_HEAT)!; service.getCharacteristic(this.Characteristic.HeatingThresholdTemperature) .onGet(() => { if (this.getMode() === AC_MODE_AUTO) { return minValue; } return this.getTemp(); }) .onSet(async value => { if (this.getMode() === AC_MODE_AUTO) { return; } this.setTemp(value); }) .setProps({ minValue, maxValue, minStep: 1 }); } if (key_range.find(item => item.mode === AC_MODE_COOL)) { const [minValue, maxValue] = this.getTempRange(AC_MODE_COOL)!; service.getCharacteristic(this.Characteristic.CoolingThresholdTemperature) .onGet(this.getTemp.bind(this)) .onSet(this.setTemp.bind(this)) .setProps({ minValue, maxValue, minStep: 1 }); } } configureDehumidifier() { if (!this.supportDehumidifier()) { return; } const service = this.dehumidifierService(); const { INACTIVE, ACTIVE } = this.Characteristic.Active; // Required Characteristics service.getCharacteristic(this.Characteristic.Active) .onGet(() => { return (this.getMode() === AC_MODE_DEHUMIDIFIER && this.getPower() === POWER_ON) ? ACTIVE : INACTIVE; }) .onSet(async value => { if (value === ACTIVE) { // Turn off AC & Fan this.mainService().getCharacteristic(this.Characteristic.Active).updateValue(INACTIVE); this.supportFan() && this.fanService().getCharacteristic(this.Characteristic.Active).updateValue(INACTIVE); } this.setMode(AC_MODE_DEHUMIDIFIER); this.setPower((value === ACTIVE) ? POWER_ON : POWER_OFF); }); const { DEHUMIDIFYING } = this.Characteristic.CurrentHumidifierDehumidifierState; service.setCharacteristic(this.Characteristic.CurrentHumidifierDehumidifierState, DEHUMIDIFYING); const { DEHUMIDIFIER } = this.Characteristic.TargetHumidifierDehumidifierState; service.getCharacteristic(this.Characteristic.TargetHumidifierDehumidifierState) .updateValue(DEHUMIDIFIER) .setProps({ validValues: [DEHUMIDIFIER] }); service.getCharacteristic(this.Characteristic.CurrentRelativeHumidity) .onGet(() => { const handler = this.getParentAccessory().accessory .getService(this.Service.HumiditySensor) ?.getCharacteristic(this.Characteristic.CurrentRelativeHumidity)['getHandler']; const humidity = handler ? handler() : 0; return humidity; }); // Optional Characteristics this.configureRotationSpeed(service); } configureFan() { if (!this.supportFan()) { return; } const service = this.fanService(); const { INACTIVE, ACTIVE } = this.Characteristic.Active; // Required Characteristics service.getCharacteristic(this.Characteristic.Active) .onGet(() => { return (this.getMode() === AC_MODE_FAN && this.getPower() === POWER_ON) ? ACTIVE : INACTIVE; }) .onSet(async value => { if (value === ACTIVE) { // Turn off AC & Dehumidifier this.mainService().getCharacteristic(this.Characteristic.Active).updateValue(INACTIVE); this.supportDehumidifier() && this.dehumidifierService().getCharacteristic(this.Characteristic.Active).updateValue(INACTIVE); } this.setMode(AC_MODE_FAN); this.setPower((value === ACTIVE) ? POWER_ON : POWER_OFF); }); // Optional Characteristics this.configureTargetFanState(service); this.configureRotationSpeed(service); } mainService() { return this.accessory.getService(this.Service.HeaterCooler) || this.accessory.addService(this.Service.HeaterCooler); } dehumidifierService() { return this.accessory.getService(this.Service.HumidifierDehumidifier) || this.accessory.addService(this.Service.HumidifierDehumidifier, this.accessory.displayName + ' Dehumidifier'); } fanService() { return this.accessory.getService(this.Service.Fanv2) || this.accessory.addService(this.Service.Fanv2, this.accessory.displayName + ' Fan'); } getPower() { const value = this.getStatus('power')?.value || '0'; return (value === true || parseInt(value.toString()) === 1) ? POWER_ON : POWER_OFF; } setPower(value) { this.getStatus('power')!.value = value; this.debounceSendACCommands(); } getMode() { const value = this.getStatus('mode')?.value || '0'; return parseInt(value.toString()); } setMode(value) { this.getStatus('mode')!.value = value; this.debounceSendACCommands(); } getWind() { const value = this.getStatus('wind')?.value || '0'; return parseInt(value.toString()); } setWind(value) { this.getStatus('wind')!.value = value; this.debounceSendACCommands(); } getTemp() { const value = this.getStatus('temp')?.value || '0'; return parseInt(value.toString()); } setTemp(value) { this.getStatus('temp')!.value = value; this.debounceSendACCommands(); } getKeyRangeItem(mode: number) { const key_range = this.device.remote_keys?.key_range || []; return key_range.find(item => item.mode === mode); } supportDehumidifier() { return this.getKeyRangeItem(AC_MODE_DEHUMIDIFIER) !== undefined; } supportFan() { return this.getKeyRangeItem(AC_MODE_FAN) !== undefined; } getTempRange(mode: number) { const keyRangeItem = this.getKeyRangeItem(mode); if (!keyRangeItem || !keyRangeItem.temp_list || keyRangeItem.temp_list.length === 0) { return undefined; } const tempList = keyRangeItem.temp_list.map((temp) => temp.temp); const min = Math.min(...tempList); const max = Math.max(...tempList); return [min, max]; } getParentAccessory() { return this.platform.accessoryHandlers.find(accessory => accessory.device.id === this.device.parent_id)!; } configureTargetState() { const { AUTO, HEAT, COOL } = this.Characteristic.TargetHeaterCoolerState; const validValues: number[] = []; const key_range = this.device.remote_keys?.key_range || []; if (key_range.find(item => item.mode === AC_MODE_AUTO)) { validValues.push(AUTO); } if (key_range.find(item => item.mode === AC_MODE_HEAT)) { validValues.push(HEAT); } if (key_range.find(item => item.mode === AC_MODE_COOL)) { validValues.push(COOL); } if (validValues.length === 0) { this.log.warn('Invalid mode range for TargetHeaterCoolerState:', key_range); return; } this.mainService().getCharacteristic(this.Characteristic.TargetHeaterCoolerState) .onGet(() => ({ [AC_MODE_COOL.toString()]: COOL, [AC_MODE_HEAT.toString()]: HEAT, [AC_MODE_AUTO.toString()]: AUTO, }[this.getMode().toString()] || AUTO)) .onSet(async value => { this.setMode({ [COOL.toString()]: AC_MODE_COOL, [HEAT.toString()]: AC_MODE_HEAT, [AUTO.toString()]: AC_MODE_AUTO, }[value.toString()]); }) .setProps({ validValues }); } configureCurrentTemperature() { this.mainService().getCharacteristic(this.Characteristic.CurrentTemperature) .onGet(() => { const handler = this.getParentAccessory().accessory .getService(this.Service.TemperatureSensor) ?.getCharacteristic(this.Characteristic.CurrentTemperature)['getHandler']; const temp = handler ? handler() : this.getTemp(); return temp; }); } configureTargetFanState(service) { const { MANUAL, AUTO } = this.Characteristic.TargetFanState; service.getCharacteristic(this.Characteristic.TargetFanState) .onGet(() => (this.getWind() === FAN_SPEED_AUTO) ? AUTO : MANUAL) .onSet(async value => { this.setWind((value === AUTO) ? FAN_SPEED_AUTO : FAN_SPEED_LOW); }); } configureRotationSpeed(service) { service.getCharacteristic(this.Characteristic.RotationSpeed) .onGet(() => (this.getWind() === FAN_SPEED_AUTO) ? FAN_SPEED_HIGH : this.getWind()) .onSet(async value => { // if (this.getWind() === FAN_SPEED_AUTO) { // return; // } if (value !== 0) { this.setWind(value); } }) .setProps({ minValue: 0, maxValue: 3, minStep: 1, unit: 'speed' }); } debounceSendACCommands = debounce(this.sendACCommands, 100); async sendACCommands() { const { parent_id, id } = this.device; await this.deviceManager.sendInfraredACCommands(parent_id!, id, this.getPower(), this.getMode(), this.getTemp(), this.getWind()); } } ================================================ FILE: src/accessory/IRControlHubAccessory.ts ================================================ import { TuyaDeviceStatus } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureCurrentRelativeHumidity } from './characteristic/CurrentRelativeHumidity'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; const SCHEMA_CODE = { CURRENT_TEMP: ['va_temperature'], CURRENT_HUMIDITY: ['va_humidity', 'humidity_value'], }; export default class IRControlHubAccessory extends BaseAccessory { requiredSchema() { return []; } configureServices() { configureCurrentTemperature(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); configureCurrentRelativeHumidity(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_HUMIDITY)); } getSubAccessories() { return this.platform.accessoryHandlers.filter(accessory => accessory.device.parent_id === this.device.id); } async onDeviceStatusUpdate(status: TuyaDeviceStatus[]) { super.onDeviceStatusUpdate(status); // Trigger sub device update temperature & humidity from parent device. for (const subAccessory of this.getSubAccessories()) { await subAccessory.updateAllValues(); } } } ================================================ FILE: src/accessory/IRGenericAccessory.ts ================================================ import { TuyaIRRemoteKeyListItem } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureName } from './characteristic/Name'; export default class IRGenericAccessory extends BaseAccessory { configureServices() { let key_list = this.device.remote_keys?.key_list || []; // Max 99 services allowed (one for AccessoryInformation) if (key_list.length > 99) { this.log.warn(`Skipping ${key_list.length - 99} keys for ${this.device.name}, ` + 'as we reached the limit of HomeKit (100 services per accessory)'); } key_list = key_list.slice(0, 99); for (const key of key_list) { this.configureSwitch(key); } } configureSwitch(key: TuyaIRRemoteKeyListItem) { const service = this.accessory.getService(key.key) || this.accessory.addService(this.Service.Switch, key.key, key.key); configureName(this, service, key.key_name); service.getCharacteristic(this.Characteristic.On) .onGet(() => false) .onSet(async value => { if (value === false) { return; } this.sendInfraredCommands(key); setTimeout(() => { service.getCharacteristic(this.Characteristic.On).updateValue(false); }, 150); }); } async sendInfraredCommands(key: TuyaIRRemoteKeyListItem) { const { parent_id, id } = this.device; const { category_id, remote_index } = this.device.remote_keys!; if (key.learning_code) { await this.deviceManager.sendInfraredDIYCommands(parent_id!, id, key.learning_code); } else { await this.deviceManager.sendInfraredCommands(parent_id!, id, category_id, remote_index, key.key, key.key_id); } } } ================================================ FILE: src/accessory/LeakSensorAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = { LEAK: ['gas_sensor_status', 'gas_sensor_state', 'ch4_sensor_state', 'watersensor_state'], }; export default class LeakSensor extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.LEAK]; } configureServices() { const { LEAK_NOT_DETECTED, LEAK_DETECTED } = this.Characteristic.LeakDetected; const service = this.accessory.getService(this.Service.LeakSensor) || this.accessory.addService(this.Service.LeakSensor); service.getCharacteristic(this.Characteristic.LeakDetected) .onGet(() => { const gas = this.getStatus('gas_sensor_status') || this.getStatus('gas_sensor_state'); const ch4 = this.getStatus('ch4_sensor_state'); const water = this.getStatus('watersensor_state'); if ((gas && (gas.value === 'alarm' || gas.value === '1')) || (ch4 && ch4.value === 'alarm') || (water && water.value === 'alarm')) { return LEAK_DETECTED; } else { return LEAK_NOT_DETECTED; } }); } } ================================================ FILE: src/accessory/LightAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureOn } from './characteristic/On'; import { configureMotionDetected } from './characteristic/MotionDetected'; import { configureLight } from './characteristic/Light'; const SCHEMA_CODE = { ON: ['switch_led'], BRIGHTNESS: ['bright_value', 'bright_value_v2'], COLOR_TEMP: ['temp_value', 'temp_value_v2'], COLOR: ['colour_data', 'colour_data_v2'], WORK_MODE: ['work_mode'], PIR: ['pir_state'], PIR_ON: ['switch_pir'], POWER_SWITCH: ['switch'], }; export default class LightAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ON]; } configureServices() { const service = this.accessory.getService(this.Service.Lightbulb) || this.accessory.addService(this.Service.Lightbulb); configureLight( this, service, this.getSchema(...SCHEMA_CODE.ON), this.getSchema(...SCHEMA_CODE.BRIGHTNESS), this.getSchema(...SCHEMA_CODE.COLOR_TEMP), this.getSchema(...SCHEMA_CODE.COLOR), this.getSchema(...SCHEMA_CODE.WORK_MODE), ); // PIR configureOn(this, undefined, this.getSchema(...SCHEMA_CODE.PIR_ON)); configureMotionDetected(this, undefined, this.getSchema(...SCHEMA_CODE.PIR)); // RGB Power Switch configureOn(this, undefined, this.getSchema(...SCHEMA_CODE.POWER_SWITCH)); } } ================================================ FILE: src/accessory/LightSensorAccessory.ts ================================================ import { TuyaDeviceSchemaIntegerProperty } from '../device/TuyaDevice'; import { limit } from '../util/util'; import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = { BRIGHT_LEVEL: ['bright_value'], }; export default class LightSensorAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.BRIGHT_LEVEL]; } configureServices() { const schema = this.getSchema(...SCHEMA_CODE.BRIGHT_LEVEL); if (!schema) { return; } const service = this.accessory.getService(this.Service.LightSensor) || this.accessory.addService(this.Service.LightSensor); const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property ? property.scale : 0); service.getCharacteristic(this.Characteristic.CurrentAmbientLightLevel) .onGet(() => { const status = this.getStatus(schema.code)!; return limit(status.value as number / multiple, 0.0001, 100000); }); } } ================================================ FILE: src/accessory/LockAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = { LOCK_CURRENT_STATE: ['open_close', 'closed_opened', 'lock_motor_state'], LOCK_TARGET_STATE: ['lock_motor_state'], }; export default class LockAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.LOCK_CURRENT_STATE]; } configureServices() { this.configureLockCurrentState(); this.configureLockTargetState(); } mainService() { return this.accessory.getService(this.Service.LockMechanism) || this.accessory.addService(this.Service.LockMechanism); } configureLockCurrentState() { const schema = this.getSchema(...SCHEMA_CODE.LOCK_CURRENT_STATE); if (!schema) { return; } const { UNSECURED, SECURED } = this.Characteristic.LockCurrentState; this.mainService().getCharacteristic(this.Characteristic.LockCurrentState) .onGet(() => { const status = this.getStatus(schema.code)!; return (status.value as boolean) ? UNSECURED : SECURED; }); } configureLockTargetState() { const schema = this.getSchema(...SCHEMA_CODE.LOCK_TARGET_STATE); if (!schema) { return; } const { UNSECURED, SECURED } = this.Characteristic.LockTargetState; this.mainService().getCharacteristic(this.Characteristic.LockTargetState) .onGet(() => { const status = this.getStatus(schema.code)!; return (status.value as boolean) ? UNSECURED : SECURED; }) .onSet(async value => { const res = await this.deviceManager.getLockTemporaryKey(this.device.id); if (!res.success) { return; } await this.deviceManager.sendLockCommands(this.device.id, res.result.ticket_id, (value === UNSECURED)); }); } } ================================================ FILE: src/accessory/MotionSensorAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureMotionDetected } from './characteristic/MotionDetected'; const SCHEMA_CODE = { PIR: ['pir'], }; export default class MotionSensorAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.PIR]; } configureServices() { configureMotionDetected(this, undefined, this.getSchema(...SCHEMA_CODE.PIR)); } } ================================================ FILE: src/accessory/OutletAccessory.ts ================================================ import SwitchAccessory from './SwitchAccessory'; export default class OutletAccessory extends SwitchAccessory { mainService() { return this.Service.Outlet; } } ================================================ FILE: src/accessory/PetFeederAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureActive } from './characteristic/Active'; import { CharacteristicValue } from 'homebridge'; const SCHEMA_CODE = { ACTIVE: ['switch'], LIGHT: ['light'], QUICK_FEED: ['quick_feed'], SLOW_FEED: ['slow_feed'], MANUAL_FEED: ['manual_feed'], MEAL_PLAN: ['meal_plan'], BATTERY_PERCENTAGE: ['battery_percentage'], FEED_REPORT: ['feed_report'], FEED_STATE: ['feed_state'], }; export default class PetFeederAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ACTIVE]; } configureServices() { configureActive(this, this.mainService(), this.getSchema(...SCHEMA_CODE.ACTIVE)); this.configureLight(); this.configureQuickFeed(); this.configureSlowFeed(); this.configureManualFeed(); this.configureMealPlan(); this.configureBatteryPercentage(); this.configureFeedReport(); this.configureFeedState(); } mainService() { return this.accessory.getService(this.Service.Switch) || this.accessory.addService(this.Service.Switch); } configureLight() { const schema = this.getSchema(...SCHEMA_CODE.LIGHT); if (!schema) { this.log.warn('Light is not supported.'); return; } this.mainService().getCharacteristic(this.Characteristic.On) .onSet(async (value: CharacteristicValue) => { await this.sendCommands([{ code: schema.code, value: value as boolean }]); }); } configureQuickFeed() { const schema = this.getSchema(...SCHEMA_CODE.QUICK_FEED); if (!schema) { this.log.warn('Quick feed is not supported.'); return; } this.mainService().getCharacteristic(this.Characteristic.On) .onSet(async (value: CharacteristicValue) => { if (value as boolean) { await this.sendCommands([{ code: schema.code, value: true }]); } }); } configureSlowFeed() { const schema = this.getSchema(...SCHEMA_CODE.SLOW_FEED); if (!schema) { this.log.warn('Slow feed is not supported.'); return; } this.mainService().getCharacteristic(this.Characteristic.On) .onSet(async (value: CharacteristicValue) => { if (value as boolean) { await this.sendCommands([{ code: schema.code, value: true }]); } }); } configureManualFeed() { const schema = this.getSchema(...SCHEMA_CODE.MANUAL_FEED); if (!schema) { this.log.warn('Manual feed is not supported.'); return; } this.mainService().getCharacteristic(this.Characteristic.On) .onSet(async (value: CharacteristicValue) => { if (value as boolean) { await this.sendCommands([{ code: schema.code, value: 1 }]); } }); } configureMealPlan() { const schema = this.getSchema(...SCHEMA_CODE.MEAL_PLAN); if (!schema) { this.log.warn('Meal plan is not supported.'); return; } this.mainService().getCharacteristic(this.Characteristic.On) .onSet(async (value: CharacteristicValue) => { if (value as boolean) { await this.sendCommands([{ code: schema.code, value: value as boolean }]); } }); } configureBatteryPercentage() { const schema = this.getSchema(...SCHEMA_CODE.BATTERY_PERCENTAGE); if (!schema) { this.log.warn('Battery percentage is not supported.'); return; } this.mainService().getCharacteristic(this.Characteristic.BatteryLevel) .onGet(() => { const status = this.getStatus(schema.code)!; return status.value as number; }); } configureFeedReport() { const schema = this.getSchema(...SCHEMA_CODE.FEED_REPORT); if (!schema) { this.log.warn('Feed report is not supported.'); return; } this.mainService().getCharacteristic(this.Characteristic.StatusActive) .onGet(() => { const status = this.getStatus(schema.code)!; return status.value as number; }); } configureFeedState() { const schema = this.getSchema(...SCHEMA_CODE.FEED_STATE); if (!schema) { this.log.warn('Feed state is not supported.'); return; } this.mainService().getCharacteristic(this.Characteristic.StatusActive) .onGet(() => { const status = this.getStatus(schema.code)!; return status.value === 'feeding'; }); } } ================================================ FILE: src/accessory/SaunaAccessory.ts ================================================ import { TuyaDeviceSchemaIntegerProperty, TuyaDeviceStatus } from '../device/TuyaDevice'; import { limit } from '../util/util'; import BaseAccessory from './BaseAccessory'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; import { configureTempDisplayUnits } from './characteristic/TemperatureDisplayUnits'; import { configureLight } from './characteristic/Light'; const SCHEMA_CODE = { ON: ['powerswitch'], CURRENT_TEMP: ['currtemp', 'settemp'], TARGET_TEMP: ['settemp'], TEMP_UNIT_CONVERT: ['temp_unit_convert', 'c_f'], LIGHT: ['lightswitch'], LED: ['ledswitch'], // TIMER: ['settime'], // Not currently supppored by homekit }; export default class SaunaAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.CURRENT_TEMP, SCHEMA_CODE.TARGET_TEMP]; } configureServices() { this.configureCurrentState(); this.configureTargetState(); configureCurrentTemperature(this, this.mainService(), this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); this.configureTargetTemp(); configureTempDisplayUnits(this, this.mainService(), this.getSchema(...SCHEMA_CODE.TEMP_UNIT_CONVERT)); this.configureLight(); } mainService() { return this.accessory.getService(this.Service.Thermostat) || this.accessory.addService(this.Service.Thermostat); } configureCurrentState() { const { OFF, HEAT } = this.Characteristic.CurrentHeatingCoolingState; this.mainService().getCharacteristic(this.Characteristic.CurrentHeatingCoolingState) .onGet(() => { const on = this.getStatus('powerswitch'); if (on && on.value === false) { return OFF; } else { return HEAT; } }); } configureTargetState() { const { OFF, HEAT } = this.Characteristic.TargetHeatingCoolingState; this.mainService().getCharacteristic(this.Characteristic.TargetHeatingCoolingState) .onGet(() => { const on = this.getStatus('powerswitch'); if (on && on.value === false) { return OFF; } else { return HEAT; } }) .onSet(async value => { const commands: TuyaDeviceStatus[] = []; if (value === OFF) { commands.push({ code: 'powerswitch', value: false, }); } else if (value === HEAT) { commands.push({ code: 'powerswitch', value: true, }); } if (commands.length !== 0) { await this.sendCommands(commands); } }) .setProps({ validValues: [OFF, HEAT] }); } configureTargetTemp() { const schema = this.getSchema(...SCHEMA_CODE.TARGET_TEMP); if (!schema) { this.log.warn('TargetTemperature not supported.'); return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; let multiple = Math.pow(10, property.scale); let props = { minValue: Math.max(30, property.min / multiple), maxValue: Math.min(90, property.max / multiple), minStep: Math.max(0.1, property.step / multiple), }; if (props.maxValue <= props.minValue) { this.log.warn('Invalid schema: %o, props will be reset to the default value.', schema); multiple = 1; props = { minValue: 30, maxValue: 90, minStep: 1 }; } this.log.debug('Set props for TargetTemperature:', props); this.mainService().getCharacteristic(this.Characteristic.TargetTemperature) .onGet(() => { const status = this.getStatus(schema.code); if (!status || typeof status.value !== 'number') { this.log.debug('No valid settemp available, returning default.'); return props.minValue; // or any fallback value like 45 } const temp = (status.value as number) / multiple; return limit(temp, props.minValue, props.maxValue); }) .onSet(async value => { await this.sendCommands([{ code: schema.code, value: value as number * multiple, }]); }) .setProps(props); } configureLight() { const lightswitchSchema = this.getSchema('lightswitch'); const ledswitchSchema = this.getSchema('ledswitch'); const light1Service = this.accessory.getService('Sauna Main Light') || this.accessory.addService(this.Service.Lightbulb, 'Sauna Main Light', 'lightswitch'); const light2Service = this.accessory.getService('Sauna LED Light') || this.accessory.addService(this.Service.Lightbulb, 'Sauna LED Light', 'ledswitch'); if (lightswitchSchema) { configureLight(this, light1Service, lightswitchSchema); } if (ledswitchSchema) { configureLight(this, light2Service, ledswitchSchema); } } } ================================================ FILE: src/accessory/SceneAccessory.ts ================================================ import { PlatformAccessory } from 'homebridge'; import TuyaHomeDeviceManager from '../device/TuyaHomeDeviceManager'; import { TuyaPlatform } from '../platform'; import BaseAccessory from './BaseAccessory'; export default class SceneAccessory extends BaseAccessory { constructor(platform: TuyaPlatform, accessory: PlatformAccessory) { super(platform, accessory); const service = this.accessory.getService(this.Service.Switch) || this.accessory.addService(this.Service.Switch); service.getCharacteristic(this.Characteristic.On) .onGet(() => false) .onSet(async value => { if (value === false) { return; } const deviceManager = this.platform.deviceManager as TuyaHomeDeviceManager; const res = await deviceManager.executeScene(this.device.owner_id, this.device.id); setTimeout(() => { service.getCharacteristic(this.Characteristic.On).updateValue(false); }, 150); if (res.success === false) { this.log.warn('ExecuteScene failed. homeId = %s, code = %s, msg = %s', this.device.owner_id, res.code, res.msg); const { HapStatusError, HAPStatus } = this.platform.api.hap; throw new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE); } }); } } ================================================ FILE: src/accessory/SceneSwitchAccessory.ts ================================================ import { TuyaDeviceSchema, TuyaDeviceSchemaType } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureName } from './characteristic/Name'; export default class SceneSwitchAccessory extends BaseAccessory { configureServices() { const schema = this.device.schema.filter((schema) => schema.code.startsWith('switch') && schema.type === TuyaDeviceSchemaType.Boolean); for (const _schema of schema) { const name = (schema.length === 1) ? this.device.name : _schema.code; this.configureSwitch(_schema, name); } } configureSwitch(schema: TuyaDeviceSchema, name: string) { if (!schema) { return; } const service = this.accessory.getService(schema.code) || this.accessory.addService(this.Service.Switch, name, schema.code); configureName(this, service, name); const suffix = schema.code.replace('switch', ''); const modeSchema = this.getSchema('mode' + suffix); service.getCharacteristic(this.Characteristic.On) .onGet(() => { const status = this.getStatus(schema.code)!; return status.value as boolean; }) .onSet(async value => { if (modeSchema) { const mode = this.getStatus(modeSchema.code)!; if ((mode.value as string).startsWith('scene')) { await this.sendCommands([{ code: schema.code, value: false }]); return; } } await this.sendCommands([{ code: schema.code, value: value as boolean }]); }); } } ================================================ FILE: src/accessory/SecuritySystemAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureSecuritySystemCurrentState, configureSecuritySystemTargetState } from './characteristic/SecuritySystemState'; import { configureName } from './characteristic/Name'; const SCHEMA_CODE = { MASTER_MODE: ['master_mode'], SOS_STATE: ['sos_state'], }; export default class SecuritySystemAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.MASTER_MODE, SCHEMA_CODE.SOS_STATE]; } isNightArm = false; configureServices() { const service = this.accessory.getService(this.Service.SecuritySystem) || this.accessory.addService(this.Service.SecuritySystem); configureName(this, service, this.device.name); configureSecuritySystemCurrentState(this, service, this.getSchema(...SCHEMA_CODE.MASTER_MODE), this.getSchema(...SCHEMA_CODE.SOS_STATE)); configureSecuritySystemTargetState(this, service, this.getSchema(...SCHEMA_CODE.MASTER_MODE), this.getSchema(...SCHEMA_CODE.SOS_STATE)); } } ================================================ FILE: src/accessory/SmokeSensorAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = { SENSOR_STATUS: ['smoke_sensor_status', 'smoke_sensor_state'], }; export default class SmokeSensor extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.SENSOR_STATUS]; } configureServices() { const schema = this.getSchema(...SCHEMA_CODE.SENSOR_STATUS); if (!schema) { return; } const { SMOKE_NOT_DETECTED, SMOKE_DETECTED } = this.Characteristic.SmokeDetected; const service = this.accessory.getService(this.Service.SmokeSensor) || this.accessory.addService(this.Service.SmokeSensor); service.getCharacteristic(this.Characteristic.SmokeDetected) .onGet(() => { const status = this.getStatus(schema.code)!; if ((status.value === 'alarm' || status.value === '1')) { return SMOKE_DETECTED; } else { return SMOKE_NOT_DETECTED; } }); } } ================================================ FILE: src/accessory/SwitchAccessory.ts ================================================ import { TuyaDeviceSchema, TuyaDeviceSchemaType } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureName } from './characteristic/Name'; import { configureOn } from './characteristic/On'; import { configureEnergyUsage } from './characteristic/EnergyUsage'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; import { configureCurrentRelativeHumidity } from './characteristic/CurrentRelativeHumidity'; const SCHEMA_CODE = { ON: ['switch', 'switch_1'], // switch_2, switch_3, switch_4, ..., switch_usb1, switch_usb2, switch_usb3, ..., switch_backlight CURRENT: ['cur_current'], POWER: ['cur_power'], VOLTAGE: ['cur_voltage'], TOTAL_POWER: ['add_ele'], CURRENT_TEMP: ['va_temperature', 'temp_current'], CURRENT_HUMIDITY: ['va_humidity', 'humidity_value'], INCHING: ['switch_inching'], }; export default class SwitchAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ON]; } configureServices() { const oldService = this.accessory.getService(this.mainService()); if (oldService && oldService?.subtype === undefined) { this.platform.log.warn('Remove old service:', oldService.UUID); this.accessory.removeService(oldService); } const schemata = this.device.schema.filter( (schema) => schema.code.startsWith('switch') && schema.type === TuyaDeviceSchemaType.Boolean, ); schemata.forEach((schema) => { const name = (schemata.length === 1) ? this.device.name : schema.code; this.configureSwitch(schema, name); }); // Other configureCurrentTemperature(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); configureCurrentRelativeHumidity(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_HUMIDITY)); this.configureInching(); } mainService() { return this.Service.Switch; } configureSwitch(schema: TuyaDeviceSchema, name: string) { const service = this.accessory.getService(schema.code) || this.accessory.addService(this.mainService(), name, schema.code); configureName(this, service, name); configureOn(this, service, schema); if (schema.code === this.getSchema(...SCHEMA_CODE.ON)?.code) { configureEnergyUsage( this.platform.api, this, service, this.getSchema(...SCHEMA_CODE.CURRENT), this.getSchema(...SCHEMA_CODE.POWER), this.getSchema(...SCHEMA_CODE.VOLTAGE), this.getSchema(...SCHEMA_CODE.TOTAL_POWER), ); } } configureInching() { const schema = this.getSchema(...SCHEMA_CODE.INCHING); if (!schema || schema.type !== TuyaDeviceSchemaType.String) { return; } const service = this.accessory.getService(schema.code) || this.accessory.addService(this.Service.Switch, schema.code, schema.code); configureName(this, service, schema.code); service.getCharacteristic(this.Characteristic.On) .onGet(() => { this.checkOnlineStatus(); const status = this.getStatus(schema.code)!; const buffer = Buffer.from(status.value as string, 'base64'); return (buffer.length === 3) && (buffer[0] === 1); }) .onSet(async value => { const status = this.getStatus(schema.code)!; let buffer = Buffer.from(status.value as string, 'base64'); if (buffer.length !== 3) { buffer = Buffer.alloc(3); } buffer[0] = (value as boolean) ? 1 : 0; await this.sendCommands([{ code: schema.code, value: buffer.toString('base64'), }], true); }); } } ================================================ FILE: src/accessory/TemperatureHumiditySensorAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureCurrentRelativeHumidity } from './characteristic/CurrentRelativeHumidity'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; const SCHEMA_CODE = { SENSOR_STATUS: ['va_temperature', 'va_humidity', 'humidity_value'], CURRENT_TEMP: ['va_temperature'], CURRENT_HUMIDITY: ['va_humidity', 'humidity_value'], }; export default class TemperatureHumiditySensorAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.SENSOR_STATUS]; } configureServices(): void { configureCurrentTemperature(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); configureCurrentRelativeHumidity(this, undefined, this.getSchema(...SCHEMA_CODE.CURRENT_HUMIDITY)); } } ================================================ FILE: src/accessory/ThermostatAccessory.ts ================================================ import { TuyaDeviceSchemaEnumProperty, TuyaDeviceSchemaIntegerProperty, TuyaDeviceStatus } from '../device/TuyaDevice'; import { limit } from '../util/util'; import BaseAccessory from './BaseAccessory'; import { configureCurrentTemperature } from './characteristic/CurrentTemperature'; import { configureTempDisplayUnits } from './characteristic/TemperatureDisplayUnits'; const SCHEMA_CODE = { ON: ['switch'], CURRENT_MODE: ['work_state', 'mode'], TARGET_MODE: ['mode'], CURRENT_TEMP: ['temp_current', 'temp_set'], TARGET_TEMP: ['temp_set'], TEMP_UNIT_CONVERT: ['temp_unit_convert', 'c_f'], }; export default class ThermostatAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.CURRENT_TEMP, SCHEMA_CODE.TARGET_TEMP]; } configureServices() { this.configureCurrentState(); this.configureTargetState(); configureCurrentTemperature(this, this.mainService(), this.getSchema(...SCHEMA_CODE.CURRENT_TEMP)); this.configureTargetTemp(); configureTempDisplayUnits(this, this.mainService(), this.getSchema(...SCHEMA_CODE.TEMP_UNIT_CONVERT)); } mainService() { return this.accessory.getService(this.Service.Thermostat) || this.accessory.addService(this.Service.Thermostat); } configureCurrentState() { const { OFF, HEAT, COOL } = this.Characteristic.CurrentHeatingCoolingState; this.mainService().getCharacteristic(this.Characteristic.CurrentHeatingCoolingState) .onGet(() => { const on = this.getStatus('switch'); if (on && on.value === false) { return OFF; } const schema = this.getSchema(...SCHEMA_CODE.CURRENT_MODE); if (!schema) { // If don't support mode, compare current and target temp. const currentSchema = this.getSchema(...SCHEMA_CODE.CURRENT_TEMP); const targetSchema = this.getSchema(...SCHEMA_CODE.TARGET_TEMP); if (!currentSchema || !targetSchema) { return OFF; } const current = this.getStatus(currentSchema.code)!; const target = this.getStatus(targetSchema.code)!; if (target.value > current.value) { return HEAT; } else if (target.value < current.value) { return COOL; } else { return OFF; } } const status = this.getStatus(schema.code)!; if (status.value === 'hot' || status.value === 'opened' || status.value === 'heating') { return HEAT; } else if ( status.value === 'cold' || status.value === 'eco' || status.value === 'idle' || status.value === 'window_opened' ) { return COOL; } // Don't know how to display unsupported work mode. return OFF; }); } configureTargetState() { const { OFF, HEAT, COOL, AUTO } = this.Characteristic.TargetHeatingCoolingState; const validValues = [AUTO]; // Thermostat valve may not support 'Power Off' if (this.getStatus('switch')) { validValues.push(OFF); } const schema = this.getSchema(...SCHEMA_CODE.TARGET_MODE); const property = schema?.property as TuyaDeviceSchemaEnumProperty; if (property) { if (property.range.includes('hot')) { validValues.push(HEAT); } if (property.range.includes('cold') || property.range.includes('eco')) { validValues.push(COOL); } } this.mainService().getCharacteristic(this.Characteristic.TargetHeatingCoolingState) .onGet(() => { const on = this.getStatus('switch'); if (on && on.value === false) { return OFF; } if (!schema) { // If don't support mode, display auto. return AUTO; } const status = this.getStatus(schema.code)!; if (status.value === 'hot') { return HEAT; } else if (status.value === 'cold' || status.value === 'eco') { return COOL; } else if (status.value === 'auto' || status.value === 'temp_auto') { return AUTO; } // Don't know how to display unsupported mode. return AUTO; }) .onSet(async value => { const commands: TuyaDeviceStatus[] = []; // Thermostat valve may not support 'Power Off' const on = this.getStatus('switch'); if (on) { if (value === OFF) { commands.push({ code: 'switch', value: false, }); } else if (on.value === false) { commands.push({ code: 'switch', value: true, }); } } if (schema) { if ((value === HEAT) && property.range.includes('hot')) { commands.push({ code: schema.code, value: 'hot' }); } else if (value === COOL) { if (property.range.includes('eco')) { commands.push({ code: schema.code, value: 'eco' }); } else if (property.range.includes('cold')) { commands.push({ code: schema.code, value: 'cold' }); } } else if ((value === AUTO) && property.range.includes('auto')) { commands.push({ code: schema.code, value: 'auto' }); } } if (commands.length !== 0) { await this.sendCommands(commands); } }) .setProps({ validValues }); } configureTargetTemp() { const schema = this.getSchema(...SCHEMA_CODE.TARGET_TEMP); if (!schema) { this.log.warn('TargetTemperature not supported.'); return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; let multiple = Math.pow(10, property.scale); let props = { minValue: Math.max(10, property.min / multiple), maxValue: Math.min(38, property.max / multiple), minStep: Math.max(0.1, property.step / multiple), }; if (props.maxValue <= props.minValue) { this.log.warn('Invalid schema: %o, props will be reset to the default value.', schema); multiple = 1; props = { minValue: 10, maxValue: 38, minStep: 1 }; } this.log.debug('Set props for TargetTemperature:', props); this.mainService().getCharacteristic(this.Characteristic.TargetTemperature) .onGet(() => { const status = this.getStatus(schema.code)!; const temp = status.value as number / multiple; return limit(temp, props.minValue, props.maxValue); }) .onSet(async value => { await this.sendCommands([{ code: schema.code, value: value as number * multiple, }]); }) .setProps(props); } } ================================================ FILE: src/accessory/ValveAccessory.ts ================================================ import { TuyaDeviceSchema, TuyaDeviceSchemaType } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureActive } from './characteristic/Active'; import { configureName } from './characteristic/Name'; const SCHEMA_CODE = { ON: ['switch', 'switch_1'], }; export default class ValveAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ON]; } configureServices(): void { const oldService = this.accessory.getService(this.Service.Valve); if (oldService && oldService?.subtype === undefined) { this.platform.log.warn('Remove old service:', oldService.UUID); this.accessory.removeService(oldService); } const schema = SCHEMA_CODE.ON.map(code => this.getSchema(code)) .filter((s: TuyaDeviceSchema | undefined): s is TuyaDeviceSchema => !!s && s.type === TuyaDeviceSchemaType.Boolean); for (const _schema of schema) { const name = (schema.length === 1) ? this.device.name : _schema.code; this.configureValve(_schema, name); } } configureValve(schema: TuyaDeviceSchema, name: string) { const service = this.accessory.getService(schema.code) || this.accessory.addService(this.Service.Valve, name, schema.code); configureName(this, service, name); service.setCharacteristic(this.Characteristic.ValveType, this.Characteristic.ValveType.IRRIGATION); const { NOT_IN_USE, IN_USE } = this.Characteristic.InUse; service.getCharacteristic(this.Characteristic.InUse) .onGet(() => { const status = this.getStatus(schema.code)!; return status.value ? IN_USE : NOT_IN_USE; }); configureActive(this, service, schema); } } ================================================ FILE: src/accessory/VibrationSensorAccessory.ts ================================================ import { TuyaDeviceStatus } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = { STATE: ['shock_state'], }; export default class VibrationSensorAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.STATE]; } configureServices() { this.getMotionService().setCharacteristic(this.Characteristic.MotionDetected, false); } getMotionService() { return this.accessory.getService(this.Service.MotionSensor) || this.accessory.addService(this.Service.MotionSensor); } async onDeviceStatusUpdate(status: TuyaDeviceStatus[]) { super.onDeviceStatusUpdate(status); const motionSchema = this.getSchema(...SCHEMA_CODE.STATE)!; const motionStatus = status.find(_status => _status.code === motionSchema.code); motionStatus && this.onMotionDetected(motionStatus); } private timer?: NodeJS.Timeout; onMotionDetected(status: TuyaDeviceStatus) { if (!this.intialized) { return; } if (status.value !== 'vibration' && status.value !== 'drop') { return; } this.log.info('Motion event:', status.value); const characteristic = this.getMotionService().getCharacteristic(this.Characteristic.MotionDetected); characteristic.updateValue(true); this.timer && clearTimeout(this.timer); this.timer = setTimeout(() => characteristic.updateValue(false), 3 * 1000); } } ================================================ FILE: src/accessory/WeatherStationAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; export default class TemperatureHumiditySensorAccessory extends BaseAccessory { configureServices(): void { const { temperatureSchemas, humiditySchemas } = this.getDynamicSchemaCodes(); temperatureSchemas.forEach((schema, index) => { const serviceName = `Temperature Sensor ${index + 1}`; const serviceSubtype = `temperature_sensor_${index + 1}`; const service = this.accessory.getServiceById(this.Service.TemperatureSensor, serviceSubtype) || this.accessory.addService(this.Service.TemperatureSensor, serviceName, serviceSubtype); service .getCharacteristic(this.Characteristic.CurrentTemperature) .onGet(() => { const status = this.getStatus(schema.code); if (status) { const property = this.getSchema(schema.code)?.property as { scale: number }; const multiple = Math.pow(10, property.scale || 0); return Math.min(Math.max((status.value as number) / multiple, -100), 100); } return 0; // Default value if no status is found }); }); humiditySchemas.forEach((schema, index) => { const serviceName = `Humidity Sensor ${index + 1}`; const serviceSubtype = `humidity_sensor_${index + 1}`; const service = this.accessory.getServiceById(this.Service.HumiditySensor, serviceSubtype) || this.accessory.addService(this.Service.HumiditySensor, serviceName, serviceSubtype); service .getCharacteristic(this.Characteristic.CurrentRelativeHumidity) .onGet(() => { const status = this.getStatus(schema.code); if (status) { return status.value as number; } return 0; // Default value if no status is found }); }); } private getDynamicSchemaCodes() { const temperatureSchemas: { code: string }[] = []; const humiditySchemas: { code: string }[] = []; this.device.schema.forEach((schema) => { if (schema.code.includes('ToutCh')) { temperatureSchemas.push(schema); } else if (schema.code.includes('HoutCh')) { humiditySchemas.push(schema); } }); return { temperatureSchemas, humiditySchemas }; } } ================================================ FILE: src/accessory/WhiteNoiseLightAccessory.ts ================================================ import BaseAccessory from './BaseAccessory'; import { configureOn } from './characteristic/On'; import { configureLight } from './characteristic/Light'; const SCHEMA_CODE = { LIGHT_ON: ['switch_led'], LIGHT_COLOR: ['colour_data'], MUSIC_ON: ['switch_music'], }; export default class WhiteNoiseLightAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.LIGHT_ON, SCHEMA_CODE.MUSIC_ON]; } configureServices() { // Light if (this.lightServiceType() === this.Service.Lightbulb) { configureLight( this, this.lightService(), this.getSchema(...SCHEMA_CODE.LIGHT_ON), undefined, undefined, this.lightColorSchema(), undefined, ); } else if (this.lightServiceType() === this.Service.Switch) { configureOn(this, undefined, this.getSchema(...SCHEMA_CODE.LIGHT_ON)); const unusedService = this.accessory.getService(this.Service.Lightbulb); unusedService && this.accessory.removeService(unusedService); } // White Noise configureOn(this, undefined, this.getSchema(...SCHEMA_CODE.MUSIC_ON)); } lightColorSchema() { const colorSchema = this.getSchema(...SCHEMA_CODE.LIGHT_COLOR); if (!colorSchema) { return; } const { h, s, v } = (colorSchema.property || {}) as never; if (!h || !s || !v) { // Set sensible defaults for missing properties colorSchema.property = { h: { min: 0, scale: 0, unit: '', max: 360, step: 1 }, s: { min: 0, scale: 0, unit: '', max: 1000, step: 1 }, v: { min: 0, scale: 0, unit: '', max: 1000, step: 1 }, }; } return colorSchema; } lightServiceType() { if (this.lightColorSchema()) { return this.Service.Lightbulb; } return this.Service.Switch; } lightService() { return ( this.accessory.getService(this.Service.Lightbulb) || this.accessory.addService(this.Service.Lightbulb) ); } } ================================================ FILE: src/accessory/WindowAccessory.ts ================================================ import WindowCoveringAccessory from './WindowCoveringAccessory'; export default class WindowAccessory extends WindowCoveringAccessory { mainService() { return this.accessory.getService(this.Service.Window) || this.accessory.addService(this.Service.Window); } } ================================================ FILE: src/accessory/WindowCoveringAccessory.ts ================================================ import { TuyaDeviceSchemaEnumProperty } from '../device/TuyaDevice'; import { limit } from '../util/util'; import BaseAccessory from './BaseAccessory'; const SCHEMA_CODE = [ { NAME : 'control', CURRENT_POSITION: ['percent_state'], TARGET_POSITION_CONTROL: ['control', 'mach_operate'], TARGET_POSITION_PERCENT: ['percent_control', 'position'], }, { NAME : 'control_2', CURRENT_POSITION: ['percent_state'], TARGET_POSITION_CONTROL: ['control_2', 'mach_operate'], TARGET_POSITION_PERCENT: ['percent_control_2', 'position'], }, ]; export default class WindowCoveringAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE[0].TARGET_POSITION_CONTROL];//, SCHEMA_CODE[1].TARGET_POSITION_CONTROL]; } configureServices() { let amount = 1; const schema = this.getSchema('control_2'); if (schema) { amount = 2; } this.log.warn('Curtain amount:', amount); for (let i = 0; i < amount; i++) { this.configureCurrentPosition(i); this.configurePositionState(i); if (this.getSchema(...SCHEMA_CODE[i].TARGET_POSITION_PERCENT)) { this.configureTargetPositionPercent(i); } else { this.configureTargetPositionControl(i); } } } configureCurrentPosition(i : number) { const currentSchema = this.getSchema(...SCHEMA_CODE[i].CURRENT_POSITION); const targetSchema = this.getSchema(...SCHEMA_CODE[i].TARGET_POSITION_PERCENT); const targetControlSchema = this.getSchema(...SCHEMA_CODE[i].TARGET_POSITION_CONTROL)!; const service = this.accessory.getService(SCHEMA_CODE[i].NAME) || this.accessory.addService(this.Service.WindowCovering, SCHEMA_CODE[i].NAME, SCHEMA_CODE[i].NAME); service.getCharacteristic(this.Characteristic.CurrentPosition) .onGet(() => { if (currentSchema) { const status = this.getStatus(currentSchema.code)!; return limit(status.value as number, 0, 100); } else if (targetSchema) { const status = this.getStatus(targetSchema.code)!; return limit(status.value as number, 0, 100); } const status = this.getStatus(targetControlSchema.code)!; if (status.value === 'close' || status.value === 'FZ') { return 0; } else if (status.value === 'stop' || status.value === 'STOP') { return 50; } else if (status.value === 'open' || status.value === 'ZZ') { return 100; } this.log.warn('Unknown CurrentPosition:', status.value); return 50; }); } configurePositionState(i : number) { const currentSchema = this.getSchema(...SCHEMA_CODE[i].CURRENT_POSITION); const targetSchema = this.getSchema(...SCHEMA_CODE[i].TARGET_POSITION_PERCENT); const { DECREASING, INCREASING, STOPPED } = this.Characteristic.PositionState; const service = this.accessory.getService(SCHEMA_CODE[i].NAME) || this.accessory.addService(this.Service.WindowCovering, SCHEMA_CODE[i].NAME, SCHEMA_CODE[i].NAME); service.getCharacteristic(this.Characteristic.PositionState) .onGet(() => { if (!currentSchema || !targetSchema) { return STOPPED; } const currentStatus = this.getStatus(currentSchema.code)!; const targetStatus = this.getStatus(targetSchema.code)!; if (targetStatus.value === 100 && currentStatus.value !== 100) { return INCREASING; } else if (targetStatus.value === 0 && currentStatus.value !== 0) { return DECREASING; } else { return STOPPED; } }); } configureTargetPositionPercent(i : number) { const schema = this.getSchema(...SCHEMA_CODE[i].TARGET_POSITION_PERCENT); if (!schema) { return; } const service = this.accessory.getService(SCHEMA_CODE[i].NAME) || this.accessory.addService(this.Service.WindowCovering, SCHEMA_CODE[i].NAME, SCHEMA_CODE[i].NAME); service.getCharacteristic(this.Characteristic.TargetPosition) .onGet(() => { const status = this.getStatus(schema.code)!; return limit(status.value as number, 0, 100); }) .onSet(async value => { await this.sendCommands([{ code: schema.code, value: value as number }], true); }); } configureTargetPositionControl(i : number) { const schema = this.getSchema(...SCHEMA_CODE[i].TARGET_POSITION_CONTROL); if (!schema) { return; } const isOldSchema = !(schema.property as TuyaDeviceSchemaEnumProperty).range.includes('open'); const service = this.accessory.getService(SCHEMA_CODE[i].NAME) || this.accessory.addService(this.Service.WindowCovering, SCHEMA_CODE[i].NAME, SCHEMA_CODE[i].NAME); service.getCharacteristic(this.Characteristic.TargetPosition) .onGet(() => { const status = this.getStatus(schema.code)!; if (status.value === 'close' || status.value === 'FZ') { return 0; } else if (status.value === 'stop' || status.value === 'STOP') { return 50; } else if (status.value === 'open' || status.value === 'ZZ') { return 100; } this.log.warn('Unknown TargetPosition:', status.value); return 50; }) .onSet(async value => { let control: string; if (value === 0) { control = isOldSchema ? 'FZ' : 'close'; } else if (value === 100) { control = isOldSchema ? 'ZZ' : 'open'; } else { control = isOldSchema ? 'STOP' :'stop'; } await this.sendCommands([{ code: schema.code, value: control }], true); }) .setProps({ minStep: 50, }); } } ================================================ FILE: src/accessory/WirelessSwitchAccessory.ts ================================================ import { TuyaDeviceSchema, TuyaDeviceStatus } from '../device/TuyaDevice'; import BaseAccessory from './BaseAccessory'; import { configureProgrammableSwitchEvent, onProgrammableSwitchEvent } from './characteristic/ProgrammableSwitchEvent'; const SCHEMA_CODE = { ON: ['switch_mode1', 'switch1_value'], }; export default class SwitchAccessory extends BaseAccessory { requiredSchema() { return [SCHEMA_CODE.ON]; } configureServices() { const schema = this.device.schema.filter(schema => schema.code.match(/switch_mode(\d+)/) || schema.code.match(/switch(\d+)_value/)); for (const _schema of schema) { const name = (schema.length === 1) ? this.device.name : _schema.code; this.configureSwitch(_schema, name); } } configureSwitch(schema: TuyaDeviceSchema, name: string) { const service = this.accessory.getService(schema.code) || this.accessory.addService(this.Service.StatelessProgrammableSwitch, name, schema.code); const group = schema.code.match(/switch_mode(\d+)/) || schema.code.match(/switch(\d+)_value/); const index = group![1]; service.setCharacteristic(this.Characteristic.ServiceLabelIndex, index); configureProgrammableSwitchEvent(this, service, schema); } async onDeviceStatusUpdate(status: TuyaDeviceStatus[]) { super.onDeviceStatusUpdate(status); for (const _status of status) { const service = this.accessory.getService(_status.code); if (!service) { continue; } onProgrammableSwitchEvent(this, service, _status); } } } ================================================ FILE: src/accessory/characteristic/Active.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; export function configureActive(accessory: BaseAccessory, service: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } const { ACTIVE, INACTIVE } = accessory.Characteristic.Active; service.getCharacteristic(accessory.Characteristic.Active) .onGet(() => { accessory.checkOnlineStatus(); const status = accessory.getStatus(schema.code)!; return status.value as boolean ? ACTIVE : INACTIVE; }) .onSet(async value => { await accessory.sendCommands([{ code: schema.code, value: (value === ACTIVE) ? true : false, }], true); }); } ================================================ FILE: src/accessory/characteristic/AirQuality.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema, TuyaDeviceSchemaIntegerProperty, TuyaDeviceSchemaType } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; import { limit } from '../../util/util'; export function configureAirQuality( accessory: BaseAccessory, service?: Service, airQualitySchema?: TuyaDeviceSchema, pm2_5Schema?: TuyaDeviceSchema, pm10Schema?: TuyaDeviceSchema, vocSchema?: TuyaDeviceSchema, ) { if (!airQualitySchema) { return; } if (!service) { service = accessory.accessory.getService(accessory.Service.AirQualitySensor) || accessory.accessory.addService(accessory.Service.AirQualitySensor); } const property = airQualitySchema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property ? property.scale : 0); const { UNKNOWN, EXCELLENT, GOOD, FAIR, INFERIOR, POOR } = accessory.Characteristic.AirQuality; service.getCharacteristic(accessory.Characteristic.AirQuality) .onGet(() => { const status = accessory.getStatus(airQualitySchema.code)!; if (airQualitySchema.type === TuyaDeviceSchemaType.Integer) { const value = limit(status.value as number / multiple, 0, 1000); if (value <= 10) { return EXCELLENT; } else if (value <= 50) { return GOOD; } else if (value <= 100) { return FAIR; } else if (value <= 200) { return INFERIOR; } else { return POOR; } } else if (airQualitySchema.type === TuyaDeviceSchemaType.Enum) { if (status.value === 'great') { return EXCELLENT; } else if (status.value === 'good') { return GOOD; } else if (status.value === 'mild') { return FAIR; } else if (status.value === 'medium') { return INFERIOR; } else if (status.value === 'severe') { return POOR; } } return UNKNOWN; }); pm2_5Schema && configureDensity(accessory, service, accessory.Characteristic.PM2_5Density, pm2_5Schema); pm10Schema && configureDensity(accessory, service, accessory.Characteristic.PM10Density, pm10Schema); vocSchema && configureDensity(accessory, service, accessory.Characteristic.VOCDensity, vocSchema); } function configureDensity( accessory: BaseAccessory, service: Service, characteristic, schema?: TuyaDeviceSchema, ) { if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property ? property.scale : 0); service.getCharacteristic(characteristic) .onGet(() => { const status = accessory.getStatus(schema.code)!; const value = limit(status.value as number / multiple, 0, 1000); return value; }); } ================================================ FILE: src/accessory/characteristic/CurrentRelativeHumidity.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema, TuyaDeviceSchemaIntegerProperty } from '../../device/TuyaDevice'; import { limit } from '../../util/util'; import BaseAccessory from '../BaseAccessory'; export function configureCurrentRelativeHumidity(accessory: BaseAccessory, service?: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } if (!service) { service = accessory.accessory.getService(accessory.Service.HumiditySensor) || accessory.accessory.addService(accessory.Service.HumiditySensor); } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property ? property.scale : 0); service.getCharacteristic(accessory.Characteristic.CurrentRelativeHumidity) .onGet(() => { const status = accessory.getStatus(schema.code)!; return limit(status.value as number / multiple, 0, 100); }); } ================================================ FILE: src/accessory/characteristic/CurrentTemperature.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema, TuyaDeviceSchemaIntegerProperty } from '../../device/TuyaDevice'; import { limit } from '../../util/util'; import BaseAccessory from '../BaseAccessory'; export function configureCurrentTemperature(accessory: BaseAccessory, service?: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } if (!service) { service = accessory.accessory.getService(accessory.Service.TemperatureSensor) || accessory.accessory.addService(accessory.Service.TemperatureSensor); } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property ? property.scale : 0); const props = { minValue: Math.max(-270, property.min / multiple), maxValue: Math.min(100, property.max / multiple), minStep: Math.max(0.1, property.step / multiple), }; accessory.log.debug('Set props for CurrentTemperature:', props); service.getCharacteristic(accessory.Characteristic.CurrentTemperature) .onGet(() => { const status = accessory.getStatus(schema.code)!; return limit(status.value as number / multiple, props.minValue, props.maxValue); }) .setProps(props); } ================================================ FILE: src/accessory/characteristic/EnergyUsage.ts ================================================ import BaseAccessory from '../BaseAccessory'; import { API, Service } from 'homebridge'; import { TuyaDeviceSchema, TuyaDeviceSchemaIntegerProperty } from '../../device/TuyaDevice'; import OverridedBaseAccessory from '../BaseAccessory'; export function configureEnergyUsage( api: API, accessory: OverridedBaseAccessory, service: Service, currentSchema?: TuyaDeviceSchema, powerSchema?: TuyaDeviceSchema, voltageSchema?: TuyaDeviceSchema, totalSchema?: TuyaDeviceSchema, ) { if (currentSchema) { const amperes = createAmperesCharacteristic(api); if (!service.testCharacteristic(amperes)) { service.addCharacteristic(amperes); } service.getCharacteristic(amperes).onGet( createStatusGetter(accessory, currentSchema, isUnit(currentSchema, 'mA') ? 1000 : 0), ); } if (powerSchema) { const watts = createWattsCharacteristic(api); if (!service.testCharacteristic(watts)) { service.addCharacteristic(watts); } service.getCharacteristic(watts).onGet(createStatusGetter(accessory, powerSchema)); } if (voltageSchema) { const volts = createVoltsCharacteristic(api); if (!service.testCharacteristic(volts)) { service.addCharacteristic(volts); } service.getCharacteristic(volts).onGet(createStatusGetter(accessory, voltageSchema)); } if (totalSchema) { const kwh = createKilowattHourCharacteristic(api); if (!service.testCharacteristic(kwh)) { service.addCharacteristic(kwh); } service.getCharacteristic(kwh).onGet(createStatusGetter(accessory, totalSchema)); } } function isUnit(schema: TuyaDeviceSchema, ...units: string[]): boolean { return units.includes((schema.property as TuyaDeviceSchemaIntegerProperty).unit); } function createStatusGetter(accessory: BaseAccessory, schema: TuyaDeviceSchema, divisor = 1): () => number { const property = schema.property as TuyaDeviceSchemaIntegerProperty; divisor *= Math.pow(10, property.scale); return () => { const status = accessory.getStatus(schema.code)!; return (status.value as number) / divisor; }; } function createAmperesCharacteristic(api: API) { return class Amperes extends api.hap.Characteristic { static readonly UUID = 'E863F126-079E-48FF-8F27-9C2605A29F52'; constructor() { super('Amperes', Amperes.UUID, { format: api.hap.Formats.FLOAT, perms: [api.hap.Perms.NOTIFY, api.hap.Perms.PAIRED_READ], unit: 'A', }); } }; } function createWattsCharacteristic(api: API) { return class Watts extends api.hap.Characteristic { static readonly UUID = 'E863F10D-079E-48FF-8F27-9C2605A29F52'; constructor() { super('Consumption', Watts.UUID, { format: api.hap.Formats.FLOAT, perms: [api.hap.Perms.NOTIFY, api.hap.Perms.PAIRED_READ], unit: 'W', }); } }; } function createVoltsCharacteristic(api: API) { return class Volts extends api.hap.Characteristic { static readonly UUID = 'E863F10A-079E-48FF-8F27-9C2605A29F52'; constructor() { super('Volts', Volts.UUID, { format: api.hap.Formats.FLOAT, perms: [api.hap.Perms.NOTIFY, api.hap.Perms.PAIRED_READ], unit: 'V', }); } }; } function createKilowattHourCharacteristic(api: API) { return class KilowattHour extends api.hap.Characteristic { static readonly UUID = 'E863F10C-079E-48FF-8F27-9C2605A29F52'; constructor() { super('Total Consumption', KilowattHour.UUID, { format: api.hap.Formats.FLOAT, perms: [api.hap.Perms.NOTIFY, api.hap.Perms.PAIRED_READ], unit: 'kWh', }); } }; } ================================================ FILE: src/accessory/characteristic/Light.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema, TuyaDeviceSchemaEnumProperty, TuyaDeviceSchemaIntegerProperty, TuyaDeviceStatus } from '../../device/TuyaDevice'; import { kelvinToHSV, kelvinToMired, miredToKelvin } from '../../util/color'; import { limit, remap } from '../../util/util'; import BaseAccessory from '../BaseAccessory'; import { configureOn } from './On'; const DEFAULT_COLOR_TEMPERATURE_KELVIN = 6500; enum LightType { Unknown = 'Unknown', Normal = 'Normal', // Normal Accessory, similar to SwitchAccessory, OutletAccessory. C = 'C', // Accessory with brightness. CW = 'CW', // Accessory with brightness and color temperature (Cold and Warm). RGB = 'RGB', // Accessory with color (RGB <--> HSB). RGBC = 'RGBC', // Accessory with color and brightness. RGBCW = 'RGBCW', // Accessory with color, brightness and color temperature (two work mode). } type TuyaDeviceSchemaColorProperty = { h: TuyaDeviceSchemaIntegerProperty; s: TuyaDeviceSchemaIntegerProperty; v: TuyaDeviceSchemaIntegerProperty; }; function getLightType( accessory: BaseAccessory, on?: TuyaDeviceSchema, bright?: TuyaDeviceSchema, temp?: TuyaDeviceSchema, color?: TuyaDeviceSchema, mode?: TuyaDeviceSchema, ) { const modeRange = mode && (mode.property as TuyaDeviceSchemaEnumProperty).range; const { h, s, v } = (color?.property || {}) as never; let lightType: LightType; if (on && bright && temp && h && s && v && modeRange && modeRange.includes('colour') && modeRange.includes('white')) { lightType = LightType.RGBCW; } else if (on && bright && !temp && h && s && v && modeRange && modeRange.includes('colour') && modeRange.includes('white')) { lightType = LightType.RGBC; } else if (on && !temp && h && s && v) { lightType = LightType.RGB; } else if (on && bright && temp) { lightType = LightType.CW; } else if (on && bright && !temp) { lightType = LightType.C; } else if (on && !bright && !temp) { lightType = LightType.Normal; } else { lightType = LightType.Unknown; } return lightType; } function getColorValue(accessory: BaseAccessory, schema: TuyaDeviceSchema) { const status = accessory.getStatus(schema!.code); if (!status || !status.value || status.value === '' || status.value === '{}') { return { h: 0, s: 0, v: 0 }; } const { h, s, v } = JSON.parse(status.value as string); return { h: h as number, s: s as number, v: v as number, }; } function inWhiteMode( accessory: BaseAccessory, lightType: LightType, modeSchema?: TuyaDeviceSchema, ) { if (lightType === LightType.C || lightType === LightType.CW) { return true; } else if (lightType === LightType.RGB) { return false; } if (!modeSchema) { return false; } const status = accessory.getStatus(modeSchema.code)!; return (status.value === 'white'); } function inColorMode( accessory: BaseAccessory, lightType: LightType, modeSchema?: TuyaDeviceSchema, ) { if (lightType === LightType.RGB) { return true; } else if (lightType === LightType.C || lightType === LightType.CW) { return false; } if (!modeSchema) { return false; } const status = accessory.getStatus(modeSchema.code)!; return (status.value === 'colour'); } function configureLightOn( accessory: BaseAccessory, service: Service, onSchema: TuyaDeviceSchema, brightSchema?: TuyaDeviceSchema, ) { service.getCharacteristic(accessory.Characteristic.On) .onGet(() => { accessory.checkOnlineStatus(); const status = accessory.getStatus(onSchema.code)!; return status.value as boolean; }) .onSet(async value => { const commands: TuyaDeviceStatus[] = [{ code: onSchema.code, value: value as boolean }]; // Bundle cached brightness with ON to prevent the device from turning on // at stale brightness when commands arrive in separate debounce batches // (e.g. HomeKit automations controlling multiple services simultaneously). if (value && brightSchema) { const brightStatus = accessory.getStatus(brightSchema.code); if (brightStatus) { commands.push({ code: brightSchema.code, value: brightStatus.value }); } } await accessory.sendCommands(commands, true); }); } function configureBrightness( accessory: BaseAccessory, service: Service, lightType: LightType, brightSchema?: TuyaDeviceSchema, colorSchema?: TuyaDeviceSchema, modeSchema?: TuyaDeviceSchema, ) { service.getCharacteristic(accessory.Characteristic.Brightness) .onGet(() => { if (inColorMode(accessory, lightType, modeSchema) && colorSchema) { // Color mode, get brightness from `color_data.v` const { max } = (colorSchema.property as TuyaDeviceSchemaColorProperty).v; const colorValue = getColorValue(accessory, colorSchema); const value = Math.round(100 * colorValue.v / max); return limit(value, 0, 100); } else if (inWhiteMode(accessory, lightType, modeSchema) && brightSchema) { // White mode, get brightness from `brightness_value` const { max } = brightSchema.property as TuyaDeviceSchemaIntegerProperty; const status = accessory.getStatus(brightSchema.code)!; const value = Math.round(100 * (status.value as number) / max); return limit(value, 0, 100); } else { // Unsupported mode return 100; } }) .onSet(async value => { accessory.log.debug(`Characteristic.Brightness set to: ${value}`); if (inColorMode(accessory, lightType, modeSchema) && colorSchema) { // Color mode, set brightness to `color_data.v` const { min, max } = (colorSchema.property as TuyaDeviceSchemaColorProperty).v; const colorValue = getColorValue(accessory, colorSchema); colorValue.v = Math.round(value as number * max / 100); colorValue.v = limit(colorValue.v, min, max); await accessory.sendCommands([{ code: colorSchema.code, value: JSON.stringify(colorValue) }], true); } else if (inWhiteMode(accessory, lightType, modeSchema) && brightSchema) { // White mode, set brightness to `brightness_value` const { min, max } = brightSchema.property as TuyaDeviceSchemaIntegerProperty; let brightValue = Math.round(value as number * max / 100); brightValue = limit(brightValue, min, max); await accessory.sendCommands([{ code: brightSchema.code, value: brightValue }], true); } else { // Unsupported mode accessory.log.warn('Neither color mode nor white mode.'); } }); } function configureColourTemperature( accessory: BaseAccessory, service: Service, lightType: LightType, tempSchema: TuyaDeviceSchema, modeSchema?: TuyaDeviceSchema, ) { const props = { minValue: 140, maxValue: 500, minStep: 1 }; if (lightType === LightType.RGBC) { props.minValue = props.maxValue = Math.round(kelvinToMired(DEFAULT_COLOR_TEMPERATURE_KELVIN)); } accessory.log.debug('Set props for ColorTemperature:', props); service.getCharacteristic(accessory.Characteristic.ColorTemperature) .onGet(() => { if (lightType === LightType.RGBC) { return props.minValue; } // const schema = accessory.getSchema(...SCHEMA_CODE.COLOR_TEMP)!; const { min, max } = tempSchema.property as TuyaDeviceSchemaIntegerProperty; const status = accessory.getStatus(tempSchema.code)!; const kelvin = remap(status.value as number, min, max, miredToKelvin(props.maxValue), miredToKelvin(props.minValue)); const mired = Math.round(kelvinToMired(kelvin)); return limit(mired, props.minValue, props.maxValue); }) .onSet(async value => { accessory.log.debug(`Characteristic.ColorTemperature set to: ${value}`); const commands: TuyaDeviceStatus[] = []; if (modeSchema) { commands.push({ code: modeSchema.code, value: 'white' }); } if (lightType !== LightType.RGBC) { const { min, max } = tempSchema.property as TuyaDeviceSchemaIntegerProperty; const kelvin = miredToKelvin(value as number); const temp = Math.round(remap(kelvin, miredToKelvin(props.maxValue), miredToKelvin(props.minValue), min, max)); commands.push({ code: tempSchema.code, value: temp }); } await accessory.sendCommands(commands, true); }) .setProps(props); } function configureHue( accessory: BaseAccessory, service: Service, lightType: LightType, colorSchema: TuyaDeviceSchema, modeSchema?: TuyaDeviceSchema, ) { const { min, max } = (colorSchema.property as TuyaDeviceSchemaColorProperty).h; service.getCharacteristic(accessory.Characteristic.Hue) .onGet(() => { if (inWhiteMode(accessory, lightType, modeSchema)) { return kelvinToHSV(DEFAULT_COLOR_TEMPERATURE_KELVIN)!.h; } const hue = Math.round(360 * getColorValue(accessory, colorSchema).h / max); return limit(hue, 0, 360); }) .onSet(async value => { accessory.log.debug(`Characteristic.Hue set to: ${value}`); const colorValue = getColorValue(accessory, colorSchema); colorValue.h = Math.round(value as number * max / 360); colorValue.h = limit(colorValue.h, min, max); const commands: TuyaDeviceStatus[] = [{ code: colorSchema.code, value: JSON.stringify(colorValue), }]; if (modeSchema) { commands.push({ code: modeSchema.code, value: 'colour' }); } await accessory.sendCommands(commands, true); }); } function configureSaturation( accessory: BaseAccessory, service: Service, lightType: LightType, colorSchema: TuyaDeviceSchema, modeSchema?: TuyaDeviceSchema, ) { const { min, max } = (colorSchema.property as TuyaDeviceSchemaColorProperty).s; service.getCharacteristic(accessory.Characteristic.Saturation) .onGet(() => { if (inWhiteMode(accessory, lightType, modeSchema)) { return kelvinToHSV(DEFAULT_COLOR_TEMPERATURE_KELVIN)!.s; } const saturation = Math.round(100 * getColorValue(accessory, colorSchema).s / max); return limit(saturation, 0, 100); }) .onSet(async value => { accessory.log.debug(`Characteristic.Saturation set to: ${value}`); const colorValue = getColorValue(accessory, colorSchema); colorValue.s = Math.round(value as number * max / 100); colorValue.s = limit(colorValue.s, min, max); const commands: TuyaDeviceStatus[] = [{ code: colorSchema.code, value: JSON.stringify(colorValue), }]; if (modeSchema) { commands.push({ code: modeSchema.code, value: 'colour' }); } await accessory.sendCommands(commands, true); }); } export function configureLight( accessory: BaseAccessory, service?: Service, onSchema?: TuyaDeviceSchema, brightSchema?: TuyaDeviceSchema, tempSchema?: TuyaDeviceSchema, colorSchema?: TuyaDeviceSchema, modeSchema?: TuyaDeviceSchema, ) { if (!onSchema) { return; } if (!service) { service = accessory.accessory.getService(accessory.Service.Lightbulb) || accessory.accessory.addService(accessory.Service.Lightbulb, accessory.accessory.displayName + ' Light'); } const lightType = getLightType(accessory, onSchema, brightSchema, tempSchema, colorSchema, modeSchema); accessory.log.info('Light type:', lightType); switch (lightType) { case LightType.Normal: configureOn(accessory, service, onSchema); break; case LightType.C: configureLightOn(accessory, service!, onSchema, brightSchema); configureBrightness(accessory, service!, lightType, brightSchema, colorSchema, modeSchema); break; case LightType.CW: configureLightOn(accessory, service!, onSchema, brightSchema); configureBrightness(accessory, service!, lightType, brightSchema, colorSchema, modeSchema); configureColourTemperature(accessory, service!, lightType, tempSchema!, modeSchema); break; case LightType.RGB: configureLightOn(accessory, service!, onSchema, brightSchema); configureBrightness(accessory, service!, lightType, brightSchema, colorSchema, modeSchema); configureHue(accessory, service!, lightType, colorSchema!, modeSchema); configureSaturation(accessory, service!, lightType, colorSchema!, modeSchema); break; case LightType.RGBC: case LightType.RGBCW: configureLightOn(accessory, service!, onSchema, brightSchema); configureBrightness(accessory, service!, lightType, brightSchema, colorSchema, modeSchema); configureColourTemperature(accessory, service!, lightType, tempSchema!, modeSchema); configureHue(accessory, service!, lightType, colorSchema!, modeSchema); configureSaturation(accessory, service!, lightType, colorSchema!, modeSchema); break; } configureAdaptiveLighting(accessory, service, brightSchema, tempSchema); } function configureAdaptiveLighting( accessory: BaseAccessory, service: Service, brightSchema?: TuyaDeviceSchema, tempSchema?: TuyaDeviceSchema, ) { const config = accessory.platform.getDeviceConfig(accessory.device); if (!config || config.adaptiveLighting !== true) { accessory.log.info('Adaptive Lighting disabled.'); return; } accessory.log.info('Adaptive Lighting enabled.'); if (!brightSchema || !tempSchema) { accessory.log.warn('Adaptive Lighting not supported. Missing brightness or color temperature schema.'); return; } const { AdaptiveLightingController } = accessory.platform.api.hap; const controller = new AdaptiveLightingController(service); accessory.accessory.configureController(controller); accessory.adaptiveLightingController = controller; } ================================================ FILE: src/accessory/characteristic/LockPhysicalControls.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; export function configureLockPhysicalControls(accessory: BaseAccessory, service: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } const { CONTROL_LOCK_DISABLED, CONTROL_LOCK_ENABLED } = accessory.Characteristic.LockPhysicalControls; service.getCharacteristic(accessory.Characteristic.LockPhysicalControls) .onGet(() => { const status = accessory.getStatus(schema.code)!; return (status.value as boolean) ? CONTROL_LOCK_ENABLED : CONTROL_LOCK_DISABLED; }) .onSet(async value => { await accessory.sendCommands([{ code: schema.code, value: (value === CONTROL_LOCK_ENABLED) ? true : false, }], true); }); } ================================================ FILE: src/accessory/characteristic/MotionDetected.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema, TuyaDeviceSchemaType } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; export function configureMotionDetected(accessory: BaseAccessory, service?: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } if (!service) { service = accessory.accessory.getService(accessory.Service.MotionSensor) || accessory.accessory.addService(accessory.Service.MotionSensor); } service.getCharacteristic(accessory.Characteristic.MotionDetected) .onGet(() => { const status = accessory.getStatus(schema.code)!; if (schema.type === TuyaDeviceSchemaType.Enum) { // pir return (status.value === 'pir'); } return false; }); } ================================================ FILE: src/accessory/characteristic/Name.ts ================================================ import { Service } from 'homebridge'; import BaseAccessory from '../BaseAccessory'; export function configureName(accessory: BaseAccessory, service: Service, name: string) { service.setCharacteristic(accessory.Characteristic.Name, name); if (!service.testCharacteristic(accessory.Characteristic.ConfiguredName)) { service.addOptionalCharacteristic(accessory.Characteristic.ConfiguredName); // silence warning service.setCharacteristic(accessory.Characteristic.ConfiguredName, name); // only add once } } ================================================ FILE: src/accessory/characteristic/OccupancyDetected.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; export function configureOccupancyDetected(accessory: BaseAccessory, service?: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } if (!service) { service = accessory.accessory.getService(accessory.Service.OccupancySensor) || accessory.accessory.addService(accessory.Service.OccupancySensor); } const { OCCUPANCY_DETECTED, OCCUPANCY_NOT_DETECTED } = accessory.Characteristic.OccupancyDetected; service.getCharacteristic(accessory.Characteristic.OccupancyDetected) .onGet(() => { const status = accessory.getStatus(schema.code)!; return (status.value === 'presence') ? OCCUPANCY_DETECTED : OCCUPANCY_NOT_DETECTED; }); } ================================================ FILE: src/accessory/characteristic/On.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; export function configureOn(accessory: BaseAccessory, service?: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } if (!service) { service = accessory.accessory.getService(schema.code) || accessory.accessory.addService(accessory.Service.Switch, schema.code, schema.code); } service.getCharacteristic(accessory.Characteristic.On) .onGet(() => { accessory.checkOnlineStatus(); const status = accessory.getStatus(schema.code)!; return status.value as boolean; }) .onSet(async value => { await accessory.sendCommands([{ code: schema.code, value: value as boolean, }], true); }); } ================================================ FILE: src/accessory/characteristic/ProgrammableSwitchEvent.ts ================================================ import { CharacteristicProps, PartialAllowingNull, Service } from 'homebridge'; import { TuyaDeviceSchema, TuyaDeviceSchemaEnumProperty, TuyaDeviceSchemaType, TuyaDeviceStatus } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; const SINGLE_PRESS = 0; const DOUBLE_PRESS = 1; const LONG_PRESS = 2; export function configureProgrammableSwitchEvent(accessory: BaseAccessory, service: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } let props: PartialAllowingNull; if (schema.type === TuyaDeviceSchemaType.Enum) { const { range } = schema.property as TuyaDeviceSchemaEnumProperty; props = GetStatelessSwitchProps( range.includes('click') || range.includes('single_click') || range.includes('1'), range.includes('double_click'), range.includes('press') || range.includes('long_press'), ); } else { props = GetStatelessSwitchProps(true, false, false); } service.getCharacteristic(accessory.Characteristic.ProgrammableSwitchEvent) .setProps(props); } export function onProgrammableSwitchEvent(accessory: BaseAccessory, service: Service, status: TuyaDeviceStatus) { if (!accessory.intialized) { return; } let value: number | undefined; const schema = accessory.getSchema(status.code)!; if (schema.type === TuyaDeviceSchemaType.Raw || schema.type === TuyaDeviceSchemaType.String) { // doorbell_pic or alarm_message const url = Buffer.from(status.value as string, 'base64').toString('binary'); if (url.length === 0) { return; } accessory.log.info('Alarm message:', url); value = SINGLE_PRESS; } else if (schema.type === TuyaDeviceSchemaType.Enum) { if (status.value === 'click' || status.value === 'single_click' || status.value === '1') { value = SINGLE_PRESS; } else if (status.value === 'double_click') { value = DOUBLE_PRESS; } else if (status.value === 'press' || status.value === 'long_press') { value = LONG_PRESS; } } else if (schema.type === TuyaDeviceSchemaType.Integer) { if (status.value as number > 0) { value = SINGLE_PRESS; } } if (value === undefined) { accessory.log.warn('Unknown ProgrammableSwitchEvent status:', status); return; } accessory.log.debug('ProgrammableSwitchEvent updateValue: %o %o', status.code, value); service.getCharacteristic(accessory.Characteristic.ProgrammableSwitchEvent) .updateValue(value); } // Modified version of // https://github.com/benzman81/homebridge-http-webhooks/blob/master/src/homekit/accessories/HttpWebHookStatelessSwitchAccessory.js function GetStatelessSwitchProps(single_press: boolean, double_press: boolean, long_press: boolean) { const props: PartialAllowingNull = {}; if (single_press) { props.minValue = SINGLE_PRESS; } else if (double_press) { props.minValue = DOUBLE_PRESS; } else if (long_press) { props.minValue = LONG_PRESS; } if (single_press) { props.maxValue = SINGLE_PRESS; } if (double_press) { props.maxValue = DOUBLE_PRESS; } if (long_press) { props.maxValue = LONG_PRESS; } if (single_press && !double_press && long_press) { props.validValues = [SINGLE_PRESS, LONG_PRESS]; } return props; } ================================================ FILE: src/accessory/characteristic/RelativeHumidityDehumidifierThreshold.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema, TuyaDeviceSchemaIntegerProperty } from '../../device/TuyaDevice'; import { limit } from '../../util/util'; import BaseAccessory from '../BaseAccessory'; export function configureRelativeHumidityDehumidifierThreshold(accessory: BaseAccessory, service: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property.scale); const props = { minValue: 0, maxValue: 100, minStep: Math.max(1, property.step / multiple), }; accessory.log.debug('Set props for RelativeHumidityDehumidifierThreshold:', props); service.getCharacteristic(accessory.Characteristic.RelativeHumidityDehumidifierThreshold) .onGet(() => { const status = accessory.getStatus(schema.code)!; return limit(status.value as number / multiple, 0, 100); }) .onSet(async value => { const dehumidity_set = limit(value as number * multiple, property.min, property.max); await accessory.sendCommands([{ code: schema.code, value: dehumidity_set }]); }) .setProps(props); } ================================================ FILE: src/accessory/characteristic/RotationSpeed.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema, TuyaDeviceSchemaEnumProperty, TuyaDeviceSchemaIntegerProperty } from '../../device/TuyaDevice'; import { limit } from '../../util/util'; import BaseAccessory from '../BaseAccessory'; export function configureRotationSpeed( accessory: BaseAccessory, service: Service, schema?: TuyaDeviceSchema, ) { if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaIntegerProperty; const multiple = Math.pow(10, property.scale); const props = { minValue: property.min / multiple, maxValue: property.max / multiple, minStep: Math.max(1, property.step / multiple), }; service.getCharacteristic(accessory.Characteristic.RotationSpeed) .onGet(() => { const status = accessory.getStatus(schema.code)!; const value = status.value as number / multiple; return limit(value, props.minValue, props.maxValue); }) .onSet(async value => { const speed = (value as number) * multiple; await accessory.sendCommands([{ code: schema.code, value: speed }], true); }) .setProps(props); } export function configureRotationSpeedLevel( accessory: BaseAccessory, service: Service, schema?: TuyaDeviceSchema, ignoreValues?: string[], ) { if (!schema) { return; } const property = schema.property as TuyaDeviceSchemaEnumProperty; const range: string[] = []; for (const value of property.range) { if (ignoreValues?.includes(value)) { continue; } range.push(value); } const props = { minValue: 0, maxValue: range.length, minStep: 1, unit: 'speed' }; accessory.log.debug('Set props for RotationSpeed:', props); const onGetHandler = () => { const status = accessory.getStatus(schema.code)!; const index = range.indexOf(status.value as string); return limit(index + 1, props.minValue, props.maxValue); }; service.getCharacteristic(accessory.Characteristic.RotationSpeed) .onGet(onGetHandler) .onSet(async value => { accessory.log.debug('Set RotationSpeed to:', value); const index = Math.round(value as number - 1); if (index < 0 || index >= range.length) { accessory.log.debug('Out of range, return.'); return; } const speedLevel = range[index].toString(); accessory.log.debug('Set RotationSpeedLevel to:', speedLevel); await accessory.sendCommands([{ code: schema.code, value: speedLevel }], true); }) .updateValue(onGetHandler()) // ensure the value is correct before set props .setProps(props); } export function configureRotationSpeedOn( accessory: BaseAccessory, service: Service, schema?: TuyaDeviceSchema, ) { if (!schema) { return; } const props = { minValue: 0, maxValue: 100, minStep: 100 }; accessory.log.debug('Set props for RotationSpeed:', props); service.getCharacteristic(accessory.Characteristic.RotationSpeed) .onGet(() => { const status = accessory.getStatus(schema.code)!; return (status.value as boolean) ? 100 : 0; }) .setProps(props); } ================================================ FILE: src/accessory/characteristic/SecuritySystemState.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; import SecuritySystemAccessory from '../SecuritySystemAccessory'; const TUYA_CODES = { MASTER_MODE: { ARMED: 'arm', DISARMED: 'disarmed', HOME: 'home', }, }; function getTuyaHomebridgeMap(accessory: BaseAccessory) { const tuyaHomebridgeMap = new Map(); tuyaHomebridgeMap.set(TUYA_CODES.MASTER_MODE.ARMED, accessory.Characteristic.SecuritySystemCurrentState.AWAY_ARM); tuyaHomebridgeMap.set(TUYA_CODES.MASTER_MODE.DISARMED, accessory.Characteristic.SecuritySystemCurrentState.DISARMED); tuyaHomebridgeMap.set(TUYA_CODES.MASTER_MODE.HOME, accessory.Characteristic.SecuritySystemCurrentState.STAY_ARM); tuyaHomebridgeMap.set(accessory.Characteristic.SecuritySystemCurrentState.AWAY_ARM, TUYA_CODES.MASTER_MODE.ARMED); tuyaHomebridgeMap.set(accessory.Characteristic.SecuritySystemCurrentState.DISARMED, TUYA_CODES.MASTER_MODE.DISARMED); tuyaHomebridgeMap.set(accessory.Characteristic.SecuritySystemCurrentState.STAY_ARM, TUYA_CODES.MASTER_MODE.HOME); tuyaHomebridgeMap.set(accessory.Characteristic.SecuritySystemCurrentState.NIGHT_ARM, TUYA_CODES.MASTER_MODE.HOME); return tuyaHomebridgeMap; } export function configureSecuritySystemCurrentState(accessory: SecuritySystemAccessory, service: Service, masterModeSchema?: TuyaDeviceSchema, sosStateSchema?: TuyaDeviceSchema) { if (!masterModeSchema || !sosStateSchema) { return; } const tuyaHomebridgeMap = getTuyaHomebridgeMap(accessory); service.getCharacteristic(accessory.Characteristic.SecuritySystemCurrentState) .onGet(() => { const alarmTriggered = accessory.getStatus(sosStateSchema.code)!.value; if (alarmTriggered) { return accessory.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED; } else { const currentState = accessory.getStatus(masterModeSchema.code)!.value; if (currentState === TUYA_CODES.MASTER_MODE.HOME) { return accessory.isNightArm ? accessory.Characteristic.SecuritySystemCurrentState.NIGHT_ARM : accessory.Characteristic.SecuritySystemCurrentState.STAY_ARM; } return tuyaHomebridgeMap.get(currentState); } }); } export function configureSecuritySystemTargetState(accessory: SecuritySystemAccessory, service: Service, masterModeSchema?: TuyaDeviceSchema, sosStateSchema?: TuyaDeviceSchema) { if (!masterModeSchema || !sosStateSchema) { return; } const tuyaHomebridgeMap = getTuyaHomebridgeMap(accessory); service.getCharacteristic(accessory.Characteristic.SecuritySystemTargetState) .onGet(() => { const currentState = accessory.getStatus(masterModeSchema.code)!.value; if (currentState === TUYA_CODES.MASTER_MODE.HOME) { return accessory.isNightArm ? accessory.Characteristic.SecuritySystemCurrentState.NIGHT_ARM : accessory.Characteristic.SecuritySystemCurrentState.STAY_ARM; } return tuyaHomebridgeMap.get(currentState); }) .onSet(async value => { const sosState = accessory.getStatus(sosStateSchema.code)?.value; // If we received a request to disarm the alarm, we make sure sos_state is set to false if (sosState && value === accessory.Characteristic.SecuritySystemTargetState.DISARM) { await accessory.sendCommands([{ code: sosStateSchema.code, value: false, }], true); } accessory.isNightArm = value === accessory.Characteristic.SecuritySystemTargetState.NIGHT_ARM; await accessory.sendCommands([{ code: masterModeSchema.code, value: tuyaHomebridgeMap.get(value), }], true); }); } ================================================ FILE: src/accessory/characteristic/SwingMode.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; export function configureSwingMode(accessory: BaseAccessory, service: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } const { SWING_DISABLED, SWING_ENABLED } = accessory.Characteristic.SwingMode; service.getCharacteristic(accessory.Characteristic.SwingMode) .onGet(() => { const status = accessory.getStatus(schema.code)!; return (status.value as boolean) ? SWING_ENABLED : SWING_DISABLED; }) .onSet(async (value) => { await accessory.sendCommands([{ code: schema.code, value: (value === SWING_ENABLED) ? true : false, }], true); }); } ================================================ FILE: src/accessory/characteristic/TemperatureDisplayUnits.ts ================================================ import { Service } from 'homebridge'; import { TuyaDeviceSchema } from '../../device/TuyaDevice'; import BaseAccessory from '../BaseAccessory'; export function configureTempDisplayUnits(accessory: BaseAccessory, service: Service, schema?: TuyaDeviceSchema) { if (!schema) { return; } const { CELSIUS, FAHRENHEIT } = accessory.Characteristic.TemperatureDisplayUnits; service.getCharacteristic(accessory.Characteristic.TemperatureDisplayUnits) .onGet(() => { const status = accessory.getStatus(schema.code)!; return ((status.value as string).toLowerCase() === 'c') ? CELSIUS : FAHRENHEIT; }) .onSet(async value => { const status = accessory.getStatus(schema.code)!; const isLowerCase = (status.value as string).toLowerCase() === status.value; let unit = (value === CELSIUS) ? 'c' : 'f'; unit = isLowerCase ? unit.toLowerCase() : unit.toUpperCase(); await accessory.sendCommands([{ code: schema.code, value: unit, }]); }); } ================================================ FILE: src/config.ts ================================================ import { PlatformConfig } from 'homebridge'; import { TuyaDeviceSchemaProperty, TuyaDeviceSchemaType } from './device/TuyaDevice'; export interface TuyaPlatformDeviceSchemaConfig { code: string; newCode?: string; type?: TuyaDeviceSchemaType; property?: TuyaDeviceSchemaProperty; onGet?: string; onSet?: string; hidden?: boolean; } export interface TuyaPlatformDeviceConfig { id: string; category?: string; schema?: Array; unbridged?: boolean; adaptiveLighting?: boolean; } export interface TuyaPlatformCustomConfigOptions { projectType: '1'; endpoint: string; accessId: string; accessKey: string; username: string; password: string; deviceOverrides?: Array; debug?: boolean; debugLevel?: string; } export interface TuyaPlatformHomeConfigOptions { projectType: '2'; endpoint?: string; accessId: string; accessKey: string; countryCode: number; username: string; password: string; appSchema: string; homeWhitelist?: Array; deviceOverrides?: Array; debug?: boolean; debugLevel?: string; } export type TuyaPlatformConfigOptions = TuyaPlatformCustomConfigOptions | TuyaPlatformHomeConfigOptions; export interface TuyaPlatformConfig extends PlatformConfig { options: TuyaPlatformConfigOptions; } export const customOptionsSchema = { properties: { endpoint: { type: 'string', format: 'url', required: true }, accessId: { type: 'string', required: true }, accessKey: { type: 'string', required: true }, deviceOverrides: { 'type': 'array' }, debug: { type: 'boolean' }, debugLevel: { 'type': 'string' }, }, }; export const homeOptionsSchema = { properties: { accessId: { type: 'string', required: true }, accessKey: { type: 'string', required: true }, endpoint: { type: 'string', format: 'url' }, countryCode: { 'type': 'integer', 'minimum': 1, required: true }, username: { type: 'string', required: true }, password: { type: 'string', required: true }, appSchema: { 'type': 'string', required: true }, homeWhitelist: { 'type': 'array' }, deviceOverrides: { 'type': 'array' }, debug: { type: 'boolean' }, debugLevel: { 'type': 'string' }, }, }; ================================================ FILE: src/core/TuyaOpenAPI.ts ================================================ /* eslint-disable max-len */ /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-unused-vars */ import https from 'https'; import Crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import retry from 'async-await-retry'; // eslint-disable-next-line // @ts-ignore import { version } from '../../package.json'; import Logger, { PrefixLogger } from '../util/Logger'; enum Endpoints { AMERICA = 'https://openapi.tuyaus.com', AMERICA_EAST = 'https://openapi-ueaz.tuyaus.com', CHINA = 'https://openapi.tuyacn.com', EUROPE = 'https://openapi.tuyaeu.com', EUROPE_WEST = 'https://openapi-weaz.tuyaeu.com', INDIA = 'https://openapi.tuyain.com', } const DEFAULT_ENDPOINTS = { [Endpoints.AMERICA.toString()]: [1, 51, 52, 54, 55, 56, 57, 58, 60, 62, 63, 64, 66, 81, 82, 84, 95, 239, 245, 246, 500, 502, 591, 593, 594, 595, 597, 598, 670, 672, 674, 675, 677, 678, 682, 683, 686, 690, 852, 853, 886, 970, 1721, 1787, 1809, 1829, 1849, 4779, 5999, 35818], [Endpoints.CHINA.toString()]: [86], [Endpoints.EUROPE.toString()]: [7, 20, 27, 30, 31, 32, 33, 34, 36, 39, 40, 41, 43, 44, 45, 46, 47, 48, 49, 61, 65, 90, 92, 93, 94, 212, 213, 216, 218, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 240, 241, 242, 243, 244, 248, 250, 251, 252, 253, 254, 255, 256, 257, 258, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 291, 297, 298, 299, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 385, 386, 387, 389, 420, 421, 423, 501, 503, 504, 505, 506, 507, 508, 509, 590, 592, 596, 673, 676, 679, 680, 681, 685, 687, 688, 689, 691, 692, 855, 856, 880, 960, 961, 962, 964, 965, 966, 967, 968, 971, 972, 973, 974, 975, 976, 977, 992, 993, 994, 995, 996, 998, 1242, 1246, 1264, 1268, 1284, 1340, 1345, 1441, 1473, 1649, 1664, 1670, 1671, 1684, 1758, 1767, 1784, 1868, 1869, 1876], [Endpoints.INDIA.toString()]: [91], }; export const LOGIN_ERROR_MESSAGES = { 1004: 'Please make sure your endpoint, accessId, accessKey is right.', 1106: 'Please make sure your countryCode, username, password, appSchema is correct, and app account is linked with cloud project.', 1114: 'Please make sure your endpoint, accessId, accessKey is right.', 2401: 'Username or password is wrong.', 2406: 'Please make sure you selected the right data center where your app account located, and the app account is linked with cloud project.', }; const API_NOT_SUBSCRIBED_ERROR = ` API not subscribed. Please go to "Tuya IoT Platform -> Cloud -> Development -> Project -> Service API", and Authorize the following APIs before using: - Authorization Token Management - Device Status Notification - IoT Core - Industry Project Client Service (for "Custom" project) `; const API_ERROR_MESSAGES = { 1010: 'Token expired. Tuya Cloud don\'t support running multiple HomeBridge/HomeAssistant instance with same tuya account.', 28841002: 'API subscription expired. Please renew the API subscription at Tuya IoT Platform.', 28841101: API_NOT_SUBSCRIBED_ERROR, 28841105: API_NOT_SUBSCRIBED_ERROR, }; type TuyaOpenAPIResponseSuccess = { success: true; // eslint-disable-next-line @typescript-eslint/no-explicit-any result: any; t: number; tid: string; }; type TuyaOpenAPIResponseError = { success: false; result: unknown; code: number; msg: string; t: number; tid: string; }; export type TuyaOpenAPIResponse = TuyaOpenAPIResponseSuccess | TuyaOpenAPIResponseError; export default class TuyaOpenAPI { static readonly Endpoints = Endpoints; public assetIDArr: Array = []; public deviceArr: Array = []; public tokenInfo = { access_token: '', refresh_token: '', uid: '', expire: 0 }; constructor( public endpoint: Endpoints | string, public accessId: string, public accessKey: string, public log: Logger = console, public lang = 'en', public debug = false, ) { this.log = new PrefixLogger(log, TuyaOpenAPI.name, debug); } static getDefaultEndpoint(countryCode: number) { for (const endpoint of Object.keys(DEFAULT_ENDPOINTS)) { const countryCodeList = DEFAULT_ENDPOINTS[endpoint]; if (countryCodeList.includes(countryCode)) { return endpoint; } } return Endpoints.AMERICA; } isLogin() { return this.tokenInfo.access_token.length > 0; } isTokenExpired() { return (this.tokenInfo.expire - 60 * 1000 <= new Date().getTime()); } isTokenManagementAPI(path: string) { if (path.startsWith('/v1.0/token')) { return true; } return false; } async _refreshAccessTokenIfNeed(path: string) { if (!this.isLogin()) { return; } if (!this.isTokenExpired()) { return; } if (this.isTokenManagementAPI(path)) { return; } this.log.debug('Refreshing access_token'); const res = await this.get(`/v1.0/token/${this.tokenInfo.refresh_token}`); if (res.success === false) { this.log.error('Refresh access_token failed. code = %s, msg = %s', res.code, res.msg); return; } const { access_token, refresh_token, uid, expire_time } = res.result; this.tokenInfo = { access_token: access_token, refresh_token: refresh_token, uid: uid, expire: expire_time * 1000 + new Date().getTime(), }; } /** * In 'Custom' project, get a token directly. (Login with admin) * Have permission on asset management, user management. * But lost some permission on device management. * @returns */ async getToken() { const res = await this.get('/v1.0/token', { grant_type: 1 }); if (res.success) { const { access_token, refresh_token, uid, expire_time } = res.result; this.tokenInfo = { access_token: access_token, refresh_token: refresh_token, uid: uid, expire: expire_time * 1000 + new Date().getTime(), }; } return res; } /** * In 'Smart Home' project, login with App's user. * @param countryCode 2-digit Country Code * @param username Username * @param password Password * @param appSchema App Schema: 'tuyaSmart', 'smartlife' * @returns */ async homeLogin(countryCode: number, username: string, password: string, appSchema: string) { if (this._isSaltedPassword(password)) { this.log.info('Login with md5 salted password.'); } else { password = Crypto.createHash('md5').update(password).digest('hex'); } this.log.info('Login to: %s', this.endpoint); this.tokenInfo = { access_token: '', refresh_token: '', uid: '', expire: 0 }; const res = await this.post('/v1.0/iot-01/associated-users/actions/authorized-login', { country_code: countryCode, username: username, password: password, schema: appSchema, }); if (res.success) { const { access_token, refresh_token, uid, expire_time, platform_url } = res.result; this.endpoint = platform_url || this.endpoint; this.tokenInfo = { access_token: access_token, refresh_token: refresh_token, uid: uid, expire: expire_time * 1000 + new Date().getTime(), }; } return res; } /** * In 'Custom' project, Search user by username. * @param username Username * @returns */ async customGetUserInfo(username: string) { const res = await this.get(`/v1.2/iot-02/users/${username}`); return res; } /** * In 'Custom' project, create a user. * @param username Username * @param password Password * @param country_code Country Code (Useless) * @returns */ async customCreateUser(username: string, password: string, country_code = 1) { const res = await this.post('/v1.0/iot-02/users', { username, password: Crypto.createHash('sha256').update(password).digest('hex'), country_code, }); return res; } /** * In 'Custom' project, login with user. * @param username Username * @param password Password * @returns */ async customLogin(username: string, password: string) { this.tokenInfo = { access_token: '', refresh_token: '', uid: '', expire: 0 }; const res = await this.post('/v1.0/iot-03/users/login', { username: username, password: Crypto.createHash('sha256').update(password).digest('hex'), }); if (res.success) { const { access_token, refresh_token, uid, expire } = res.result; this.tokenInfo = { access_token: access_token, refresh_token: refresh_token, uid: uid, expire: expire * 1000 + new Date().getTime(), }; } return res; } async request(method: string, path: string, params?, body?) { await this._refreshAccessTokenIfNeed(path); const now = new Date().getTime(); const nonce = uuidv4(); const accessToken = this.tokenInfo.access_token || ''; const stringToSign = this._getStringToSign(method, path, params, body); const headers = { 't': `${now}`, 'client_id': this.accessId, 'nonce': nonce, 'Signature-Headers': 'client_id', 'sign': this._getSign(this.accessId, this.accessKey, this.isTokenManagementAPI(path) ? '' : this.tokenInfo.access_token, now, nonce, stringToSign), 'sign_method': 'HMAC-SHA256', 'access_token': accessToken, 'lang': this.lang, 'dev_lang': 'javascript', 'dev_channel': 'homebridge', 'devVersion': version, }; this.log.debug('Request:\nmethod = %s\nendpoint = %s\npath = %s\nquery = %s\nheaders = %s\nbody = %s', method, this.endpoint, path, JSON.stringify(params, null, 2), JSON.stringify(headers, null, 2), JSON.stringify(body, null, 2)); if (params) { path += '?' + new URLSearchParams(params).toString(); } const res: TuyaOpenAPIResponse = await retry(async () => new Promise((resolve, reject) => { const req = https.request({ host: new URL(this.endpoint).host, method, headers, path, }, res => { if (res.statusCode !== 200) { this.log.warn('Status: %d %s', res.statusCode, res.statusMessage); return; } res.setEncoding('utf8'); let rawData = ''; res.on('data', (chunk) => { rawData += chunk; }); res.on('end', () => { resolve(JSON.parse(rawData)); }); }); if (body) { req.write(JSON.stringify(body)); } req.on('error', e => { this.log.error('Network error: %s. Retrying...', e.message); reject(e); }); req.end(); }), undefined, {retriesMax: 10, interval: 100, exponential: true, factor: 2, jitter: 100}); this.log.debug('Response:\npath = %s\ndata = %s', path, JSON.stringify(res, null, 2)); if (res && res.success !== true && API_ERROR_MESSAGES[res.code]) { this.log.error(API_ERROR_MESSAGES[res.code]); } return res; } async get(path: string, params?) { return this.request('get', path, params, null); } async post(path: string, params?) { return this.request('post', path, null, params); } async delete(path: string, params?) { return this.request('delete', path, params, null); } _getSign(accessId: string, accessKey: string, accessToken = '', timestamp = 0, nonce: string, stringToSign: string) { const message = [accessId, accessToken, timestamp, nonce, stringToSign].join(''); const sign = Crypto.createHmac('SHA256', accessKey).update(message).digest('hex').toUpperCase(); return sign; } _getStringToSign(method: string, path: string, params, body) { const httpMethod = method.toUpperCase(); const bodyStream = body ? JSON.stringify(body) : ''; const contentSHA256 = Crypto.createHash('sha256').update(bodyStream).digest('hex'); const headers = `client_id:${this.accessId}\n`; const url = this._getSignUrl(path, params); const result = [httpMethod, contentSHA256, headers, url].join('\n'); return result; } _getSignUrl(path: string, params) { if (!params) { return path; } const sortedKeys = Object.keys(params).sort(); const kv: string[] = []; for (const key of sortedKeys) { if (params[key] !== null && params[key] !== undefined) { kv.push(`${key}=${params[key]}`); } } const url = `${path}?${kv.join('&')}`; return url; } _isSaltedPassword(password: string) { return Buffer.from(password, 'hex').length === 16; } } ================================================ FILE: src/core/TuyaOpenMQ.ts ================================================ import mqtt from 'mqtt'; import { v4 as uuid_v4 } from 'uuid'; import Crypto from 'crypto'; import CryptoJS from 'crypto-js'; import TuyaOpenAPI from './TuyaOpenAPI'; import Logger, { PrefixLogger } from '../util/Logger'; const GCM_TAG_LENGTH = 16; interface TuyaMQTTConfigSourceTopic { device: string; } interface TuyaMQTTConfig { url: string; client_id: string; username: string; password: string; expire_time: number; source_topic: TuyaMQTTConfigSourceTopic; sink_topic: object; } type TuyaMQTTCallback = (topic: string, protocol: number, data) => void; export default class TuyaOpenMQ { public client?: mqtt.MqttClient; public config?: TuyaMQTTConfig; public version = '1.0'; public messageListeners = new Set(); public linkId = uuid_v4(); public timer?: NodeJS.Timer; constructor( public api: TuyaOpenAPI, public log: Logger = console, public debug = false, ) { this.log = new PrefixLogger(log, TuyaOpenMQ.name, debug); } start() { this._connect(); } stop() { if (this.timer) { clearTimeout(this.timer); } if (this.client) { this.client.removeAllListeners(); this.client.end(); } } async _connect() { this.stop(); const res = await this._getMQConfig('mqtt'); if (res.success === false) { this.log.warn('Get MQTT config failed. code = %s, msg = %s', res.code, res.msg); return; } const { url, client_id, username, password, expire_time, source_topic } = res.result; this.log.debug('Connecting to:', url); const client = mqtt.connect(url, { clientId: client_id, username: username, password: password, }); client.on('connect', this._onConnect.bind(this)); client.on('error', this._onError.bind(this)); client.on('end', this._onEnd.bind(this)); client.on('message', this._onMessage.bind(this)); client.subscribe(source_topic.device); this.client = client; this.config = res.result; // reconnect every 2 hours required this.timer = setTimeout(this._connect.bind(this), (expire_time - 60) * 1000); } async _getMQConfig(linkType: string) { const res = await this.api.post('/v1.0/iot-03/open-hub/access-config', { 'uid': this.api.tokenInfo.uid, 'link_id': this.linkId, 'link_type': linkType, 'topics': 'device', 'msg_encrypted_version': this.version, }); return res; } _onConnect() { this.log.debug('Connected'); } _onError(error: Error) { this.log.error('Error:', error); } _onEnd() { this.log.debug('End'); } async _onMessage(topic: string, payload: Buffer) { const { protocol, data, t } = JSON.parse(payload.toString()); const messageData = this._decodeMQMessage(data, this.config!.password, t); if (!messageData) { this.log.warn('Message decode failed:', payload.toString()); return; } const message = JSON.parse(messageData); this.log.debug('onMessage:\ntopic = %s\nprotocol = %s\nmessage = %s\nt = %s', topic, protocol, JSON.stringify(message, null, 2), t); this._fixWrongOrderMessage(protocol, message, t); for (const listener of this.messageListeners) { listener(topic, protocol, message); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any private consumedQueue: any[] = []; _fixWrongOrderMessage(protocol: number, message, t: number) { if (protocol !== 4) { return; } const currentPayload = { protocol, message, t }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const lastPayload : any = this.consumedQueue[this.consumedQueue.length - 1]; if (lastPayload && currentPayload.t < lastPayload.t) { this.log.debug('Message received with wrong order.'); this.log.debug('LastMessage: dataId = %s, t = %s', lastPayload.message.dataId, lastPayload.t); this.log.debug('CurrentMessage: dataId = %s, t = %s', message.dataId, t); this.log.debug('This may cause outdated device status update.'); // Use newer status to override current status. for (const _status of message.status) { for (const payload of this.consumedQueue.reverse()) { if (message.devId !== payload.message.devId) { continue; } const latestStatus = payload.message.status.find(item => item.code === _status.code); if (latestStatus) { if (latestStatus.value !== _status.value) { this.log.debug('Override status %o => %o', latestStatus, _status); _status.value = latestStatus.value; _status.t = latestStatus.t; } break; } } } return; } this.consumedQueue.push(currentPayload); while (this.consumedQueue.length > 0) { let t = this.consumedQueue[0].t as number; if (t > Math.pow(10, 12)) { // timestamp format always changing, seconds or milliseconds is not certain :( t = t / 1000; } // Remove message older than 30 seconds if (Date.now() / 1000 > t + 30) { this.consumedQueue.shift(); } else { break; } } } _decodeMQMessage_1_0(b64msg: string, password: string) { password = password.substring(8, 24); const msg = CryptoJS.AES.decrypt(b64msg, CryptoJS.enc.Utf8.parse(password), { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7, }).toString(CryptoJS.enc.Utf8); return msg; } _decodeMQMessage_2_0(data: string, password: string, t: number) { // Base64 decoding generates Buffers const tmpbuffer = Buffer.from(data, 'base64'); const key = password.substring(8, 24).toString(); //get iv_length & iv_buffer const iv_length = tmpbuffer.readUIntBE(0, 4); const iv_buffer = tmpbuffer.slice(4, iv_length + 4); //Removes the IV bits of the head and 16 bits of the tail tags const data_buffer = tmpbuffer.slice(iv_length + 4, tmpbuffer.length - GCM_TAG_LENGTH); const cipher = Crypto.createDecipheriv('aes-128-gcm', key, iv_buffer); //setAuthTag buffer cipher.setAuthTag(tmpbuffer.slice(tmpbuffer.length - GCM_TAG_LENGTH, tmpbuffer.length)); //setAAD buffer const buf = Buffer.allocUnsafe(6); buf.writeUIntBE(t, 0, 6); cipher.setAAD(buf); const msg = cipher.update(data_buffer); return msg.toString('utf8'); } _decodeMQMessage(data: string, password: string, t: number) { if (this.version === '2.0') { return this._decodeMQMessage_2_0(data, password, t); } else { return this._decodeMQMessage_1_0(data, password); } } addMessageListener(listener: TuyaMQTTCallback) { this.messageListeners.add(listener); } removeMessageListener(listener: TuyaMQTTCallback) { this.messageListeners.delete(listener); } } ================================================ FILE: src/device/TuyaCustomDeviceManager.ts ================================================ import TuyaOpenAPI from '../core/TuyaOpenAPI'; import TuyaDevice from './TuyaDevice'; import TuyaDeviceManager from './TuyaDeviceManager'; export default class TuyaCustomDeviceManager extends TuyaDeviceManager { constructor( public api: TuyaOpenAPI, public debug = false, ) { super(api, debug); this.mq.version = '2.0'; } async getAssetList(parent_asset_id = -1) { // const res = await this.api.get('/v1.0/iot-03/users/assets', { const res = await this.api.get(`/v1.0/iot-02/assets/${parent_asset_id}/sub-assets`, { 'page_no': 0, 'page_size': 100, }); return res; } async authorizeAssetList(uid: string, asset_ids: string[] = [], authorized_children = false) { const res = await this.api.post(`/v1.0/iot-03/users/${uid}/actions/batch-assets-authorized`, { asset_ids: asset_ids.join(','), authorized_children, }); return res; } async getAssetDeviceIDList(assetID: string) { let deviceIDs: string[] = []; const params = { page_size: 50, }; // eslint-disable-next-line no-constant-condition while (true) { const res = await this.api.get(`/v1.0/iot-02/assets/${assetID}/devices`, params); deviceIDs = deviceIDs.concat((res.result.list as []).map(item => item['device_id'])); params['last_row_key'] = res.result.last_row_key; if (!res.result.has_next) { break; } } return deviceIDs; } async updateDevices(assetIDList: string[]) { let deviceIDs: string[] = []; for (const assetID of assetIDList) { deviceIDs = deviceIDs.concat(await this.getAssetDeviceIDList(assetID)); } if (deviceIDs.length === 0) { return []; } const res = await this.getDeviceListInfo(deviceIDs); const devices = (res.result.devices as []).map(obj => new TuyaDevice(obj)); for (const device of devices) { device.schema = await this.getDeviceSchema(device.id); } // this.log.debug('Devices updated.\n', JSON.stringify(devices, null, 2)); this.devices = devices; return devices; } } ================================================ FILE: src/device/TuyaDevice.ts ================================================ export enum TuyaDeviceSchemaMode { UNKNOWN = '', READ_WRITE = 'rw', READ_ONLY = 'ro', WRITE_ONLY = 'wo', } export enum TuyaDeviceSchemaType { Boolean = 'Boolean', Integer = 'Integer', Enum = 'Enum', String = 'String', Json = 'Json', Raw = 'Raw', } export type TuyaDeviceSchemaIntegerProperty = { min: number; max: number; scale: number; step: number; unit: string; }; export type TuyaDeviceSchemaEnumProperty = { range: string[]; }; export type TuyaDeviceSchemaStringProperty = string; export type TuyaDeviceSchemaJSONProperty = object; export type TuyaDeviceSchemaProperty = TuyaDeviceSchemaIntegerProperty | TuyaDeviceSchemaEnumProperty | TuyaDeviceSchemaStringProperty | TuyaDeviceSchemaJSONProperty; export type TuyaDeviceSchema = { code: string; // name: string; mode: TuyaDeviceSchemaMode; type: TuyaDeviceSchemaType; property: TuyaDeviceSchemaProperty; }; export type TuyaDeviceStatus = { code: string; value: string | number | boolean; }; export type TuyaIRRemoteKeyListItem = { key: string; key_id: number; key_name: string; standard_key: boolean; learning_code?: string; // IR DIY device learning code. }; export type TuyaIRRemoteTempListItem = { temp: number; temp_name: string; fan_list: TuyaIRRemoteFanListItem[]; }; export type TuyaIRRemoteKeyRangeItem = { mode: number; mode_name: string; temp_list: TuyaIRRemoteTempListItem[]; }; export type TuyaIRRemoteFanListItem = { fan: number; fan_name: string; }; export type TuyaIRRemoteKeys = { category_id: number; brand_id: number; remote_index: number; single_air: boolean; duplicate_power: boolean; key_list: TuyaIRRemoteKeyListItem[]; key_range: TuyaIRRemoteKeyRangeItem[]; }; export default class TuyaDevice { // device id!: string; uuid!: string; name!: string; online!: boolean; owner_id!: string; // homeID or assetID // product product_id!: string; product_name!: string; icon!: string; category!: string; unbridged?: boolean; schema!: TuyaDeviceSchema[]; // status status!: TuyaDeviceStatus[]; // location ip!: string; lat!: string; lon!: string; time_zone!: string; // time create_time!: number; active_time!: number; update_time!: number; // ... sub!: boolean; parent_id?: string; remote_keys?: TuyaIRRemoteKeys; constructor(obj: Partial) { Object.assign(this, obj); this.status.sort((a, b) => a.code > b.code ? 1 : -1); } isVirtualDevice() { return this.id.startsWith('vdevo'); } isIRControlHub() { return ['wnykq', 'hwktwkq', 'wsdykq'] .includes(this.category); } isIRRemoteControl() { return this.remote_keys !== undefined; } } ================================================ FILE: src/device/TuyaDeviceManager.ts ================================================ import EventEmitter from 'events'; import TuyaOpenAPI from '../core/TuyaOpenAPI'; import TuyaOpenMQ from '../core/TuyaOpenMQ'; import Logger, { PrefixLogger } from '../util/Logger'; import TuyaDevice, { TuyaDeviceSchema, TuyaDeviceSchemaMode, TuyaDeviceSchemaProperty, TuyaDeviceStatus, } from './TuyaDevice'; enum Events { DEVICE_ADD = 'DEVICE_ADD', DEVICE_INFO_UPDATE = 'DEVICE_INFO_UPDATE', DEVICE_STATUS_UPDATE = 'DEVICE_STATUS_UPDATE', DEVICE_DELETE = 'DEVICE_DELETE', } enum TuyaMQTTProtocol { DEVICE_STATUS_UPDATE = 4, DEVICE_INFO_UPDATE = 20, } export default class TuyaDeviceManager extends EventEmitter { static readonly Events = Events; public mq: TuyaOpenMQ; public ownerIDs: string[] = []; public devices: TuyaDevice[] = []; public log: Logger; constructor( public api: TuyaOpenAPI, public debug = false, ) { super(); const log = (this.api.log as PrefixLogger).log; this.log = new PrefixLogger(log, TuyaDeviceManager.name, debug); this.mq = new TuyaOpenMQ(api, log); this.mq.addMessageListener(this.onMQTTMessage.bind(this)); } getDevice(deviceID: string) { return Array.from(this.devices).find(device => device.id === deviceID); } // eslint-disable-next-line @typescript-eslint/no-unused-vars async updateDevices(ownerIDs: []): Promise { return []; } async updateDevice(deviceID: string) { const res = await this.getDeviceInfo(deviceID); if (!res.success) { return null; } const device = new TuyaDevice(res.result); device.schema = await this.getDeviceSchema(deviceID); const oldDevice = this.getDevice(deviceID); if (oldDevice) { this.devices.splice(this.devices.indexOf(oldDevice), 1); } this.devices.push(device); return device; } async getDeviceInfo(deviceID: string) { const res = await this.api.get(`/v1.0/devices/${deviceID}`); return res; } async getDeviceListInfo(deviceIDs: string[] = []) { const res = await this.api.get('/v1.0/devices', { 'device_ids': deviceIDs.join(',') }); return res; } async getDeviceSchema(deviceID: string) { // const res = await this.api.get(`/v1.2/iot-03/devices/${deviceID}/specification`); const res = await this.api.get(`/v1.0/devices/${deviceID}/specifications`); if (res.success === false) { this.log.warn('Get device specification failed. devId = %s, code = %s, msg = %s', deviceID, res.code, res.msg); return []; } // Combine functions and status together, as it used to be. const schemas = new Map(); for (const { code, type, values } of [...res.result.status, ...res.result.functions]) { if (schemas[code]) { continue; } const read = (res.result.status).find(schema => schema.code === code) !== undefined; const write = (res.result.functions).find(schema => schema.code === code) !== undefined; let mode = TuyaDeviceSchemaMode.UNKNOWN; if (read && write) { mode = TuyaDeviceSchemaMode.READ_WRITE; } else if (read && !write) { mode = TuyaDeviceSchemaMode.READ_ONLY; } else if (!read && write) { mode = TuyaDeviceSchemaMode.WRITE_ONLY; } let property: TuyaDeviceSchemaProperty; try { property = JSON.parse(values); schemas[code] = { code, mode, type, property }; } catch (error) { // ignore infrared remote's invalid schema because it's not used. } } return Object.values(schemas).sort((a, b) => a.code > b.code ? 1 : -1) as TuyaDeviceSchema[]; } async getInfraredRemotes(infraredID: string) { const res = await this.api.get(`/v2.0/infrareds/${infraredID}/remotes`); return res; } async getInfraredKeys(infraredID: string, remoteID: string) { const res = await this.api.get(`/v2.0/infrareds/${infraredID}/remotes/${remoteID}/keys`); return res; } async getInfraredACStatus(infraredID: string, remoteID: string) { const res = await this.api.get(`/v2.0/infrareds/${infraredID}/remotes/${remoteID}/ac/status`); return res; } async getInfraredDIYKeys(infraredID: string, remoteID: string) { const res = await this.api.get(`/v2.0/infrareds/${infraredID}/remotes/${remoteID}/learning-codes`); return res; } async updateInfraredRemotes(allDevices: TuyaDevice[]) { const irDevices = allDevices.filter(device => device.isIRControlHub()); for (const irDevice of irDevices) { const res = await this.getInfraredRemotes(irDevice.id); if (!res.success) { this.log.warn('Get infrared remotes failed. deviceId = %d, code = %s, msg = %s', irDevice.id, res.code, res.msg); continue; } for (const { category_id, remote_id } of res.result) { const subDevice = allDevices.find(device => device.id === remote_id); if (!subDevice) { continue; } subDevice.parent_id = irDevice.id; subDevice.schema = []; const res = await this.getInfraredKeys(irDevice.id, subDevice.id); if (!res.success) { this.log.warn('Get infrared remote keys failed. deviceId = %d, code = %s, msg = %s', subDevice.id, res.code, res.msg); continue; } subDevice.remote_keys = res.result; if (subDevice.category === 'infrared_ac') { // AC Device const res = await this.getInfraredACStatus(irDevice.id, subDevice.id); if (!res.success) { this.log.warn('Get infrared ac status failed. deviceId = %d, code = %s, msg = %s', subDevice.id, res.code, res.msg); continue; } subDevice.status = Object.entries(res.result).map(([key, value]) => ({code: key, value} as TuyaDeviceStatus)); } else if (category_id === 999) { // DIY Device const res = await this.getInfraredDIYKeys(irDevice.id, subDevice.id); if (!res.success) { this.log.warn('Get infrared diy keys failed. deviceId = %d, code = %s, msg = %s', subDevice.id, res.code, res.msg); continue; } const key_list = subDevice.remote_keys?.key_list || []; for (const key of key_list) { const item = (res.result as []).find(item => item['id'] === key.key_id && item['key'] === key.key); if (!item) { continue; } this.log.debug('learning_code:', item['code']); key.learning_code = item['code']; } } } } } async sendInfraredCommands(infraredID: string, remoteID: string, category_id: number, remote_index: number, key: string, key_id: number) { const res = await this.api.post(`/v2.0/infrareds/${infraredID}/remotes/${remoteID}/raw/command`, { category_id, remote_index, key, key_id, }); return res; } async sendInfraredACCommands(infraredID: string, remoteID: string, power: number, mode: number, temp: number, wind: number) { const commands = (power === 1) ? { power, mode, temp, wind } : { power }; const res = await this.api.post(`/v2.0/infrareds/${infraredID}/air-conditioners/${remoteID}/scenes/command`, commands); if (!res.success) { this.log.info('Send AC command failed. code = %d, msg = %s', res.code, res.msg); } return res; } async sendInfraredDIYCommands(infraredID: string, remoteID: string, code: string) { const res = await this.api.post(`/v2.0/infrareds/${infraredID}/remotes/${remoteID}/learning-codes`, { code }); return res; } async getLockTemporaryKey(deviceID: string) { // const res = await this.api.post(`/v1.0/smart-lock/devices/${deviceID}/door-lock/password-ticket`); const res = await this.api.post(`/v1.0/smart-lock/devices/${deviceID}/password-ticket`); if (res.success === false) { this.log.warn('Get Temporary Pass failed. devID = %s, code = %s, msg = %s', deviceID, res.code, res.msg); } return res; } async sendLockCommands(deviceID: string, ticketID: string, open: boolean) { const res = await this.api.post(`/v1.0/smart-lock/devices/${deviceID}/password-free/door-operate`, { device_id: deviceID, ticket_id: ticketID, open, }); return res; } async sendCommands(deviceID: string, commands: TuyaDeviceStatus[]) { const res = await this.api.post(`/v1.0/devices/${deviceID}/commands`, { commands }); return res.result; } async onMQTTMessage(topic: string, protocol: TuyaMQTTProtocol, message) { switch(protocol) { case TuyaMQTTProtocol.DEVICE_STATUS_UPDATE: { const { devId, status } = message; const device = this.getDevice(devId); if (!device) { return; } for (const item of device.status) { const _item = status.find(_item => _item.code === item.code); if (!_item) { continue; } item.value = _item.value; } this.emit(Events.DEVICE_STATUS_UPDATE, device, status); break; } case TuyaMQTTProtocol.DEVICE_INFO_UPDATE: { const { bizCode, bizData, devId } = message; if (bizCode === 'bindUser') { const { ownerId } = bizData; if (!this.ownerIDs.includes(ownerId)) { this.log.warn('Update devId = %s not included in your ownerIDs. Skip.', devId); return; } // TODO failed if request to quickly await new Promise(resolve => setTimeout(resolve, 10000)); const device = await this.updateDevice(devId); if (!device) { return; } this.mq.start(); // Force reconnect, unless new device status update won't get received this.emit(Events.DEVICE_ADD, device); } else if (bizCode === 'nameUpdate') { const { name } = bizData; const device = this.getDevice(devId); if (!device) { return; } device.name = name; this.emit(Events.DEVICE_INFO_UPDATE, device, bizData); } else if (bizCode === 'online' || bizCode === 'offline') { const device = this.getDevice(devId); if (!device) { return; } device.online = (bizCode === 'online') ? true : false; this.emit(Events.DEVICE_INFO_UPDATE, device, bizData); } else if (bizCode === 'delete') { const { ownerId } = bizData; if (!this.ownerIDs.includes(ownerId)) { this.log.warn('Remove devId = %s not included in your ownerIDs. Skip.', devId); return; } const device = this.getDevice(devId); if (!device) { return; } this.devices.splice(this.devices.indexOf(device), 1); this.emit(Events.DEVICE_DELETE, devId); } else if (bizCode === 'event_notify') { // doorbell event } else if (bizCode === 'p2pSignal') { // p2p signal } else { this.log.warn('Unhandled mqtt message: bizCode = %s, bizData = %o', bizCode, bizData); } break; } default: this.log.warn('Unhandled mqtt message: protocol = %s, message = %o', protocol, message); break; } } } ================================================ FILE: src/device/TuyaHomeDeviceManager.ts ================================================ import TuyaDevice from './TuyaDevice'; import TuyaDeviceManager from './TuyaDeviceManager'; export default class TuyaHomeDeviceManager extends TuyaDeviceManager { async getHomeList() { const res = await this.api.get(`/v1.0/users/${this.api.tokenInfo.uid}/homes`); return res; } async getHomeDeviceList(homeID: number) { const res = await this.api.get(`/v1.0/homes/${homeID}/devices`); return res; } async updateDevices(homeIDList: number[]) { let devices: TuyaDevice[] = []; for (const homeID of homeIDList) { const res = await this.getHomeDeviceList(homeID); devices = devices.concat((res.result as []).map(obj => new TuyaDevice(obj))); } if (devices.length === 0) { return []; } for (const device of devices) { device.schema = await this.getDeviceSchema(device.id); } // this.log.debug('Devices updated.\n', JSON.stringify(devices, null, 2)); this.devices = devices; return devices; } async getSceneList(homeID: number) { const res = await this.api.get(`/v1.1/homes/${homeID}/scenes`); if (res.success === false) { this.log.warn('Get scene list failed. homeId = %d, code = %s, msg = %s', homeID, res.code, res.msg); return []; } const scenes: TuyaDevice[] = []; for (const { scene_id, name, enabled, status } of res.result) { if (enabled !== true || status !== '1') { continue; } scenes.push(new TuyaDevice({ id: scene_id, uuid: scene_id, name, owner_id: homeID.toString(), product_id: 'scene', category: 'scene', schema: [], status: [], online: true, })); } return scenes; } async executeScene(homeID: string | number, sceneID: string) { const res = await this.api.post(`/v1.0/homes/${homeID}/scenes/${sceneID}/trigger`); return res; } } ================================================ FILE: src/index.ts ================================================ import { API } from 'homebridge'; import { PLATFORM_NAME } from './settings'; import { TuyaPlatform } from './platform'; /** * This method registers the platform with Homebridge */ export = (api: API) => { api.registerPlatform(PLATFORM_NAME, TuyaPlatform); }; ================================================ FILE: src/platform.ts ================================================ import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge'; import { Validator } from 'jsonschema'; import path from 'path'; import fs from 'fs'; import TuyaDevice, { TuyaDeviceStatus } from './device/TuyaDevice'; import TuyaDeviceManager from './device/TuyaDeviceManager'; import TuyaCustomDeviceManager from './device/TuyaCustomDeviceManager'; import TuyaHomeDeviceManager from './device/TuyaHomeDeviceManager'; import { PLATFORM_NAME, PLUGIN_NAME } from './settings'; import { TuyaPlatformConfigOptions, customOptionsSchema, homeOptionsSchema } from './config'; import AccessoryFactory from './accessory/AccessoryFactory'; import BaseAccessory from './accessory/BaseAccessory'; import TuyaOpenAPI, { LOGIN_ERROR_MESSAGES } from './core/TuyaOpenAPI'; /** * HomebridgePlatform * This class is the main constructor for your plugin, this is where you should * parse the user config and discover/register accessories with Homebridge. */ export class TuyaPlatform implements DynamicPlatformPlugin { public readonly Service: typeof Service = this.api.hap.Service; public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic; public options = this.config.options as TuyaPlatformConfigOptions; // this is used to track restored cached accessories public cachedAccessories: PlatformAccessory[] = []; public deviceManager?: TuyaDeviceManager; public accessoryHandlers: BaseAccessory[] = []; validate() { let result; if (!this.options) { this.log.error('Not configured, exit.'); return false; } else if (this.options.projectType === '1') { result = new Validator().validate(this.options, customOptionsSchema); } else if (this.options.projectType === '2') { result = new Validator().validate(this.options, homeOptionsSchema); } else { this.log.error(`Unsupported projectType: ${this.options['projectType']}, exit.`); return false; } result.errors.forEach(error => this.log.error(error.stack)); if (result.errors.length > 0) { return false; } if (!this.validateDeviceOverrides() || !this.validateSchema()) { return false; } return true; } validateDeviceOverrides() { if (!this.options.deviceOverrides) { return true; } const idMap = new Map(); for (const item of this.options.deviceOverrides) { if (idMap.has(item.id)) { idMap.get(item.id)?.push(item); } else { idMap.set(item.id, [item]); } } for (const items of idMap.values()) { if (items.length > 1) { this.log.error('"deviceOverrides" conflict, "id" must be unique: %o.', items); return false; } } return true; } validateSchema() { if (!this.options.deviceOverrides) { return true; } for (const deviceOverride of this.options.deviceOverrides) { if (!deviceOverride.schema) { continue; } const idMap = new Map(); for (const item of deviceOverride.schema) { if (idMap.has(item.code)) { idMap.get(item.code)?.push(item); } else { idMap.set(item.code, [item]); } } for (const items of idMap.values()) { if (items.length > 1) { this.log.error('"schema" conflict, "code" must be unique: %o.', items); return false; } } } return true; } constructor( public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API, ) { if (!this.validate()) { return; } this.log.debug('Finished initializing platform'); // When this event is fired it means Homebridge has restored all cached accessories from disk. // Dynamic Platform plugins should only register new accessories after this event was fired, // in order to ensure they weren't added to homebridge already. This event can also be used // to start discovery of new accessories. this.api.on('didFinishLaunching', async () => { this.log.debug('Executed didFinishLaunching callback'); // run the method to discover / register your devices as accessories await this.initDevices(); }); } /** * This function is invoked when homebridge restores cached accessories from disk at startup. * It should be used to setup event handlers for characteristics and update respective values. */ configureAccessory(accessory: PlatformAccessory) { this.log.info('Loading accessory from cache:', accessory.displayName); // add the restored accessory to the accessories cache so we can track if it has already been registered this.cachedAccessories.push(accessory); } /** * This is an example method showing how to register discovered accessories. * Accessories must only be registered once, previously created accessories * must not be registered again to prevent "duplicate UUID" errors. */ async initDevices() { let devices: TuyaDevice[] | undefined; if (this.options.projectType === '1') { devices = await this.initCustomProject(); } else if (this.options.projectType === '2') { devices = await this.initHomeProject(); } else { this.log.warn(`Unsupported projectType: ${this.config.options.projectType}.`); } if (!devices || !this.deviceManager) { return; } // override device category for (const device of devices) { const deviceConfig = this.getDeviceConfig(device); if (!deviceConfig || !deviceConfig.category) { continue; } this.log.warn('Override %o category from %o to %o', device.name, device.category, deviceConfig.category); device.category = deviceConfig.category; } // override device bridged for (const device of devices) { const deviceConfig = this.getDeviceConfig(device); if (!deviceConfig || !deviceConfig.unbridged) { continue; } this.log.warn('Unbridge %o category %o', device.name, device.category ); device.unbridged = deviceConfig.unbridged; } await this.deviceManager.updateInfraredRemotes(devices); this.log.info(`Got ${devices.length} device(s) and scene(s).`); const file = path.join(this.api.user.persistPath(), `TuyaDeviceList.${this.deviceManager.api.tokenInfo.uid}.json`); this.log.info('Device list saved at %s', file); if (!fs.existsSync(this.api.user.persistPath())) { await fs.promises.mkdir(this.api.user.persistPath()); } await fs.promises.writeFile(file, JSON.stringify(devices, null, 2)); // add accessories for (const device of devices) { this.addAccessory(device); } // remove unused accessories for (const cachedAccessory of this.cachedAccessories) { this.log.warn('Removing unused accessory from cache:', cachedAccessory.displayName); this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [cachedAccessory]); } this.cachedAccessories = []; this.deviceManager!.on(TuyaDeviceManager.Events.DEVICE_ADD, this.addAccessory.bind(this)); this.deviceManager!.on(TuyaDeviceManager.Events.DEVICE_INFO_UPDATE, this.updateAccessoryInfo.bind(this)); this.deviceManager!.on(TuyaDeviceManager.Events.DEVICE_STATUS_UPDATE, this.updateAccessoryStatus.bind(this)); this.deviceManager!.on(TuyaDeviceManager.Events.DEVICE_DELETE, this.removeAccessory.bind(this)); } getDeviceConfig(device: TuyaDevice) { if (!this.options.deviceOverrides) { return undefined; } const deviceConfig = this.options.deviceOverrides.find(config => config.id === device.id || config.id === device.uuid); const productConfig = this.options.deviceOverrides.find(config => config.id === device.product_id); const globalConfig = this.options.deviceOverrides.find(config => config.id === 'global'); return deviceConfig || productConfig || globalConfig; } getDeviceSchemaConfig(device: TuyaDevice, code: string) { const deviceConfig = this.getDeviceConfig(device); if (!deviceConfig || !deviceConfig.schema) { return undefined; } // migrate old config deviceConfig.schema.forEach(item => { if (item['oldCode']) { item.newCode = item.code; item.code = item['oldCode']; item['oldCode'] = undefined; } }); const schemaConfig = deviceConfig.schema.find(item => item.newCode ? item.newCode === code : item.code === code); if (!schemaConfig) { return undefined; } return schemaConfig; } async initCustomProject() { if (this.options.projectType !== '1') { return undefined; } const DEFAULT_USER = 'homebridge'; const DEFAULT_PASS = 'homebridge'; let res; const { endpoint, accessId, accessKey, debug, debugLevel } = this.options; const debugMode = debug && ((debugLevel ?? '').length > 0 ? debugLevel?.includes('api') : true); const api = new TuyaOpenAPI(endpoint, accessId, accessKey, this.log, 'en', debugMode); const deviceManager = new TuyaCustomDeviceManager(api, debugMode); this.log.info('Get token.'); res = await api.getToken(); if (res.success === false) { this.log.error(`Get token failed. code=${res.code}, msg=${res.msg}`); return undefined; } this.log.info(`Search default user "${DEFAULT_USER}"`); res = await api.customGetUserInfo(DEFAULT_USER); if (res.success === false) { this.log.error(`Search user failed. code=${res.code}, msg=${res.msg}`); return undefined; } if (!res.result.user_name) { this.log.info(`Default user "${DEFAULT_USER}" not exist.`); this.log.info(`Creating default user "${DEFAULT_USER}".`); res = await api.customCreateUser(DEFAULT_USER, DEFAULT_PASS); if (res.success === false) { this.log.error(`Create default user failed. code=${res.code}, msg=${res.msg}`); return undefined; } } else { this.log.info(`Default user "${DEFAULT_USER}" exists.`); } const uid = res.result.user_id; this.log.info('Fetching asset list.'); res = await deviceManager.getAssetList(); if (res.success === false) { this.log.error(`Fetching asset list failed. code=${res.code}, msg=${res.msg}`); return undefined; } const assetIDList: string[] = []; for (const { asset_id, asset_name } of res.result.list) { this.log.info(`Got asset_id=${asset_id}, asset_name=${asset_name}`); assetIDList.push(asset_id); } if (assetIDList.length === 0) { this.log.warn('Asset list is empty. exit.'); return undefined; } this.log.info('Authorize asset list.'); res = await deviceManager.authorizeAssetList(uid, assetIDList, true); if (res.success === false) { this.log.error(`Authorize asset list failed. code=${res.code}, msg=${res.msg}`); return undefined; } this.log.info(`Log in with user "${DEFAULT_USER}".`); res = await api.customLogin(DEFAULT_USER, DEFAULT_USER); if (res.success === false) { this.log.error(`Login failed. code=${res.code}, msg=${res.msg}`); if (LOGIN_ERROR_MESSAGES[res.code]) { this.log.error(LOGIN_ERROR_MESSAGES[res.code]); } return undefined; } this.log.info('Start MQTT connection.'); deviceManager.mq.start(); this.log.info('Fetching device list.'); deviceManager.ownerIDs = assetIDList; const devices = await deviceManager.updateDevices(assetIDList); this.deviceManager = deviceManager; return devices; } async initHomeProject() { if (this.options.projectType !== '2') { return undefined; } let res; const { accessId, accessKey, countryCode, username, password, appSchema, endpoint, debug, debugLevel } = this.options; const debugMode = debug && ((debugLevel ?? '').length > 0 ? debugLevel?.includes('api') : true); const api = new TuyaOpenAPI( (endpoint && endpoint.length > 0) ? endpoint : TuyaOpenAPI.getDefaultEndpoint(countryCode), accessId, accessKey, this.log, 'en', debugMode); const deviceManager = new TuyaHomeDeviceManager(api, debugMode); this.log.info('Log in to Tuya Cloud.'); res = await api.homeLogin(countryCode, username, password, appSchema); if (res.success === false) { this.log.error(`Login failed. code=${res.code}, msg=${res.msg}`); if (LOGIN_ERROR_MESSAGES[res.code]) { this.log.error(LOGIN_ERROR_MESSAGES[res.code]); } return undefined; } this.log.info('Start MQTT connection.'); deviceManager.mq.start(); this.log.info('Fetching home list.'); res = await deviceManager.getHomeList(); if (res.success === false) { this.log.error(`Fetching home list failed. code=${res.code}, msg=${res.msg}`); return undefined; } const homeIDList: number[] = []; for (const { home_id, name } of res.result) { this.log.info(`Got home_id=${home_id}, name=${name}`); if (this.options.homeWhitelist) { if (this.options.homeWhitelist.includes(home_id)) { this.log.info(`Found home_id=${home_id} in whitelist; including devices from this home.`); homeIDList.push(home_id); } else { this.log.info(`Did not find home_id=${home_id} in whitelist; excluding devices from this home.`); } } else { homeIDList.push(home_id); } } if (homeIDList.length === 0) { this.log.warn('Home list is empty.'); } this.log.info('Fetching device list.'); deviceManager.ownerIDs = homeIDList.map(homeID =>homeID.toString()); const devices = await deviceManager.updateDevices(homeIDList); this.log.info('Fetching scene list.'); for (const homeID of homeIDList) { const scenes = await deviceManager.getSceneList(homeID); for (const scene of scenes) { this.log.info(`Got scene_id=${scene.id}, name=${scene.name}`); } devices.push(...scenes); } this.deviceManager = deviceManager; return devices; } addAccessory(device: TuyaDevice) { if (device.category === 'hidden') { this.log.info('Hide Accessory:', device.name); return; } const uuid = this.api.hap.uuid.generate(device.id); const existingAccessory = this.cachedAccessories.find(accessory => accessory.UUID === uuid); if (existingAccessory && !device.unbridged) { this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName); // Update context if (!existingAccessory.context || !existingAccessory.context.deviceID) { this.log.info('Update accessory context:', existingAccessory.displayName); existingAccessory.context.deviceID = device.id; this.api.updatePlatformAccessories([existingAccessory]); } // create the accessory handler for the restored accessory const handler = AccessoryFactory.createAccessory(this, existingAccessory, device); this.accessoryHandlers.push(handler); const index = this.cachedAccessories.indexOf(existingAccessory); if (index >= 0) { this.cachedAccessories.splice(index, 1); } } else { // the accessory does not yet exist, so we need to create it this.log.info('Adding new accessory:', device.name); // create a new accessory const accessory = new this.api.platformAccessory(device.name, uuid); accessory.context.deviceID = device.id; // create the accessory handler for the newly create accessory const handler = AccessoryFactory.createAccessory(this, accessory, device); this.accessoryHandlers.push(handler); // link the accessory to your platform if (device.unbridged) { this.api.publishExternalAccessories(PLUGIN_NAME, [accessory]); } else { this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); } } } updateAccessoryInfo(device: TuyaDevice, info) { const handler = this.getAccessoryHandler(device.id); if (!handler) { return; } // this.log.debug('onDeviceInfoUpdate devId = %s, status = %o}', device.id, info); handler.onDeviceInfoUpdate(info); } updateAccessoryStatus(device: TuyaDevice, status: TuyaDeviceStatus[]) { const handler = this.getAccessoryHandler(device.id); if (!handler) { return; } // this.log.debug('onDeviceStatusUpdate devId = %s, status = %o}', device.id, status); handler.onDeviceStatusUpdate(status); } removeAccessory(deviceID: string) { const handler = this.getAccessoryHandler(deviceID); if (!handler) { return; } const index = this.accessoryHandlers.indexOf(handler); if (index >= 0) { this.accessoryHandlers.splice(index, 1); } this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [handler.accessory]); this.log.info('Removing existing accessory from cache:', handler.accessory.displayName); } getAccessoryHandler(deviceID: string) { return this.accessoryHandlers.find(handler => handler.device.id === deviceID); } } ================================================ FILE: src/settings.ts ================================================ // eslint-disable-next-line // @ts-ignore import { pluginAlias as platformName } from '../config.schema.json'; // eslint-disable-next-line // @ts-ignore import { name as pluginName } from '../package.json'; /** * This is the name of the platform that users will use to register the plugin in the Homebridge config.json */ export const PLATFORM_NAME = platformName; /** * This must match the name of your plugin as defined the package.json */ export const PLUGIN_NAME = pluginName; ================================================ FILE: src/util/FfmpegStreamingProcess.ts ================================================ import { ChildProcessWithoutNullStreams, spawn, } from 'child_process'; import { StreamRequestCallback, StreamSessionIdentifier, } from 'homebridge'; import os from 'os'; import readline from 'readline'; import { Writable } from 'stream'; import { PrefixLogger } from './Logger'; export interface StreamingDelegate { stopStream(sessionId: StreamSessionIdentifier): void; forceStopStream(sessionId: StreamSessionIdentifier): void; } type FfmpegProgress = { frame: number; fps: number; stream_q: number; bitrate: number; total_size: number; out_time_us: number; out_time: string; dup_frames: number; drop_frames: number; speed: number; progress: string; }; export class FfmpegStreamingProcess { private readonly process: ChildProcessWithoutNullStreams; private killTimeout?: NodeJS.Timeout; readonly stdin: Writable; constructor( sessionId: string, videoProcessor: string, ffmpegArgs: string[], log: PrefixLogger, delegate: StreamingDelegate, callback?: StreamRequestCallback, ) { log.debug(`Stream command: ${videoProcessor} ${ffmpegArgs.map(value => JSON.stringify(value)).join(' ')}`); let started = false; const startTime = Date.now(); this.process = spawn(videoProcessor, ffmpegArgs, { env: process.env }); this.stdin = this.process.stdin; this.process.stdout.on('data', (data) => { const progress = this.parseProgress(data); if (progress) { if (!started && progress.frame > 0) { started = true; const runtime = (Date.now() - startTime) / 1000; const message = 'Getting the first frames took ' + runtime + ' seconds.'; if (runtime < 5) { log.debug(message); } else if (runtime < 22) { log.warn(message); } else { log.error(message); } } } }); const stderr = readline.createInterface({ input: this.process.stderr, terminal: false, }); stderr.on('line', (line: string) => { if (callback) { callback(); callback = undefined; } if (line.match(/\[(panic|fatal|error)\]/)) { log.error(line); } }); this.process.on('error', (error: Error) => { log.error('FFmpeg process creation failed: ' + error.message); if (callback) { callback(new Error('FFmpeg process creation failed')); } delegate.stopStream(sessionId); }); this.process.on('exit', (code: number, signal: NodeJS.Signals) => { if (this.killTimeout) { clearTimeout(this.killTimeout); } const message = 'FFmpeg exited with code: ' + code + ' and signal: ' + signal; if (this.killTimeout && code === 0) { log.debug(message + ' (Expected)'); } else if (code === null || code === 255) { if (this.process.killed) { log.debug(message + ' (Forced)'); } else { log.error(message + ' (Unexpected)'); } } else { log.error(message + ' (Error)'); delegate.stopStream(sessionId); if (!started && callback) { callback(new Error(message)); } else { delegate.forceStopStream(sessionId); } } }); } parseProgress(data: Uint8Array): FfmpegProgress | undefined { const input = data.toString(); if (input.indexOf('frame=') === 0) { try { const progress = new Map(); input.split(/\r?\n/).forEach((line) => { const split = line.split('=', 2); progress.set(split[0], split[1]); }); return { frame: parseInt(progress.get('frame')!), fps: parseFloat(progress.get('fps')!), stream_q: parseFloat(progress.get('stream_0_0_q')!), bitrate: parseFloat(progress.get('bitrate')!), total_size: parseInt(progress.get('total_size')!), out_time_us: parseInt(progress.get('out_time_us')!), out_time: progress.get('out_time')!.trim(), dup_frames: parseInt(progress.get('dup_frames')!), drop_frames: parseInt(progress.get('drop_frames')!), speed: parseFloat(progress.get('speed')!), progress: progress.get('progress')!.trim(), }; } catch { return undefined; } } else { return undefined; } } getStdin() { return this.process.stdin; } public stop(): void { this.process.stdin.write('q' + os.EOL); this.killTimeout = setTimeout(() => { this.process.kill('SIGKILL'); }, 2 * 1000); } } ================================================ FILE: src/util/Logger.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ export default interface Logger { info(message?: any, ...args: any[]): void; warn(message?: any, ...args: any[]): void; debug(message?: any, ...args: any[]): void; error(message?: any, ...args: any[]): void; } export class PrefixLogger { constructor( public log: Logger, public prefix: string, public debugMode = false, ) { this.debugMode = this.debugMode || process.argv.includes('-D') || process.argv.includes('--debug'); } debug(message?: any, ...args: any[]) { if (this.debugMode) { this.log.info((this.prefix ? `[${this.prefix}] ` : '') + message, ...args); } else { this.log.debug((this.prefix ? `[${this.prefix}] ` : '') + message, ...args); } } info(message?: any, ...args: any[]) { this.log.info((this.prefix ? `[${this.prefix}] ` : '') + message, ...args); } warn(message?: any, ...args: any[]) { this.log.warn((this.prefix ? `[${this.prefix}] ` : '') + message, ...args); } error(message?: any, ...args: any[]) { this.log.error((this.prefix ? `[${this.prefix}] ` : '') + message, ...args); } } ================================================ FILE: src/util/TuyaRecordingDelegate.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import { CameraRecordingConfiguration, CameraRecordingDelegate, HDSProtocolSpecificErrorReason, RecordingPacket, } from 'homebridge'; export class TuyaRecordingDelegate implements CameraRecordingDelegate { updateRecordingActive(active: boolean): void { throw new Error('Method not implemented.'); } updateRecordingConfiguration(configuration: CameraRecordingConfiguration | undefined): void { throw new Error('Method not implemented.'); } handleRecordingStreamRequest(streamId: number): AsyncGenerator { throw new Error('Method not implemented.'); } acknowledgeStream?(streamId: number): void { throw new Error('Method not implemented.'); } closeRecordingStream(streamId: number, reason: HDSProtocolSpecificErrorReason | undefined): void { throw new Error('Method not implemented.'); } } ================================================ FILE: src/util/TuyaStreamDelegate.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable max-len */ import { AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, CameraControllerOptions, CameraRecordingOptions, CameraStreamingDelegate, CameraStreamingOptions, EventTriggerOption, HAP, H264Level, H264Profile, MediaContainerType, PrepareStreamCallback, PrepareStreamRequest, Resolution, SnapshotRequest, SnapshotRequestCallback, SRTPCryptoSuites, StreamingRequest, StreamRequestCallback, PrepareStreamResponse, StartStreamRequest, } from 'homebridge'; import { defaultFfmpegPath, reservePorts, } from '@homebridge/camera-utils'; import CameraAccessory from '../accessory/CameraAccessory'; import { TuyaRecordingDelegate, } from './TuyaRecordingDelegate'; import { spawn } from 'child_process'; import { createSocket, Socket } from 'dgram'; import { FfmpegStreamingProcess, StreamingDelegate as FfmpegStreamingDelegate } from './FfmpegStreamingProcess'; interface SessionInfo { address: string; // address of the HAP controller addressVersion: 'ipv4' | 'ipv6'; videoPort: number; videoIncomingPort: number; videoCryptoSuite: SRTPCryptoSuites; // should be saved if multiple suites are supported videoSRTP: Buffer; // key and salt concatenated videoSSRC: number; // rtp synchronisation source audioPort: number; audioIncomingPort: number; audioCryptoSuite: SRTPCryptoSuites; audioSRTP: Buffer; audioSSRC: number; } type ActiveSession = { mainProcess?: FfmpegStreamingProcess; returnProcess?: FfmpegStreamingProcess; timeout?: NodeJS.Timeout; socket?: Socket; }; /* interface SampleRateEntry { type: AudioRecordingCodecType; bitrateMode: number; samplerate: AudioRecordingSamplerate[]; audioChannels: number; } */ export class TuyaStreamingDelegate implements CameraStreamingDelegate, FfmpegStreamingDelegate { public readonly controller: CameraController; private pendingSessions: { [index: string]: SessionInfo } = {}; private ongoingSessions: { [index: string]: ActiveSession } = {}; private readonly camera: CameraAccessory; private readonly hap: HAP; constructor(camera: CameraAccessory) { this.camera = camera; this.hap = camera.platform.api.hap; // this.recordingDelegate = new TuyaRecordingDelegate(); const resolutions: Resolution[] = [ [320, 180, 30], [320, 240, 15], [320, 240, 30], [480, 270, 30], [480, 360, 30], [640, 360, 30], [640, 480, 30], [1280, 720, 30], [1280, 960, 30], [1920, 1080, 30], [1600, 1200, 30], ]; const streamingOptions: CameraStreamingOptions = { supportedCryptoSuites: [SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80], video: { codec: { profiles: [H264Profile.BASELINE, H264Profile.MAIN, H264Profile.HIGH], levels: [H264Level.LEVEL3_1, H264Level.LEVEL3_2, H264Level.LEVEL4_0], }, resolutions: resolutions, }, audio: { twoWayAudio: false, codecs: [ { type: AudioStreamingCodecType.AAC_ELD, samplerate: AudioStreamingSamplerate.KHZ_16, }, ], }, }; const recordingOptions: CameraRecordingOptions = { overrideEventTriggerOptions: [ EventTriggerOption.MOTION, EventTriggerOption.DOORBELL, ], prebufferLength: 4 * 1000, // prebufferLength always remains 4s ? mediaContainerConfiguration: [ { type: MediaContainerType.FRAGMENTED_MP4, fragmentLength: 4000, }, ], video: { parameters: { profiles: [ H264Profile.BASELINE, H264Profile.MAIN, H264Profile.HIGH, ], levels: [ H264Level.LEVEL3_1, H264Level.LEVEL3_2, H264Level.LEVEL4_0, ], }, resolutions: resolutions, type: this.hap.VideoCodecType.H264, }, audio: { codecs: [ { samplerate: this.hap.AudioRecordingSamplerate.KHZ_32, type: this.hap.AudioRecordingCodecType.AAC_LC, }, ], }, }; const options: CameraControllerOptions = { delegate: this, streamingOptions: streamingOptions, // recording: { // options: recordingOptions, // delegate: this.recordingDelegate // } }; this.controller = new this.hap.CameraController(options); } stopStream(sessionId: string): void { const session = this.ongoingSessions[sessionId]; if (session) { if (session.timeout) { clearTimeout(session.timeout); } try { session.socket?.close(); } catch (error) { this.camera.log.error(`Error occurred closing socket: ${error}`); } try { session.mainProcess?.stop(); } catch (error) { this.camera.log.error(`Error occurred terminating main FFmpeg process: ${error}`); } try { session.returnProcess?.stop(); } catch (error) { this.camera.log.error(`Error occurred terminating two-way FFmpeg process: ${error}`); } delete this.ongoingSessions[sessionId]; this.camera.log.info('Stopped video stream.'); } } forceStopStream(sessionId: string) { this.controller.forceStopStreamingSession(sessionId); } async handleSnapshotRequest( request: SnapshotRequest, callback: SnapshotRequestCallback, ) { try { this.camera.log.debug(`Snapshot requested: ${request.width} x ${request.height}`); const snapshot = await this.fetchSnapshot(); this.camera.log.debug('Sending snapshot'); callback(undefined, snapshot); } catch (error) { callback(error as Error); } } async prepareStream( request: PrepareStreamRequest, callback: PrepareStreamCallback, ) { const videoIncomingPort = await reservePorts({ count: 1, }); const videoSSRC = this.hap.CameraController.generateSynchronisationSource(); const audioIncomingPort = await reservePorts({ count: 1, }); const audioSSRC = this.hap.CameraController.generateSynchronisationSource(); const sessionInfo: SessionInfo = { address: request.targetAddress, addressVersion: request.addressVersion, audioCryptoSuite: request.audio.srtpCryptoSuite, audioPort: request.audio.port, audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]), audioSSRC: audioSSRC, audioIncomingPort: audioIncomingPort[0], videoCryptoSuite: request.video.srtpCryptoSuite, videoPort: request.video.port, videoSRTP: Buffer.concat([request.video.srtp_key, request.video.srtp_salt]), videoSSRC: videoSSRC, videoIncomingPort: videoIncomingPort[0], }; const response: PrepareStreamResponse = { video: { port: sessionInfo.videoIncomingPort, ssrc: videoSSRC, srtp_key: request.video.srtp_key, srtp_salt: request.video.srtp_salt, }, audio: { port: sessionInfo.audioIncomingPort, ssrc: audioSSRC, srtp_key: request.audio.srtp_key, srtp_salt: request.audio.srtp_salt, }, }; this.pendingSessions[request.sessionID] = sessionInfo; callback(undefined, response); } async handleStreamRequest( request: StreamingRequest, callback: StreamRequestCallback, ) { switch (request.type) { case this.hap.StreamRequestTypes.START: { this.camera.log.debug(`Start stream requested: ${request.video.width}x${request.video.height}, ${request.video.fps} fps, ${request.video.max_bit_rate} kbps`); await this.startStream(request, callback); break; } case this.hap.StreamRequestTypes.RECONFIGURE: { this.camera.log.debug(`Reconfigure stream requested: ${request.video.width}x${request.video.height}, ${request.video.fps} fps, ${request.video.max_bit_rate} kbps (Ignored)`); callback(); break; } case this.hap.StreamRequestTypes.STOP: { this.camera.log.debug('Stop stream requested'); this.stopStream(request.sessionID); callback(); break; } } } private async retrieveDeviceRTSP(): Promise { const data = await this.camera.deviceManager.api.post( `/v1.0/devices/${this.camera.device.id}/stream/actions/allocate`, { type: 'rtsp', }, ); return data.result.url; } private async startStream(request: StartStreamRequest, callback: StreamRequestCallback) { const sessionInfo = this.pendingSessions[request.sessionID]; if (!sessionInfo) { this.camera.log.error('Error finding session information.'); callback(new Error('Error finding session information')); } const vcodec = 'libx264'; const mtu = 1316; // request.video.mtu is not used const fps = request.video.fps; const videoBitrate = request.video.max_bit_rate; const rtspUrl = await this.retrieveDeviceRTSP(); const ffmpegArgs: string[] = [ '-hide_banner', '-loglevel', 'verbose', '-i', rtspUrl, '-an', '-sn', '-dn', '-r', fps.toString(), '-codec:v', vcodec, '-pix_fmt', 'yuv420p', '-color_range', 'mpeg', '-f', 'rawvideo', ]; const encoderOptions = '-preset ultrafast -tune zerolatency'; if (encoderOptions) { ffmpegArgs.push(...encoderOptions.split(/\s+/)); } if (videoBitrate > 0) { ffmpegArgs.push('-b:v', `${videoBitrate}k`); } // Video Stream ffmpegArgs.push( '-payload_type', `${request.video.pt}`, '-ssrc', `${sessionInfo.videoSSRC}`, '-f', 'rtp', '-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80', '-srtp_out_params', sessionInfo.videoSRTP.toString('base64'), `srtp://${sessionInfo.address}:${sessionInfo.videoPort}?rtcpport=${sessionInfo.videoPort}&pkt_size=${mtu}`, ); // Setting up audio if ( request.audio.codec === AudioStreamingCodecType.OPUS || request.audio.codec === AudioStreamingCodecType.AAC_ELD ) { ffmpegArgs.push('-vn', '-sn', '-dn'); if (request.audio.codec === AudioStreamingCodecType.OPUS) { ffmpegArgs.push('-acodec', 'libopus', '-application', 'lowdelay'); } else { ffmpegArgs.push('-acodec', 'libfdk_aac', '-profile:a', 'aac_eld'); } ffmpegArgs.push( '-flags', '+global_header', '-f', 'null', '-ar', `${request.audio.sample_rate}k`, '-b:a', `${request.audio.max_bit_rate}k`, '-ac', `${request.audio.channel}`, '-payload_type', `${request.audio.pt}`, '-ssrc', `${sessionInfo.audioSSRC}`, '-f', 'rtp', '-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80', '-srtp_out_params', sessionInfo.audioSRTP.toString('base64'), `srtp://${sessionInfo.address}:${sessionInfo.audioPort}?rtcpport=${sessionInfo.audioPort}&pkt_size=188`, ); } else { this.camera.log.error(`Unsupported audio codec requested: ${request.audio.codec}`); } ffmpegArgs.push('-progress', 'pipe:1'); const activeSession: ActiveSession = {}; activeSession.socket = createSocket(sessionInfo.addressVersion === 'ipv6' ? 'udp6' : 'udp4'); activeSession.socket.on('error', (err: Error) => { this.camera.log.error('Socket error: ' + err.message); this.stopStream(request.sessionID); }); activeSession.socket.on('message', () => { if (activeSession.timeout) { clearTimeout(activeSession.timeout); } activeSession.timeout = setTimeout(() => { this.camera.log.info('Device appears to be inactive. Stopping stream.'); this.controller.forceStopStreamingSession(request.sessionID); this.stopStream(request.sessionID); }, request.video.rtcp_interval * 5 * 1000); }); activeSession.socket.bind(sessionInfo.videoIncomingPort); activeSession.mainProcess = new FfmpegStreamingProcess( request.sessionID, defaultFfmpegPath, ffmpegArgs, this.camera.log, this, callback, ); this.ongoingSessions[request.sessionID] = activeSession; delete this.pendingSessions[request.sessionID]; } private async fetchSnapshot(): Promise { if (!this.camera.device.online) { this.camera.log.debug('Device is currently offline.'); throw new Error('Device is currently offline.'); } // TODO: Check if there is a stream already running to fetch snapshot. const rtspUrl = await this.retrieveDeviceRTSP(); const ffmpegArgs = [ '-i', rtspUrl, '-frames:v', '1', '-hide_banner', '-loglevel', 'error', '-f', 'image2', '-', ]; return new Promise((resolve, reject) => { this.camera.log.debug(`Running Snapshot command: ${defaultFfmpegPath} ${ffmpegArgs.map(value => JSON.stringify(value)).join(' ')}`); const ffmpeg = spawn( defaultFfmpegPath, ffmpegArgs.map(x => x.toString()), { env: process.env }, ); let errors: string[] = []; let snapshotBuffer = Buffer.alloc(0); ffmpeg.stdout.on('data', (data) => { snapshotBuffer = Buffer.concat([snapshotBuffer, data]); }); ffmpeg.on('error', (error) => { this.camera.log.error(`FFmpeg process creation failed: ${error.message} - Showing "offline" image instead.`); reject('Failed to fetch snapshot.'); }); ffmpeg.stderr.on('data', (data) => { errors = errors.slice(-5); errors.push(data.toString().replace(/(\r\n|\n|\r)/gm, ' ')); }); ffmpeg.on('close', () => { if (snapshotBuffer.length > 0) { resolve(snapshotBuffer); } else { this.camera.log.error('Failed to fetch snapshot. Showing "offline" image instead.'); if (errors.length > 0) { this.camera.log.error(errors.join(' - ')); } reject('Unable to fetch snapshot.'); } }); }); } } ================================================ FILE: src/util/color.ts ================================================ import convert from 'color-convert'; import kelvinToRgb from 'kelvin-to-rgb'; export function kelvinToHSV(kevin: number) { const [r, g, b] = kelvinToRgb(kevin); const [h, s, v] = convert.rgb.hsv(r, g, b); return { h, s, v }; } // https://en.wikipedia.org/wiki/Mired export function kelvinToMired(kelvin: number) { return 1e6 / kelvin; } export function miredToKelvin(mired: number) { return 1e6 / mired; } ================================================ FILE: src/util/util.ts ================================================ export function remap( value: number, srcStart: number, srcEnd: number, dstStart: number, dstEnd: number, ) { const percent = (value - srcStart) / (srcEnd - srcStart); const result = percent * (dstEnd - dstStart) + dstStart; return result; } export function limit( value: number, start: number, end: number, ) { let result = value; result = Math.min(end, result); result = Math.max(start, result); return result; } ================================================ FILE: test/FanAccessory.test.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, test, jest, beforeEach } from '@jest/globals'; import { API, PlatformAccessory } from 'homebridge'; import FanAccessory from '../src/accessory/FanAccessory'; import TuyaDevice, { TuyaDeviceSchemaMode, TuyaDeviceSchemaType } from '../src/device/TuyaDevice'; import { TuyaPlatform } from '../src/platform'; const mockConfigureLight = jest.fn(); const mockConfigureOn = jest.fn(); jest.mock('../src/accessory/characteristic/Light', () => ({ configureLight: (...args: any[]) => mockConfigureLight(...args), })); jest.mock('../src/accessory/characteristic/On', () => ({ configureOn: (...args: any[]) => mockConfigureOn(...args), })); jest.mock('../src/accessory/characteristic/Active'); jest.mock('../src/accessory/characteristic/RotationSpeed'); jest.mock('../src/accessory/characteristic/SwingMode'); jest.mock('../src/accessory/characteristic/LockPhysicalControls'); describe('FanAccessory', () => { let mockPlatform: any; let mockAccessory: any; let mockAPI: any; let mockDeviceManager: any; beforeEach(() => { mockConfigureLight.mockClear(); mockConfigureOn.mockClear(); mockAPI = { hap: { Service: { Fan: jest.fn(), Fanv2: jest.fn(), Lightbulb: jest.fn(), Switch: jest.fn(), AccessoryInformation: jest.fn(), }, Characteristic: { On: jest.fn(), Active: jest.fn(), RotationSpeed: jest.fn(), RotationDirection: jest.fn(), Brightness: jest.fn(), }, uuid: { generate: jest.fn(() => 'mock-uuid'), }, }, user: { persistPath: jest.fn(() => '/mock/path'), }, } as unknown as API; mockDeviceManager = { getDevice: jest.fn(), sendCommands: jest.fn(), }; mockPlatform = { api: mockAPI, Service: mockAPI.hap.Service, Characteristic: mockAPI.hap.Characteristic, log: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), }, options: { debug: false, debugLevel: '', }, deviceManager: mockDeviceManager, getDeviceConfig: jest.fn(() => undefined), getDeviceSchemaConfig: jest.fn(() => undefined), } as unknown as TuyaPlatform; mockAccessory = { UUID: 'mock-uuid', displayName: 'Test Fan', context: { deviceID: 'test-device-id', }, services: [], getService: jest.fn((name: string) => { return mockAccessory.services.find((s: any) => s.displayName === name || s.UUID === name); }), addService: jest.fn((serviceType: any, name?: string, subtype?: string) => { const service = { UUID: subtype || name || 'mock-service', displayName: name || 'Mock Service', subtype: subtype, getCharacteristic: jest.fn(() => ({ onGet: jest.fn().mockReturnThis(), onSet: jest.fn().mockReturnThis(), setProps: jest.fn().mockReturnThis(), updateValue: jest.fn().mockReturnThis(), })), setCharacteristic: jest.fn().mockReturnThis(), }; mockAccessory.services.push(service); return service; }), removeService: jest.fn(), } as unknown as PlatformAccessory; }); function createMockDevice(codes: string[]): TuyaDevice { const schema = codes.map(code => ({ code, mode: TuyaDeviceSchemaMode.READ_WRITE, type: code.includes('bright') ? TuyaDeviceSchemaType.Integer : TuyaDeviceSchemaType.Boolean, property: code.includes('bright') ? { min: 10, max: 1000, scale: 0, step: 1 } : {}, })); const status = codes.map(code => ({ code, value: code.includes('bright') ? 500 : true, })); return new TuyaDevice({ id: 'test-device-id', uuid: 'test-uuid', name: 'Test Fan', online: true, owner_id: 'owner-1', product_id: 'fs', product_name: 'Smart Fan', category: 'fs', schema, status, }); } function setupAndConfigure(codes: string[]) { const device = createMockDevice(codes); mockDeviceManager.getDevice.mockReturnValue(device); const fanAccessory = new FanAccessory(mockPlatform, mockAccessory); fanAccessory.configureServices(); return fanAccessory; } function getDualLightServices() { return mockAccessory.addService.mock.calls.filter((call: any[]) => call[1] === 'Warm Light' || call[1] === 'White Light', ); } function expectNoDualLight() { expect(getDualLightServices().length).toBe(0); } function expectLightCall(index: number, onCode: string, brightCode?: string) { const args = mockConfigureLight.mock.calls[index]; expect(args[2]).toEqual(expect.objectContaining({ code: onCode })); if (brightCode) { expect(args[3]).toEqual(expect.objectContaining({ code: brightCode })); } else { expect(args[3]).toBeUndefined(); } } // ─── Fan service type ───────────────────────────────────────────── describe('fan service type', () => { test('should use Fanv2 when child_lock present', () => { const fanAccessory = setupAndConfigure(['switch', 'fan_speed', 'child_lock']); expect(fanAccessory.fanServiceType()).toBe(mockAPI.hap.Service.Fanv2); }); test('should use Fanv2 when swing present', () => { const fanAccessory = setupAndConfigure(['switch', 'fan_speed', 'switch_horizontal']); expect(fanAccessory.fanServiceType()).toBe(mockAPI.hap.Service.Fanv2); }); test('should use Fan when no lock or swing present', () => { const fanAccessory = setupAndConfigure(['switch', 'fan_speed']); expect(fanAccessory.fanServiceType()).toBe(mockAPI.hap.Service.Fan); }); }); // ─── A. Dual-light path ─────────────────────────────────────────── // Requires ALL 4: light + bright_value + switch_led + bright_value_1 describe('dual-light (all 4 DPs present)', () => { test('light + bright_value + switch_led + bright_value_1', () => { setupAndConfigure([ 'switch', 'fan_speed', 'light', 'bright_value', 'switch_led', 'bright_value_1', ]); const lightServices = getDualLightServices(); expect(lightServices.length).toBe(2); expect(lightServices[0][1]).toBe('Warm Light'); expect(lightServices[0][2]).toBe('warm_light'); expect(lightServices[1][1]).toBe('White Light'); expect(lightServices[1][2]).toBe('white_light'); expect(mockConfigureLight).toHaveBeenCalledTimes(2); expectLightCall(0, 'light', 'bright_value'); expectLightCall(1, 'switch_led', 'bright_value_1'); }); test('dual-light with extra bright_value_v2 (ignored)', () => { setupAndConfigure([ 'switch', 'fan_speed', 'light', 'bright_value', 'bright_value_v2', 'switch_led', 'bright_value_1', ]); expect(getDualLightServices().length).toBe(2); expect(mockConfigureLight).toHaveBeenCalledTimes(2); expectLightCall(0, 'light', 'bright_value'); expectLightCall(1, 'switch_led', 'bright_value_1'); }); test('dual-light with extra temp_value (not passed to dual-light configureLight)', () => { setupAndConfigure([ 'switch', 'fan_speed', 'light', 'bright_value', 'switch_led', 'bright_value_1', 'temp_value', ]); expect(getDualLightServices().length).toBe(2); expect(mockConfigureLight).toHaveBeenCalledTimes(2); // Dual-light calls only pass on+bright, not temp/color/mode expect(mockConfigureLight.mock.calls[0].length).toBe(4); expect(mockConfigureLight.mock.calls[1].length).toBe(4); }); }); // ─── B. Single-light Lightbulb path ─────────────────────────────── // Has LIGHT_ON match + at least one of: bright_value/v2, temp_value/v2, colour_data, work_mode describe('single-light as Lightbulb', () => { test('light + bright_value', () => { setupAndConfigure(['switch', 'fan_speed', 'light', 'bright_value']); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'light', 'bright_value'); }); test('light + bright_value_v2', () => { setupAndConfigure(['switch', 'fan_speed', 'light', 'bright_value_v2']); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'light', 'bright_value_v2'); }); test('switch_led + bright_value', () => { setupAndConfigure(['switch', 'fan_speed', 'switch_led', 'bright_value']); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'switch_led', 'bright_value'); }); test('switch_led + bright_value_v2', () => { setupAndConfigure(['switch', 'fan_speed', 'switch_led', 'bright_value_v2']); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'switch_led', 'bright_value_v2'); }); test('light + bright_value + both bright versions (prefers bright_value)', () => { setupAndConfigure(['switch', 'fan_speed', 'light', 'bright_value', 'bright_value_v2']); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'light', 'bright_value'); }); test('light + temp_value only (no brightness)', () => { setupAndConfigure(['switch', 'fan_speed', 'light', 'temp_value']); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); const args = mockConfigureLight.mock.calls[0]; expect(args[2]).toEqual(expect.objectContaining({ code: 'light' })); expect(args[3]).toBeUndefined(); // no brightness DP expect(args[4]).toEqual(expect.objectContaining({ code: 'temp_value' })); }); test('switch_led + temp_value_v2 only', () => { setupAndConfigure(['switch', 'fan_speed', 'switch_led', 'temp_value_v2']); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); const args = mockConfigureLight.mock.calls[0]; expect(args[2]).toEqual(expect.objectContaining({ code: 'switch_led' })); expect(args[4]).toEqual(expect.objectContaining({ code: 'temp_value_v2' })); }); test('light + bright_value + temp_value + colour_data + work_mode (full feature set)', () => { setupAndConfigure([ 'switch', 'fan_speed', 'light', 'bright_value', 'temp_value', 'colour_data', 'work_mode', ]); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); const args = mockConfigureLight.mock.calls[0]; expect(args[2]).toEqual(expect.objectContaining({ code: 'light' })); expect(args[3]).toEqual(expect.objectContaining({ code: 'bright_value' })); expect(args[4]).toEqual(expect.objectContaining({ code: 'temp_value' })); expect(args[5]).toEqual(expect.objectContaining({ code: 'colour_data' })); expect(args[6]).toEqual(expect.objectContaining({ code: 'work_mode' })); }); test('both on/off DPs + switch_led fallback: prefers light (LIGHT_ON order)', () => { setupAndConfigure(['switch', 'fan_speed', 'light', 'switch_led', 'bright_value']); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'light', 'bright_value'); }); }); // ─── C. Single-light Switch path ────────────────────────────────── // Has LIGHT_ON match but NO brightness/temp/color/mode DPs describe('single-light as Switch (on/off only)', () => { test('light only', () => { setupAndConfigure(['switch', 'fan_speed', 'light']); expectNoDualLight(); expect(mockConfigureLight).not.toHaveBeenCalled(); expect(mockConfigureOn).toHaveBeenCalledWith( expect.anything(), undefined, expect.objectContaining({ code: 'light' }), ); }); test('switch_led only', () => { setupAndConfigure(['switch', 'fan_speed', 'switch_led']); expectNoDualLight(); expect(mockConfigureLight).not.toHaveBeenCalled(); expect(mockConfigureOn).toHaveBeenCalledWith( expect.anything(), undefined, expect.objectContaining({ code: 'switch_led' }), ); }); test('light + switch_led, no brightness (prefers light)', () => { setupAndConfigure(['switch', 'fan_speed', 'light', 'switch_led']); expectNoDualLight(); expect(mockConfigureLight).not.toHaveBeenCalled(); expect(mockConfigureOn).toHaveBeenCalledWith( expect.anything(), undefined, expect.objectContaining({ code: 'light' }), ); }); }); // ─── D. No light ────────────────────────────────────────────────── describe('no light at all', () => { test('fan-only device', () => { setupAndConfigure(['switch', 'fan_speed']); expectNoDualLight(); expect(mockConfigureLight).not.toHaveBeenCalled(); }); test('brightness DPs without any on/off DP are ignored', () => { setupAndConfigure(['switch', 'fan_speed', 'bright_value']); expectNoDualLight(); expect(mockConfigureLight).not.toHaveBeenCalled(); }); test('bright_value_1 alone is ignored', () => { setupAndConfigure(['switch', 'fan_speed', 'bright_value_1']); expectNoDualLight(); expect(mockConfigureLight).not.toHaveBeenCalled(); }); }); // ─── E. Edge cases: dual-light guard must NOT match ──────────────── // These are the critical backward-compatibility scenarios. // Each has 3 of the 4 required DPs but not the 4th, // so must fall through to the single-light path. describe('partial dual-light DPs (must NOT trigger dual-light)', () => { test('missing bright_value_1: light + bright_value + switch_led', () => { setupAndConfigure([ 'switch', 'fan_speed', 'light', 'bright_value', 'switch_led', ]); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'light', 'bright_value'); }); test('missing switch_led: light + bright_value + bright_value_1', () => { setupAndConfigure([ 'switch', 'fan_speed', 'light', 'bright_value', 'bright_value_1', ]); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'light', 'bright_value'); }); test('missing bright_value: light + switch_led + bright_value_1', () => { setupAndConfigure([ 'switch', 'fan_speed', 'light', 'switch_led', 'bright_value_1', ]); expectNoDualLight(); // lightServiceType: no bright_value/v2, no temp, no color, no mode → Switch expect(mockConfigureLight).not.toHaveBeenCalled(); expect(mockConfigureOn).toHaveBeenCalledWith( expect.anything(), undefined, expect.objectContaining({ code: 'light' }), ); }); test('missing light: switch_led + bright_value + bright_value_1', () => { setupAndConfigure([ 'switch', 'fan_speed', 'switch_led', 'bright_value', 'bright_value_1', ]); expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'switch_led', 'bright_value'); }); test('has bright_value_v2 instead of bright_value: light + bright_value_v2 + switch_led + bright_value_1', () => { setupAndConfigure([ 'switch', 'fan_speed', 'light', 'bright_value_v2', 'switch_led', 'bright_value_1', ]); // bright_value is MISSING so dual-light guard fails expectNoDualLight(); expect(mockConfigureLight).toHaveBeenCalledTimes(1); expectLightCall(0, 'light', 'bright_value_v2'); }); }); }); ================================================ FILE: test/Light.test.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, test, jest, beforeEach } from '@jest/globals'; import { configureLight } from '../src/accessory/characteristic/Light'; describe('configureLight - ON handler bundles brightness', () => { let mockAccessory: any; let mockService: any; let handlers: Record; beforeEach(() => { handlers = {}; const makeCharChain = (name: string) => ({ onGet: jest.fn(function (this: any, handler: Function) { handlers[name] = handlers[name] || {}; handlers[name].onGet = handler; return this; }), onSet: jest.fn(function (this: any, handler: Function) { handlers[name] = handlers[name] || {}; handlers[name].onSet = handler; return this; }), setProps: jest.fn().mockReturnThis(), updateValue: jest.fn().mockReturnThis(), }); const onChar = makeCharChain('On'); const brightChar = makeCharChain('Brightness'); mockService = { getCharacteristic: jest.fn((charType: any) => { if (charType === 'MockOn') return onChar; if (charType === 'MockBrightness') return brightChar; return makeCharChain('other'); }), }; mockAccessory = { Characteristic: { On: 'MockOn', Brightness: 'MockBrightness' }, Service: { Lightbulb: 'MockLightbulb' }, accessory: { displayName: 'Test Light', getService: jest.fn(() => null), addService: jest.fn(() => mockService), }, log: { info: jest.fn(), debug: jest.fn(), warn: jest.fn() }, platform: { getDeviceConfig: jest.fn(() => undefined) }, checkOnlineStatus: jest.fn(), getStatus: jest.fn((code: string) => { if (code === 'light') return { code: 'light', value: true }; if (code === 'bright_value') return { code: 'bright_value', value: 420 }; if (code === 'switch_led') return { code: 'switch_led', value: false }; if (code === 'bright_value_1') return { code: 'bright_value_1', value: 100 }; return undefined; }), sendCommands: jest.fn(), }; }); function makeSchema(code: string, type = 'Boolean', property: any = {}) { return { code, mode: 'rw', type, property }; } function brightSchema(code = 'bright_value') { return makeSchema(code, 'Integer', { min: 10, max: 1000, scale: 0, step: 1 }); } test('LightType.C: ON sends both on + cached brightness', async () => { configureLight( mockAccessory, mockService, makeSchema('light') as any, brightSchema() as any, ); expect(handlers['On']?.onSet).toBeDefined(); await handlers['On'].onSet!(true); expect(mockAccessory.sendCommands).toHaveBeenCalledWith( [ { code: 'light', value: true }, { code: 'bright_value', value: 420 }, ], true, ); }); test('LightType.C: OFF sends only the on command (no brightness)', async () => { configureLight( mockAccessory, mockService, makeSchema('light') as any, brightSchema() as any, ); await handlers['On'].onSet!(false); expect(mockAccessory.sendCommands).toHaveBeenCalledWith( [{ code: 'light', value: false }], true, ); }); test('dual-light warm channel: ON bundles warm brightness', async () => { configureLight( mockAccessory, mockService, makeSchema('light') as any, brightSchema('bright_value') as any, ); await handlers['On'].onSet!(true); expect(mockAccessory.sendCommands).toHaveBeenCalledWith( [ { code: 'light', value: true }, { code: 'bright_value', value: 420 }, ], true, ); }); test('dual-light white channel: ON bundles white brightness', async () => { configureLight( mockAccessory, mockService, makeSchema('switch_led') as any, brightSchema('bright_value_1') as any, ); await handlers['On'].onSet!(true); expect(mockAccessory.sendCommands).toHaveBeenCalledWith( [ { code: 'switch_led', value: true }, { code: 'bright_value_1', value: 100 }, ], true, ); }); test('brightness-only set does not include on command', async () => { configureLight( mockAccessory, mockService, makeSchema('light') as any, brightSchema() as any, ); await handlers['Brightness'].onSet!(42); expect(mockAccessory.sendCommands).toHaveBeenCalledWith( [{ code: 'bright_value', value: 420 }], true, ); }); test('ON without brightness schema does not crash', async () => { configureLight( mockAccessory, mockService, makeSchema('light') as any, ); expect(handlers['On']?.onSet).toBeDefined(); await handlers['On'].onSet!(true); expect(mockAccessory.sendCommands).toHaveBeenCalledWith( [{ code: 'light', value: true }], true, ); }); }); ================================================ FILE: test/custom.test.ts ================================================ /* eslint-disable no-console */ import { describe, expect, test } from '@jest/globals'; import TuyaOpenAPI from '../src/core/TuyaOpenAPI'; import TuyaDevice from '../src/device/TuyaDevice'; import TuyaCustomDeviceManager from '../src/device/TuyaCustomDeviceManager'; import { config, expectDevice, expectSuccessResponse } from './util'; const { options } = config; if (options.projectType === '1') { const api = new TuyaOpenAPI(options.endpoint, options.accessId, options.accessKey); const deviceManager = new TuyaCustomDeviceManager(api); describe('TuyaOpenAPI', () => { test('getToken()', async () => { const res = await api.getToken(); expectSuccessResponse(res); }); test('customGetUserInfo()', async () => { const res = await api.customGetUserInfo('homebridge'); expectSuccessResponse(res); }); test('customCreateUser()', async () => { const res = await api.customCreateUser('homebridge', 'homebridge'); if (res.success === false && res.code === 14520015) { // already exist } else { expectSuccessResponse(res); } }); test('customLogin()', async () => { const res = await api.customLogin('homebridge', 'homebridge'); expectSuccessResponse(res); expect(api.isLogin()).toBeTruthy(); }); test('_refreshAccessTokenIfNeed()', async () => { api.tokenInfo.expire = 0; await api._refreshAccessTokenIfNeed(''); }); }); describe('TuyaCustomDeviceManager', () => { const assetIDList: string[] = []; test('getAssetList()', async () => { const res = await deviceManager.getAssetList(); expectSuccessResponse(res); const assets = res.result.list || res.result.assets; for (const { asset_id } of assets) { assetIDList.push(asset_id); } }); test('updateDevices()', async () => { const devices = await deviceManager.updateDevices(assetIDList); expect(devices).not.toBeNull(); for (const device of devices) { expectDevice(device); } }, 30 * 1000); test('updateDevice()', async () => { let device: TuyaDevice | null = Array.from(deviceManager.devices)[0]; expectDevice(device); device = await deviceManager.updateDevice(device.id); expectDevice(device!); }); }); describe('TuyaOpenMQ', () => { test('start()', async () => { await new Promise((resolve, reject) => { deviceManager.mq._onConnect = () => { console.log('TuyaOpenMQ connected'); resolve(null); }; deviceManager.mq._onError = (err) => { console.log('TuyaOpenMQ error:', err); reject(err); }; deviceManager.mq.start(); }); }); test('stop()', async () => { deviceManager.mq.stop(); }); }); } else { test('', async () => { // }); } ================================================ FILE: test/home.test.ts ================================================ /* eslint-disable no-console */ import { describe, expect, test } from '@jest/globals'; import TuyaOpenAPI from '../src/core/TuyaOpenAPI'; import TuyaDevice from '../src/device/TuyaDevice'; import TuyaHomeDeviceManager from '../src/device/TuyaHomeDeviceManager'; import { config, expectDevice, expectSuccessResponse } from './util'; const { options } = config; if (options.projectType === '2') { const api = new TuyaOpenAPI(TuyaOpenAPI.Endpoints.CHINA, options.accessId, options.accessKey); const deviceManager = new TuyaHomeDeviceManager(api); describe('TuyaOpenAPI', () => { test('homeLogin()', async () => { await api.homeLogin(options.countryCode, options.username, options.password, options.appSchema); expect(api.isLogin()).toBeTruthy(); }); test('_refreshAccessTokenIfNeed()', async () => { api.tokenInfo.expire = 0; await api._refreshAccessTokenIfNeed(''); }); }); describe('TuyaHomeDeviceManager', () => { const homeIDList: number[] = []; test('getHomeList()', async () => { const res = await deviceManager.getHomeList(); expectSuccessResponse(res); for (const { home_id } of res.result) { homeIDList.push(home_id); } }); test('updateDevices()', async () => { const devices = await deviceManager.updateDevices(homeIDList); expect(devices).not.toBeNull(); for (const device of devices) { expectDevice(device); } }, 30 * 1000); test('updateDevice()', async () => { let device: TuyaDevice | null = Array.from(deviceManager.devices)[0]; expectDevice(device); device = await deviceManager.updateDevice(device.id); expectDevice(device!); }); }); describe('TuyaOpenMQ', () => { test('start()', async () => { await new Promise((resolve, reject) => { deviceManager.mq._onConnect = () => { console.log('TuyaOpenMQ connected'); resolve(null); }; deviceManager.mq._onError = (err) => { console.log('TuyaOpenMQ error:', err); reject(err); }; deviceManager.mq.start(); }); }); test('stop()', async () => { deviceManager.mq.stop(); }); }); } else { test('', async () => { // }); } ================================================ FILE: test/util.ts ================================================ import fs from 'fs'; import { PLATFORM_NAME } from '../src/settings'; import { TuyaPlatformConfig } from '../src/config'; import TuyaDevice, { TuyaDeviceSchema } from '../src/device/TuyaDevice'; import { TuyaOpenAPIResponse } from '../src/core/TuyaOpenAPI'; const file = fs.readFileSync(`${process.env.HOME}/.homebridge-dev/config.json`); const { platforms } = JSON.parse(file.toString()); export const config: TuyaPlatformConfig = platforms.find(platform => platform.platform === PLATFORM_NAME); export function expectDevice(device: TuyaDevice) { // console.debug(JSON.stringify(device)); expect(device).not.toBeUndefined(); expect(device.id.length).toBeGreaterThan(0); expect(device.uuid.length).toBeGreaterThan(0); expect(device.online).toBeDefined(); expect(device.product_id.length).toBeGreaterThan(0); expect(device.category.length).toBeGreaterThan(0); for (const schema of device.schema) { expectDeviceSchema(schema); } expect(device.status).toBeDefined(); } export function expectDeviceSchema(schema: TuyaDeviceSchema) { expect(schema.code.length).toBeGreaterThan(0); expect(schema.mode.length).toBeGreaterThan(0); expect(schema.type.length).toBeGreaterThan(0); expect(schema.property).toBeDefined(); } export function expectSuccessResponse(res: TuyaOpenAPIResponse) { expect(res.success).toBeTruthy(); } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2018", // ~node10 "module": "commonjs", "lib": [ "es2015", "es2016", "es2017", "es2018" ], "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "noImplicitAny": false, "resolveJsonModule": true, "allowJs": true }, "include": [ "src/" ], "exclude": [ "**/*.spec.ts" ] }