├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── Adding-a-new-device.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Overview.md ├── README.md ├── build.sh ├── firmware └── CC2531-dev-272.hex ├── generate-config-interfaces.js ├── manifest.json ├── package-lock.json ├── package.json ├── package.sh ├── src ├── constants.ts ├── driver │ ├── conbee.js │ ├── index.js │ ├── xbee.js │ └── zstack.js ├── index.js ├── zb-adapter.js ├── zb-at.js ├── zb-classifier.js ├── zb-constants.ts ├── zb-debug.ts ├── zb-families.ts ├── zb-family.ts ├── zb-node.js ├── zb-property.js ├── zb-xiaomi.ts └── zigbee2mqtt │ ├── zigbee2mqtt-adapter.ts │ ├── zigbee2mqtt-device.ts │ ├── zigbee2mqtt-driver.ts │ └── zigbee2mqtt-property.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /.eslintrc.js 2 | config.d.ts 3 | lib 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'commonjs': true, 5 | 'es6': true, 6 | 'jasmine': true, 7 | 'jest': true, 8 | 'mocha': true, 9 | 'node': true 10 | }, 11 | 'extends': [ 12 | 'eslint:recommended', 13 | 'plugin:@typescript-eslint/eslint-recommended', 14 | 'plugin:@typescript-eslint/recommended', 15 | 'prettier', 16 | 'prettier/@typescript-eslint' 17 | ], 18 | 'parser': '@typescript-eslint/parser', 19 | 'parserOptions': { 20 | 'sourceType': 'module' 21 | }, 22 | 'plugins': [ 23 | '@typescript-eslint' 24 | ], 25 | 'rules': { 26 | 'arrow-parens': [ 27 | 'error', 28 | 'always' 29 | ], 30 | 'arrow-spacing': 'error', 31 | 'block-scoped-var': 'error', 32 | 'block-spacing': [ 33 | 'error', 34 | 'always' 35 | ], 36 | '@typescript-eslint/brace-style': [ 37 | 'error', 38 | '1tbs' 39 | ], 40 | '@typescript-eslint/comma-dangle': [ 41 | 'error', 42 | 'always-multiline' 43 | ], 44 | '@typescript-eslint/comma-spacing': 'error', 45 | 'comma-style': [ 46 | 'error', 47 | 'last' 48 | ], 49 | 'computed-property-spacing': [ 50 | 'error', 51 | 'never' 52 | ], 53 | 'curly': 'error', 54 | '@typescript-eslint/default-param-last': 'error', 55 | 'dot-notation': 'error', 56 | 'eol-last': 'error', 57 | '@typescript-eslint/explicit-module-boundary-types': [ 58 | 'warn', 59 | { 60 | 'allowArgumentsExplicitlyTypedAsAny': true 61 | } 62 | ], 63 | '@typescript-eslint/explicit-function-return-type': [ 64 | 'error', 65 | { 66 | 'allowExpressions': true 67 | } 68 | ], 69 | '@typescript-eslint/func-call-spacing': [ 70 | 'error', 71 | 'never' 72 | ], 73 | '@typescript-eslint/indent': [ 74 | 'error', 75 | 2, 76 | { 77 | 'ArrayExpression': 'first', 78 | 'CallExpression': { 79 | 'arguments': 'first' 80 | }, 81 | 'FunctionDeclaration': { 82 | 'parameters': 'first' 83 | }, 84 | 'FunctionExpression': { 85 | 'parameters': 'first' 86 | }, 87 | 'ObjectExpression': 'first', 88 | 'SwitchCase': 1 89 | } 90 | ], 91 | 'key-spacing': [ 92 | 'error', 93 | { 94 | 'afterColon': true, 95 | 'beforeColon': false, 96 | 'mode': 'strict' 97 | } 98 | ], 99 | '@typescript-eslint/keyword-spacing': 'off', 100 | 'linebreak-style': [ 101 | 'error', 102 | 'unix' 103 | ], 104 | '@typescript-eslint/lines-between-class-members': [ 105 | 'error', 106 | 'always' 107 | ], 108 | 'max-len': [ 109 | 'error', 110 | 100 111 | ], 112 | '@typescript-eslint/member-delimiter-style': [ 113 | 'error', 114 | { 115 | 'singleline': { 116 | 'delimiter': 'semi', 117 | 'requireLast': false 118 | }, 119 | 'multiline': { 120 | 'delimiter': 'semi', 121 | 'requireLast': true 122 | } 123 | } 124 | ], 125 | 'multiline-ternary': [ 126 | 'error', 127 | 'always-multiline' 128 | ], 129 | 'no-console': 0, 130 | '@typescript-eslint/no-duplicate-imports': 'error', 131 | 'no-eval': 'error', 132 | '@typescript-eslint/no-explicit-any': [ 133 | 'error', 134 | { 135 | 'ignoreRestArgs': true 136 | } 137 | ], 138 | 'no-floating-decimal': 'error', 139 | 'no-implicit-globals': 'error', 140 | 'no-implied-eval': 'error', 141 | 'no-lonely-if': 'error', 142 | 'no-multi-spaces': [ 143 | 'error', 144 | { 145 | 'ignoreEOLComments': true 146 | } 147 | ], 148 | 'no-multiple-empty-lines': 'error', 149 | '@typescript-eslint/no-namespace': [ 150 | 'error', 151 | { 152 | 'allowDeclarations': true 153 | } 154 | ], 155 | '@typescript-eslint/no-non-null-assertion': 'off', 156 | 'no-prototype-builtins': 'off', 157 | 'no-return-assign': 'error', 158 | 'no-script-url': 'error', 159 | 'no-self-compare': 'error', 160 | 'no-sequences': 'error', 161 | 'no-shadow-restricted-names': 'error', 162 | 'no-tabs': 'error', 163 | 'no-throw-literal': 'error', 164 | 'no-trailing-spaces': 'error', 165 | 'no-undefined': 'error', 166 | 'no-unmodified-loop-condition': 'error', 167 | '@typescript-eslint/no-unused-vars': [ 168 | 'error', 169 | { 170 | 'argsIgnorePattern': '^_', 171 | 'varsIgnorePattern': '^_' 172 | } 173 | ], 174 | 'no-useless-computed-key': 'error', 175 | 'no-useless-concat': 'error', 176 | '@typescript-eslint/no-useless-constructor': 'error', 177 | 'no-useless-return': 'error', 178 | 'no-var': 'error', 179 | 'no-void': 'error', 180 | 'no-whitespace-before-property': 'error', 181 | 'object-curly-newline': [ 182 | 'error', 183 | { 184 | 'consistent': true 185 | } 186 | ], 187 | 'object-curly-spacing': [ 188 | 'error', 189 | 'always' 190 | ], 191 | 'object-property-newline': [ 192 | 'error', 193 | { 194 | 'allowMultiplePropertiesPerLine': true 195 | } 196 | ], 197 | 'operator-linebreak': [ 198 | 'error', 199 | 'after', 200 | { 201 | 'overrides': { 202 | '?': 'before', 203 | ':': 'before' 204 | } 205 | } 206 | ], 207 | 'padded-blocks': [ 208 | 'error', 209 | { 210 | 'blocks': 'never' 211 | } 212 | ], 213 | 'prefer-const': 'error', 214 | '@typescript-eslint/prefer-for-of': 'error', 215 | 'prefer-template': 'error', 216 | 'quote-props': [ 217 | 'error', 218 | 'as-needed' 219 | ], 220 | '@typescript-eslint/quotes': [ 221 | 'error', 222 | 'single', 223 | { 224 | 'allowTemplateLiterals': true 225 | } 226 | ], 227 | '@typescript-eslint/semi': [ 228 | 'error', 229 | 'always' 230 | ], 231 | 'semi-spacing': [ 232 | 'error', 233 | { 234 | 'after': true, 235 | 'before': false 236 | } 237 | ], 238 | 'semi-style': [ 239 | 'error', 240 | 'last' 241 | ], 242 | 'space-before-blocks': [ 243 | 'error', 244 | 'always' 245 | ], 246 | '@typescript-eslint/space-before-function-paren': [ 247 | 'error', 248 | { 249 | 'anonymous': 'always', 250 | 'asyncArrow': 'always', 251 | 'named': 'never' 252 | } 253 | ], 254 | 'space-in-parens': [ 255 | 'error', 256 | 'never' 257 | ], 258 | '@typescript-eslint/space-infix-ops': 'error', 259 | 'space-unary-ops': [ 260 | 'error', 261 | { 262 | 'nonwords': false, 263 | 'words': true 264 | } 265 | ], 266 | 'spaced-comment': [ 267 | 'error', 268 | 'always', 269 | { 270 | 'block': { 271 | 'balanced': true, 272 | 'exceptions': [ 273 | '*' 274 | ] 275 | } 276 | } 277 | ], 278 | 'switch-colon-spacing': [ 279 | 'error', 280 | { 281 | 'after': true, 282 | 'before': false 283 | } 284 | ], 285 | 'template-curly-spacing': [ 286 | 'error', 287 | 'never' 288 | ], 289 | '@typescript-eslint/type-annotation-spacing': 'error', 290 | 'yoda': 'error' 291 | }, 292 | 'overrides': [ 293 | { 294 | 'files': [ 295 | '**/*.js' 296 | ], 297 | 'rules': { 298 | '@typescript-eslint/explicit-function-return-type': 'off', 299 | '@typescript-eslint/no-var-requires': 'off' 300 | } 301 | } 302 | ] 303 | }; 304 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node_version: [ 17 | #10, 18 | #12, 19 | #14, 20 | 20, 21 | ] 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node_version }} 27 | - name: Install dependencies 28 | run: | 29 | npm ci 30 | - name: Check formatting 31 | run: | 32 | npx prettier -u -c src 33 | - name: Lint with eslint 34 | run: | 35 | npm run lint 36 | - name: Check build 37 | run: | 38 | npm run build 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | create-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Create Release 13 | id: create_release 14 | uses: actions/create-release@v1.0.0 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | with: 18 | tag_name: ${{ github.ref }} 19 | release_name: Release ${{ github.ref }} 20 | draft: false 21 | prerelease: false 22 | - name: Dump upload url to file 23 | run: echo '${{ steps.create_release.outputs.upload_url }}' > upload_url 24 | - name: Upload upload_url 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: upload_url 28 | path: upload_url 29 | 30 | build: 31 | needs: create-release 32 | strategy: 33 | matrix: 34 | platform: [ 35 | 'linux-arm', 36 | 'linux-arm64', 37 | 'linux-x64', 38 | ] 39 | pair: [ 40 | #'node:10', 41 | #'node:12', 42 | #'node:14', 43 | 'node:20', 44 | ] 45 | include: 46 | - platform: 'linux-arm' 47 | host-os: 'ubuntu-latest' 48 | - platform: 'linux-arm64' 49 | host-os: 'ubuntu-latest' 50 | - platform: 'linux-x64' 51 | host-os: 'ubuntu-latest' 52 | #- pair: 'node:10' 53 | # language: 'node' 54 | # version: '10' 55 | #- pair: 'node:12' 56 | # language: 'node' 57 | # version: '12' 58 | #- pair: 'node:14' 59 | # language: 'node' 60 | # version: '14' 61 | - pair: 'node:20' 62 | language: 'node' 63 | version: '20' 64 | 65 | runs-on: ${{ matrix.host-os }} 66 | 67 | steps: 68 | - name: Download upload_url 69 | uses: actions/download-artifact@v4 70 | with: 71 | name: upload_url 72 | - name: Display structure of downloaded files 73 | run: ls -R 74 | - name: Set upload_url 75 | run: echo "UPLOAD_URL=$(cat upload_url)" >> $GITHUB_ENV 76 | - name: Set release version 77 | run: echo "RELEASE_VERSION=${GITHUB_REF:10}" >> $GITHUB_ENV 78 | - uses: actions/checkout@v2 79 | - name: Use Node.js ${{ matrix.version }} 80 | uses: actions/setup-node@v4 81 | with: 82 | node-version: ${{ matrix.version }} 83 | - name: Build adapter 84 | run: | 85 | ./build.sh "${{ matrix.platform }}" "${{ matrix.language }}" "${{ matrix.version }}" 86 | - name: Upload Release Asset 87 | id: upload-release-asset 88 | uses: actions/upload-release-asset@v1.0.1 89 | env: 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | with: 92 | upload_url: ${{ env.UPLOAD_URL }} 93 | asset_path: zigbee-adapter-${{ env.RELEASE_VERSION }}-${{ matrix.platform }}-v${{ matrix.version }}.tgz 94 | asset_name: zigbee-adapter-${{ env.RELEASE_VERSION }}-${{ matrix.platform }}-v${{ matrix.version }}.tgz 95 | asset_content_type: application/gnutar 96 | - name: Upload Release Asset Checksum 97 | id: upload-release-asset-checksum 98 | uses: actions/upload-release-asset@v1.0.1 99 | env: 100 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 101 | with: 102 | upload_url: ${{ env.UPLOAD_URL }} 103 | asset_path: zigbee-adapter-${{ env.RELEASE_VERSION }}-${{ matrix.platform }}-v${{ matrix.version }}.tgz.sha256sum 104 | asset_name: zigbee-adapter-${{ env.RELEASE_VERSION }}-${{ matrix.platform }}-v${{ matrix.version }}.tgz.sha256sum 105 | asset_content_type: text/plain 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sha256sum 2 | *.swp 3 | *.tgz 4 | *~ 5 | SHA256SUMS 6 | lib 7 | node_modules 8 | src/config.d.ts 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /Adding-a-new-device.md: -------------------------------------------------------------------------------- 1 | ## Adding a new device 2 | 3 | Use the cli (described in the zwave adding a new device document). 4 | 5 | You can then use the discover command on the cli to trigger attribute 6 | discovery. The output will be visible in the gateway log. 7 | 8 | For example, with a sylvania 2-button switch: 9 | 10 | ``` 11 | cli https://192.168.1.20:4443 zb-8418260000e8f328> discover 1 0402 12 | ``` 13 | will cause the following to be displayed in the log: 14 | ``` 15 | 2019-10-30 16:53:44.790 INFO : zigbee: discover: **** Starting discovery for node: zb-8418260000e8f328 endpointNum: 1 clusterId: 0402 ***** 16 | 2019-10-30 16:53:44.791 INFO : zigbee: discover: ModelId: 3130 17 | 2019-10-30 16:53:45.045 INFO : zigbee: discover: AttrId: tolerance ( 3) dataType: uint16 (33) data: 200 18 | 2019-10-30 16:53:45.046 INFO : zigbee: discover: Endpoint 1 ProfileID: 0104 19 | 2019-10-30 16:53:45.046 INFO : zigbee: discover: Endpoint 1 DeviceID: 0001 20 | 2019-10-30 16:53:45.046 INFO : zigbee: discover: Endpoint 1 DeviceVersion: 0 21 | 2019-10-30 16:53:45.046 INFO : zigbee: discover: Input clusters for endpoint 1 22 | 2019-10-30 16:53:45.047 INFO : zigbee: discover: 0402 - msTemperatureMeasurement 23 | 2019-10-30 16:53:46.013 INFO : zigbee: discover: AttrId: measuredValue ( 0) dataType: int16 (41) data: 12430 24 | 2019-10-30 16:53:46.522 INFO : zigbee: discover: AttrId: minMeasuredValue ( 1) dataType: int16 (41) data: -4000 25 | 2019-10-30 16:53:47.019 INFO : zigbee: discover: AttrId: maxMeasuredValue ( 2) dataType: int16 (41) data: 12500 26 | 2019-10-30 16:53:47.514 INFO : zigbee: discover: AttrId: tolerance ( 3) dataType: uint16 (33) data: 200 27 | 2019-10-30 16:53:47.515 INFO : zigbee: discover: Output clusters for endpoint 1 28 | 2019-10-30 16:53:47.515 INFO : zigbee: discover: ***** Discovery done for node: zb-8418260000e8f328 ***** 29 | ``` 30 | 31 | Note that since the device is battery powered you may need to do the 32 | discovery very quickly after interacting with the device (while it's still 33 | awake) otherwise the device won't see the discover request. 34 | 35 | The output of the discovery should show which attrbutes are supported for 36 | a particular cluster. 37 | 38 | The clusterId member of https://github.com/dhylands/zcl-id/blob/master/definitions/common.json 39 | has the mapping of names to numbers for all of the "known" clusters. 40 | 41 | https://github.com/dhylands/zcl-id/blob/master/definitions/cluster_defs.json 42 | typically contains all of the attributes available for each cluster. 43 | I've had to modify these files, which is why we're using a fork. 44 | 45 | Any given device will only support a subset of the available attributes. 46 | Some devices don't respond to discovery for some clusters and do for other 47 | clusters. 48 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | 10 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Overview.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | Zigbee devices each have a unique 64-bit ID, which is used by the 4 | adapter to identify devices. Once a network is setup, each device 5 | gets dynamically assigned a 16-bit network address. This 16-bit network 6 | address is what's used for sending/receiving packets. The 16-bit address 7 | can change over time. 8 | 9 | Each device has a collection of clusters, and each cluster has a collection 10 | of attributes. Usually the type of device can be deduced by looking at 11 | the supported clusters, but often more detailed information is needed 12 | (to say differentiate between a colored light and a simple on/off light). 13 | 14 | ## Basic Flow 15 | 16 | The serial prober probes all of the USB serial ports looking for a 17 | Zigbee dongle. When one is found, the appropriate driver is instantiated. 18 | 19 | The driver initializes the dongle, and then once completed, calls 20 | adapterInitialized, which starts the stack proper. 21 | 22 | The zigbee adapter then initializes known nodes from the zb-XXXXXX.json file 23 | (where XXXXXX is the 64-bit ID of the zigbee dongle), and does a scan 24 | of immediate neighbors. 25 | 26 | If there is still information to be collected from a device, then messages 27 | will be sent to continue that process. 28 | 29 | The information collected from each device consists of the following: 30 | - A list of active endpoints 31 | - For each endpoint, a simple descriptor, which contains a list 32 | of input and output clusters. Input clusters are what the gateway 33 | normally uses to control a device. Output clusters are used by devices 34 | like the IKEA motion sensor when it wants to control a light. 35 | - Once the input and output clusters are known, the adpapter looks for 36 | various cluster attributes which the classifier needs in order to 37 | figure out the type of device it is. This includes the lighting color 38 | control (used for most lights) and IAS zone information (used by many 39 | alarm style sensors.) 40 | 41 | All of the information needed by the classifier should be persisted in 42 | the zb-XXXX.json file, so that we don't need to re-query each time the 43 | gateway starts up. This is especially important for battery powered 44 | devices which may only checkin once every couple of hours. 45 | 46 | Once all of the classifier attributes have been collected, the classifier 47 | is called. 48 | 49 | The classifier then looks at the information collected so far and determines 50 | what type of device it is, and which properties it should have. 51 | 52 | Generally speaking, each property will map to a single attribute. Sometimes 53 | a property will map to multiple attributes (like hue and saturation). 54 | 55 | Zigbee uses a process called binding and reporting to cause attribute 56 | updates to be reported back to the gateway. When adding properties, 57 | configReport information can be specified (how much change should cause a 58 | a report to be generated). ConfigReporting, in turn, 59 | causes bindings to be setup (the binding determines where the reports 60 | go). Many devices have limited resources to store the ConfigReporting 61 | information and will often send reports as a group of attributes. So far 62 | I've only noticed this being a real issue with the thermostats. This is 63 | why not every attribute has configReporting enabled. 64 | 65 | ## Notes 66 | 67 | The population of cluster attribute information takes place through 68 | ZigbeeAdapter::populateNodeInfo and populateXxxx 69 | 70 | These functions have a few important semantics. 71 | 1 - You should be able to call populateNodeInfo and it should only 72 | do something if something needs to be done. i.e. it should retain 73 | enough state to determine whether it still has work to do. 74 | 2 - Every call to read some data should have a callback and usually a 75 | timeoutFunc. 76 | The callback will be called when the readRsp completes and generally 77 | should wind up calling the populateXxx method again. 78 | 3 - The populateXxx methods need to protect themselves from issuing the 79 | same read over and over again. So for example looking at 80 | populateClassifierAttributes reading the zoneType. 81 | - if node.readingZoneType is set, then it should just return 82 | - Otherwise it should issue a read for the zoneType 83 | - the callback should clear readingZoneType and wind up 84 | calling back into populateClassifierAttributes (which it does 85 | indirectly through addIfReady) 86 | - the timeoutFunc should clear readingZoneType. This means that 87 | the node didn't respond, but when we hear from it again, 88 | populateNodeInfo will get called again and we'll requery the zoneType. 89 | 90 | ## ToDo 91 | 92 | Currently, there is only a single queue of outgoing commands, and 93 | sometimes this causes problems. The way the command queue works, there 94 | are often waits for a particular response is received before the next 95 | command can be sent. What can happen is that a single device which has 96 | decided to go quiet can "hang" the queue for a while until things timeout. 97 | 98 | It would be better if there were a queue for each device. Then a 99 | non-responding device wouldn't hang up other devices. 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zigbee Adapter 2 | 3 | Zigbee device adapter for WebThings Gateway. 4 | 5 | ## Compatibility 6 | 7 | This adapter is compatible with the following USB dongles: 8 | 9 | * Digi XStick2 (XU-Z11) 10 | 11 | * RaspBee module plugged into a Raspberry Pi 12 | * RaspBee module needs to be connected with UART PL011, not the mini UART, and 13 | be accesssible on /dev/ttyAMA0. 14 | * In `/boot/config.txt` add `dtoverlay=pi3-miniuart-bt` in section `[all]` 15 | * In `/boot/cmdline.txt` remove `console=tty1` to disable console on tty1 16 | * The `allowAMASerial` flag must be enabled via _Settings -> Add-ons -> Zigbee -> Configure_. 17 | * Notes on the UART config: [UART Configuration - Raspberry Pi Documentation](https://www.raspberrypi.org/documentation/configuration/uart.md) 18 | 19 | * ConBee Zigbee USB stick 20 | 21 | * ConBee II Zigbee USB stick 22 | 23 | * TI CC253x-based dongles 24 | 25 | * These must be flashed with the firmware in the `firmware` directory of 26 | this repository, following [these instructions](https://www.zigbee2mqtt.io/information/flashing_the_cc2531.html), 27 | or using [Raspberry Pi](https://lemariva.com/blog/2019/07/zigbee-flashing-cc2531-using-raspberry-pi-without-cc-debugger) 28 | * Last time [ITEAD](https://www.itead.cc/cc2531-usb-dongle.html) sells them 29 | preprogrammed with some ZNP firmware - probably working with this plugin. 30 | 31 | Additionally, the adapter can talk to one or more [Zigbee2MQTT](https://www.zigbee2mqtt.io/) instances over MQTT. 32 | The supported dongles are listed [here](https://www.zigbee2mqtt.io/information/supported_adapters.html). 33 | To see if your devices are supported, look [here](https://www.zigbee2mqtt.io/information/supported_devices.html). 34 | 35 | To use it, just add another Zigbee2MQTT entry in the config of the adapter and update the host field with the hostname or IP of your MQTT broker. 36 | 37 | If you don't have an existing Zigbee2MQTT installation, you can follow this [guide](https://www.zigbee2mqtt.io/getting_started/running_zigbee2mqtt.html) to set one up. 38 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | ADDON_ARCH="$1" 4 | LANGUAGE_NAME="$2" 5 | LANGUAGE_VERSION="$3" 6 | 7 | function map_posix_tools() { 8 | tar() { 9 | gtar "$@" 10 | return $! 11 | } 12 | export -f tar 13 | 14 | readlink() { 15 | greadlink "$@" 16 | return $! 17 | } 18 | export -f readlink 19 | 20 | find() { 21 | gfind "$@" 22 | return $! 23 | } 24 | export -f find 25 | 26 | cp() { 27 | gcp "$@" 28 | return $! 29 | } 30 | export -f cp 31 | } 32 | 33 | function install_osx_compiler() { 34 | brew install \ 35 | boost \ 36 | cmake \ 37 | coreutils \ 38 | eigen \ 39 | findutils \ 40 | gnu-tar \ 41 | pkg-config 42 | map_posix_tools 43 | } 44 | 45 | function install_linux_cross_compiler() { 46 | sudo apt -qq update 47 | sudo apt install --no-install-recommends -y \ 48 | binfmt-support \ 49 | qemu-system \ 50 | qemu-user-static 51 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 52 | } 53 | 54 | function build_native() { 55 | ADDON_ARCH=${ADDON_ARCH} ./package.sh 56 | } 57 | 58 | function build_cross_compiled() { 59 | docker run --rm -t -v $PWD:/build webthingsio/toolchain-${ADDON_ARCH}-${LANGUAGE_NAME}-${LANGUAGE_VERSION} bash -c "cd /build; ADDON_ARCH=${ADDON_ARCH} ./package.sh" 60 | } 61 | 62 | case "${ADDON_ARCH}" in 63 | darwin-x64) 64 | install_osx_compiler 65 | build_native 66 | ;; 67 | 68 | linux-arm) 69 | install_linux_cross_compiler 70 | build_cross_compiled 71 | ;; 72 | 73 | linux-arm64) 74 | install_linux_cross_compiler 75 | build_cross_compiled 76 | ;; 77 | 78 | linux-x64) 79 | install_linux_cross_compiler 80 | build_cross_compiled 81 | ;; 82 | 83 | *) 84 | echo "Unsupported architecture" 85 | exit 1 86 | ;; 87 | esac 88 | -------------------------------------------------------------------------------- /generate-config-interfaces.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { compile } = require('json-schema-to-typescript'); 3 | const manifest = require('./manifest.json'); 4 | 5 | compile(manifest.options.schema, 'Config') 6 | .then((ts) => fs.writeFileSync('src/config.d.ts', ts)); 7 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "WebThingsIO", 3 | "description": "Zigbee device support, via USB dongle or external Zigbee2MQTT instance.", 4 | "gateway_specific_settings": { 5 | "webthings": { 6 | "exec": "{nodeLoader} {path}", 7 | "primary_type": "adapter", 8 | "strict_max_version": "*", 9 | "strict_min_version": "1.0.0", 10 | "enabled": true 11 | } 12 | }, 13 | "homepage_url": "https://github.com/WebThingsIO/zigbee-adapter", 14 | "id": "zigbee-adapter", 15 | "license": "MPL-2.0", 16 | "manifest_version": 1, 17 | "name": "Zigbee", 18 | "options": { 19 | "default": { 20 | "scanChannels": 8190, 21 | "allowFTDISerial": false, 22 | "allowAMASerial": false, 23 | "showAging": false, 24 | "debug": "", 25 | "deactivateProbing": false 26 | }, 27 | "schema": { 28 | "type": "object", 29 | "required": [ 30 | "scanChannels", 31 | "allowFTDISerial" 32 | ], 33 | "properties": { 34 | "scanChannels": { 35 | "type": "integer", 36 | "default": 8190 37 | }, 38 | "allowFTDISerial": { 39 | "type": "boolean", 40 | "default": false 41 | }, 42 | "allowAMASerial": { 43 | "type": "boolean", 44 | "default": false 45 | }, 46 | "debug": { 47 | "type": "string", 48 | "default": "" 49 | }, 50 | "showAging": { 51 | "type": "boolean", 52 | "title": "Show Aging", 53 | "description": "experimental - Creates an additional 'Last seen' property to show when each device was last active on the Zigbee network" 54 | }, 55 | "deactivateProbing": { 56 | "type": "boolean", 57 | "title": "Deactivate automatic probing of the serial ports" 58 | }, 59 | "sticks": { 60 | "type": "array", 61 | "title": "List of ZigBee sticks to use", 62 | "items": { 63 | "type": "object", 64 | "title": "ZigBee Stick", 65 | "required": [ 66 | "type", 67 | "port" 68 | ], 69 | "properties": { 70 | "type": { 71 | "type": "string", 72 | "enum": [ 73 | "xbee", 74 | "conbee", 75 | "zstack" 76 | ] 77 | }, 78 | "port": { 79 | "type": "string" 80 | } 81 | } 82 | } 83 | }, 84 | "zigbee2mqtt": { 85 | "title": "Zigbee2Mqtt", 86 | "type": "object", 87 | "properties": { 88 | "zigbee2mqttDebugLogs": { 89 | "title": "Enable Zigbee2Mqtt debug logs", 90 | "type": "boolean" 91 | }, 92 | "zigbee2mqttAdapters": { 93 | "title": "List of Zigbee2MQTT adapters", 94 | "type": "array", 95 | "items": { 96 | "title": "Zigbee2MQTT adapter", 97 | "type": "object", 98 | "required": [ 99 | "host" 100 | ], 101 | "properties": { 102 | "host": { 103 | "type": "string", 104 | "title": "Hostname of the mqtt broker (e.g. localhost)" 105 | }, 106 | "port": { 107 | "type": "number", 108 | "title": "Port of the mqtt broker (default 1883)" 109 | }, 110 | "topicPrefix": { 111 | "type": "string", 112 | "title": "Topic prefix of the adapter (default zigbee2mqtt)" 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } 121 | }, 122 | "short_name": "Zigbee", 123 | "version": "0.23.0" 124 | } 125 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zigbee-adapter", 3 | "version": "0.23.0", 4 | "description": "Zigbee device support, via USB dongle or external Zigbee2MQTT instance.", 5 | "author": "WebThingsIO", 6 | "main": "lib/index.js", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "prettier": "prettier -u -w src", 10 | "build": "node generate-config-interfaces.js && rm -rf lib && cp -rL src lib && find lib -name '*.ts' -delete && tsc -p ." 11 | }, 12 | "homepage": "https://github.com/WebThingsIO/zigbee-adapter", 13 | "dependencies": { 14 | "bl": "^4.1.0", 15 | "buffer-reader": "^0.1.0", 16 | "clone-deep": "^4.0.1", 17 | "color": "^3.1.3", 18 | "command-line-args": "^5.1.1", 19 | "deconz-api": "github:WebThingsIO/deconz-api", 20 | "dissolve": "github:WebThingsIO/dissolve#moziot-changes", 21 | "dissolve-chunks": "github:WebThingsIO/dissolve-chunks#moziot-changes", 22 | "mkdirp": "^1.0.4", 23 | "mqtt": "^4.2.6", 24 | "serial-prober": "github:WebThingsIO/serial-prober-node", 25 | "unpi": "github:WebThingsIO/unpi#moziot-changes", 26 | "xbee-api": "^0.6.0", 27 | "zcl-id": "github:WebThingsIO/zcl-id#moziot-changes", 28 | "zcl-packet": "github:WebThingsIO/zcl-packet#moziot-changes", 29 | "zigbee-zdo": "github:WebThingsIO/zigbee-zdo" 30 | }, 31 | "devDependencies": { 32 | "@types/clone-deep": "^4.0.1", 33 | "@types/color": "^3.0.1", 34 | "@types/ws": "^7.4.0", 35 | "@typescript-eslint/eslint-plugin": "^4.15.1", 36 | "@typescript-eslint/parser": "^4.15.1", 37 | "eslint": "^7.20.0", 38 | "eslint-config-prettier": "^7.2.0", 39 | "gateway-addon": "^1.2.0-alpha.1", 40 | "json-schema-to-typescript": "^10.1.3", 41 | "prettier": "^2.2.1", 42 | "typescript": "^4.1.5" 43 | }, 44 | "license": "MPL-2.0", 45 | "repository": { 46 | "type": "git", 47 | "url": "https://github.com/WebThingsIO/zigbee-adapter.git" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/WebThingsIO/zigbee-adapter/issues" 51 | }, 52 | "files": [ 53 | "LICENSE", 54 | "README.md", 55 | "SHA256SUMS", 56 | "lib/driver/index.js", 57 | "lib/driver/conbee.js", 58 | "lib/driver/xbee.js", 59 | "lib/driver/zstack.js", 60 | "lib/constants.js", 61 | "lib/index.js", 62 | "lib/manifest.json", 63 | "lib/zb-adapter.js", 64 | "lib/zb-at.js", 65 | "lib/zb-classifier.js", 66 | "lib/zb-constants.js", 67 | "lib/zb-debug.js", 68 | "lib/zb-families.js", 69 | "lib/zb-family.js", 70 | "lib/zb-node.js", 71 | "lib/zb-property.js", 72 | "lib/zb-xiaomi.js", 73 | "lib/zigbee2mqtt/zigbee2mqtt-adapter.js", 74 | "lib/zigbee2mqtt/zigbee2mqtt-device.js", 75 | "lib/zigbee2mqtt/zigbee2mqtt-driver.js", 76 | "lib/zigbee2mqtt/zigbee2mqtt-property.js", 77 | "manifest.json", 78 | "node_modules" 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Setup environment for building inside Dockerized toolchain 4 | export NVM_DIR="${HOME}/.nvm" 5 | [ -s "${NVM_DIR}/nvm.sh" ] && source "${NVM_DIR}/nvm.sh" 6 | [ $(id -u) = 0 ] && umask 0 7 | 8 | rm -rf lib node_modules 9 | 10 | if [ -z "${ADDON_ARCH}" ]; then 11 | TARFILE_SUFFIX= 12 | else 13 | NODE_VERSION="$(node --version)" 14 | TARFILE_SUFFIX="-${ADDON_ARCH}-${NODE_VERSION/\.*/}" 15 | fi 16 | 17 | npm ci 18 | npm run build 19 | npm prune --production 20 | 21 | shasum --algorithm 256 manifest.json package.json lib/*.js lib/driver/*.js lib/zigbee2mqtt/*.js LICENSE README.md > SHA256SUMS 22 | 23 | find node_modules \( -type f -o -type l \) -exec shasum --algorithm 256 {} \; >> SHA256SUMS 24 | 25 | TARFILE=`npm pack` 26 | 27 | tar xzf ${TARFILE} 28 | rm ${TARFILE} 29 | TARFILE_ARCH="${TARFILE/.tgz/${TARFILE_SUFFIX}.tgz}" 30 | cp -r node_modules ./package 31 | tar czf ${TARFILE_ARCH} package 32 | 33 | shasum --algorithm 256 ${TARFILE_ARCH} > ${TARFILE_ARCH}.sha256sum 34 | 35 | rm -rf SHA256SUMS package 36 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * ZigbeeAdapter - Adapter which manages Zigbee devices. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | export const PACKAGE_ID = 'zigbee-adapter'; 11 | -------------------------------------------------------------------------------- /src/driver/conbee.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * conbee-driver - Driver to support the RaspBee and ConBee. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | 'use strict'; 11 | 12 | const deconzApi = require('deconz-api'); 13 | const util = require('util'); 14 | 15 | const C = deconzApi.constants; 16 | 17 | const { APS_STATUS, NWK_STATUS, MAC_STATUS } = require('../zb-constants'); 18 | 19 | const { 20 | Command, 21 | FUNC, 22 | PERMIT_JOIN_PRIORITY, 23 | WATCHDOG_PRIORITY, 24 | SEND_FRAME, 25 | WAIT_FRAME, 26 | ZigbeeDriver, 27 | } = require('./index'); 28 | 29 | const { 30 | DEBUG_flow, 31 | DEBUG_frameDetail, 32 | // DEBUG_frames, 33 | DEBUG_rawFrames, 34 | DEBUG_slip, 35 | } = require('../zb-debug').default; 36 | 37 | const { Utils } = require('gateway-addon'); 38 | 39 | const PARAM = [ 40 | C.PARAM_ID.MAC_ADDRESS, 41 | C.PARAM_ID.NETWORK_PANID16, 42 | C.PARAM_ID.NETWORK_ADDR16, 43 | C.PARAM_ID.NETWORK_PANID64, 44 | C.PARAM_ID.APS_DESIGNATED_COORDINATOR, 45 | C.PARAM_ID.SCAN_CHANNELS, 46 | C.PARAM_ID.APS_PANID64, 47 | C.PARAM_ID.TRUST_CENTER_ADDR64, 48 | C.PARAM_ID.SECURITY_MODE, 49 | C.PARAM_ID.NETWORK_KEY, 50 | C.PARAM_ID.OPERATING_CHANNEL, 51 | C.PARAM_ID.PROTOCOL_VERSION, 52 | C.PARAM_ID.NETWORK_UPDATE_ID, 53 | C.PARAM_ID.PERMIT_JOIN, 54 | C.PARAM_ID.WATCHDOG_TTL, 55 | ]; 56 | 57 | const WATCHDOG_TIMEOUT_SECS = 3600; // 1 hour 58 | 59 | function serialWriteError(error) { 60 | if (error) { 61 | console.error('SerialPort.write error:', error); 62 | throw error; 63 | } 64 | } 65 | 66 | class ConBeeDriver extends ZigbeeDriver { 67 | constructor(addonManager, config, portName, serialPort) { 68 | super(addonManager, config); 69 | this.portName = portName; 70 | this.serialPort = serialPort; 71 | 72 | this.dataConfirm = false; 73 | this.dataIndication = false; 74 | this.dataRequest = true; // assume we have space to send the first frame 75 | this.dataIndicationInProgress = false; 76 | this.dataConfirmInProgress = false; 77 | 78 | this.rawFrameQueue = []; 79 | this.waitingForResponseType = 0; 80 | this.waitingForSequenceNum = 0; 81 | 82 | this.dc = new deconzApi.DeconzAPI({ raw_frames: DEBUG_rawFrames }); 83 | 84 | this.dc.on('error', (err) => { 85 | console.error('deConz error:', err); 86 | }); 87 | 88 | if (DEBUG_rawFrames) { 89 | this.dc.on('frame_raw', (rawFrame) => { 90 | console.log('Rcvd:', rawFrame); 91 | if (this.dc.canParse(rawFrame)) { 92 | try { 93 | const frame = this.dc.parseFrame(rawFrame); 94 | try { 95 | this.handleFrame(frame); 96 | } catch (e) { 97 | console.error('Error handling frame_raw'); 98 | console.error(e); 99 | console.error(util.inspect(frame, { depth: null })); 100 | } 101 | } catch (e) { 102 | console.error('Error parsing frame_raw'); 103 | console.error(e); 104 | console.error(rawFrame); 105 | } 106 | } else { 107 | console.error('canParse returned false for frame - ignoring'); 108 | console.error(rawFrame); 109 | } 110 | }); 111 | } else { 112 | this.dc.on('frame_object', (frame) => { 113 | try { 114 | this.handleFrame(frame); 115 | } catch (e) { 116 | console.error('Error handling frame_object'); 117 | console.error(e); 118 | console.error(util.inspect(frame, { depth: null })); 119 | } 120 | }); 121 | } 122 | 123 | console.log(`ConBeeDriver: Using serial port ${portName}`); 124 | this.serialPort.on('data', (chunk) => { 125 | if (DEBUG_slip) { 126 | console.log('Rcvd Chunk:', chunk); 127 | } 128 | this.dc.parseRaw(chunk); 129 | }); 130 | 131 | this.queueCommands([ 132 | FUNC(this, this.version), 133 | FUNC(this, this.readParameters), 134 | FUNC(this, this.kickWatchDog), 135 | FUNC(this, this.permitJoin, [0]), 136 | FUNC(this, this.dumpParameters), 137 | FUNC(this, this.adapterInitialized), 138 | ]); 139 | } 140 | 141 | adapterInitialized() { 142 | this.adapter.networkAddr16 = '0000'; 143 | this.adapter.adapterInitialized(); 144 | } 145 | 146 | asDeviceInfo() { 147 | const dict = {}; 148 | for (const paramId of PARAM) { 149 | const param = C.PARAM_ID[paramId]; 150 | let value = this[param.fieldName]; 151 | if (paramId == C.PARAM_ID.SCAN_CHANNELS) { 152 | value = value.toString(16).padStart(8, '0'); 153 | } 154 | dict[param.fieldName] = value; 155 | } 156 | dict.version = this.version; 157 | return dict; 158 | } 159 | 160 | buildAndSendRawFrame(frame) { 161 | if (!frame.hasOwnProperty('type')) { 162 | frame.type = C.FRAME_TYPE.APS_DATA_REQUEST; 163 | } 164 | const sentPrefix = frame.resend ? 'Re' : ''; 165 | const rawFrame = this.dc.buildFrame(frame, false); 166 | if (DEBUG_rawFrames) { 167 | console.log(`${sentPrefix}Queued:`, rawFrame); 168 | } 169 | 170 | this.rawFrameQueue.push(rawFrame); 171 | this.processRawFrameQueue(); 172 | } 173 | 174 | // All requests to the ConBee dongle get a response using the same frame 175 | // type. See deconzApi.constants.FRAME_TYPE for the valid frame types. 176 | // I discovered that the comms seem to flow much more smoothly if we wait 177 | // for the corresponding response before proceeding to the send the next 178 | // frame. this.rawFrameQueue holds these outgoing commands. All of the 179 | // responses should be sent immediately after the dongle receives the 180 | // request. So the only delays should be waiting for serial bytes to be 181 | // sent and received. 182 | // 183 | // For all of the commands sent to the dongle, the first byte of the 184 | // frame is the frame type and the second byte is a sequence number. 185 | 186 | processRawFrameQueue() { 187 | if (this.waitingForResponseType != 0) { 188 | // We've sent a frame to the dongle and we're waiting for a response. 189 | if (DEBUG_rawFrames) { 190 | console.log('processRawFrameQueue: waiting for type:', this.waitingForResponseType); 191 | } 192 | return; 193 | } 194 | 195 | let rawFrame; 196 | if (this.dataIndication) { 197 | // There is an incoming frame waiting for us 198 | if (DEBUG_rawFrames) { 199 | console.log('Incoming Frame available -', 'requesting it (via APS_DATA_INDICATION)'); 200 | } 201 | rawFrame = this.dc.buildFrame({ type: C.FRAME_TYPE.APS_DATA_INDICATION }, false); 202 | } else if (this.dataConfirm) { 203 | // There is an outgoing frame sent confirmation waiting for us 204 | if (DEBUG_rawFrames) { 205 | console.log( 206 | 'Outgoing Frame confirmation available -', 207 | 'requesting it (via APS_DATA_CONFIRM)' 208 | ); 209 | } 210 | rawFrame = this.dc.buildFrame({ type: C.FRAME_TYPE.APS_DATA_CONFIRM }, false); 211 | } else if (this.dataRequest) { 212 | // There is space for an outgoing frame 213 | if (this.rawFrameQueue.length > 0) { 214 | if (DEBUG_rawFrames) { 215 | console.log('Sending queued frame'); 216 | } 217 | rawFrame = this.rawFrameQueue.pop(); 218 | } else { 219 | if (DEBUG_rawFrames) { 220 | console.log('No raw frames to send'); 221 | } 222 | // No frames to send. 223 | return; 224 | } 225 | } else { 226 | if (DEBUG_rawFrames) { 227 | console.log('No space to send any frames - wait for space'); 228 | } 229 | // We need to wait for conditions to change. 230 | return; 231 | } 232 | 233 | // we have a raw frame to send 234 | this.waitingForResponseType = rawFrame[0]; 235 | this.waitingForSequenceNum = rawFrame[1]; 236 | 237 | if (DEBUG_rawFrames) { 238 | console.log('Sent:', rawFrame); 239 | } 240 | const slipFrame = this.dc.encapsulateFrame(rawFrame); 241 | if (DEBUG_slip) { 242 | console.log(`Sent Chunk:`, slipFrame); 243 | } 244 | this.serialPort.write(slipFrame, serialWriteError); 245 | } 246 | 247 | close() { 248 | if (this.watchDogTimeout) { 249 | clearTimeout(this.watchDogTimeout); 250 | this.watchDogTimeout = null; 251 | } 252 | this.serialPort.close(); 253 | } 254 | 255 | deviceStateStr(frame) { 256 | let devStateStr = ''; 257 | devStateStr += frame.dataConfirm ? 'S' : '-'; 258 | devStateStr += frame.dataIndication ? 'D' : '-'; 259 | devStateStr += frame.dataRequest ? 'L' : '-'; 260 | devStateStr += frame.configChanged ? 'C' : '-'; 261 | return `Net:${'OJCL'[frame.networkState]} Dev:${devStateStr}`; 262 | } 263 | 264 | dumpFrame(label, frame, dumpFrameDetail) { 265 | if (typeof dumpFrameDetail === 'undefined') { 266 | dumpFrameDetail = DEBUG_frameDetail; 267 | } 268 | try { 269 | this.dumpFrameInternal(label, frame, dumpFrameDetail); 270 | } catch (e) { 271 | console.error('Error dumping frame'); 272 | console.error(e); 273 | console.error(util.inspect(frame, { depth: null })); 274 | } 275 | } 276 | 277 | dumpFrameInternal(label, frame, dumpFrameDetail) { 278 | let frameTypeStr = this.frameTypeAsStr(frame); 279 | if (!frameTypeStr) { 280 | frameTypeStr = `Unknown(0x${frame.type.toString(16)})`; 281 | } 282 | if (frame.response) { 283 | frameTypeStr += ' Response'; 284 | } else { 285 | frameTypeStr += ' Request '; 286 | } 287 | 288 | switch (frame.type) { 289 | case C.FRAME_TYPE.READ_PARAMETER: 290 | case C.FRAME_TYPE.WRITE_PARAMETER: { 291 | let paramStr; 292 | if (frame.paramId in C.PARAM_ID) { 293 | paramStr = C.PARAM_ID[frame.paramId].label; 294 | } else { 295 | paramStr = `Unknown(${frame.paramId})`; 296 | } 297 | const param = C.PARAM_ID[frame.paramId]; 298 | if (param) { 299 | if (frame.hasOwnProperty(param.fieldName)) { 300 | paramStr += `: ${frame[param.fieldName]}`; 301 | } 302 | } 303 | console.log(label, frameTypeStr, paramStr); 304 | break; 305 | } 306 | 307 | case C.FRAME_TYPE.APS_DATA_CONFIRM: { 308 | // Query Send State 309 | if (dumpFrameDetail) { 310 | if (!frame.response) { 311 | console.log(label, 'Explicit Tx State (APS Data Confirm) Request'); 312 | break; 313 | } 314 | const dstAddr = frame.destination64 || frame.destination16; 315 | console.log( 316 | label, 317 | 'Explicit Tx State (APS Data Confirm) Response', 318 | dstAddr, 319 | `ID:${frame.id}`, 320 | this.deviceStateStr(frame) 321 | ); 322 | } 323 | break; 324 | } 325 | 326 | case C.FRAME_TYPE.APS_DATA_INDICATION: { 327 | // Read Received Data 328 | if (!frame.response) { 329 | if (dumpFrameDetail) { 330 | console.log(label, 'Explicit Rx (APS Data Indication) Request'); 331 | } 332 | break; 333 | } 334 | this.dumpZigbeeRxFrame(label, frame); 335 | break; 336 | } 337 | 338 | case C.FRAME_TYPE.APS_DATA_REQUEST: { 339 | // Enqueue Send Data 340 | if (frame.response) { 341 | if (dumpFrameDetail) { 342 | console.log( 343 | label, 344 | 'Explicit Tx (APS Data Request) Response', 345 | this.deviceStateStr(frame) 346 | ); 347 | } 348 | break; 349 | } 350 | this.dumpZigbeeTxFrame(label, frame); 351 | break; 352 | } 353 | 354 | case C.FRAME_TYPE.DEVICE_STATE: 355 | case C.FRAME_TYPE.DEVICE_STATE_CHANGED: 356 | if (dumpFrameDetail) { 357 | if (frame.response) { 358 | console.log(label, frameTypeStr, this.deviceStateStr(frame)); 359 | } else { 360 | console.log(label, frameTypeStr); 361 | } 362 | } 363 | break; 364 | 365 | case C.FRAME_TYPE.VERSION: 366 | if (frame.response) { 367 | console.log(label, frameTypeStr, frame.version); 368 | } else { 369 | console.log(label, frameTypeStr); 370 | } 371 | break; 372 | 373 | default: 374 | console.log(label, frameTypeStr); 375 | } 376 | if (dumpFrameDetail) { 377 | const frameStr = util.inspect(frame, { depth: null }).replace(/\n/g, `\n${label} `); 378 | console.log(label, frameStr); 379 | } 380 | } 381 | 382 | dumpParameters() { 383 | for (const paramId of PARAM) { 384 | const param = C.PARAM_ID[paramId]; 385 | const label = param.label.padStart(20, ' '); 386 | let value = this[param.fieldName]; 387 | if (paramId == C.PARAM_ID.SCAN_CHANNELS) { 388 | value = value.toString(16).padStart(8, '0'); 389 | } 390 | console.log(`${label}: ${value}`); 391 | } 392 | console.log(` Version: ${this.version}`); 393 | } 394 | 395 | frameTypeAsStr(frame) { 396 | if (C.FRAME_TYPE.hasOwnProperty(frame.type)) { 397 | return C.FRAME_TYPE[frame.type]; 398 | } 399 | return `${frame.type} (0x${frame.type.toString(16)})`; 400 | } 401 | 402 | getFrameHandler(frame) { 403 | return ConBeeDriver.frameHandler[frame.type]; 404 | } 405 | 406 | getExplicitRxFrameType() { 407 | return C.FRAME_TYPE.APS_DATA_INDICATION; 408 | } 409 | 410 | getExplicitTxFrameType() { 411 | return C.FRAME_TYPE.APS_DATA_REQUEST; 412 | } 413 | 414 | getTransmitStatusFrameType() { 415 | return C.FRAME_TYPE.APS_DATA_CONFIRM; 416 | } 417 | 418 | // Response to APS_DATA_CONFIRM request (i.e. confirm frame sent) 419 | handleApsDataConfirm(frame) { 420 | DEBUG_flow && console.log('handleApsDataConfirm: seqNum:', frame.seqNum, 'id', frame.id); 421 | this.dataConfirmInProgress = false; 422 | if (frame.confirmStatus != 0) { 423 | this.reportConfirmStatus(frame); 424 | } 425 | this.updateFlags(frame); 426 | } 427 | 428 | // Response to APS_DATA_INDICATION request (i.e. frame received) 429 | handleApsDataIndication(frame) { 430 | DEBUG_flow && console.log('handleApsDataIndication: seqNum:', frame.seqNum); 431 | this.dataIndicationInProgress = false; 432 | if (frame.status != 0) { 433 | this.reportStatus(frame); 434 | } 435 | this.updateFlags(frame); 436 | this.handleExplicitRx(frame); 437 | } 438 | 439 | // Reponse to APS_DATA_REQUEST (i.e. frame was queued for sending) 440 | handleApsDataRequest(frame) { 441 | DEBUG_flow && console.log('handleApsDataRequest: seqNum:', frame.seqNum); 442 | this.dataConfirmInProgress = false; 443 | if (frame.status != 0) { 444 | this.reportStatus(frame); 445 | } 446 | this.updateFlags(frame); 447 | } 448 | 449 | // APS_MAC_POLL - this seems to be sent by the ConBee II and not the ConBee 450 | // We just ignore the frame for now. 451 | handleApsMacPoll(_frame) { 452 | // pass 453 | } 454 | 455 | // Response to DEVICE_STATE request 456 | handleDeviceState(frame) { 457 | DEBUG_flow && console.log('handleDeviceState: seqNum:', frame.seqNum); 458 | this.updateFlags(frame); 459 | } 460 | 461 | // Unsolicited indication of state change 462 | handleDeviceStateChanged(frame) { 463 | DEBUG_flow && console.log('handleDeviceStateChanged: seqNum:', frame.seqNum); 464 | if (frame.status != 0) { 465 | this.reportStatus(frame); 466 | } 467 | this.updateFlags(frame); 468 | } 469 | 470 | handleFrame(frame) { 471 | if (this.waitingForResponseType == frame.type && this.waitingForSequenceNum == frame.seqNum) { 472 | // We got the frame we're waiting for 473 | this.waitingForResponseType = 0; 474 | this.waitingForSequenceNum = 0; 475 | } 476 | if (frame.status == 0) { 477 | super.handleFrame(frame); 478 | } 479 | 480 | // Send out any queued raw frames, if there are any. 481 | this.processRawFrameQueue(); 482 | } 483 | 484 | handleReadParameter(frame) { 485 | if (frame.status != 0) { 486 | this.reportStatus(frame); 487 | } 488 | const paramId = frame.paramId; 489 | if (C.PARAM_ID.hasOwnProperty(paramId)) { 490 | const fieldName = C.PARAM_ID[paramId].fieldName; 491 | this[fieldName] = frame[fieldName]; 492 | if (fieldName == 'macAddress') { 493 | this.adapter.networkAddr64 = this.macAddress; 494 | this.neworkAddr16 = '0000'; 495 | } 496 | } 497 | } 498 | 499 | handleWriteParameter(frame) { 500 | if (frame.status != 0) { 501 | this.reportStatus(frame); 502 | } 503 | } 504 | 505 | handleVersion(frame) { 506 | console.log('ConBee Firmware version:', Utils.hexStr(frame.version, 8)); 507 | } 508 | 509 | kickWatchDog() { 510 | if (this.protocolVersion < C.WATCHDOG_PROTOCOL_VERSION) { 511 | // eslint-disable-next-line @typescript-eslint/quotes 512 | console.error("This version of ConBee doesn't support the watchdog"); 513 | return; 514 | } 515 | console.log('Kicking WatchDog for', WATCHDOG_TIMEOUT_SECS, 'seconds'); 516 | this.queueCommandsAtFront( 517 | this.writeParameterCommands(C.PARAM_ID.WATCHDOG_TTL, WATCHDOG_TIMEOUT_SECS, WATCHDOG_PRIORITY) 518 | ); 519 | if (this.watchDogTimeout) { 520 | clearTimeout(this.watchDogTimeout); 521 | this.watchDogTimeout = null; 522 | } 523 | this.watchDogTimeout = setTimeout(() => { 524 | this.watchDogTimeout = null; 525 | this.kickWatchDog(); 526 | }, (WATCHDOG_TIMEOUT_SECS / 2) * 1000); 527 | } 528 | 529 | nextFrameId() { 530 | return deconzApi._frame_builder.nextFrameId(); 531 | } 532 | 533 | permitJoinCommands(duration) { 534 | return this.writeParameterCommands(C.PARAM_ID.PERMIT_JOIN, duration, PERMIT_JOIN_PRIORITY); 535 | } 536 | 537 | processDeviceState() { 538 | DEBUG_flow && 539 | console.log( 540 | 'processDeviceState:', 541 | 'dataIndication', 542 | this.dataIndication, 543 | 'inProgress:', 544 | this.dataIndicationInProgress, 545 | 'dataConfirm', 546 | this.dataConfirm, 547 | 'inProgress:', 548 | this.dataConfirmInProgress 549 | ); 550 | if (this.dataIndication && !this.dataIndicationInProgress) { 551 | // There is a frame ready to be read. 552 | this.dataIndicationInProgress = true; 553 | this.sendFrameNow({ type: C.FRAME_TYPE.APS_DATA_INDICATION }); 554 | } 555 | if (this.dataConfirm && !this.dataConfirmInProgress) { 556 | // There is a data confirm ready to be read. 557 | this.dataConfirmInProgress = true; 558 | this.sendFrameNow({ type: C.FRAME_TYPE.APS_DATA_CONFIRM }); 559 | } 560 | } 561 | 562 | readParameter() { 563 | if (this.paramIdx >= PARAM.length) { 564 | return; 565 | } 566 | const paramId = PARAM[this.paramIdx]; 567 | if (paramId == C.PARAM_ID.WATCHDOG_TTL) { 568 | if (this.protocolVersion < C.WATCHDOG_PROTOCOL_VERSION) { 569 | // This version of the ConBee firmware doesn't support the 570 | // watchdog parameter - skip 571 | this.paramIdx++; 572 | this.readParameter(); 573 | return; 574 | } 575 | } 576 | const readParamFrame = { 577 | type: C.FRAME_TYPE.READ_PARAMETER, 578 | paramId: paramId, 579 | }; 580 | this.queueCommandsAtFront([ 581 | new Command(SEND_FRAME, readParamFrame), 582 | new Command(WAIT_FRAME, { 583 | type: C.FRAME_TYPE.READ_PARAMETER, 584 | paramId: paramId, 585 | callback: (_frame) => { 586 | if (this.paramIdx < PARAM.length) { 587 | this.paramIdx++; 588 | this.readParameter(); 589 | } 590 | }, 591 | }), 592 | ]); 593 | } 594 | 595 | readParameters() { 596 | this.paramIdx = 0; 597 | this.readParameter(); 598 | } 599 | 600 | reportStatus(frame) { 601 | const status = frame.status; 602 | if (status < C.STATUS_STR.length) { 603 | if (status == 0) { 604 | console.log(`Frame Status: ${status}: ${C.STATUS_STR[status]}`); 605 | } else { 606 | console.error(`Frame Status: ${status}: ${C.STATUS_STR[status]}`); 607 | console.error(frame); 608 | } 609 | } else { 610 | console.error(`Frame Status: ${status}: unknown`); 611 | console.error(frame); 612 | } 613 | } 614 | 615 | reportConfirmStatus(frame) { 616 | if (frame.payloadLen < 11) { 617 | // This is an invalid frame. We've already reported it. 618 | return; 619 | } 620 | 621 | // These are common statuses, so don't report them unless we're 622 | // debugging. 623 | const noReport = [APS_STATUS.NO_ACK, APS_STATUS.NO_SHORT_ADDRESS]; 624 | const status = frame.confirmStatus; 625 | 626 | let addr = 'unknown'; 627 | let node; 628 | if (frame.hasOwnProperty('destination16')) { 629 | addr = frame.destination16; 630 | node = this.adapter.findNodeByAddr16(addr); 631 | } else if (frame.hasOwnProperty('destination64')) { 632 | addr = frame.destination64; 633 | node = this.adapter.nodes[addr]; 634 | } 635 | if (node) { 636 | addr = `${node.addr64} ${node.addr16}`; 637 | } 638 | 639 | if (APS_STATUS.hasOwnProperty(status)) { 640 | if (status == 0) { 641 | console.log(`Confirm Status: ${status}: ${APS_STATUS[status]}`, `, addr: ${addr}`); 642 | } else if (DEBUG_frameDetail || !noReport.includes(status)) { 643 | console.error(`Confirm Status: ${status}: ${APS_STATUS[status]}`, `, addr: ${addr}`); 644 | } 645 | } else if (NWK_STATUS.hasOwnProperty(status)) { 646 | console.error(`Confirm Status: ${status}: ${NWK_STATUS[status]}`, `, addr: ${addr}`); 647 | } else if (MAC_STATUS.hasOwnProperty(status)) { 648 | console.error(`Confirm Status: ${status}: ${MAC_STATUS[status]}`, `, addr: ${addr}`); 649 | } else { 650 | console.error(`Confirm Status: ${status}: unknown`, `, addr: ${addr}`); 651 | console.error(frame); 652 | } 653 | } 654 | 655 | updateFlags(frame) { 656 | this.dataConfirm = frame.dataConfirm; 657 | this.dataIndication = frame.dataIndication; 658 | this.dataRequest = frame.dataRequest; 659 | } 660 | 661 | version() { 662 | const versionFrame = { 663 | type: C.FRAME_TYPE.VERSION, 664 | }; 665 | this.queueCommandsAtFront([ 666 | new Command(SEND_FRAME, versionFrame), 667 | new Command(WAIT_FRAME, { 668 | type: C.FRAME_TYPE.VERSION, 669 | callback: (frame) => { 670 | this.version = frame.version; 671 | }, 672 | }), 673 | ]); 674 | } 675 | 676 | writeParameter(paramId, value, priority) { 677 | this.queueCommandsAtFront(this.writeParameterCommands(paramId, value, priority)); 678 | } 679 | 680 | writeParameterCommands(paramId, value, priority) { 681 | if (!C.PARAM_ID.hasOwnProperty(paramId)) { 682 | console.error(`Unknown parameter ID: ${paramId}`); 683 | return []; 684 | } 685 | const fieldName = C.PARAM_ID[paramId].fieldName; 686 | this[fieldName] = value; 687 | const writeParamFrame = { 688 | type: C.FRAME_TYPE.WRITE_PARAMETER, 689 | paramId: paramId, 690 | [fieldName]: value, 691 | }; 692 | if (typeof priority !== 'undefined') { 693 | writeParamFrame.priority = priority; 694 | } 695 | return [ 696 | new Command(SEND_FRAME, writeParamFrame), 697 | new Command(WAIT_FRAME, { 698 | type: C.FRAME_TYPE.WRITE_PARAMETER, 699 | paramId: paramId, 700 | }), 701 | ]; 702 | } 703 | } 704 | 705 | ConBeeDriver.frameHandler = { 706 | [C.FRAME_TYPE.APS_DATA_CONFIRM]: ConBeeDriver.prototype.handleApsDataConfirm, 707 | [C.FRAME_TYPE.APS_DATA_INDICATION]: ConBeeDriver.prototype.handleApsDataIndication, 708 | [C.FRAME_TYPE.APS_DATA_REQUEST]: ConBeeDriver.prototype.handleApsDataRequest, 709 | [C.FRAME_TYPE.APS_MAC_POLL]: ConBeeDriver.prototype.handleApsMacPoll, 710 | [C.FRAME_TYPE.DEVICE_STATE]: ConBeeDriver.prototype.handleDeviceState, 711 | [C.FRAME_TYPE.DEVICE_STATE_CHANGED]: ConBeeDriver.prototype.handleDeviceStateChanged, 712 | [C.FRAME_TYPE.VERSION]: ConBeeDriver.prototype.handleVersion, 713 | [C.FRAME_TYPE.READ_PARAMETER]: ConBeeDriver.prototype.handleReadParameter, 714 | [C.FRAME_TYPE.WRITE_PARAMETER]: ConBeeDriver.prototype.handleWriteParameter, 715 | }; 716 | 717 | module.exports = ConBeeDriver; 718 | -------------------------------------------------------------------------------- /src/driver/xbee.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * xbee-driver - Driver to support the Digi XStick. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | 'use strict'; 11 | 12 | const at = require('../zb-at'); 13 | const util = require('util'); 14 | const xbeeApi = require('xbee-api'); 15 | 16 | const { 17 | Command, 18 | FUNC, 19 | PERMIT_JOIN_PRIORITY, 20 | SEND_FRAME, 21 | WAIT_FRAME, 22 | ZigbeeDriver, 23 | } = require('./index'); 24 | 25 | const { DEBUG_flow, DEBUG_frameDetail, DEBUG_frames, DEBUG_rawFrames } = 26 | require('../zb-debug').default; 27 | 28 | const C = xbeeApi.constants; 29 | const AT_CMD = at.AT_CMD; 30 | 31 | const DEVICE_TYPE = { 32 | 0x30001: 'ConnectPort X8 Gateway', 33 | 0x30002: 'ConnectPort X4 Gateway', 34 | 0x30003: 'ConnectPort X2 Gateway', 35 | 0x30005: 'RS-232 Adapter', 36 | 0x30006: 'RS-485 Adapter', 37 | 0x30007: 'XBee Sensor Adapter', 38 | 0x30008: 'Wall Router', 39 | 0x3000a: 'Digital I/O Adapter', 40 | 0x3000b: 'Analog I/O Adapter', 41 | 0x3000c: 'XStick', 42 | 0x3000f: 'Smart Plug', 43 | 0x30011: 'XBee Large Display', 44 | 0x30012: 'XBee Small Display', 45 | }; 46 | 47 | function serialWriteError(error) { 48 | if (error) { 49 | console.error('SerialPort.write error:', error); 50 | throw error; 51 | } 52 | } 53 | 54 | class XBeeDriver extends ZigbeeDriver { 55 | constructor(addonManager, config, portName, serialPort) { 56 | super(addonManager, config); 57 | this.portName = portName; 58 | this.serialPort = serialPort; 59 | 60 | this.serialNumber = '0000000000000000'; 61 | 62 | this.xb = new xbeeApi.XBeeAPI({ 63 | api_mode: 1, 64 | raw_frames: DEBUG_rawFrames, 65 | }); 66 | this.at = new at.AtApi(); 67 | 68 | if (DEBUG_rawFrames) { 69 | this.xb.on('frame_raw', (rawFrame) => { 70 | console.log('Rcvd:', rawFrame); 71 | if (this.xb.canParse(rawFrame)) { 72 | try { 73 | const frame = this.xb.parseFrame(rawFrame); 74 | try { 75 | this.handleFrame(frame); 76 | } catch (e) { 77 | console.error('Error handling frame_raw'); 78 | console.error(e); 79 | console.error(util.inspect(frame, { depth: null })); 80 | } 81 | } catch (e) { 82 | console.error('Error parsing raw frame_raw'); 83 | console.error(e); 84 | console.error(rawFrame); 85 | } 86 | } 87 | }); 88 | } else { 89 | this.xb.on('frame_object', (frame) => { 90 | try { 91 | this.handleFrame(frame); 92 | } catch (e) { 93 | console.error('Error handling frame_object'); 94 | console.error(e); 95 | console.error(util.inspect(frame, { depth: null })); 96 | } 97 | }); 98 | } 99 | 100 | console.log(`XBeeDriver: Using serial port ${portName}`); 101 | this.serialPort.on('data', (chunk) => { 102 | this.xb.parseRaw(chunk); 103 | }); 104 | 105 | this.queueCommands([ 106 | this.AT(AT_CMD.API_MODE), 107 | FUNC(this, this.configureApiModeIfNeeded), 108 | this.AT(AT_CMD.DEVICE_TYPE_IDENTIFIER), 109 | this.AT(AT_CMD.CONFIGURED_64_BIT_PAN_ID), 110 | this.AT(AT_CMD.SERIAL_NUMBER_HIGH), 111 | this.AT(AT_CMD.SERIAL_NUMBER_LOW), 112 | this.AT(AT_CMD.NETWORK_ADDR_16_BIT), 113 | this.AT(AT_CMD.OPERATING_64_BIT_PAN_ID), 114 | this.AT(AT_CMD.OPERATING_16_BIT_PAN_ID), 115 | this.AT(AT_CMD.OPERATING_CHANNEL), 116 | this.AT(AT_CMD.SCAN_CHANNELS), 117 | this.AT(AT_CMD.NODE_IDENTIFIER), 118 | this.AT(AT_CMD.NUM_REMAINING_CHILDREN), 119 | this.AT(AT_CMD.ZIGBEE_STACK_PROFILE), 120 | this.AT(AT_CMD.API_OPTIONS), 121 | this.AT(AT_CMD.ENCRYPTION_ENABLED), 122 | this.AT(AT_CMD.ENCRYPTION_OPTIONS), 123 | FUNC(this, this.configureIfNeeded, []), 124 | FUNC(this, this.permitJoin, [0]), 125 | FUNC(this, this.adapterInitialized, []), 126 | ]); 127 | } 128 | 129 | adapterInitialized() { 130 | this.dumpInfo(); 131 | this.adapter.adapterInitialized(); 132 | } 133 | 134 | asDeviceInfo() { 135 | return { 136 | deviceType: `0x${this.deviceTypeIdentifier.toString(16)}`, 137 | deviceTypeStr: `${this.deviceTypeString(this.deviceTypeIdentifier)}`, 138 | serialNumber: this.serialNumber, 139 | nodeIdentifier: this.nodeIdentifier, 140 | configuredPanId64: this.configuredPanId64, 141 | operatingPanId64: this.operatingPanId64, 142 | operatingPanId16: this.operatingPanId16, 143 | operatingChannel: this.operatingChannel, 144 | scanChannels: `0x${this.scanChannels.toString(16)}`, 145 | apiOptions: this.apiOptions, 146 | }; 147 | } 148 | 149 | AT(command, frame, priority) { 150 | if (frame) { 151 | frame.shortDescr = util.inspect(frame); 152 | } 153 | return [ 154 | new Command(SEND_FRAME, this.at.makeFrame(command, frame), priority), 155 | new Command(WAIT_FRAME, { 156 | type: C.FRAME_TYPE.AT_COMMAND_RESPONSE, 157 | command: command, 158 | }), 159 | ]; 160 | } 161 | 162 | buildAndSendRawFrame(frame) { 163 | if (!frame.hasOwnProperty('type')) { 164 | frame.type = C.FRAME_TYPE.EXPLICIT_ADDRESSING_ZIGBEE_COMMAND_FRAME; 165 | } 166 | const sentPrefix = frame.resend ? 'Re' : ''; 167 | const rawFrame = this.xb.buildFrame(frame); 168 | if (DEBUG_rawFrames) { 169 | console.log(`${sentPrefix}Sent:`, rawFrame); 170 | } 171 | this.serialPort.write(rawFrame, serialWriteError); 172 | } 173 | 174 | close() { 175 | this.serialPort.close(); 176 | } 177 | 178 | configureApiModeIfNeeded() { 179 | if (DEBUG_flow) { 180 | console.log('configureApiModeIfNeeded'); 181 | } 182 | const configCommands = []; 183 | if (this.apiMode != 1) { 184 | configCommands.push(this.AT(AT_CMD.API_MODE, { apiMode: 1 })); 185 | configCommands.push(this.AT(AT_CMD.API_MODE)); 186 | 187 | console.log('Setting API mode to 1'); 188 | configCommands.push(this.AT(AT_CMD.WRITE_PARAMETERS)); 189 | this.queueCommandsAtFront(configCommands); 190 | } else { 191 | console.log('API Mode already set to 1 (i.e. no need to change)'); 192 | } 193 | } 194 | 195 | configureIfNeeded() { 196 | if (DEBUG_flow) { 197 | console.log('configureIfNeeded'); 198 | } 199 | const configCommands = []; 200 | if (this.configuredPanId64 === '0000000000000000') { 201 | configCommands.push( 202 | this.AT(AT_CMD.CONFIGURED_64_BIT_PAN_ID, { configuredPanId: this.operatingPanId64 }) 203 | ); 204 | configCommands.push(this.AT(AT_CMD.CONFIGURED_64_BIT_PAN_ID)); 205 | } 206 | if (this.zigBeeStackProfile != 2) { 207 | configCommands.push(this.AT(AT_CMD.ZIGBEE_STACK_PROFILE, { zigBeeStackProfile: 2 })); 208 | configCommands.push(this.AT(AT_CMD.ZIGBEE_STACK_PROFILE)); 209 | } 210 | // API Options = 1 allows Explicit Rx frames to be rcvd 211 | // API Options = 3 enables ZDO passthrough 212 | // i.e. Simple Descriptor Request, Active Endpoint Request 213 | // and Match Descriptor Requests which come from an 214 | // endpoint will be passed through (received). 215 | if (this.apiOptions != 3) { 216 | configCommands.push(this.AT(AT_CMD.API_OPTIONS, { apiOptions: 3 })); 217 | configCommands.push(this.AT(AT_CMD.API_OPTIONS)); 218 | } 219 | if (this.encryptionEnabled != 1) { 220 | configCommands.push(this.AT(AT_CMD.ENCRYPTION_ENABLED, { encryptionEnabled: 1 })); 221 | configCommands.push(this.AT(AT_CMD.ENCRYPTION_ENABLED)); 222 | } 223 | if (this.encryptionOptions != 2) { 224 | configCommands.push(this.AT(AT_CMD.ENCRYPTION_OPTIONS, { encryptionOptions: 2 })); 225 | configCommands.push(this.AT(AT_CMD.ENCRYPTION_OPTIONS)); 226 | } 227 | let configScanChannels = this.config.scanChannels; 228 | if (typeof configScanChannels === 'string') { 229 | configScanChannels = parseInt(configScanChannels, 16); 230 | } else if (typeof configScanChannels !== 'number') { 231 | configScanChannels = 0x1ffe; 232 | } 233 | if (this.scanChannels != configScanChannels) { 234 | // For reference, the most likely values to use as configScanChannels 235 | // would be channels 15 and 20, which sit between the Wifi channels. 236 | // Channel 15 corresponds to a mask of 0x0010 237 | // Channel 20 corresponds to a mask of 0x0200 238 | configCommands.push(this.AT(AT_CMD.SCAN_CHANNELS, { scanChannels: configScanChannels })); 239 | configCommands.push(this.AT(AT_CMD.SCAN_CHANNELS)); 240 | } 241 | if (configCommands.length > 0) { 242 | // We're going to change something, so we might as well set the link 243 | // key, since it's write only and we can't tell if its been set before. 244 | configCommands.push(this.AT(AT_CMD.LINK_KEY, { linkKey: 'ZigBeeAlliance09' })); 245 | configCommands.push(this.AT(AT_CMD.WRITE_PARAMETERS)); 246 | 247 | // TODO: It sends a modem status, but only the first time after the 248 | // dongle powers up. So I'm not sure if we need to wait on anything 249 | // after doing the WR command. 250 | // configCommands.push(new Command(WAIT_FRAME, { 251 | // type: C.FRAME_TYPE.MODEM_STATUS 252 | // })); 253 | this.queueCommandsAtFront(configCommands); 254 | } else { 255 | console.log('No configuration required'); 256 | } 257 | } 258 | 259 | deviceTypeString() { 260 | if (DEVICE_TYPE.hasOwnProperty(this.deviceTypeIdentifier)) { 261 | return DEVICE_TYPE[this.deviceTypeIdentifier]; 262 | } 263 | return '??? Unknown ???'; 264 | } 265 | 266 | dumpFrame(label, frame, dumpFrameDetail) { 267 | if (typeof dumpFrameDetail === 'undefined') { 268 | dumpFrameDetail = DEBUG_frameDetail; 269 | } 270 | this.frameDumped = true; 271 | let frameTypeStr = this.frameTypeAsStr(frame); 272 | if (!frameTypeStr) { 273 | frameTypeStr = `Unknown(0x${frame.type.toString(16)})`; 274 | } 275 | let atCmdStr; 276 | 277 | switch (frame.type) { 278 | case C.FRAME_TYPE.AT_COMMAND: 279 | if (frame.command in AT_CMD) { 280 | atCmdStr = AT_CMD[frame.command]; 281 | } else { 282 | atCmdStr = frame.command; 283 | } 284 | if (frame.commandParameter.length > 0) { 285 | console.log(label, frameTypeStr, 'Set', atCmdStr); 286 | } else { 287 | console.log(label, frameTypeStr, 'Get', atCmdStr); 288 | } 289 | break; 290 | 291 | case C.FRAME_TYPE.AT_COMMAND_RESPONSE: 292 | if (frame.command in AT_CMD) { 293 | atCmdStr = AT_CMD[frame.command]; 294 | } else { 295 | atCmdStr = frame.command; 296 | } 297 | console.log(label, frameTypeStr, atCmdStr); 298 | break; 299 | 300 | case C.FRAME_TYPE.EXPLICIT_ADDRESSING_ZIGBEE_COMMAND_FRAME: { 301 | this.dumpZigbeeTxFrame(label, frame); 302 | break; 303 | } 304 | 305 | case C.FRAME_TYPE.ZIGBEE_EXPLICIT_RX: { 306 | this.dumpZigbeeRxFrame(label, frame); 307 | break; 308 | } 309 | 310 | case C.FRAME_TYPE.ZIGBEE_TRANSMIT_STATUS: 311 | if (dumpFrameDetail || frame.deliveryStatus !== 0) { 312 | console.log( 313 | label, 314 | frameTypeStr, 315 | 'id:', 316 | frame.id, 317 | 'Remote16:', 318 | frame.remote16, 319 | 'Retries:', 320 | frame.transmitRetryCount, 321 | 'Delivery:', 322 | this.getDeliveryStatusAsString(frame.deliveryStatus), 323 | 'Discovery:', 324 | this.getDiscoveryStatusAsString(frame.discoveryStatus) 325 | ); 326 | } 327 | break; 328 | 329 | case C.FRAME_TYPE.MODEM_STATUS: 330 | console.log( 331 | label, 332 | frameTypeStr, 333 | 'modemStatus:', 334 | this.getModemStatusAsString(frame.modemStatus) 335 | ); 336 | break; 337 | 338 | case C.FRAME_TYPE.ROUTE_RECORD: 339 | if (dumpFrameDetail) { 340 | console.log(label, frameTypeStr); 341 | } 342 | break; 343 | 344 | default: 345 | console.log(label, frameTypeStr); 346 | } 347 | if (dumpFrameDetail) { 348 | const frameStr = util.inspect(frame, { depth: null }).replace(/\n/g, `\n${label} `); 349 | console.log(label, frameStr); 350 | } 351 | } 352 | 353 | dumpInfo() { 354 | let deviceTypeString = DEVICE_TYPE[this.deviceTypeIdentifier]; 355 | if (!deviceTypeString) { 356 | deviceTypeString = '??? Unknown ???'; 357 | } 358 | console.log( 359 | ' Device Type:', 360 | `0x${this.deviceTypeIdentifier.toString(16)} -`, 361 | this.deviceTypeString(this.deviceTypeIdentifier) 362 | ); 363 | console.log(' Network Address:', this.serialNumber, this.networkAddr16); 364 | console.log(' Node Identifier:', this.nodeIdentifier); 365 | console.log(' Configured PAN Id:', this.configuredPanId64); 366 | console.log(' Operating PAN Id:', this.operatingPanId64, this.operatingPanId16); 367 | console.log(' Operating Channel:', this.operatingChannel); 368 | console.log(' Channel Scan Mask:', this.scanChannels.toString(16)); 369 | console.log(' Join Time:', this.networkJoinTime); 370 | console.log('Remaining Children:', this.numRemainingChildren); 371 | console.log(' Stack Profile:', this.zigBeeStackProfile); 372 | console.log(' API Options:', this.apiOptions); 373 | console.log('Encryption Enabled:', this.encryptionEnabled); 374 | console.log('Encryption Options:', this.encryptionOptions); 375 | } 376 | 377 | frameTypeAsStr(frame) { 378 | if (C.FRAME_TYPE.hasOwnProperty(frame.type)) { 379 | return C.FRAME_TYPE[frame.type]; 380 | } 381 | return `${frame.type} (0x${frame.type.toString(16)})`; 382 | } 383 | 384 | getDeliveryStatusAsString(deliveryStatus) { 385 | if (deliveryStatus in C.DELIVERY_STATUS) { 386 | return C.DELIVERY_STATUS[deliveryStatus]; 387 | } 388 | return `??? 0x${deliveryStatus.toString(16)} ???`; 389 | } 390 | 391 | getDiscoveryStatusAsString(discoveryStatus) { 392 | if (discoveryStatus in C.DISCOVERY_STATUS) { 393 | return C.DISCOVERY_STATUS[discoveryStatus]; 394 | } 395 | return `??? 0x${discoveryStatus.toString(16)} ???`; 396 | } 397 | 398 | getExplicitRxFrameType() { 399 | return C.FRAME_TYPE.ZIGBEE_EXPLICIT_RX; 400 | } 401 | 402 | getExplicitTxFrameType() { 403 | return C.FRAME_TYPE.EXPLICIT_ADDRESSING_ZIGBEE_COMMAND_FRAME; 404 | } 405 | 406 | getFrameHandler(frame) { 407 | return XBeeDriver.frameHandler[frame.type]; 408 | } 409 | 410 | getModemStatusAsString(modemStatus) { 411 | if (modemStatus in C.MODEM_STATUS) { 412 | return C.MODEM_STATUS[modemStatus]; 413 | } 414 | return `??? 0x${modemStatus.toString(16)} ???`; 415 | } 416 | 417 | getTransmitStatusFrameType() { 418 | return C.FRAME_TYPE.ZIGBEE_TRANSMIT_STATUS; 419 | } 420 | 421 | handleTransmitStatus(frame) { 422 | if (frame.deliveryStatus !== 0) { 423 | // Note: For failed transmissions, the remote16 will always be set 424 | // to 0xfffd so there isn't any point in reporting it. 425 | if (DEBUG_frames) { 426 | console.log( 427 | 'Transmit Status ERROR:', 428 | this.getDeliveryStatusAsString(frame.deliveryStatus), 429 | 'id:', 430 | frame.id 431 | ); 432 | console.log(frame); 433 | } 434 | } 435 | if (frame.discoveryStatus == C.DISCOVERY_STATUS.EXTENDED_TIMEOUT_DISCOVERY) { 436 | const node = this.adapter.findNodeByAddr16(frame.remote16); 437 | if (node) { 438 | node.extendedTimeout = true; 439 | } else { 440 | console.log('Unable to find node for remote16 =', frame.remote16); 441 | } 442 | } 443 | } 444 | 445 | handleRouteRecord(frame) { 446 | if (DEBUG_flow) { 447 | console.log('Processing ROUTE_RECORD'); 448 | } 449 | this.adapter.findNodeFromRxFrame(frame); 450 | } 451 | 452 | // ----- AT Commands ------------------------------------------------------- 453 | 454 | handleAtResponse(frame) { 455 | if (frame.commandData.length) { 456 | this.at.parseFrame(frame); 457 | if (frame.command in XBeeDriver.atCommandMap) { 458 | const varName = XBeeDriver.atCommandMap[frame.command]; 459 | this[varName] = frame[varName]; 460 | } else if (frame.command in XBeeDriver.atResponseHandler) { 461 | XBeeDriver.atResponseHandler[frame.command].call(this, frame); 462 | } 463 | } 464 | } 465 | 466 | handleAtSerialNumberHigh(frame) { 467 | this.serialNumber = frame.serialNumberHigh + this.serialNumber.slice(8, 8); 468 | this.adapter.networkAddr64 = this.serialNumber; 469 | this.adapter.networkAddr16 = '0000'; 470 | } 471 | 472 | handleAtSerialNumberLow(frame) { 473 | this.serialNumber = this.serialNumber.slice(0, 8) + frame.serialNumberLow; 474 | this.adapter.networkAddr64 = this.serialNumber; 475 | this.adapter.networkAddr16 = '0000'; 476 | } 477 | 478 | // ------------------------------------------------------------------------- 479 | 480 | nextFrameId() { 481 | return xbeeApi._frame_builder.nextFrameId(); 482 | } 483 | 484 | permitJoinCommands(duration) { 485 | return this.AT(AT_CMD.NODE_JOIN_TIME, { networkJoinTime: duration }, PERMIT_JOIN_PRIORITY); 486 | } 487 | } 488 | 489 | XBeeDriver.atCommandMap = { 490 | [AT_CMD.API_OPTIONS]: 'apiOptions', 491 | [AT_CMD.API_MODE]: 'apiMode', 492 | [AT_CMD.CONFIGURED_64_BIT_PAN_ID]: 'configuredPanId64', 493 | [AT_CMD.DEVICE_TYPE_IDENTIFIER]: 'deviceTypeIdentifier', 494 | [AT_CMD.ENCRYPTION_ENABLED]: 'encryptionEnabled', 495 | [AT_CMD.ENCRYPTION_OPTIONS]: 'encryptionOptions', 496 | [AT_CMD.NETWORK_ADDR_16_BIT]: 'networkAddr16', 497 | [AT_CMD.NODE_IDENTIFIER]: 'nodeIdentifier', 498 | [AT_CMD.NODE_JOIN_TIME]: 'networkJoinTime', 499 | [AT_CMD.NUM_REMAINING_CHILDREN]: 'numRemainingChildren', 500 | [AT_CMD.OPERATING_16_BIT_PAN_ID]: 'operatingPanId16', 501 | [AT_CMD.OPERATING_64_BIT_PAN_ID]: 'operatingPanId64', 502 | [AT_CMD.OPERATING_CHANNEL]: 'operatingChannel', 503 | [AT_CMD.SCAN_CHANNELS]: 'scanChannels', 504 | [AT_CMD.ZIGBEE_STACK_PROFILE]: 'zigBeeStackProfile', 505 | }; 506 | 507 | XBeeDriver.atResponseHandler = { 508 | [AT_CMD.SERIAL_NUMBER_HIGH]: XBeeDriver.prototype.handleAtSerialNumberHigh, 509 | [AT_CMD.SERIAL_NUMBER_LOW]: XBeeDriver.prototype.handleAtSerialNumberLow, 510 | }; 511 | 512 | XBeeDriver.frameHandler = { 513 | [C.FRAME_TYPE.AT_COMMAND_RESPONSE]: XBeeDriver.prototype.handleAtResponse, 514 | [C.FRAME_TYPE.ZIGBEE_EXPLICIT_RX]: ZigbeeDriver.prototype.handleExplicitRx, 515 | [C.FRAME_TYPE.ZIGBEE_TRANSMIT_STATUS]: XBeeDriver.prototype.handleTransmitStatus, 516 | [C.FRAME_TYPE.ROUTE_RECORD]: XBeeDriver.prototype.handleRouteRecord, 517 | }; 518 | 519 | module.exports = XBeeDriver; 520 | -------------------------------------------------------------------------------- /src/driver/zstack.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * ZStackDriver - Zigbee driver for TI's ZStack-based dongles (ex. CC253x) 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | 'use strict'; 11 | 12 | const zdo = require('zigbee-zdo'); 13 | const BufferReader = require('buffer-reader'); 14 | const BufferBuilder = require('buffer-builder'); 15 | const Unpi = require('unpi'); 16 | 17 | const { Command, FUNC, SEND_FRAME, WAIT_FRAME, ZigbeeDriver } = require('./index'); 18 | 19 | // const { 20 | // DEBUG_flow, 21 | // DEBUG_frameDetail, 22 | // DEBUG_frames, 23 | // DEBUG_rawFrames, 24 | // DEBUG_slip, 25 | // } = require('../zb-debug').default; 26 | 27 | const { PROFILE_ID } = require('../zb-constants'); 28 | 29 | const cmdType = { 30 | POLL: 0, 31 | SREQ: 1, 32 | AREQ: 2, 33 | SRSP: 3, 34 | }; 35 | 36 | const subSys = { 37 | RES0: 0, 38 | SYS: 1, 39 | MAC: 2, 40 | NWK: 3, 41 | AF: 4, 42 | ZDO: 5, 43 | SAPI: 6, 44 | UTIL: 7, 45 | DBG: 8, 46 | APP: 9, 47 | DEBUG: 15, 48 | }; 49 | 50 | const devStates = { 51 | DEV_HOLD: 0, 52 | DEV_INIT: 1, 53 | DEV_NWK_DISC: 2, 54 | DEV_NWK_JOINING: 3, 55 | DEV_NWK_SEC_REJOIN_CURR_CHANNEL: 4, 56 | DEV_END_DEVICE_UNAUTH: 5, 57 | DEV_END_DEVICE: 6, 58 | DEV_ROUTER: 7, 59 | DEV_COORD_STARTING: 8, 60 | DEV_ZB_COORD: 9, 61 | DEV_NWK_ORPHAN: 10, 62 | DEV_NWK_KA: 11, 63 | DEV_NWK_BACKOFF: 12, 64 | DEV_NWK_SEC_REJOIN_ALL_CHANNEL: 13, 65 | DEV_NWK_TC_REJOIN_CURR_CHANNEL: 14, 66 | DEV_NWK_TC_REJOIN_ALL_CHANNEL: 15, 67 | }; 68 | 69 | const nvItems = { 70 | ZCD_NV_USERDESC: 0x0081, 71 | ZCD_NV_NWKKEY: 0x0082, 72 | ZCD_NV_PANID: 0x0083, 73 | ZCD_NV_CHANLIST: 0x0084, 74 | ZCD_NV_LEAVE_CTRL: 0x0085, 75 | ZCD_NV_SCAN_DURATION: 0x0086, 76 | ZCD_NV_LOGICAL_TYPE: 0x0087, 77 | }; 78 | 79 | const BEACON_MAX_DEPTH = 0x0f; 80 | const DEF_RADIUS = 2 * BEACON_MAX_DEPTH; 81 | 82 | let self; 83 | 84 | class ZStackDriver extends ZigbeeDriver { 85 | constructor(addonManager, manifest, portName, serialPort) { 86 | super(addonManager, manifest); 87 | 88 | self = this; 89 | 90 | this.lastIDSeq = 0; 91 | this.lastZDOSeq = 0; 92 | 93 | this.idSeq = 0; 94 | 95 | this.serialPort = serialPort; 96 | this.unpi = new Unpi({ lenBytes: 1, phy: serialPort }); 97 | 98 | this.unpi.on('data', this.onZStackFrame); 99 | 100 | this.queueInitCmds(); 101 | } 102 | 103 | queueInitCmds() { 104 | this.queueCommands([ 105 | FUNC(this, this.resetZNP), 106 | FUNC(this, this.getExtAddr), 107 | FUNC(this, this.registerApp), 108 | FUNC(this, this.disableTCKeyExchange), 109 | FUNC(this, this.startCoordinator), 110 | ]); 111 | } 112 | 113 | resetZNP() { 114 | const frame = { 115 | type: cmdType.SREQ, 116 | subsys: 'SAPI', 117 | cmd: 0x09, // ZB_SYSTEM_RESET 118 | }; 119 | this.queueCommandsAtFront([ 120 | new Command(SEND_FRAME, frame), 121 | new Command(WAIT_FRAME, { 122 | type: cmdType.AREQ, 123 | subsys: subSys.SYS, 124 | cmd: 0x80, 125 | waitRetryTimeout: 5000, 126 | }), // SYS_RESET_IND 127 | ]); 128 | } 129 | 130 | registerApp() { 131 | const frame = { 132 | type: cmdType.SREQ, 133 | subsys: 'AF', 134 | cmd: 0x00, // register app 135 | payload: Buffer.from([ 136 | 0x01, // EP number 137 | 0x04, 138 | 0x01, // ZHA profile 139 | 0x50, 140 | 0x00, // DeviceID = Home Gateway 141 | this.product, // device 142 | 0x00, // no latency 143 | 0x02, // 1 input clusters 144 | 0x00, 145 | 0x00, 146 | 0x15, 147 | 0x00, 148 | 0x00, // 1 output clusters 149 | ]), 150 | }; 151 | this.queueCommandsAtFront([ 152 | new Command(SEND_FRAME, frame), 153 | new Command(WAIT_FRAME, { type: cmdType.SRSP }), 154 | ]); 155 | } 156 | 157 | getExtAddr() { 158 | const frame = { 159 | type: cmdType.SREQ, 160 | subsys: 'SYS', 161 | cmd: 0x04, 162 | }; 163 | this.queueCommandsAtFront([ 164 | new Command(SEND_FRAME, frame), 165 | new Command(WAIT_FRAME, { type: cmdType.SRSP }), 166 | ]); 167 | } 168 | 169 | getNVInfo() { 170 | const frame = { 171 | type: cmdType.SREQ, 172 | subsys: 'UTIL', 173 | cmd: 0x01, 174 | }; 175 | this.queueCommands([ 176 | new Command(SEND_FRAME, frame), 177 | new Command(WAIT_FRAME, { type: cmdType.SRSP }), 178 | ]); 179 | } 180 | 181 | getNWKInfo() { 182 | const frame = { 183 | type: cmdType.SREQ, 184 | subsys: 'ZDO', 185 | cmd: 0x50, 186 | }; 187 | this.queueCommands([ 188 | new Command(SEND_FRAME, frame), 189 | new Command(WAIT_FRAME, { type: cmdType.SRSP, cmd: 0x50 }), 190 | ]); 191 | } 192 | 193 | zdoRegisterCallbacks() { 194 | const frame = { 195 | type: cmdType.SREQ, 196 | subsys: 'ZDO', 197 | cmd: 0x3e, // ZDO_MSG_CB_REG 198 | payload: Buffer.from([0xff, 0xff]), 199 | }; 200 | this.queueCommandsAtFront([ 201 | new Command(SEND_FRAME, frame), 202 | new Command(WAIT_FRAME, { type: cmdType.SRSP }), 203 | ]); 204 | } 205 | 206 | startCoordinator() { 207 | const frame = { 208 | type: cmdType.SREQ, 209 | subsys: 'SAPI', 210 | cmd: 0x00, // ZB_START_REQ 211 | }; 212 | this.queueCommandsAtFront([ 213 | new Command(SEND_FRAME, frame), 214 | new Command(WAIT_FRAME, { type: cmdType.SRSP }), 215 | ]); 216 | } 217 | 218 | allowBind() { 219 | const frame = { 220 | type: cmdType.SREQ, 221 | subsys: 'SAPI', 222 | cmd: 0x02, // MT_SAPI_ALLOW_BIND_REQ 223 | payload: Buffer.from([0xff]), 224 | }; 225 | this.queueCommandsAtFront([ 226 | new Command(SEND_FRAME, frame), 227 | new Command(WAIT_FRAME, { type: cmdType.SRSP }), 228 | ]); 229 | } 230 | 231 | disableTCKeyExchange() { 232 | const frame = { 233 | type: cmdType.SREQ, 234 | subsys: 'DEBUG', 235 | cmd: 0x09, // MT_APP_CNF_BDB_SET_TC_REQUIRE_KEY_EXCHANGE 236 | payload: Buffer.from([0x00]), 237 | }; 238 | this.queueCommandsAtFront([ 239 | new Command(SEND_FRAME, frame), 240 | new Command(WAIT_FRAME, { type: cmdType.SRSP }), 241 | ]); 242 | } 243 | 244 | setUseMulticast() { 245 | const frame = { 246 | type: cmdType.SREQ, 247 | subsys: 'ZDO', 248 | cmd: 0x53, 249 | payload: Buffer.from([0x81]), 250 | }; 251 | this.queueCommandsAtFront([ 252 | new Command(SEND_FRAME, frame), 253 | new Command(WAIT_FRAME, { type: cmdType.SRSP }), 254 | ]); 255 | } 256 | 257 | readNVItem(item) { 258 | const frame = { 259 | type: cmdType.SREQ, 260 | subsys: 'SYS', 261 | cmd: 0x08, // OSAL_NV_READ 262 | payload: Buffer.from([0, 0, 0]), 263 | }; 264 | 265 | this.lastNVReadItem = item; 266 | frame.payload.writeUInt16LE(item, 0); 267 | 268 | this.queueCommandsAtFront([ 269 | new Command(SEND_FRAME, frame), 270 | new Command(WAIT_FRAME, { type: cmdType.SRSP, cmd: 0x08 }), 271 | ]); 272 | } 273 | 274 | writeNVItem(item, data) { 275 | const frame = { 276 | type: cmdType.SREQ, 277 | subsys: 'SYS', 278 | cmd: 0x09, 279 | }; 280 | 281 | const nvData = Buffer.alloc(4 + data.length); 282 | const builder = new BufferBuilder(nvData); 283 | 284 | builder.appendUInt16LE(item); 285 | builder.appendUInt8(0x00); 286 | builder.appendUInt8(data.length); 287 | builder.appendBuffer(Buffer.from(data)); 288 | frame.payload = nvData.slice(0, builder.length); 289 | 290 | this.queueCommandsAtFront([ 291 | new Command(SEND_FRAME, frame), 292 | new Command(WAIT_FRAME, { type: cmdType.SRSP }), 293 | ]); 294 | } 295 | 296 | buildAndSendRawFrame(frame) { 297 | // console.log('buildAndSendRawFrame: '); 298 | // console.log(frame); 299 | 300 | if (frame.hasOwnProperty('subsys') && frame.hasOwnProperty('cmd')) { 301 | this.lastIDSeq = frame.id; 302 | this.unpi.send(frame.type, frame.subsys, frame.cmd, frame.payload); 303 | } else { 304 | const zsf = { type: frame.type }; 305 | 306 | if (frame.profileId === PROFILE_ID.ZDO) { 307 | zsf.subsys = 'ZDO'; 308 | zsf.cmd = parseInt(frame.clusterId, 16); 309 | self.lastZDOSeq = frame.zdoSeq; 310 | 311 | const zdoData = Buffer.alloc(256); 312 | const builder = new BufferBuilder(zdoData); 313 | 314 | console.log(`Sending ${zdo.CLUSTER_ID[zsf.cmd]}`); 315 | switch (zsf.cmd) { 316 | default: 317 | console.warn(`ZDO command ${zsf.cmd} not handled!`); 318 | break; 319 | case zdo.CLUSTER_ID.NETWORK_ADDRESS_REQUEST: 320 | builder.appendBuffer(frame.data.slice(1)); 321 | break; 322 | case zdo.CLUSTER_ID.MANAGEMENT_LQI_REQUEST: 323 | builder.appendUInt16LE(parseInt(frame.destination16, 16)); 324 | builder.appendUInt8(frame.startIndex); 325 | break; 326 | case zdo.CLUSTER_ID.MANAGEMENT_PERMIT_JOIN_REQUEST: { 327 | const dstAddr = parseInt(frame.destination16, 16); 328 | 329 | builder.appendUInt8(0x02); 330 | builder.appendUInt16LE(dstAddr); 331 | builder.appendUInt8(frame.permitDuration); 332 | builder.appendUInt8(frame.trustCenterSignificance); 333 | break; 334 | } 335 | case zdo.CLUSTER_ID.MANAGEMENT_LEAVE_REQUEST: { 336 | builder.appendUInt16LE(parseInt(frame.destination16, 16)); 337 | builder.appendBuffer(frame.data.slice(1)); 338 | break; 339 | } 340 | case zdo.CLUSTER_ID.SIMPLE_DESCRIPTOR_REQUEST: 341 | builder.appendUInt16LE(parseInt(frame.destination16, 16)); 342 | builder.appendBuffer(frame.data.slice(1)); 343 | break; 344 | case zdo.CLUSTER_ID.NODE_DESCRIPTOR_REQUEST: 345 | case zdo.CLUSTER_ID.ACTIVE_ENDPOINTS_REQUEST: 346 | { 347 | const dstAddr = parseInt(frame.destination16, 16); 348 | 349 | builder.appendUInt16LE(dstAddr); 350 | builder.appendUInt16LE(dstAddr); 351 | builder.appendUInt8(zsf.cmd); 352 | zsf.cmd = 0x29; // address of interest 353 | } 354 | break; 355 | case zdo.CLUSTER_ID.BIND_REQUEST: 356 | { 357 | builder.appendUInt16LE(parseInt(frame.destination16, 16)); 358 | builder.appendBuffer(frame.data.slice(1)); 359 | } 360 | break; 361 | } 362 | 363 | zsf.payload = zdoData.slice(0, builder.length); 364 | } else if (this.isZclFrame(frame)) { 365 | zsf.subsys = 'AF'; 366 | zsf.cmd = 0x01; // AF_DATA_REQ 367 | 368 | const zclData = Buffer.alloc(256); 369 | const builder = new BufferBuilder(zclData); 370 | 371 | builder.appendUInt16LE(parseInt(frame.destination16, 16)); 372 | builder.appendUInt8(frame.destinationEndpoint); 373 | builder.appendUInt8(frame.sourceEndpoint); 374 | builder.appendUInt16LE(parseInt(frame.clusterId, 16)); 375 | builder.appendUInt8(frame.id); 376 | builder.appendUInt8(frame.options); 377 | builder.appendUInt8(DEF_RADIUS); 378 | builder.appendUInt8(frame.data.length); 379 | builder.appendBuffer(frame.data); 380 | zsf.payload = zclData.slice(0, builder.length); 381 | 382 | // console.log('AF data req: ', zsf.payload); 383 | } else { 384 | console.warn(`Profile ${frame.profileId} not handled! Skipping sending frame!`); 385 | return; 386 | } 387 | 388 | this.lastIDSeq = frame.id; 389 | this.unpi.send(zsf.type, zsf.subsys, zsf.cmd, zsf.payload); 390 | } 391 | } 392 | 393 | onZStackFrame(frame) { 394 | // console.log('ZStack frame:', frame); 395 | 396 | if (frame.type == cmdType.AREQ) { 397 | self.parseAREQ(frame); 398 | } else if (frame.type == cmdType.SRSP) { 399 | self.parseSRSP(frame); 400 | } else { 401 | console.warn('No handler defined for frame type: ', frame.type); 402 | return; 403 | } 404 | 405 | if ( 406 | (frame.type == cmdType.AREQ && frame.subsys == subSys.ZDO && frame.cmd == 0xff) || 407 | frame.drop === true 408 | ) { 409 | return; 410 | } 411 | 412 | self.handleFrame(frame); 413 | } 414 | 415 | parseAREQ(frame) { 416 | // console.log('AREQ:'); 417 | const reader = new BufferReader(frame.payload); 418 | 419 | if (frame.subsys == subSys.ZDO) { 420 | frame.id = self.lastIDSeq; 421 | 422 | if ( 423 | (frame.cmd >= 0x82 && // nodeDescRsp 424 | frame.cmd <= 0x8a) || // serverDiscRsp 425 | (frame.cmd >= 0xb0 && // mgmtNwkDiscRsp 426 | frame.cmd <= 0xb6) || // mgmtPermitJoinRsp 427 | (frame.cmd >= 0xa0 && // bindings 428 | frame.cmd <= 0xa2) 429 | ) { 430 | frame.remote16 = reader.nextString(2, 'hex').swapHex(); 431 | reader.move(-1); 432 | frame.data = reader.restAll(); 433 | frame.data[0] = this.lastZDOSeq; 434 | frame.profileId = 0; 435 | frame.clusterId = (0x8000 | (frame.cmd & 0x7f)).toString(16); 436 | 437 | // console.log(frame); 438 | // console.log(this.adapter.nodes); 439 | const node = this.adapter.findNodeByAddr16(frame.remote16); 440 | if (node) { 441 | frame.remote64 = node.addr64; 442 | } 443 | frame.destination64 = this.adapter.destination64; 444 | // console.log('ZDO RSP: ', frame); 445 | } else if (frame.cmd == 0x80) { 446 | // nwkAddrRsp 447 | frame.clusterId = (0x8000 | (frame.cmd & 0x7f)).toString(16); 448 | frame.profileId = 0; 449 | frame.data = Buffer.allocUnsafe(frame.payload.length + 1); 450 | frame.data.writeUInt8(this.lastZDOSeq); 451 | frame.payload.copy(frame.data, 1, 0, 1 + 8 + 2); 452 | frame.data[12] = frame.payload[12]; 453 | frame.data[13] = frame.payload[11]; 454 | } else if (frame.cmd == 0xc0) { 455 | // DevStateChanged 456 | console.log('ZStack device state changed to ', frame.payload[0]); 457 | if (frame.payload[0] == devStates.DEV_ZB_COORD) { 458 | console.log('Zigbee coordinator started!'); 459 | this.getNWKInfo(); 460 | this.getNVInfo(); 461 | this.adapter.adapterInitialized(); 462 | } else if ( 463 | frame.payload[0] == devStates.DEV_END_DEVICE || 464 | frame.payload[0] == devStates.DEV_ROUTER 465 | ) { 466 | console.log('ZStack role is router or enddevice. Chaning to coodinator!'); 467 | this.resetZNP(); 468 | this.writeNVItem(nvItems.ZCD_NV_LOGICAL_TYPE, [0x00]); 469 | } 470 | } else if (frame.cmd == 0xc1) { 471 | // endDeviceAnnceInd 472 | frame.profileId = 0; 473 | frame.clusterId = '0013'; 474 | frame.remote16 = reader.nextString(2, 'hex').swapHex(); 475 | reader.move(-1); 476 | frame.data = reader.restAll(); 477 | frame.data[0] = this.lastZDOSeq; 478 | } else if (frame.cmd == 0xc4) { 479 | // SRC RTG indication 480 | frame.drop = true; 481 | } else if (frame.cmd == 0xc9) { 482 | // leave indication 483 | frame.remote16 = reader.nextString(2, 'hex').swapHex(); 484 | frame.remote64 = reader.nextString(8, 'hex').swapHex(); 485 | 486 | console.log(`Device ${frame.remote64}:${frame.remote16} left network!`); 487 | frame.drop = true; 488 | } else if (frame.cmd == 0xca) { 489 | // TC indication 490 | frame.drop = true; 491 | } else if (frame.cmd == 0xff) { 492 | // zdoMsgCbIncomming 493 | frame.profileId = PROFILE_ID.ZDO; 494 | frame.remote16 = reader.nextString(2, 'hex').swapHex(); 495 | frame.broadcast = reader.nextUInt8() == 0 ? false : true; 496 | frame.clusterId = reader.nextString(2, 'hex').swapHex(); 497 | frame.securityUse = reader.nextUInt8() == 0 ? false : true; 498 | frame.zdoSeq = reader.nextUInt8(); 499 | frame.destination16 = reader.nextString(2, 'hex').swapHex(); 500 | reader.move(-1); 501 | frame.data = reader.restAll(); 502 | frame.data[0] = self.lastZDOSeq; 503 | 504 | const node = this.adapter.findNodeByAddr16(frame.remote16); 505 | if (node) { 506 | frame.remote64 = node.addr64; 507 | // console.log(node); 508 | } 509 | } 510 | } else if (frame.subsys == subSys.AF) { 511 | frame.id = self.lastIDSeq; 512 | if (frame.cmd == 0x81) { 513 | frame.profileId = PROFILE_ID.ZHA.toString(16).padStart(4, '0'); 514 | frame.groupId = reader.nextUInt16LE(); 515 | frame.clusterId = reader.nextString(2, 'hex').swapHex(); 516 | frame.remote16 = reader.nextString(2, 'hex').swapHex(); 517 | frame.sourceEndpoint = reader.nextString(1, 'hex'); 518 | frame.destinationEndpoint = reader.nextString(1, 'hex'); 519 | frame.broadcast = reader.nextUInt8() == 0 ? false : true; 520 | frame.lqi = reader.nextUInt8(); 521 | frame.securityUse = reader.nextUInt8() == 0 ? false : true; 522 | frame.timestamp = reader.nextInt32LE(); 523 | frame.zdoSeq = reader.nextUInt8(); 524 | const dataLen = reader.nextUInt8(); 525 | frame.data = reader.restAll().slice(0, dataLen); 526 | 527 | const node = this.adapter.findNodeByAddr16(frame.remote16); 528 | if (node) { 529 | frame.remote64 = node.addr64; 530 | } 531 | } else if (frame.cmd == 0x80) { 532 | // AF data confirm 533 | frame.drop = true; 534 | } else { 535 | console.warn(`AF AREQ, cmd ${frame.cmd} not handled!`); 536 | } 537 | } else if (frame.subsys == subSys.SYS) { 538 | if (frame.cmd == 0x80) { 539 | // SYS_RESET_IND 540 | // version response 541 | this.transportRev = frame.payload[1]; 542 | this.product = frame.payload[2]; 543 | this.version = `${frame.payload[3].toString(16)}.${frame.payload[4].toString( 544 | 16 545 | )}.${frame.payload[5].toString(16)}`; 546 | 547 | console.log('ZStack reset. Reason ', frame.payload[0]); 548 | console.log( 549 | `ZStack dongle ${this.transportRev}, product: ${this.product}, version: ${this.version}` 550 | ); 551 | } else { 552 | console.warn(`SYS AREQ, cmd ${frame.cmd} not handled!`); 553 | } 554 | } else { 555 | console.warn(`No parser for AREQ, subsystem ${frame.subsys}`); 556 | } 557 | } 558 | 559 | parseSRSP(frame) { 560 | // console.log('SRSP: ', frame); 561 | if (frame.subsys == subSys.SYS) { 562 | if (frame.cmd == 0x04) { 563 | const br = new BufferReader(frame.payload); 564 | 565 | this.adapter.networkAddr64 = br.nextString(8, 'hex').swapHex(); 566 | this.adapter.networkAddr16 = '0000'; 567 | } 568 | // else if (frame.cmd == 0x08) { // OSAL_NV_READ 569 | // if (frame.payload[0] == 0x00) // success 570 | // { 571 | // if (this.lastNVReadItem == nvItems.ZCD_NV_PANID) { 572 | // const panID = frame.payload.readUInt16LE(2); 573 | // if (panID == 0xFFFF) { 574 | 575 | // } 576 | // } 577 | // } 578 | } else if (frame.subsys == subSys.ZDO) { 579 | if (frame.cmd == 0x50) { 580 | // NWK info rsp 581 | const br = new BufferReader(frame.payload); 582 | this.shortAddr = br.nextString(2, 'hex').swapHex(); 583 | this.PANID = br.nextString(2, 'hex').swapHex(); 584 | this.parentAddr = br.nextString(2, 'hex').swapHex(); 585 | this.ExtPANID = br.nextString(8, 'hex').swapHex(); 586 | this.ExtParentAddr = br.nextString(8, 'hex').swapHex(); 587 | this.channel = br.nextUInt8(); 588 | 589 | console.log('NWK info:'); 590 | console.log('PANID: ', this.PANID); 591 | console.log('Ext PANID: ', this.ExtPANID); 592 | console.log('Current channel: ', this.channel); 593 | } else { 594 | frame.status = frame.payload[0]; 595 | if (frame.status == 0x00) { 596 | frame.id = self.lastIDSeq; 597 | } else { 598 | console.log(`ZDO SRSP for cmd ${frame.cmd} status error ${frame.status}!`); 599 | } 600 | } 601 | } else if (frame.subsys == subSys.AF) { 602 | frame.status = frame.payload[0]; 603 | frame.type = self.getExplicitRxFrameType(); 604 | // console.log('last frame: ', JSON.stringify(this.lastFrameSent)); 605 | if (this.lastFrameSent.destination64) { 606 | frame.remote64 = this.lastFrameSent.destination64; 607 | } 608 | if (frame.status == 0x00) { 609 | frame.id = self.lastIDSeq; 610 | } else { 611 | console.log(`AF SRSP for cmd ${frame.cmd} status error ${frame.status}!`); 612 | } 613 | } else if (frame.subsys == subSys.UTIL) { 614 | if (frame.cmd == 0x01) { 615 | // get NV info 616 | const br = new BufferReader(frame.payload); 617 | const status = br.nextUInt8(); 618 | 619 | if ((status & 0x01) == 0) { 620 | console.log('IEEE: ', br.nextString(8, 'hex').swapHex()); 621 | } 622 | if ((status & 0x02) == 0) { 623 | console.log('Channels: ', br.nextString(4, 'hex').swapHex()); 624 | } 625 | if ((status & 0x04) == 0) { 626 | const nvPANID = br.nextString(2, 'hex').swapHex(); 627 | console.log('PanID: ', nvPANID); 628 | if (this.PANID !== nvPANID) { 629 | console.log(`Saving PAN ID: ${this.PANID} to NV ram!`); 630 | const p = parseInt(this.PANID, 16); 631 | this.writeNVItem(nvItems.ZCD_NV_PANID, [p & 0xff, p >> 8]); 632 | } 633 | } 634 | } 635 | } else { 636 | console.warn('No parser for SRSP, subsystem ', frame.subsys); 637 | } 638 | } 639 | 640 | nextFrameId() { 641 | self.idSeq++; 642 | if (self.idSeq > 0xff) { 643 | self.idSeq = 0; 644 | } 645 | return self.idSeq; 646 | } 647 | 648 | frameTypeAsStr(frame) { 649 | return `${frame.type} (0x${frame.type.toString(16)})`; 650 | } 651 | 652 | dumpFrame(label, _frame, _dumpFrameDetail) { 653 | console.log(label); 654 | } 655 | 656 | getExplicitRxFrameType() { 657 | return cmdType.AREQ; 658 | } 659 | 660 | getExplicitTxFrameType() { 661 | return cmdType.SREQ; 662 | } 663 | 664 | getTransmitStatusFrameType() { 665 | return cmdType.SRSP; 666 | } 667 | 668 | permitJoinCommands(_duration) { 669 | return []; 670 | } 671 | 672 | asDeviceInfo() { 673 | return { 674 | deviceType: 'coordinator', 675 | version: this.version, 676 | configuredPanId64: this.adapter.networkAddr64, 677 | }; 678 | } 679 | 680 | handleAREQ(frame) { 681 | // console.log('AREQ post handle: ', frame); 682 | if (frame.subsys == subSys.ZDO) { 683 | self.handleExplicitRx(frame); 684 | } else if (frame.subsys == subSys.AF && frame.cmd == 0x81) { 685 | self.handleExplicitRx(frame); 686 | } 687 | } 688 | 689 | handleSRSP(_frame) { 690 | // pass 691 | } 692 | 693 | getFrameHandler(frame) { 694 | return ZStackDriver.frameHandler[frame.type]; 695 | } 696 | 697 | close() { 698 | this.serialPort.close(); 699 | } 700 | } 701 | 702 | ZStackDriver.frameHandler = { 703 | [cmdType.AREQ]: ZStackDriver.prototype.handleAREQ, 704 | [cmdType.SRSP]: ZStackDriver.prototype.handleSRSP, 705 | }; 706 | 707 | module.exports = ZStackDriver; 708 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * index.js - Loads the Zigbee adapter. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | 'use strict'; 11 | 12 | const { Database } = require('gateway-addon'); 13 | const { PACKAGE_ID } = require('./constants'); 14 | const SerialProber = require('serial-prober'); 15 | const { Zigbee2MqttDriver } = require('./zigbee2mqtt/zigbee2mqtt-driver'); 16 | const SerialPort = require('serialport'); 17 | 18 | const XBEE_FTDI_FILTER = { 19 | // Devices like the UartSBee, use a generic FTDI chip and with 20 | // an XBee S2 programmed with the right firmware can act like 21 | // a Zigbee coordinator. 22 | vendorId: '0403', 23 | productId: '6001', 24 | manufacturer: 'FTDI', 25 | }; 26 | 27 | const xbeeSerialProber = new SerialProber({ 28 | name: 'XBee', 29 | allowAMASerial: false, 30 | baudRate: 9600, 31 | // XBee Get API Mode Command 32 | probeCmd: [ 33 | 0x7e, // Start of frame 34 | 0x00, 35 | 0x04, // Payload Length 36 | 0x08, // AT Command Request 37 | 0x01, // Frame ID 38 | 0x41, 39 | 0x50, // AP - API Enable 40 | 0x65, // Checksum 41 | ], 42 | probeRsp: [ 43 | 0x7e, // Start of frame 44 | 0x00, 45 | 0x06, // Payload length 46 | 0x88, // AT Command Response 47 | 0x01, // Frame ID 48 | 0x41, 49 | 0x50, // AP 50 | // This would normally be followed by the current API mode, and a 51 | // checksum, but since we don't know those will be, we only match on 52 | // the first part of the response. 53 | ], 54 | filter: [ 55 | { 56 | // The green Zigbee dongle from Digi has a manufacturer of 'Digi' 57 | // even though it uses the FTDI vendorId. 58 | vendorId: /0403/i, 59 | productId: /6001/i, 60 | manufacturer: 'Digi', 61 | }, 62 | ], 63 | }); 64 | 65 | const conbeeSerialProber = new SerialProber({ 66 | name: 'conbee', 67 | allowAMASerial: false, 68 | baudRate: 38400, 69 | // conbee VERSION Command 70 | probeCmd: [ 71 | 0xc0, // END - SLIP Framing 72 | 0x0d, // VERSION Command 73 | 0x01, // Sequence number 74 | 0x00, // Reserved - set to zero 75 | 0x05, 76 | 0x00, // Frame length 77 | 0xed, 78 | 0xff, // CRC 79 | 0xc0, // END - SLIP framing 80 | ], 81 | probeRsp: [ 82 | 0xc0, // END - SLIP framing 83 | 0x0d, // VERSION Command 84 | 0x01, // Sequence NUmber 85 | 0x00, // Reserved 86 | 0x09, 87 | 0x00, // Frame length 88 | // This would normally be followed a 4 byte version code, CRC, and END 89 | // but since we don't know what those will be we only match on the first 90 | // part of the response. 91 | ], 92 | filter: [ 93 | { 94 | vendorId: /0403/i, 95 | productId: /6015/i, 96 | }, 97 | { 98 | vendorId: /1cf1/i, 99 | productId: /0030/i, 100 | }, 101 | ], 102 | }); 103 | 104 | // cloned from conbeeSerialProber, just changing the probeCmd sequence 105 | // Assumes that if both probes could succeed, the first will have taken 106 | // ownership of the serial port before the 2nd is instantiated 107 | const conbeeNewerFirmwareSerialProber = new SerialProber({ 108 | name: 'conbee', 109 | allowAMASerial: false, 110 | baudRate: 38400, 111 | // conbee VERSION Command 112 | probeCmd: [ 113 | 0xc0, // END - SLIP Framing 114 | 0x0d, // VERSION Command 115 | 0x01, // Sequence number 116 | 0x00, // Reserved - set to zero 117 | 0x09, 118 | 0x00, // Frame length 119 | 0x00, 120 | 0x00, 121 | 0x00, 122 | 0x00, // additional VERSION payload / padding 123 | 0xe9, 124 | 0xff, // CRC 125 | 0xc0, // END - SLIP framing 126 | ], 127 | probeRsp: [ 128 | 0xc0, // END - SLIP framing 129 | 0x0d, // VERSION Command 130 | 0x01, // Sequence NUmber 131 | 0x00, // Reserved 132 | 0x09, 133 | 0x00, // Frame length 134 | // This would normally be followed a 4 byte version code, CRC, and END 135 | // but since we don't know what those will be we only match on the first 136 | // part of the response. 137 | ], 138 | filter: [ 139 | { 140 | vendorId: /0403/i, 141 | productId: /6015/i, 142 | }, 143 | { 144 | vendorId: /1cf1/i, 145 | productId: /0030/i, 146 | }, 147 | ], 148 | }); 149 | 150 | const cc2531SerialProber = new SerialProber({ 151 | name: 'cc2531', 152 | baudRate: 115200, 153 | allowAMASerial: false, 154 | probeCmd: [ 155 | 0xfe, // SOF 156 | 0x00, // length 157 | 0x21, 158 | 0x01, // CMD: PING REQ 159 | 0x20, // FCS 160 | ], 161 | 162 | probeRsp: [ 163 | 0xfe, 0x02, 0x61, 0x01, 164 | // CAPABILITIES 165 | ], 166 | 167 | filter: [ 168 | { 169 | vendorId: /0451/i, 170 | productId: /16a8/i, 171 | }, 172 | ], 173 | }); 174 | 175 | const PROBERS = [ 176 | xbeeSerialProber, 177 | conbeeSerialProber, 178 | cc2531SerialProber, 179 | conbeeNewerFirmwareSerialProber, 180 | ]; 181 | 182 | // Scan the serial ports looking for an XBee adapter. 183 | async function loadZigbeeAdapters(addonManager, _, errorCallback) { 184 | let allowFTDISerial = false; 185 | let allowAMASerial = false; 186 | 187 | let config = {}; 188 | // Attempt to move to new config format 189 | const db = new Database(PACKAGE_ID); 190 | await db 191 | .open() 192 | .then(() => { 193 | return db.loadConfig(); 194 | }) 195 | .then((cfg) => { 196 | config = cfg; 197 | 198 | if (config.hasOwnProperty('discoverAttributes')) { 199 | delete config.discoverAttributes; 200 | } 201 | 202 | if (config.hasOwnProperty('scanChannels') && typeof config.scanChannels === 'string') { 203 | config.scanChannels = parseInt(config.scanChannels, 16); 204 | } 205 | allowFTDISerial = config.allowFTDISerial; 206 | allowAMASerial = config.allowAMASerial; 207 | 208 | if (config.hasOwnProperty('debug')) { 209 | console.log(`DEBUG config = '${config.debug}'`); 210 | require('./zb-debug').set(config.debug); 211 | } 212 | 213 | return db.saveConfig(config); 214 | }); 215 | 216 | let zigbee2mqttConfigured = false; 217 | 218 | if ( 219 | config.zigbee2mqtt && 220 | config.zigbee2mqtt.zigbee2mqttAdapters && 221 | config.zigbee2mqtt.zigbee2mqttAdapters.length > 0 222 | ) { 223 | zigbee2mqttConfigured = true; 224 | } 225 | 226 | for (const stick of config.sticks || []) { 227 | console.log(`Creating ${stick.type} driver for ${stick.port}`); 228 | 229 | switch (stick.type) { 230 | case 'xbee': { 231 | const XBeeDriver = require('./driver/xbee'); 232 | const serialPort = new SerialPort(stick.port, { 233 | baudRate: 9600, 234 | lock: true, 235 | }); 236 | new XBeeDriver(addonManager, config, stick.port, serialPort); 237 | break; 238 | } 239 | case 'conbee': { 240 | const ConBeeDriver = require('./driver/conbee'); 241 | const serialPort = new SerialPort(stick.port, { 242 | baudRate: 38400, 243 | lock: true, 244 | }); 245 | new ConBeeDriver(addonManager, config, stick.port, serialPort); 246 | break; 247 | } 248 | case 'zstack': { 249 | const ZStackDriver = require('./driver/zstack'); 250 | const serialPort = new SerialPort(stick.port, { 251 | baudRate: 115200, 252 | lock: true, 253 | }); 254 | new ZStackDriver(addonManager, config, stick.port, serialPort); 255 | break; 256 | } 257 | } 258 | } 259 | 260 | if (!config.deactivateProbing) { 261 | console.log('Probing serial ports'); 262 | 263 | const { DEBUG_serialProber } = require('./zb-debug').default; 264 | SerialProber.debug(DEBUG_serialProber); 265 | if (allowFTDISerial) { 266 | xbeeSerialProber.param.filter.push(XBEE_FTDI_FILTER); 267 | } 268 | if (allowAMASerial) { 269 | conbeeSerialProber.param.allowAMASerial = true; 270 | } 271 | SerialProber.probeAll(PROBERS) 272 | .then((matches) => { 273 | if (matches.length == 0) { 274 | SerialProber.listAll() 275 | .then(() => { 276 | if (!zigbee2mqttConfigured) { 277 | errorCallback(PACKAGE_ID, 'No Zigbee dongle found'); 278 | } else { 279 | console.debug('No Zigbee dongle found'); 280 | } 281 | }) 282 | .catch((err) => { 283 | if (!zigbee2mqttConfigured) { 284 | errorCallback(PACKAGE_ID, err); 285 | } else { 286 | console.debug(`Could not probe serial ports: ${err}`); 287 | } 288 | }); 289 | return; 290 | } 291 | // We put the driver requires here rather than at the top of 292 | // the file so that the debug config gets initialized before we 293 | // import the driver class. 294 | const XBeeDriver = require('./driver/xbee'); 295 | const ConBeeDriver = require('./driver/conbee'); 296 | const ZStackDriver = require('./driver/zstack'); 297 | const driver = { 298 | [xbeeSerialProber.param.name]: XBeeDriver, 299 | [conbeeSerialProber.param.name]: ConBeeDriver, 300 | [cc2531SerialProber.param.name]: ZStackDriver, 301 | [conbeeNewerFirmwareSerialProber.param.name]: ConBeeDriver, 302 | }; 303 | for (const match of matches) { 304 | new driver[match.prober.param.name]( 305 | addonManager, 306 | config, 307 | match.port.path, 308 | match.serialPort 309 | ); 310 | } 311 | }) 312 | .catch((err) => { 313 | if (!zigbee2mqttConfigured) { 314 | errorCallback(PACKAGE_ID, err); 315 | } else { 316 | console.debug(`Could not load serial drivers: ${err}`); 317 | } 318 | }); 319 | } 320 | 321 | new Zigbee2MqttDriver(addonManager, config); 322 | } 323 | 324 | module.exports = loadZigbeeAdapters; 325 | -------------------------------------------------------------------------------- /src/zb-at.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * AT command builder/parser 4 | * 5 | * This follows the pattern used for the xbee-api, and builds the 6 | * buffer needed for the frame.commandParameters (for AT commands) 7 | * or parses frame.commandData (for AT responses). 8 | * 9 | * This Source Code Form is subject to the terms of the Mozilla Public 10 | * License, v. 2.0. If a copy of the MPL was not distributed with this 11 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 12 | */ 13 | 14 | 'use strict'; 15 | 16 | const assert = require('assert'); 17 | const BufferBuilder = require('buffer-builder'); 18 | const BufferReader = require('buffer-reader'); 19 | const xbeeApi = require('xbee-api'); 20 | 21 | const C = xbeeApi.constants; 22 | 23 | const ac = (exports.AT_CMD = {}); 24 | 25 | ac.APPLY_CHANGES = 'AC'; 26 | ac[ac.APPLY_CHANGES] = 'Apply Changes (AC)'; 27 | 28 | ac.API_OPTIONS = 'AO'; 29 | ac[ac.API_OPTIONS] = 'API Options (AO)'; 30 | 31 | ac.API_MODE = 'AP'; 32 | ac[ac.API_MODE] = 'API Mode'; 33 | 34 | ac.OPERATING_CHANNEL = 'CH'; 35 | ac[ac.OPERATING_CHANNEL] = 'Operating Channel (CH)'; 36 | 37 | ac.DEVICE_TYPE_IDENTIFIER = 'DD'; 38 | ac[ac.DEVICE_TYPE_IDENTIFIER] = 'Device Type Identifier (DD)'; 39 | 40 | ac.ENCRYPTION_ENABLED = 'EE'; 41 | ac[ac.ENCRYPTION_ENABLED] = 'Encyption Enabled (EE)'; 42 | 43 | ac.ENCRYPTION_OPTIONS = 'EO'; 44 | ac[ac.ENCRYPTION_OPTIONS] = 'Encryption Options (EO)'; 45 | 46 | ac.CONFIGURED_64_BIT_PAN_ID = 'ID'; 47 | ac[ac.CONFIGURED_64_BIT_PAN_ID] = 'Configured 64-bit PAN ID (ID)'; 48 | 49 | ac.LINK_KEY = 'KY'; 50 | ac[ac.LINK_KEY] = 'Link Key (KY)'; 51 | 52 | ac.NETWORK_ADDR_16_BIT = 'MY'; 53 | ac[ac.NETWORK_ADDR_16_BIT] = '16-bit Network Address (MY)'; 54 | 55 | ac.NUM_REMAINING_CHILDREN = 'NC'; 56 | ac[ac.NUM_REMAINING_CHILDREN] = 'Num Remaining Children (NC)'; 57 | 58 | ac.NODE_IDENTIFIER = 'NI'; 59 | ac[ac.NODE_IDENTIFIER] = 'Node Identifier (NI)'; 60 | 61 | ac.NODE_JOIN_TIME = 'NJ'; 62 | ac[ac.NODE_JOIN_TIME] = 'Node Join Time (NJ)'; 63 | 64 | ac.OPERATING_16_BIT_PAN_ID = 'OI'; 65 | ac[ac.OPERATING_16_BIT_PAN_ID] = 'Operating 16-bit PAN ID (OI)'; 66 | 67 | ac.OPERATING_64_BIT_PAN_ID = 'OP'; 68 | ac[ac.OPERATING_64_BIT_PAN_ID] = 'Operating 64-bit PAN ID (OP)'; 69 | 70 | ac.SCAN_CHANNELS = 'SC'; 71 | ac[ac.SCAN_CHANNELS] = 'Scan Channels (SC)'; 72 | 73 | ac.SERIAL_NUMBER_HIGH = 'SH'; 74 | ac[ac.SERIAL_NUMBER_HIGH] = 'Serial Number High (SH)'; 75 | 76 | ac.SERIAL_NUMBER_LOW = 'SL'; 77 | ac[ac.SERIAL_NUMBER_LOW] = 'Serial Number Low (SL)'; 78 | 79 | ac.WRITE_PARAMETERS = 'WR'; 80 | ac[ac.WRITE_PARAMETERS] = 'Write Parameters (WR)'; 81 | 82 | ac.ZIGBEE_STACK_PROFILE = 'ZS'; 83 | ac[ac.ZIGBEE_STACK_PROFILE] = 'Zigbee Stack Profile (ZS)'; 84 | 85 | const atBuilder = (module.exports.atBuilder = {}); 86 | const atParser = (module.exports.atParser = {}); 87 | 88 | class AtApi { 89 | makeFrame(command, frame) { 90 | assert(command, 'Caller must provide command'); 91 | 92 | if (frame) { 93 | frame.type = C.FRAME_TYPE.AT_COMMAND; 94 | frame.command = command; 95 | 96 | if (!(command in atBuilder)) { 97 | throw new Error(`This library does not implement data for the AT "${command}" command.`); 98 | } 99 | 100 | const atData = Buffer.alloc(32); // AT Command Data 101 | const builder = new BufferBuilder(atData); 102 | 103 | atBuilder[command](frame, builder); 104 | 105 | frame.commandParameter = atData.slice(0, builder.length); 106 | } else { 107 | frame = { 108 | type: C.FRAME_TYPE.AT_COMMAND, 109 | command: command, 110 | commandParameter: [], 111 | }; 112 | } 113 | 114 | return frame; 115 | } 116 | 117 | parseFrame(frame) { 118 | assert(frame, 'Frame parameter must be a frame object'); 119 | if (frame.command in atParser) { 120 | const reader = new BufferReader(frame.commandData); 121 | atParser[frame.command](frame, reader); 122 | } 123 | } 124 | } 125 | 126 | exports.AtApi = AtApi; 127 | 128 | // --------------------------------------------------------------------------- 129 | // 130 | // Builders 131 | // 132 | // --------------------------------------------------------------------------- 133 | 134 | atBuilder[ac.API_MODE] = function (frame, builder) { 135 | builder.appendUInt8(frame.apiMode); 136 | }; 137 | 138 | atBuilder[ac.API_OPTIONS] = function (frame, builder) { 139 | builder.appendUInt8(frame.apiOptions); 140 | }; 141 | 142 | atBuilder[ac.CONFIGURED_64_BIT_PAN_ID] = function (frame, builder) { 143 | builder.appendString(frame.configuredPanId, 'hex'); 144 | }; 145 | 146 | atBuilder[ac.ENCRYPTION_ENABLED] = function (frame, builder) { 147 | builder.appendUInt8(frame.encryptionEnabled); 148 | }; 149 | 150 | atBuilder[ac.ENCRYPTION_OPTIONS] = function (frame, builder) { 151 | builder.appendUInt8(frame.encryptionOptions); 152 | }; 153 | 154 | atBuilder[ac.LINK_KEY] = function (frame, builder) { 155 | let data; 156 | if (Array.isArray(frame.linkKey) || Buffer.isBuffer(frame.linkKey)) { 157 | data = Buffer.from(frame.linkKey); 158 | } else { 159 | data = Buffer.from(frame.linkKey, 'ascii'); 160 | } 161 | builder.appendBuffer(data); 162 | }; 163 | 164 | atBuilder[ac.NODE_IDENTIFIER] = function (builder, data) { 165 | assert(typeof data === 'string', 'data must be a string'); 166 | 167 | // Leading spaces aren't allowed (so we remove them) 168 | // Embedded commas aren't allowed (so we remove them) 169 | // Finally, the length is limited to 20 printable ASCII characters. 170 | data = data 171 | .replace(/,/g, '') 172 | .replace(/[^\x20-\x7e]+/g, '') 173 | .trim() 174 | .slice(0, 20); 175 | 176 | builder.appendString(data, 'ascii'); 177 | }; 178 | 179 | atBuilder[ac.NODE_JOIN_TIME] = function (frame, builder) { 180 | builder.appendUInt8(frame.nodeJoinTime); 181 | }; 182 | 183 | atBuilder[ac.SCAN_CHANNELS] = function (frame, builder) { 184 | builder.appendUInt16BE(frame.scanChannels); 185 | }; 186 | 187 | atBuilder[ac.ZIGBEE_STACK_PROFILE] = function (frame, builder) { 188 | builder.appendUInt8(frame.zigBeeStackProfile); 189 | }; 190 | 191 | // --------------------------------------------------------------------------- 192 | // 193 | // Parsers 194 | // 195 | // --------------------------------------------------------------------------- 196 | 197 | atParser[ac.API_MODE] = function (frame, reader) { 198 | frame.apiMode = reader.nextUInt8(); 199 | }; 200 | 201 | atParser[ac.API_OPTIONS] = function (frame, reader) { 202 | frame.apiOptions = reader.nextUInt8(); 203 | }; 204 | 205 | atParser[ac.CONFIGURED_64_BIT_PAN_ID] = function (frame, reader) { 206 | frame.configuredPanId64 = reader.nextString(8, 'hex'); 207 | }; 208 | 209 | atParser[ac.DEVICE_TYPE_IDENTIFIER] = function (frame, reader) { 210 | frame.deviceTypeIdentifier = reader.nextUInt32BE(); 211 | }; 212 | 213 | atParser[ac.ENCRYPTION_ENABLED] = function (frame, reader) { 214 | frame.encryptionEnabled = reader.nextUInt8(); 215 | }; 216 | 217 | atParser[ac.ENCRYPTION_OPTIONS] = function (frame, reader) { 218 | frame.encryptionOptions = reader.nextUInt8(); 219 | }; 220 | 221 | atParser[ac.NETWORK_ADDR_16_BIT] = function (frame, reader) { 222 | frame.networkAddr16 = reader.nextString(2, 'hex'); 223 | }; 224 | 225 | atParser[ac.NODE_IDENTIFIER] = function (frame, reader) { 226 | frame.nodeIdentifier = reader.nextString(frame.commandData.length, 'ascii').trim(); 227 | }; 228 | 229 | atParser[ac.NODE_JOIN_TIME] = function (frame, reader) { 230 | frame.networkJoinTime = reader.nextUInt8(); 231 | }; 232 | 233 | atParser[ac.NUM_REMAINING_CHILDREN] = function (frame, reader) { 234 | frame.numRemainingChildren = reader.nextUInt8(); 235 | }; 236 | 237 | atParser[ac.OPERATING_16_BIT_PAN_ID] = function (frame, reader) { 238 | frame.operatingPanId16 = reader.nextString(2, 'hex'); 239 | }; 240 | 241 | atParser[ac.OPERATING_64_BIT_PAN_ID] = function (frame, reader) { 242 | frame.operatingPanId64 = reader.nextString(8, 'hex'); 243 | }; 244 | 245 | atParser[ac.OPERATING_CHANNEL] = function (frame, reader) { 246 | frame.operatingChannel = reader.nextUInt8(); 247 | }; 248 | 249 | atParser[ac.SCAN_CHANNELS] = function (frame, reader) { 250 | frame.scanChannels = reader.nextUInt16BE(); 251 | }; 252 | 253 | atParser[ac.SERIAL_NUMBER_HIGH] = function (frame, reader) { 254 | frame.serialNumberHigh = reader.nextString(4, 'hex'); 255 | }; 256 | 257 | atParser[ac.SERIAL_NUMBER_LOW] = function (frame, reader) { 258 | frame.serialNumberLow = reader.nextString(4, 'hex'); 259 | }; 260 | 261 | atParser[ac.ZIGBEE_STACK_PROFILE] = function (frame, reader) { 262 | frame.zigBeeStackProfile = reader.nextUInt8(); 263 | }; 264 | -------------------------------------------------------------------------------- /src/zb-constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * zb-constants - Exports constants used by the zigbee adapter. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | import { Utils } from 'gateway-addon'; 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-var-requires 13 | const zclId = require('zcl-id'); 14 | 15 | function addHexValues(dict: Record): Record { 16 | const obj: Record = {}; 17 | for (const [key, value] of Object.entries(dict)) { 18 | obj[key] = value; 19 | obj[`${key}_HEX`] = Utils.hexStr(value, 4); 20 | } 21 | 22 | return obj; 23 | } 24 | 25 | // For each key/value pair, add an entry where the key is the value and 26 | // vice-versa. 27 | function addInverseMap(dict: Record): Record { 28 | const obj: Record = {}; 29 | for (const [key, value] of Object.entries(dict)) { 30 | obj[key] = value; 31 | obj[value] = parseInt(key); 32 | } 33 | 34 | return obj; 35 | } 36 | 37 | // The following come from the Zigbee Specification, 38 | // section 2.2.9 APS Sub-Layer Status values 39 | export const APS_STATUS = addInverseMap({ 40 | 0x00: 'SUCCESS', 41 | 0xa0: 'ASDU_TOO_LONG', 42 | 0xa1: 'DEFRAG_DEFERRED', 43 | 0xa2: 'DEFRAG_UNSUPPORTED', 44 | 0xa3: 'ILLEGAL_REQUEST', 45 | 0xa4: 'INVALID_BINDING', 46 | 0xa5: 'INVALID_GROUP', 47 | 0xa6: 'INVALID_PARAMETER', 48 | 0xa7: 'NO_ACK', 49 | 0xa8: 'NO_BOUND_DEVICE', 50 | 0xa9: 'NO_SHORT_ADDRESS', 51 | 0xaa: 'NOT_SUPPORTED', 52 | 0xab: 'SECURED_LINK_KEY', 53 | 0xac: 'SECURED_NWK_KEY', 54 | 0xad: 'SECURITY_FAIL', 55 | 0xae: 'TABLE_FULL', 56 | 0xaf: 'UNSECURED', 57 | 0xb0: 'UNSUPPORTED_ATTRIBUTE', 58 | }); 59 | 60 | // The following come from the Zigbee Specification, 61 | // section 3.7 NWK Layer Status Values 62 | export const NWK_STATUS = addInverseMap({ 63 | 0x00: 'SUCCESS', 64 | 0xc1: 'INVALID_PARAMETER', 65 | 0xc2: 'INVALID_REQUEST', 66 | 0xc3: 'NOT_PERMITTED', 67 | 0xc4: 'STARTUP_FAILURE', 68 | 0xc5: 'ALREADY_PRESENT', 69 | 0xc6: 'SYNC_FAILURE', 70 | 0xc7: 'NEIGHBOR_TABLE_FULL', 71 | 0xc8: 'UNKNOWN_DEVICE', 72 | 0xc9: 'UNSUPPORTED_ATTRIBUTE', 73 | 0xca: 'NO_NETWORKS', 74 | 0xcb: 'RESERVED1', 75 | 0xcc: 'MAX_FRM_COUNTER', 76 | 0xcd: 'NO_KEY', 77 | 0xce: 'BAD_CCM_OUTPUT', 78 | 0xcf: 'RESERVED2', 79 | 0xd0: 'ROUTE_DISCOVERY_FAILED', 80 | 0xd1: 'ROUTE_ERROR', 81 | 0xd2: 'BT_TABLE_FULL', 82 | 0xd3: 'FRAME_NOT_BUFFERED', 83 | }); 84 | 85 | // The following came from the Zigbee PRO Stack User Guide published by NXP. 86 | // https://www.nxp.com/docs/en/user-guide/JN-UG-3048.pdf 87 | 88 | /* eslint-disable max-len */ 89 | export const MAC_STATUS = addInverseMap({ 90 | 0x00: 'MAC_SUCCESS', // Success 91 | 0xe0: 'MAC_BEACON_LOSS', // Beacon loss after synchronisation request 92 | 0xe1: 'MAC_CHANNEL_ACCESS_FAILURE', // CSMA/CA channel access failure 93 | 0xe2: 'MAC_DENIED', // GTS request denied 94 | 0xe3: 'MAC_DISABLE_TRX_FAILURE', // Could not disable transmit or receive 95 | 0xe4: 'MAC_FAILED_SECURITY_CHECK', // Incoming frame failed security check 96 | 0xe5: 'MAC_FRAME_TOO_LONG', // Frame too long, after security processing, to be sent 97 | 0xe6: 'MAC_INVALID_GTS', // GTS transmission failed 98 | 0xe7: 'MAC_INVALID_HANDLE', // Purge request failed to find entry in queue 99 | 0xe8: 'MAC_INVALID_PARAMETER', // Out-of-range parameter in function 100 | 0xe9: 'MAC_NO_ACK', // No acknowledgement received when expected 101 | 0xea: 'MAC_NO_BEACON', // Scan failed to find any beacons 102 | 0xeb: 'MAC_NO_DATA', // No response data after a data request 103 | 0xec: 'MAC_NO_SHORT_ADDRESS', // No allocated network (short) address for operation 104 | 0xed: 'MAC_OUT_OF_CAP', // Receiver-enable request could not be executed, as CAP finished 105 | 0xee: 'MAC_PAN_ID_CONFLICT', // PAN ID conflict has been detected 106 | 0xef: 'MAC_REALIGNMENT', // Co-ordinator realignment has been received 107 | 0xf0: 'MAC_TRANSACTION_EXPIRED', // Pending transaction has expired and data discarded 108 | 0xf1: 'MAC_TRANSACTION_OVERFLOW', // No capacity to store transaction 109 | 0xf2: 'MAC_TX_ACTIVE', // Receiver-enable request could not be executed, as in transmit state 110 | 0xf3: 'MAC_UNAVAILABLE_KEY', // Appropriate key is not available in ACL 111 | 0xf4: 'MAC_UNSUPPORTED_ATTRIBUTE', // PIB Set/Get on unsupported attribute 112 | }); 113 | /* eslint-enable max-len */ 114 | 115 | export const BROADCAST_ADDR = { 116 | ALL: 'ffff', 117 | NON_SLEEPING: 'fffd', // i.e. rxOnWhenIdle = true 118 | ROUTERS: 'fffc', 119 | LOW_POWER_ROUTERS: 'fffb', 120 | }; 121 | 122 | export const UNKNOWN_ADDR_16 = 'fffe'; 123 | 124 | export const CLUSTER_ID = addHexValues({ 125 | CLOSURESSHADECFG: zclId.cluster('closuresShadeCfg').value, 126 | CLOSURESWINDOWCOVERING: zclId.cluster('closuresWindowCovering').value, 127 | DOORLOCK: zclId.cluster('closuresDoorLock').value, 128 | GENBASIC: zclId.cluster('genBasic').value, 129 | GENBINARYINPUT: zclId.cluster('genBinaryInput').value, 130 | GENDEVICETEMPCFG: zclId.cluster('genDeviceTempCfg').value, 131 | GENLEVELCTRL: zclId.cluster('genLevelCtrl').value, 132 | GENONOFF: zclId.cluster('genOnOff').value, 133 | GENOTA: zclId.cluster('genOta').value, 134 | GENPOLLCTRL: zclId.cluster('genPollCtrl').value, 135 | GENPOWERCFG: zclId.cluster('genPowerCfg').value, 136 | GENGROUPS: zclId.cluster('genGroups').value, 137 | GENSCENES: zclId.cluster('genScenes').value, 138 | GENMULTISTATEINPUT: zclId.cluster('genMultistateInput').value, 139 | GENANALOGINPUT: zclId.cluster('genAnalogInput').value, 140 | HAELECTRICAL: zclId.cluster('haElectricalMeasurement').value, 141 | HVACTHERMOSTAT: zclId.cluster('hvacThermostat').value, 142 | HVACFANCTRL: zclId.cluster('hvacFanCtrl').value, 143 | HVACUSERINTERFACECFG: zclId.cluster('hvacUserInterfaceCfg').value, 144 | ILLUMINANCE_MEASUREMENT: zclId.cluster('msIlluminanceMeasurement').value, 145 | LIGHTINGCOLORCTRL: zclId.cluster('lightingColorCtrl').value, 146 | LIGHTLINK: zclId.cluster('lightLink').value, 147 | OCCUPANCY_SENSOR: zclId.cluster('msOccupancySensing').value, 148 | PRESSURE: zclId.cluster('msPressureMeasurement').value, 149 | RELATIVE_HUMIDITY: zclId.cluster('msRelativeHumidity').value, 150 | SEMETERING: zclId.cluster('seMetering').value, 151 | SSIASZONE: zclId.cluster('ssIasZone').value, 152 | TEMPERATURE: zclId.cluster('msTemperatureMeasurement').value, 153 | }); 154 | 155 | export const ATTR_ID: Record> = {}; 156 | function makeAttrIds(clusterName: string, attrNames: string[]): void { 157 | const clusterId = CLUSTER_ID[clusterName]; 158 | const attrIdDict: Record = {}; 159 | for (const attrName of attrNames) { 160 | attrIdDict[attrName.toUpperCase()] = zclId.attr(clusterId, attrName).value; 161 | } 162 | ATTR_ID[clusterName] = attrIdDict; 163 | } 164 | 165 | makeAttrIds('GENBASIC', [ 166 | 'zclVersion', // 0 167 | 'appVersion', // 1 168 | 'modelId', // 5 169 | 'powerSource', // 7 170 | ]); 171 | makeAttrIds('GENPOLLCTRL', [ 172 | 'checkinInterval', // 0 173 | 'longPollInterval', // 1 174 | 'shortPollInterval', // 2 175 | 'fastPollTimeout', // 3 176 | 'checkinIntervalMin', // 4 177 | 'longPollIntervalMin', // 5 178 | 'fastPollTimeoutMax', // 6 179 | ]); 180 | makeAttrIds('LIGHTINGCOLORCTRL', [ 181 | 'currentHue', // 0 182 | 'currentSaturation', // 1 183 | 'currentX', // 3 184 | 'currentY', // 4 185 | 'colorMode', // 8 186 | 'colorCapabilities', // 16394 (0x400a) 187 | ]); 188 | makeAttrIds('SSIASZONE', [ 189 | 'zoneState', // 0 190 | 'zoneType', // 1 191 | 'zoneStatus', // 2 192 | 'iasCieAddr', // 16 (0x10) 193 | 'zoneId', // 17 (0x11) 194 | ]); 195 | 196 | // COLOR_CAPABILITY describes values for the colorCapability attribute from 197 | // the lightingColorCtrl cluster. 198 | export const COLOR_CAPABILITY = { 199 | HUE_SAT: 1 << 0, 200 | ENHANCED_HUE_SAT: 1 << 1, 201 | XY: 1 << 3, 202 | COLOR: (1 << 0) | (1 << 1) | (1 << 3), 203 | 204 | TEMPERATURE: 1 << 4, 205 | }; 206 | 207 | // COLOR_MODE describes values for the colorMode attribute from 208 | // the lightingColorCtrl cluster. 209 | export const COLOR_MODE = { 210 | HUE_SAT: 0, 211 | XY: 1, 212 | TEMPERATURE: 2, 213 | }; 214 | 215 | // Server in this context means "server of the cluster" 216 | export const DIR = { 217 | CLIENT_TO_SERVER: 0, 218 | SERVER_TO_CLIENT: 1, 219 | }; 220 | 221 | export const DOORLOCK_EVENT_CODES = [ 222 | 'Unknown', // 0 223 | 'Lock', // 1 224 | 'Unlock', // 2 225 | 'LockFailInvalidPinOrID', // 3 226 | 'LockFailInvalidSchedule', // 4 227 | 'UnlockFailInvalidPinOrID', // 5 228 | 'UnlockFailInvalidSchedule', // 6 229 | 'OneTouchLock', // 7 230 | 'KeyLock', // 8 231 | 'KeyUnlock', // 9 232 | 'AutoLock', // 10 (0x0A) 233 | 'ScheduleLock', // 11 (0x0B) 234 | 'ScheduleUnlock', // 12 (0x0C) 235 | 'ManualLock', // 13 (0x0D) 236 | 'ManualUnlock', // 14 (0x0E) 237 | 'NonAccessUserEvent', // 15 (0X0F) 238 | ]; 239 | 240 | // POWERSOURCE describes the values for the powerSource attribute from 241 | // the genBasic cluster 242 | export const POWERSOURCE = { 243 | UNKNOWN: 0, 244 | MAINS_SINGLE_PHASE: 1, 245 | MAINS_3_PHASE: 2, 246 | BATTERY: 3, 247 | DC_SOURCE: 4, 248 | EMERGENCY_MAINS_CONSTANTLY_POWERED: 5, 249 | EMERGENCY_MAINS_AND_TRANSFER_SWITCH: 6, 250 | }; 251 | 252 | export const PROFILE_ID = addHexValues({ 253 | ZDO: 0, 254 | ZHA: zclId.profile('HA').value, 255 | ZLL: zclId.profile('LL').value, 256 | GREEN: 0xa1e0, 257 | }); 258 | 259 | export const STATUS = { 260 | SUCCESS: zclId.status('success').value, 261 | UNSUPPORTED_ATTRIB: zclId.status('unsupAttribute').value, 262 | INSUFFICIENT_SPACE: zclId.status('insufficientSpace').value, 263 | }; 264 | 265 | // THERMOSTAT_MODE is used for the systemMode attribute 266 | // for the hvacThermostat cluster. 267 | export const THERMOSTAT_SYSTEM_MODE = [ 268 | 'off', // 0 269 | 'auto', // 1 270 | null, // 2 - not used in the spec 271 | 'cool', // 3 272 | 'heat', // 4 273 | // There are other values, but these are the only ones we support 274 | // now. 275 | ]; 276 | 277 | // THERMOSTAT_MODE is used for the runningMode attribute 278 | // for the hvacThermostat cluster. 279 | export const THERMOSTAT_RUN_MODE = [ 280 | 'off', // 0 281 | null, // 1 - not used in the spec 282 | null, // 2 - not used in the spec 283 | 'cooling', // 3 284 | 'heating', // 4 285 | // There are other values, but these are the only ones we support 286 | // now. 287 | ]; 288 | 289 | // THERMOSTAT_STATE is used for the runningState attribute from the 290 | // hvacThermostat cluster. 291 | export const THERMOSTAT_STATE = [ 292 | 'heating', // 0 Heat 1st stage State On 293 | 'cooling', // 1 Cool 1st stage State On 294 | 'fan', // 2 Fan 1st stage State On 295 | 'heating2', // 3 Heat 2nd stage State On 296 | 'cooling2', // 4 Cool 2nd stage State On 297 | 'fan2', // 5 Fan 2nd stage State On 298 | 'fan3', // 6 Fan 3rd stage State On 299 | ]; 300 | 301 | // HVAC_FAN_MODE describes the fanMode attribute from the hvacFanCtrl 302 | // cluster 303 | export const HVAC_FAN_MODE = [ 304 | 'Off', // 0 305 | 'Low', // 1 306 | 'Medium', // 2 307 | 'High', // 3 308 | 'On', // 4 309 | 'Auto', // 5 310 | 'Smart', // 6 311 | ]; 312 | 313 | // HVAC_FAN_SEQ describes options available for the fanModeSequence 314 | // attribute from the hvacFanCtrl cluster. Each of the labels 315 | // separated by slashes must appear in the fan mode above. 316 | export const HVAC_FAN_SEQ = [ 317 | 'Low/Medium/High', // 0 318 | 'Low/High', // 1 319 | 'Low/Medium/High/Auto', // 2 320 | 'Low/High/Auto', // 3 321 | 'On/Auto', // 4 322 | ]; 323 | 324 | // The following came from: 325 | // http://www.zigbee.org/wp-content/uploads/2017/12/ 326 | // docs-15-0014-05-0plo-Lighting-OccupancyDevice-Specification-V1.0.pdf 327 | // 328 | // Zigbee Lighting & Occupancy Device Specification Version 1.0 329 | export const ZHA_DEVICE_ID = { 330 | ON_OFF_SWITCH: '0000', 331 | ON_OFF_OUTPUT: '0002', 332 | SMART_PLUG: '0051', 333 | ON_OFF_LIGHT: '0100', 334 | DIMMABLE_LIGHT: '0101', 335 | COLORED_DIMMABLE_LIGHT: '0102', 336 | ON_OFF_LIGHT_SWITCH: '0103', 337 | DIMMER_SWITCH: '0104', 338 | COLOR_DIMMER_SWITCH: '0105', 339 | LIGHT_SENSOR: '0106', 340 | OCCUPANCY_SENSOR: '0107', 341 | ON_OFF_BALLAST: '0108', 342 | DIMMABLE_PLUGIN: '010b', 343 | COLOR_TEMPERATURE_LIGHT: '010c', 344 | EXTENDED_COLOR_LIGHT: '010d', 345 | LIGHT_LEVEL_SENSOR: '010e', 346 | COLOR_CONTROLLER: '0800', 347 | COLOR_SCENE_CONTROLLER: '0810', 348 | NON_COLOR_CONTROLLER: '0820', 349 | NON_COLOR_SCENE_CONTROLLER: '0830', 350 | CONTROL_BRIDGE: '0840', 351 | ON_OFF_SENSOR: '0850', 352 | 353 | isLight: function isLight(deviceId: string): boolean { 354 | return ( 355 | deviceId == ZHA_DEVICE_ID.ON_OFF_LIGHT || 356 | deviceId == ZHA_DEVICE_ID.DIMMABLE_LIGHT || 357 | deviceId == ZHA_DEVICE_ID.COLORED_DIMMABLE_LIGHT || 358 | deviceId == ZHA_DEVICE_ID.COLOR_TEMPERATURE_LIGHT || 359 | deviceId == ZHA_DEVICE_ID.EXTENDED_COLOR_LIGHT 360 | ); 361 | }, 362 | 363 | isColorLight: function isColorLight(deviceId: string): boolean { 364 | return ( 365 | deviceId == ZHA_DEVICE_ID.COLORED_DIMMABLE_LIGHT || 366 | deviceId == ZHA_DEVICE_ID.EXTENDED_COLOR_LIGHT 367 | ); 368 | }, 369 | isColorTemperatureLight: function isColorTemperatureLight(deviceId: string): boolean { 370 | return deviceId == ZHA_DEVICE_ID.COLOR_TEMPERATURE_LIGHT; 371 | }, 372 | }; 373 | 374 | // ZLL Device Id describes device IDs from the ZLL spec. 375 | export const ZLL_DEVICE_ID = { 376 | ON_OFF_LIGHT: '0000', 377 | ON_OFF_SWITCH: '0010', 378 | DIMMABLE_LIGHT: '0100', 379 | DIMMABLE_SWITCH: '0110', 380 | COLOR_LIGHT: '0200', 381 | EXTENDED_COLOR_LIGHT: '0210', 382 | COLOR_TEMPERATURE_LIGHT: '0220', 383 | COLOR_CONTROLLER: '0800', 384 | COLOR_SCENE_CONTROLLER: '0810', 385 | NON_COLOR_CONTROLLER: '0820', 386 | NON_COLOR_SCENE_CONTROLLER: '0830', 387 | CONTROL_BRIDGE: '0840', 388 | ON_OFF_SENSOR: '0850', 389 | 390 | isLight: function isLight(deviceId: string): boolean { 391 | return ( 392 | deviceId == ZLL_DEVICE_ID.ON_OFF_LIGHT || 393 | deviceId == ZLL_DEVICE_ID.DIMMABLE_LIGHT || 394 | deviceId == ZLL_DEVICE_ID.COLOR_LIGHT || 395 | deviceId == ZLL_DEVICE_ID.EXTENDED_COLOR_LIGHT || 396 | deviceId == ZLL_DEVICE_ID.COLOR_TEMPERATURE_LIGHT 397 | ); 398 | }, 399 | 400 | isColorLight: function isColorLight(deviceId: string): boolean { 401 | return deviceId == ZLL_DEVICE_ID.COLOR_LIGHT || deviceId == ZLL_DEVICE_ID.EXTENDED_COLOR_LIGHT; 402 | }, 403 | 404 | isColorTemperatureLight: function isColorTemperatureLight(deviceId: string): boolean { 405 | return deviceId == ZLL_DEVICE_ID.COLOR_TEMPERATURE_LIGHT; 406 | }, 407 | }; 408 | 409 | // ZONE_STATUS describes values for the zoneStatus attribute from 410 | // the ssIasZone cluster. 411 | export const ZONE_STATUS = { 412 | ALARM_MASK: 0x03, 413 | TAMPER_MASK: 0x04, 414 | LOW_BATTERY_MASK: 0x08, 415 | }; 416 | -------------------------------------------------------------------------------- /src/zb-debug.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * zb-debug - manage debug configuration. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | const DEBUG_FLAG = { 11 | // Use DEBUG_classifier for debugging the behaviour of the classifier. 12 | DEBUG_classifier: false, 13 | 14 | // Use DEBUG_flow if you need to debug the flow of the program. This causes 15 | // prints at the beginning of many functions to print some info. 16 | DEBUG_flow: false, 17 | 18 | // DEBUG_frames causes a 1-line summary to be printed for each frame 19 | // which is sent or received. 20 | DEBUG_frames: false, 21 | 22 | // DEBUG_frameDetail causes detailed information about each frame to be 23 | // printed. 24 | DEBUG_frameDetail: false, 25 | 26 | // DEBUG_frameParsing causes frame detail about the initial frame, before 27 | // we do any parsing of the data to be printed. This is useful to enable 28 | // if the frame parsing code is crashing. 29 | DEBUG_frameParsing: false, 30 | 31 | // DEBUG_node causes additional debug information from the zb-node.js 32 | // file to be printed. 33 | DEBUG_node: false, 34 | 35 | // DEBUG_property causes additional debug information to be printed 36 | // from zb-property.js 37 | DEBUG_property: false, 38 | 39 | // DEBUG_rawFrames causes the raw serial frames to/from the dongle 40 | // to be reported. 41 | DEBUG_rawFrames: false, 42 | 43 | // DEBUG_serialProber causes information about the serial probing at 44 | // module load time to be printed. 45 | DEBUG_serialProber: false, 46 | 47 | // DEBUG_slip causes SLIP encapsulated raw data (used by deConz) 48 | // to be printed. 49 | DEBUG_slip: false, 50 | 51 | // DEBUG_xiaomi causes additional debug information to be printed 52 | // from zb-xiaomi.js 53 | DEBUG_xiaomi: false, 54 | 55 | // DEBUG_zigbee2mqtt causes additional debug information to be printed 56 | // from zigbee2mqtt classes 57 | DEBUG_zigbee2mqtt: false, 58 | }; 59 | 60 | export function set(names: string): void { 61 | for (const name of names.split(/[, ]+/)) { 62 | if (name === '') { 63 | // If names is empty then split returns [''] 64 | continue; 65 | } 66 | const debugName = `DEBUG_${name}`; 67 | if (DEBUG_FLAG.hasOwnProperty(debugName)) { 68 | console.log(`Enabling ${debugName}`); 69 | DEBUG_FLAG[debugName] = true; 70 | } else { 71 | console.log(`DEBUG: Unrecognized flag: '${debugName}' (ignored)`); 72 | } 73 | } 74 | } 75 | 76 | export default DEBUG_FLAG; 77 | -------------------------------------------------------------------------------- /src/zb-families.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * zb-families.js - Registers all families 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | import ZigbeeFamily from './zb-family'; 11 | import XiaomiFamily from './zb-xiaomi'; 12 | 13 | export default function registerFamilies(): void { 14 | ZigbeeFamily.register(new XiaomiFamily()); 15 | } 16 | -------------------------------------------------------------------------------- /src/zb-family.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * zb-family.js - Provides customizations for a family of devices. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-var-requires 11 | const ZigbeeNode = require('./zb-node'); 12 | 13 | export default class ZigbeeFamily { 14 | static families: Record = {}; 15 | 16 | name: string; 17 | 18 | constructor(name: string) { 19 | this.name = name; 20 | } 21 | 22 | /** 23 | * @function classify 24 | * 25 | * The classify function is called by the framework either during 26 | * the initial scan, or when an end device announces itself, and 27 | * it hasn't yet been classified. The classify method is responsible 28 | * for creating appropriate properties for the node. 29 | * 30 | * @param {ZigbeeNode} node 31 | */ 32 | classify(_node: typeof ZigbeeNode): void { 33 | // pass 34 | } 35 | 36 | static findFamily(findFamilyName: string): ZigbeeFamily | null { 37 | for (const familyName in ZigbeeFamily.families) { 38 | if (familyName == findFamilyName) { 39 | return ZigbeeFamily.families[familyName]; 40 | } 41 | } 42 | 43 | return null; 44 | } 45 | 46 | /** 47 | * @function identify 48 | * 49 | * This function is used to identify the family of a node. 50 | * 51 | * @param {ZigbeeNode} node 52 | * @return Returns true to indicate that the indicated node is 53 | * an instance of this family. 54 | */ 55 | identify(_node: typeof ZigbeeNode): boolean { 56 | return false; 57 | } 58 | 59 | /** 60 | * @function identifyFamily 61 | * 62 | * This function walks through all of the registered families and sees 63 | * if any of them are able to identify the node. 64 | * 65 | * @param {ZigbeeNode} node 66 | * @return Returns true to indicate that the indicated node is 67 | * an instance of this family. 68 | */ 69 | static identifyFamily(node: typeof ZigbeeNode): boolean { 70 | for (const familyName in ZigbeeFamily.families) { 71 | const family = ZigbeeFamily.families[familyName]; 72 | if (family.identify(node)) { 73 | node.family = family; 74 | return true; 75 | } 76 | } 77 | return false; 78 | } 79 | 80 | /** 81 | * @function register 82 | * 83 | * Called to register a device fmaily. 84 | * 85 | * @param {ZigbeeFamily} family - An instance of this class. 86 | */ 87 | static register(family: ZigbeeFamily): void { 88 | ZigbeeFamily.families[family.name] = family; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/zb-xiaomi.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * zb-xiaomi.js - special case code for xiami devices 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | import cloneDeep from 'clone-deep'; 11 | import ZigbeeFamily from './zb-family'; 12 | import DEBUG_FLAG from './zb-debug'; 13 | import { CLUSTER_ID, PROFILE_ID, POWERSOURCE, ZONE_STATUS } from './zb-constants'; 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-var-requires 16 | const ZigbeeProperty = require('./zb-property'); 17 | // eslint-disable-next-line @typescript-eslint/no-var-requires 18 | const ZigbeeNode = require('./zb-node'); 19 | 20 | const DEBUG = DEBUG_FLAG.DEBUG_xiaomi; 21 | 22 | // The following github repository has a bunch of useful information 23 | // for each of the xiaomi sensors. 24 | // https://github.com/Frans-Willem/AqaraHub/tree/master/documentation/devices 25 | 26 | const MODEL_IDS = { 27 | 'lumi.sensor_magnet': { 28 | name: 'magnet', 29 | '@type': ['BinarySensor'], 30 | powerSource: POWERSOURCE.BATTERY, 31 | activeEndpoints: { 32 | 1: { 33 | profileId: PROFILE_ID.ZHA_HEX, 34 | inputClusters: [CLUSTER_ID.GENBASIC_HEX, CLUSTER_ID.GENONOFF_HEX], 35 | outputClusters: [], 36 | }, 37 | }, 38 | properties: { 39 | on: { 40 | descr: { 41 | '@type': 'BooleanProperty', 42 | label: 'Open', 43 | type: 'boolean', 44 | description: 'Magnet Sensor', 45 | }, 46 | profileId: PROFILE_ID.ZHA, 47 | endpoint: 1, 48 | clusterId: CLUSTER_ID.GENONOFF, 49 | attr: 'onOff', 50 | value: false, 51 | parseValueFromAttr: 'parseOnOffAttr', 52 | }, 53 | }, 54 | }, 55 | 'lumi.sensor_switch': { 56 | name: 'switch', 57 | '@type': ['BinarySensor'], 58 | powerSource: POWERSOURCE.BATTERY, 59 | activeEndpoints: { 60 | 1: { 61 | profileId: PROFILE_ID.ZHA_HEX, 62 | inputClusters: [CLUSTER_ID.GENBASIC_HEX, CLUSTER_ID.GENONOFF_HEX], 63 | outputClusters: [], 64 | }, 65 | }, 66 | properties: { 67 | on: { 68 | descr: { 69 | '@type': 'BooleanProperty', 70 | label: 'Pressed', 71 | type: 'boolean', 72 | description: 'Magnet Sensor', 73 | }, 74 | profileId: PROFILE_ID.ZHA, 75 | endpoint: 1, 76 | clusterId: CLUSTER_ID.GENONOFF, 77 | attr: 'onOff', 78 | value: false, 79 | parseValueFromAttr: 'parseOffOnAttr', 80 | }, 81 | multiClick: { 82 | descr: { 83 | '@type': 'MultiClickProperty', 84 | label: 'MultiClick', 85 | type: 'number', 86 | description: 'Switch Sensor', 87 | }, 88 | profileId: PROFILE_ID.ZHA, 89 | endpoint: 1, 90 | clusterId: CLUSTER_ID.GENONOFF, 91 | attr: '', 92 | attrId: 0x8000, 93 | value: 0, 94 | parseValueFromAttr: 'parseNumericAttr', 95 | }, 96 | }, 97 | }, 98 | 'lumi.sensor_switch.aq2': { 99 | name: 'switch', 100 | '@type': ['BinarySensor'], 101 | powerSource: POWERSOURCE.BATTERY, 102 | activeEndpoints: { 103 | 1: { 104 | profileId: PROFILE_ID.ZHA_HEX, 105 | inputClusters: [CLUSTER_ID.GENBASIC_HEX, CLUSTER_ID.GENONOFF_HEX], 106 | outputClusters: [], 107 | }, 108 | }, 109 | properties: { 110 | multiClick: { 111 | descr: { 112 | '@type': 'MultiClickProperty', 113 | label: 'MultiClick', 114 | type: 'number', 115 | description: 'Switch Sensor', 116 | }, 117 | profileId: PROFILE_ID.ZHA, 118 | endpoint: 1, 119 | clusterId: CLUSTER_ID.GENONOFF, 120 | attr: '', 121 | attrId: 0x8000, 122 | value: 0, 123 | parseValueFromAttr: 'parseNumericAttr', 124 | }, 125 | }, 126 | }, 127 | 'lumi.remote.b1acn01': { 128 | name: 'switch', 129 | '@type': ['BinarySensor'], 130 | powerSource: POWERSOURCE.BATTERY, 131 | activeEndpoints: { 132 | 1: { 133 | profileId: PROFILE_ID.ZHA_HEX, 134 | inputClusters: [CLUSTER_ID.GENBASIC_HEX, CLUSTER_ID.GENONOFF_HEX], 135 | outputClusters: [], 136 | }, 137 | }, 138 | properties: { 139 | multiClick: { 140 | descr: { 141 | '@type': 'MultiClickProperty', 142 | label: 'MultiClick', 143 | type: 'number', 144 | description: 'Switch Sensor', 145 | }, 146 | profileId: PROFILE_ID.ZHA, 147 | endpoint: 1, 148 | clusterId: CLUSTER_ID.GENONOFF, 149 | attr: '', 150 | attrId: 0x8000, 151 | value: 0, 152 | parseValueFromAttr: 'parseNumericAttr', 153 | }, 154 | }, 155 | }, 156 | 'lumi.sensor_motion': { 157 | name: 'motion', 158 | '@type': ['MotionSensor'], 159 | powerSource: POWERSOURCE.BATTERY, 160 | occupancyTimeout: 10, // seconds 161 | activeEndpoints: { 162 | 1: { 163 | profileId: PROFILE_ID.ZHA_HEX, 164 | inputClusters: [CLUSTER_ID.GENBASIC_HEX, CLUSTER_ID.OCCUPANCY_SENSOR_HEX], 165 | outputClusters: [], 166 | }, 167 | }, 168 | properties: { 169 | occupied: { 170 | descr: { 171 | '@type': 'MotionProperty', 172 | type: 'boolean', 173 | label: 'Motion', 174 | description: 'Motion Sensor', 175 | }, 176 | profileId: PROFILE_ID.ZHA, 177 | endpoint: 1, 178 | clusterId: CLUSTER_ID.OCCUPANCY_SENSOR, 179 | attr: 'occupancy', 180 | value: false, 181 | parseValueFromAttr: 'parseOccupiedAttr', 182 | }, 183 | }, 184 | }, 185 | 'lumi.sensor_motion.aq2': { 186 | // RTCGQ11LM 187 | name: 'motion', 188 | '@type': ['MotionSensor'], 189 | powerSource: POWERSOURCE.BATTERY, 190 | occupancyTimeout: 10, // seconds 191 | activeEndpoints: { 192 | 1: { 193 | profileId: PROFILE_ID.ZHA_HEX, 194 | inputClusters: [ 195 | CLUSTER_ID.GENBASIC_HEX, 196 | CLUSTER_ID.OCCUPANCY_SENSOR_HEX, 197 | CLUSTER_ID.ILLUMINANCE_MEASUREMENT_HEX, 198 | ], 199 | outputClusters: [], 200 | }, 201 | }, 202 | properties: { 203 | occupied: { 204 | descr: { 205 | '@type': 'MotionProperty', 206 | type: 'boolean', 207 | label: 'Motion', 208 | description: 'Motion Sensor', 209 | }, 210 | profileId: PROFILE_ID.ZHA, 211 | endpoint: 1, 212 | clusterId: CLUSTER_ID.OCCUPANCY_SENSOR, 213 | attr: 'occupancy', 214 | value: false, 215 | parseValueFromAttr: 'parseOccupiedAttr', 216 | }, 217 | illuminance: { 218 | descr: { 219 | '@type': 'LevelProperty', 220 | type: 'number', 221 | label: 'Illuminance', 222 | unit: 'lux', 223 | minimum: 0, 224 | maximum: 1500, 225 | description: 'Lux Sensor', 226 | readOnly: true, 227 | }, 228 | profileId: PROFILE_ID.ZHA, 229 | endpoint: 1, 230 | clusterId: CLUSTER_ID.ILLUMINANCE_MEASUREMENT, 231 | attr: 'measuredValue', 232 | value: 0, 233 | parseValueFromAttr: 'parseNumericAttr', 234 | }, 235 | }, 236 | }, 237 | 'lumi.sensor_ht': { 238 | // WSDCGQ01LM (round) 239 | name: 'temperature', 240 | '@type': ['TemperatureSensor', 'HumiditySensor'], 241 | powerSource: POWERSOURCE.BATTERY, 242 | activeEndpoints: { 243 | 1: { 244 | profileId: PROFILE_ID.ZHA_HEX, 245 | inputClusters: [ 246 | CLUSTER_ID.GENBASIC_HEX, 247 | CLUSTER_ID.TEMPERATURE_HEX, 248 | CLUSTER_ID.RELATIVE_HUMIDITY_HEX, 249 | ], 250 | outputClusters: [], 251 | }, 252 | }, 253 | properties: { 254 | temperature: { 255 | descr: { 256 | '@type': 'TemperatureProperty', 257 | label: 'Temperature', 258 | type: 'number', 259 | unit: 'degree celsius', 260 | minimum: -20, 261 | maximum: 60, 262 | readOnly: true, 263 | }, 264 | profileId: PROFILE_ID.ZHA, 265 | endpoint: 1, 266 | clusterId: CLUSTER_ID.TEMPERATURE, 267 | attr: 'measuredValue', 268 | value: 0, 269 | parseValueFromAttr: 'parseTemperatureMeasurementAttr', 270 | }, 271 | humidity: { 272 | descr: { 273 | '@type': 'HumidityProperty', 274 | label: 'Humidity', 275 | type: 'number', 276 | unit: 'percent', 277 | minimum: 0, 278 | maximum: 100, 279 | description: 'Relative Humidity', 280 | readOnly: true, 281 | }, 282 | profileId: PROFILE_ID.ZHA, 283 | endpoint: 1, 284 | clusterId: CLUSTER_ID.RELATIVE_HUMIDITY, 285 | attr: 'measuredValue', 286 | value: 0, 287 | parseValueFromAttr: 'parseNumericHundredthsAttr', 288 | }, 289 | }, 290 | }, 291 | 'lumi.weather': { 292 | // WSDCGQ11LM (square) 293 | name: 'temperature', 294 | '@type': ['TemperatureSensor', 'HumiditySensor', 'BarometricPressureSensor'], 295 | powerSource: POWERSOURCE.BATTERY, 296 | activeEndpoints: { 297 | 1: { 298 | profileId: PROFILE_ID.ZHA_HEX, 299 | inputClusters: [ 300 | CLUSTER_ID.GENBASIC_HEX, 301 | CLUSTER_ID.TEMPERATURE_HEX, 302 | CLUSTER_ID.PRESSURE_HEX, 303 | CLUSTER_ID.RELATIVE_HUMIDITY_HEX, 304 | ], 305 | outputClusters: [], 306 | }, 307 | }, 308 | properties: { 309 | temperature: { 310 | descr: { 311 | '@type': 'TemperatureProperty', 312 | label: 'Temperature', 313 | type: 'number', 314 | unit: 'degree celsius', 315 | minimum: -20, 316 | maximum: 60, 317 | readOnly: true, 318 | }, 319 | profileId: PROFILE_ID.ZHA, 320 | endpoint: 1, 321 | clusterId: CLUSTER_ID.TEMPERATURE, 322 | attr: 'measuredValue', 323 | value: 0, 324 | parseValueFromAttr: 'parseTemperatureMeasurementAttr', 325 | }, 326 | humidity: { 327 | descr: { 328 | '@type': 'HumidityProperty', 329 | label: 'Humidity', 330 | type: 'number', 331 | unit: 'percent', 332 | minimum: 0, 333 | maximum: 100, 334 | description: 'Relative Humidity', 335 | readOnly: true, 336 | }, 337 | profileId: PROFILE_ID.ZHA, 338 | endpoint: 1, 339 | clusterId: CLUSTER_ID.RELATIVE_HUMIDITY, 340 | attr: 'measuredValue', 341 | value: 0, 342 | parseValueFromAttr: 'parseNumericHundredthsAttr', 343 | }, 344 | pressure: { 345 | descr: { 346 | '@type': 'BarometricPressureProperty', 347 | label: 'Pressure', 348 | type: 'number', 349 | unit: 'hPa', 350 | minimum: 800, 351 | maximum: 1100, 352 | readOnly: true, 353 | }, 354 | profileId: PROFILE_ID.ZHA, 355 | endpoint: 1, 356 | clusterId: CLUSTER_ID.PRESSURE, 357 | attr: 'measuredValue', 358 | value: 0, 359 | parseValueFromAttr: 'parseNumericAttr', 360 | }, 361 | }, 362 | }, 363 | 'lumi.sensor_cube': { 364 | name: 'sensor-cube', 365 | '@type': ['BinarySensor'], 366 | powerSource: POWERSOURCE.BATTERY, 367 | activeEndpoints: { 368 | 1: { 369 | profileId: PROFILE_ID.ZHA_HEX, 370 | inputClusters: [ 371 | CLUSTER_ID.GENBASIC_HEX, 372 | CLUSTER_ID.GENOTA_HEX, 373 | CLUSTER_ID.GENMULTISTATEINPUT_HEX, 374 | ], 375 | outputClusters: [], 376 | }, 377 | 2: { 378 | profileId: PROFILE_ID.ZHA_HEX, 379 | inputClusters: [CLUSTER_ID.GENMULTISTATEINPUT_HEX], 380 | outputClusters: [], 381 | }, 382 | 3: { 383 | profileId: PROFILE_ID.ZHA_HEX, 384 | inputClusters: [CLUSTER_ID.GENANALOGINPUT_HEX], 385 | outputClusters: [], 386 | }, 387 | }, 388 | properties: { 389 | transitionString: { 390 | descr: { 391 | '@type': 'MultiClickProperty', 392 | label: 'State', 393 | type: 'string', 394 | description: 'Cube Motion Sensor', 395 | readOnly: true, 396 | }, 397 | profileId: PROFILE_ID.ZHA, 398 | endpoint: 2, 399 | clusterId: CLUSTER_ID.GENMULTISTATEINPUT, 400 | attr: 'presentValue', 401 | value: '', 402 | parseValueFromAttr: 'parseCubeNumericAttr', 403 | }, 404 | current_side: { 405 | descr: { 406 | '@type': 'MultiClickProperty', 407 | label: 'Side', 408 | type: 'integer', 409 | description: 'Current side of the cube', 410 | readOnly: true, 411 | }, 412 | profileId: PROFILE_ID.ZHA, 413 | endpoint: 2, 414 | clusterId: CLUSTER_ID.GENMULTISTATEINPUT, 415 | attr: 'presentValue', 416 | value: '', 417 | parseValueFromAttr: 'decodeCurrentCubeSide', 418 | }, 419 | /* 420 | transitionNumeric: { 421 | descr: { 422 | '@type': 'MultiClickProperty', 423 | label: 'State', 424 | type: 'number', 425 | description: 'Cube Motion Sensor', 426 | readOnly: true, 427 | }, 428 | profileId: PROFILE_ID.ZHA, 429 | endpoint: 2, 430 | clusterId: CLUSTER_ID.GENMULTISTATEINPUT, 431 | attr: 'presentValue', 432 | value: 0, 433 | parseValueFromAttr: 'parseNumericAttr', 434 | }, 435 | */ 436 | rotate: { 437 | descr: { 438 | '@type': 'MultiClickProperty', 439 | label: 'Rotation', 440 | type: 'number', 441 | unit: '°', 442 | description: 'Cube Rotation', 443 | minimum: -180, 444 | maximum: 180, 445 | readOnly: true, 446 | }, 447 | profileId: PROFILE_ID.ZHA, 448 | endpoint: 3, 449 | clusterId: CLUSTER_ID.GENANALOGINPUT, 450 | attr: 'presentValue', 451 | value: '', 452 | parseValueFromAttr: 'parseNumericAttr', 453 | }, 454 | }, 455 | }, 456 | 'lumi.plug.maeu01': { 457 | name: 'smartplug', 458 | '@type': ['SmartPlug', 'EnergyMonitor', 'OnOffSwitch'], 459 | activeEndpoints: { 460 | 1: { 461 | profileId: PROFILE_ID.ZHA_HEX, 462 | inputClusters: [ 463 | CLUSTER_ID.GENONOFF_HEX, 464 | CLUSTER_ID.SEMETERING_HEX, 465 | CLUSTER_ID.HAELECTRICAL_HEX, 466 | ], 467 | outputClusters: [], 468 | }, 469 | }, 470 | properties: { 471 | switch: { 472 | descr: { 473 | '@type': 'OnOffProperty', 474 | label: 'On/Off', 475 | type: 'boolean', 476 | }, 477 | profileId: PROFILE_ID.ZHA, 478 | endpoint: 1, 479 | clusterId: CLUSTER_ID.GENONOFF, 480 | attr: 'onOff', 481 | setAttrFromValue: 'setOnOffValue', 482 | parseValueFromAttr: 'parseOnOffAttr', 483 | }, 484 | instantaneousPower: { 485 | descr: { 486 | '@type': 'InstantaneousPowerProperty', 487 | label: 'Power', 488 | type: 'number', 489 | unit: 'watt', 490 | readOnly: true, 491 | }, 492 | profileId: PROFILE_ID.ZHA, 493 | endpoint: 1, 494 | clusterId: CLUSTER_ID.HAELECTRICAL, 495 | attr: 'activePower', 496 | parseValueFromAttr: 'parseNumericTenthsAttr', 497 | }, 498 | counter: { 499 | descr: { 500 | label: 'Energy Total', 501 | type: 'number', 502 | unit: 'watt', 503 | description: 'Total consumed energy', 504 | readOnly: true, 505 | }, 506 | profileId: PROFILE_ID.ZHA, 507 | endpoint: 1, 508 | clusterId: CLUSTER_ID.SEMETERING, 509 | attr: 'currentSummDelivered', 510 | parseValueFromAttr: 'parseUInt48NumericAttr', 511 | }, 512 | }, 513 | }, 514 | 'lumi.sensor_wleak.aq1': { 515 | name: 'water-sensor', 516 | '@type': ['LeakSensor'], 517 | powerSource: POWERSOURCE.BATTERY, 518 | activeEndpoints: { 519 | 1: { 520 | profileId: PROFILE_ID.ZHA_HEX, 521 | inputClusters: [CLUSTER_ID.GENPOWERCFG_HEX, CLUSTER_ID.SSIASZONE_HEX], 522 | outputClusters: [], 523 | }, 524 | }, 525 | properties: { 526 | waterLeak: { 527 | descr: { 528 | '@type': 'LeakProperty', 529 | label: 'Water Leak', 530 | type: 'boolean', 531 | description: 'Water Leak detected', 532 | readOnly: true, 533 | }, 534 | profileId: PROFILE_ID.ZHA, 535 | endpoint: 1, 536 | clusterId: CLUSTER_ID.SSIASZONE, 537 | attr: '', 538 | mask: ZONE_STATUS.ALARM_MASK, 539 | }, 540 | tamper: { 541 | descr: { 542 | '@type': 'BooleanProperty', 543 | label: 'Tamper', 544 | type: 'boolean', 545 | description: 'Tamper', 546 | readOnly: true, 547 | }, 548 | profileId: PROFILE_ID.ZHA, 549 | endpoint: 1, 550 | clusterId: CLUSTER_ID.SSIASZONE, 551 | attr: '', 552 | mask: ZONE_STATUS.TAMPER_MASK, 553 | }, 554 | lowBattery: { 555 | descr: { 556 | '@type': 'BooleanProperty', 557 | label: 'Low Battery', 558 | type: 'boolean', 559 | description: 'Low Battery', 560 | readOnly: true, 561 | }, 562 | profileId: PROFILE_ID.ZHA, 563 | endpoint: 1, 564 | clusterId: CLUSTER_ID.SSIASZONE, 565 | attr: '', 566 | mask: ZONE_STATUS.LOW_BATTERY_MASK, 567 | }, 568 | }, 569 | }, 570 | }; 571 | 572 | const MODEL_IDS_MAP = { 573 | 'lumi.sensor_magnet.aq2': 'lumi.sensor_magnet', 574 | 'lumi.sensor_cube.aqgl01': 'lumi.sensor_cube', 575 | }; 576 | 577 | export default class XiaomiFamily extends ZigbeeFamily { 578 | constructor() { 579 | super('xiaomi'); 580 | } 581 | 582 | classify(_node: typeof ZigbeeNode): void { 583 | // The xiaomi fmaily does the classification as part of the init 584 | // function, so we don't need to do anything here. 585 | } 586 | 587 | identify(node: typeof ZigbeeNode): boolean { 588 | if (MODEL_IDS.hasOwnProperty(this.mapModelID(node))) { 589 | this.init(node); 590 | return true; 591 | } 592 | return false; 593 | } 594 | 595 | mapModelID(node: typeof ZigbeeNode): string { 596 | if (MODEL_IDS_MAP.hasOwnProperty(node.modelId)) { 597 | return MODEL_IDS_MAP[node.modelId]; 598 | } 599 | return node.modelId; 600 | } 601 | 602 | init(node: typeof ZigbeeNode): void { 603 | const modelId = this.mapModelID(node); 604 | if (!MODEL_IDS.hasOwnProperty(modelId)) { 605 | console.log('xiaomi.classify: Unknown modelId:', node.modelId); 606 | return; 607 | } 608 | 609 | const attribs = MODEL_IDS[modelId]; 610 | if (node.inited) { 611 | return; 612 | } 613 | node.inited = true; 614 | 615 | DEBUG && console.log('xiaomi.init: modelId:', node.modelId); 616 | for (const [attribName, attrib] of Object.entries(attribs)) { 617 | switch (attribName) { 618 | case 'name': 619 | node.name = `${node.id}-${attrib}`; 620 | break; 621 | 622 | case 'properties': 623 | for (const propertyName in attrib) { 624 | const xiaomiProperty = attrib[propertyName]; 625 | const property = new ZigbeeProperty( 626 | node, 627 | propertyName, 628 | xiaomiProperty.descr, 629 | xiaomiProperty.profileId, 630 | xiaomiProperty.endpoint, 631 | xiaomiProperty.clusterId, 632 | xiaomiProperty.attr, 633 | xiaomiProperty.setAttrFromValue || '', 634 | xiaomiProperty.parseValueFromAttr || '' 635 | ); 636 | 637 | if (xiaomiProperty.hasOwnProperty('mask')) { 638 | property.mask = xiaomiProperty.mask; 639 | } 640 | 641 | property.configReportNeeded = false; 642 | property.initialReadNeeded = false; 643 | 644 | if (xiaomiProperty.hasOwnProperty('attrId')) { 645 | property.attrId = xiaomiProperty.attrId; 646 | } 647 | if (xiaomiProperty.hasOwnProperty('value')) { 648 | property.setCachedValue(xiaomiProperty.value); 649 | } 650 | 651 | DEBUG && console.log('xiaomi.init: added property:', propertyName, property.asDict()); 652 | 653 | node.properties.set(propertyName, property); 654 | } 655 | break; 656 | 657 | default: 658 | node[attribName] = cloneDeep(attrib); 659 | break; 660 | } 661 | } 662 | 663 | for (const endpointNum in node.activeEndpoints) { 664 | const endpoint = node.activeEndpoints[endpointNum]; 665 | endpoint.classifierAttributesPopulated = true; 666 | } 667 | node.activeEndpointsPopulated = true; 668 | node.nodeInfoEndpointsPopulated = true; 669 | node.rebindRequired = false; 670 | 671 | // Make sure that the family is set before calling 672 | // handleDeviceAdded. This ensures that our classifier gets 673 | // called and not the generic one. 674 | node.family = this; 675 | node.adapter.saveDeviceInfo(); 676 | node.adapter.handleDeviceAdded(node); 677 | } 678 | } 679 | 680 | ZigbeeFamily.register(new XiaomiFamily()); 681 | -------------------------------------------------------------------------------- /src/zigbee2mqtt/zigbee2mqtt-adapter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Zigbee2MqttAdapter - Adapter which communicates over MQTT with the Zigbee2Mqtt stack. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | import { Adapter, AddonManagerProxy, Device } from 'gateway-addon'; 11 | import { Config, Zigbee2MQTTAdapter } from '../config'; 12 | import mqtt from 'mqtt'; 13 | import { Zigbee2MqttDevice } from './zigbee2mqtt-device'; 14 | import DEBUG_FLAG from '../zb-debug'; 15 | 16 | function debug(): boolean { 17 | return DEBUG_FLAG.DEBUG_zigbee2mqtt; 18 | } 19 | 20 | interface Response { 21 | data?: { 22 | id?: string; 23 | block?: boolean; 24 | force?: boolean; 25 | value: boolean; 26 | }; 27 | status?: string; 28 | error?: string; 29 | } 30 | 31 | interface Log { 32 | level?: string; 33 | message?: string; 34 | } 35 | 36 | const DEVICES_POSTFIX = '/bridge/devices'; 37 | const PERMIT_REQUEST_POSTFIX = '/bridge/request/permit_join'; 38 | const PERMIT_RESPONSE_POSTFIX = '/bridge/response/permit_join'; 39 | const REMOVE_REQUEST_POSTFIX = '/bridge/request/device/remove'; 40 | const REMOVE_RESPONSE_POSTFIX = '/bridge/response/device/remove'; 41 | const LOGGING_POSTFIX = '/bridge/logging'; 42 | 43 | const DEFAULT_PORT = 1883; 44 | 45 | export class Zigbee2MqttAdapter extends Adapter { 46 | private prefix: string; 47 | 48 | private client?: mqtt.Client; 49 | 50 | private deviceByFriendlyName: Record = {}; 51 | 52 | constructor( 53 | addonManager: AddonManagerProxy, 54 | private config: Config, 55 | private adapterConfig: Zigbee2MQTTAdapter 56 | ) { 57 | super( 58 | addonManager, 59 | `zb-zigbee2mqtt-${adapterConfig.host}:${adapterConfig.port ?? DEFAULT_PORT}`, 60 | 'zigbee-adapter' 61 | ); 62 | this.prefix = adapterConfig.topicPrefix ?? 'zigbee2mqtt'; 63 | this.connect(); 64 | } 65 | 66 | async connect(): Promise { 67 | const host = this.adapterConfig.host; 68 | const port = this.adapterConfig.port || DEFAULT_PORT; 69 | const broker = `mqtt://${host}:${port}`; 70 | console.log(`Connecting to broker ${broker}`); 71 | const client = mqtt.connect(broker); 72 | this.client = client; 73 | 74 | client.on('connect', () => { 75 | console.log(`Successfully connected to ${broker}`); 76 | 77 | this.subscribe(`${this.prefix}${DEVICES_POSTFIX}`); 78 | this.subscribe(`${this.prefix}${PERMIT_RESPONSE_POSTFIX}`); 79 | this.subscribe(`${this.prefix}${REMOVE_RESPONSE_POSTFIX}`); 80 | if (this.config.zigbee2mqtt?.zigbee2mqttDebugLogs) { 81 | this.subscribe(`${this.prefix}${LOGGING_POSTFIX}`); 82 | } 83 | }); 84 | 85 | client.on('error', (error) => { 86 | console.error(`Could not connect to broker: ${error}`); 87 | }); 88 | 89 | client.on('message', (topic, message) => { 90 | const raw = message.toString(); 91 | 92 | if (debug()) { 93 | console.log(`Received on ${topic}: ${raw}`); 94 | } 95 | 96 | try { 97 | const json = JSON.parse(raw); 98 | 99 | const parts = topic.split('/'); 100 | 101 | if (topic.endsWith(DEVICES_POSTFIX)) { 102 | this.handleDevices(client, json); 103 | } else if (parts.length == 2) { 104 | const friendlyName = parts[1]; 105 | const device = this.deviceByFriendlyName[friendlyName]; 106 | 107 | if (device) { 108 | device.update(json); 109 | } else { 110 | console.log(`Could not find device with friendlyName ${friendlyName}`); 111 | } 112 | } else if (topic.endsWith(PERMIT_RESPONSE_POSTFIX)) { 113 | const response: Response = json; 114 | 115 | if (response.error) { 116 | console.log(`Could not enable permit join mode: ${response.error}`); 117 | } else if (response.status === 'ok') { 118 | if (response.data?.value) { 119 | console.log('Bridge is now permitting new devices to join'); 120 | } else { 121 | console.log('Bridge is no longer permitting new devices to join'); 122 | } 123 | } 124 | } else if (topic.endsWith(REMOVE_RESPONSE_POSTFIX)) { 125 | const response: Response = json; 126 | const id = response.data?.id ?? 'unknown'; 127 | 128 | if (response.error) { 129 | console.log(`Could not remove device ${id}: ${response.error}`); 130 | } else if (response.status === 'ok') { 131 | console.log(`Removed device ${id} successfully`); 132 | 133 | const existingDevice = this.getDevice(id); 134 | 135 | if (existingDevice) { 136 | this.handleDeviceRemoved(existingDevice); 137 | } else { 138 | console.warn(`Could not find device with id ${id}`); 139 | } 140 | } 141 | } else if (topic.indexOf(LOGGING_POSTFIX) > -1) { 142 | const log: Log = json; 143 | console.log(`Zigbee2Mqtt::${log.level}: ${log.message}`); 144 | } 145 | } catch (error) { 146 | console.error(`Could not process message ${raw}: ${error}`); 147 | } 148 | }); 149 | } 150 | 151 | private subscribe(topic: string): void { 152 | console.log(`Subscribing to ${topic}`); 153 | 154 | if (!this.client) { 155 | console.log('No client to subscribe to'); 156 | return; 157 | } 158 | 159 | this.client.subscribe(topic, (err) => { 160 | if (err) { 161 | console.error(`Could not subscribe to ${topic}: ${err}`); 162 | } else { 163 | console.log(`Successfully subscribed to ${topic}`); 164 | } 165 | }); 166 | } 167 | 168 | private handleDevices(client: mqtt.Client, deviceDefinitions: DeviceDefinition[]): void { 169 | if (!Array.isArray(deviceDefinitions)) { 170 | console.log(`Expected list of devices but got ${typeof deviceDefinitions}`); 171 | return; 172 | } 173 | 174 | for (const deviceDefinition of deviceDefinitions) { 175 | if (deviceDefinition.type == 'EndDevice' || deviceDefinition.type == 'Router') { 176 | const id = deviceDefinition.ieee_address; 177 | 178 | if (id) { 179 | const existingDevice = this.getDevice(id); 180 | 181 | if (!existingDevice) { 182 | const device = new Zigbee2MqttDevice(this, id, deviceDefinition, client, this.prefix); 183 | this.handleDeviceAdded(device); 184 | this.deviceByFriendlyName[deviceDefinition.friendly_name as string] = device; 185 | device.fetchValues(); 186 | } else if (debug()) { 187 | console.log(`Device ${id} already exists`); 188 | } 189 | } else { 190 | console.log(`Ignoring device without id: ${JSON.stringify(deviceDefinition)}`); 191 | } 192 | } else { 193 | console.log(`Ignoring device of type ${deviceDefinition.type}`); 194 | } 195 | } 196 | } 197 | 198 | startPairing(timeoutSeconds: number): void { 199 | console.log(`Permit joining for ${timeoutSeconds} seconds`); 200 | const permitTopic = `${this.prefix}${PERMIT_REQUEST_POSTFIX}`; 201 | this.publish(permitTopic, JSON.stringify({ value: true, time: timeoutSeconds })); 202 | } 203 | 204 | cancelPairing(): void { 205 | console.log('Deny joining'); 206 | const permitTopic = `${this.prefix}${PERMIT_REQUEST_POSTFIX}`; 207 | this.publish(permitTopic, JSON.stringify({ value: false })); 208 | } 209 | 210 | removeThing(device: Device): void { 211 | console.log(`Removing ${device.getTitle()} (${device.getId()})`); 212 | const removeTopic = `${this.prefix}${REMOVE_REQUEST_POSTFIX}`; 213 | this.publish(removeTopic, JSON.stringify({ id: device.getId() })); 214 | } 215 | 216 | private publish(topic: string, payload: string): void { 217 | if (debug()) { 218 | console.log(`Sending ${payload} to ${topic}`); 219 | } 220 | 221 | this?.client?.publish(topic, payload, (error) => { 222 | if (error) { 223 | console.log(`Could not send ${payload} to ${topic}: ${error}`); 224 | } 225 | }); 226 | } 227 | } 228 | 229 | export interface DeviceDefinition { 230 | definition?: Definition; 231 | friendly_name?: string; 232 | ieee_address?: string; 233 | interview_completed?: boolean; 234 | interviewing?: boolean; 235 | model_id?: string; 236 | network_address?: number; 237 | power_source?: string; 238 | supported?: boolean; 239 | type?: string; 240 | } 241 | 242 | export interface Definition { 243 | description?: string; 244 | exposes?: Expos[]; 245 | model?: string; 246 | supports_ota?: boolean; 247 | vendor?: string; 248 | } 249 | 250 | export interface Expos { 251 | access?: number; 252 | description?: string; 253 | name?: string; 254 | property?: string; 255 | type?: string; 256 | unit?: string; 257 | value_max?: number; 258 | value_min?: number; 259 | value_step?: number; 260 | values?: string[]; 261 | features: Expos[]; 262 | } 263 | -------------------------------------------------------------------------------- /src/zigbee2mqtt/zigbee2mqtt-device.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Zigbee2MqttDevice - A Zigbee2Mqtt device. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | import { Action, Device, Event } from 'gateway-addon'; 11 | import { Any, Event as EventSchema } from 'gateway-addon/lib/schema'; 12 | import { Zigbee2MqttAdapter, DeviceDefinition, Expos } from './zigbee2mqtt-adapter'; 13 | import { 14 | OnOffProperty, 15 | BrightnessProperty, 16 | ColorTemperatureProperty, 17 | ColorProperty, 18 | Zigbee2MqttProperty, 19 | WRITE_BIT, 20 | parseType, 21 | parseUnit, 22 | HeatingCoolingProperty, 23 | } from './zigbee2mqtt-property'; 24 | import mqtt from 'mqtt'; 25 | import DEBUG_FLAG from '../zb-debug'; 26 | 27 | function debug(): boolean { 28 | return DEBUG_FLAG.DEBUG_zigbee2mqtt; 29 | } 30 | 31 | const IGNORED_PROPERTIES = [ 32 | 'linkquality', 33 | 'local_temperature_calibration', 34 | 'update', 35 | 'update_available', 36 | 'color_temp_startup', 37 | 'voltage', 38 | 'led_indication', 39 | 'occupancy_timeout', 40 | 'illuminance', 41 | 'motion_sensitivity', 42 | 'requested_brightness_percent', 43 | 'requested_brightness_level', 44 | 'action_side', 45 | 'eurotronic_trv_mode', 46 | 'eurotronic_valve_position', 47 | ]; 48 | 49 | export class Zigbee2MqttDevice extends Device { 50 | private deviceTopic: string; 51 | 52 | constructor( 53 | adapter: Zigbee2MqttAdapter, 54 | id: string, 55 | deviceDefinition: DeviceDefinition, 56 | private client: mqtt.Client, 57 | topicPrefix: string 58 | ) { 59 | super(adapter, id); 60 | this.deviceTopic = `${topicPrefix}/${deviceDefinition.friendly_name}`; 61 | 62 | this.detectProperties(deviceDefinition); 63 | 64 | console.log(`Subscribing to ${this.deviceTopic}`); 65 | 66 | client.subscribe(this.deviceTopic, (err) => { 67 | if (err) { 68 | console.error(`Could not subscribe to ${this.deviceTopic}: ${err}`); 69 | } 70 | }); 71 | 72 | if (deviceDefinition.friendly_name) { 73 | this.setTitle(deviceDefinition.friendly_name); 74 | } else { 75 | this.setTitle(`Zigbee2MQTT (${id})`); 76 | } 77 | } 78 | 79 | protected detectProperties(deviceDefinition: DeviceDefinition): void { 80 | for (const expose of deviceDefinition?.definition?.exposes ?? []) { 81 | switch (expose.type ?? '') { 82 | case 'light': 83 | this.createLightProperties(expose); 84 | break; 85 | case 'switch': 86 | this.createSmartPlugProperties(expose); 87 | break; 88 | case 'climate': 89 | this.createThermostatProperties(expose); 90 | break; 91 | default: 92 | if (expose.name === 'action') { 93 | this.createEvents(expose.values as string[]); 94 | } else { 95 | const isWriteOnly = (expose.access ?? 0) == WRITE_BIT; 96 | 97 | if (isWriteOnly) { 98 | this.createAction(expose); 99 | } else { 100 | this.createProperty(expose); 101 | } 102 | } 103 | break; 104 | } 105 | } 106 | } 107 | 108 | private createLightProperties(expose: Expos): void { 109 | if (expose.features) { 110 | (this as unknown as { '@type': string[] })['@type'].push('Light'); 111 | 112 | for (const feature of expose.features) { 113 | if (feature.name) { 114 | switch (feature.name) { 115 | case 'state': 116 | { 117 | console.log(`Creating property for ${feature.name}`); 118 | 119 | const property = new OnOffProperty( 120 | this, 121 | feature.name, 122 | feature, 123 | this.client, 124 | this.deviceTopic 125 | ); 126 | 127 | this.addProperty(property); 128 | } 129 | break; 130 | case 'brightness': 131 | { 132 | console.log(`Creating property for ${feature.name}`); 133 | 134 | const property = new BrightnessProperty( 135 | this, 136 | feature.name, 137 | feature, 138 | this.client, 139 | this.deviceTopic 140 | ); 141 | 142 | this.addProperty(property); 143 | } 144 | break; 145 | case 'color_temp': 146 | { 147 | console.log(`Creating property for ${feature.name}`); 148 | 149 | const property = new ColorTemperatureProperty( 150 | this, 151 | feature.name, 152 | feature, 153 | this.client, 154 | this.deviceTopic 155 | ); 156 | 157 | this.addProperty(property); 158 | } 159 | break; 160 | case 'color_xy': 161 | { 162 | console.log(`Creating property for ${feature.name}`); 163 | 164 | const property = new ColorProperty( 165 | this, 166 | 'color', 167 | feature, 168 | this.client, 169 | this.deviceTopic 170 | ); 171 | 172 | this.addProperty(property); 173 | } 174 | break; 175 | } 176 | } else { 177 | console.log(`Ignoring property without name: ${JSON.stringify(expose, null, 0)}`); 178 | } 179 | } 180 | } else { 181 | console.warn(`Expected features array in light expose: ${JSON.stringify(expose)}`); 182 | } 183 | } 184 | 185 | private createSmartPlugProperties(expose: Expos): void { 186 | if (expose.features) { 187 | (this as unknown as { '@type': string[] })['@type'].push('SmartPlug'); 188 | 189 | for (const feature of expose.features) { 190 | if (feature.name) { 191 | switch (feature.name) { 192 | case 'state': 193 | { 194 | console.log(`Creating property for ${feature.name}`); 195 | 196 | const property = new OnOffProperty( 197 | this, 198 | feature.name, 199 | feature, 200 | this.client, 201 | this.deviceTopic 202 | ); 203 | 204 | this.addProperty(property); 205 | } 206 | break; 207 | } 208 | } else { 209 | console.log(`Ignoring property without name: ${JSON.stringify(expose, null, 0)}`); 210 | } 211 | } 212 | } else { 213 | console.warn(`Expected features array in light expose: ${JSON.stringify(expose)}`); 214 | } 215 | } 216 | 217 | private createThermostatProperties(expose: Expos): void { 218 | if (expose.features) { 219 | (this as unknown as { '@type': string[] })['@type'].push('Thermostat'); 220 | 221 | for (const feature of expose.features) { 222 | if (feature.name) { 223 | switch (feature.name) { 224 | case 'system_mode': { 225 | console.log(`Creating property for ${feature.name}`); 226 | 227 | const property = new Zigbee2MqttProperty( 228 | this, 229 | feature.name, 230 | feature, 231 | this.client, 232 | this.deviceTopic, 233 | { 234 | '@type': 'ThermostatModeProperty', 235 | type: 'string', 236 | } 237 | ); 238 | 239 | this.addProperty(property); 240 | break; 241 | } 242 | case 'running_state': 243 | { 244 | console.log(`Creating property for ${feature.name}`); 245 | 246 | const property = new HeatingCoolingProperty( 247 | this, 248 | feature.name, 249 | feature, 250 | this.client, 251 | this.deviceTopic 252 | ); 253 | 254 | this.addProperty(property); 255 | } 256 | break; 257 | default: 258 | this.createProperty(feature); 259 | break; 260 | } 261 | } else { 262 | console.log(`Ignoring property without name: ${JSON.stringify(expose, null, 0)}`); 263 | } 264 | } 265 | } else { 266 | console.warn(`Expected features array in thermostat expose: ${JSON.stringify(expose)}`); 267 | } 268 | } 269 | 270 | private createEvents(values: string[]): void { 271 | if (Array.isArray(values)) { 272 | if (values.length > 0) { 273 | let isPushbutton = false; 274 | 275 | for (const value of values) { 276 | console.log(`Creating property for ${value}`); 277 | 278 | const additionalProperties: Record = {}; 279 | 280 | if (value.indexOf('single') > -1 || value === 'on' || value === 'toggle') { 281 | additionalProperties['@type'] = 'PressedEvent'; 282 | isPushbutton = true; 283 | } 284 | 285 | if (value.indexOf('double') > -1) { 286 | additionalProperties['@type'] = 'DoublePressedEvent'; 287 | isPushbutton = true; 288 | } 289 | 290 | if (value.indexOf('release') > -1) { 291 | additionalProperties['@type'] = 'LongPressedEvent'; 292 | isPushbutton = true; 293 | } 294 | 295 | this.addEvent(value, { 296 | name: value, 297 | ...additionalProperties, 298 | }); 299 | 300 | console.log({ 301 | name: value, 302 | ...additionalProperties, 303 | }); 304 | } 305 | 306 | if (isPushbutton) { 307 | const device = this as unknown as { '@type': string[] }; 308 | device['@type'].push('PushButton'); 309 | } 310 | } else { 311 | console.log(`Expected list of values but got ${JSON.stringify(values)}`); 312 | } 313 | } else { 314 | console.log(`Expected array but got ${typeof values}`); 315 | } 316 | } 317 | 318 | private createAction(expose: Expos): void { 319 | if (expose.name) { 320 | console.log(`Creating action for ${expose.name}`); 321 | 322 | this.addAction(expose.name, { 323 | description: expose.description, 324 | input: { 325 | type: parseType(expose), 326 | unit: parseUnit(expose.unit), 327 | enum: expose.values, 328 | minimum: expose.value_min, 329 | maximum: expose.value_max, 330 | }, 331 | }); 332 | } else { 333 | console.log(`Ignoring action without name: ${JSON.stringify(expose, null, 0)}`); 334 | } 335 | } 336 | 337 | private createProperty(expose: Expos): void { 338 | if (expose.name) { 339 | if (IGNORED_PROPERTIES.includes(expose.name)) { 340 | return; 341 | } 342 | 343 | console.log(`Creating property for ${expose.name}`); 344 | 345 | const property = new Zigbee2MqttProperty( 346 | this, 347 | expose.name, 348 | expose, 349 | this.client, 350 | this.deviceTopic 351 | ); 352 | 353 | this.addProperty(property); 354 | } else { 355 | console.log(`Ignoring property without name: ${JSON.stringify(expose, null, 0)}`); 356 | } 357 | } 358 | 359 | update(update: Record): void { 360 | if (typeof update !== 'object') { 361 | console.log(`Expected object but got ${typeof update}`); 362 | } 363 | 364 | for (const [key, value] of Object.entries(update)) { 365 | if (IGNORED_PROPERTIES.includes(key)) { 366 | continue; 367 | } 368 | 369 | if (key === 'action') { 370 | if (typeof value !== 'string') { 371 | console.log(`Expected event of type string but got ${typeof value}`); 372 | continue; 373 | } 374 | 375 | const exists = (this as unknown as { events: Map }).events.has(value); 376 | 377 | if (!exists) { 378 | if (debug()) { 379 | console.log(`Event '${value}' does not exist on ${this.getTitle()} (${this.getId()})`); 380 | } 381 | continue; 382 | } 383 | 384 | const event = new Event(this, value as string); 385 | this.eventNotify(event); 386 | } else { 387 | const property = this.findProperty(key) as Zigbee2MqttProperty; 388 | 389 | if (property) { 390 | property.update(value, update); 391 | } else if (debug()) { 392 | console.log(`Property '${key}' does not exist on ${this.getTitle()} (${this.getId()})`); 393 | } 394 | } 395 | } 396 | } 397 | 398 | performAction(action: Action): Promise { 399 | const { name, input } = action.asDict(); 400 | 401 | action.start(); 402 | 403 | return new Promise((resolve, reject) => { 404 | const writeTopic = `${this.deviceTopic}/set`; 405 | const json = { [name]: input }; 406 | 407 | if (debug()) { 408 | console.log(`Sending ${JSON.stringify(json)} to ${writeTopic}`); 409 | } 410 | 411 | this.client.publish(writeTopic, JSON.stringify(json), (error) => { 412 | action.finish(); 413 | 414 | if (error) { 415 | reject(error); 416 | } else { 417 | resolve(); 418 | } 419 | }); 420 | }); 421 | } 422 | 423 | fetchValues(): void { 424 | const { properties } = this as unknown as { 425 | properties: Map>; 426 | }; 427 | 428 | const payload: Record = {}; 429 | 430 | for (const property of properties.values()) { 431 | if (property.isReadable()) { 432 | payload[property.getName()] = ''; 433 | } 434 | } 435 | 436 | if (Object.keys(payload).length > 0) { 437 | const readTopic = `${this.deviceTopic}/get`; 438 | const readPayload = JSON.stringify(payload); 439 | 440 | if (debug()) { 441 | console.log(`Sending ${readPayload} to ${readTopic}`); 442 | } 443 | 444 | this.client.publish(readTopic, readPayload, (error) => { 445 | if (error) { 446 | console.warn(`Could not send ${readPayload} to ${readTopic}: ${console.error()}`); 447 | } 448 | }); 449 | } else if (debug()) { 450 | console.log(`${this.getTitle()} has no readable properties`); 451 | } 452 | } 453 | } 454 | -------------------------------------------------------------------------------- /src/zigbee2mqtt/zigbee2mqtt-driver.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Zigbee2MqttDriver - A driver for the Zigbee2Mqtt stack. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | import { AddonManagerProxy } from 'gateway-addon'; 11 | import { Config } from '../config'; 12 | import { Zigbee2MqttAdapter } from './zigbee2mqtt-adapter'; 13 | 14 | export class Zigbee2MqttDriver { 15 | constructor(addonManager: AddonManagerProxy, config: Config) { 16 | if (config.zigbee2mqtt?.zigbee2mqttAdapters) { 17 | for (const zigbee2mqtt of config.zigbee2mqtt?.zigbee2mqttAdapters) { 18 | const adapter = new Zigbee2MqttAdapter(addonManager, config, zigbee2mqtt); 19 | addonManager.addAdapter(adapter); 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/zigbee2mqtt/zigbee2mqtt-property.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Zigbee2MqttProperty - A Zigbee2Mqtt property. 4 | * 5 | * This Source Code Form is subject to the terms of the Mozilla Public 6 | * License, v. 2.0. If a copy of the MPL was not distributed with this 7 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 8 | */ 9 | 10 | import Color from 'color'; 11 | import { Property } from 'gateway-addon'; 12 | import { Any, PropertyValueType, Property as PropertySchema } from 'gateway-addon/lib/schema'; 13 | import { Expos } from './zigbee2mqtt-adapter'; 14 | import { Zigbee2MqttDevice } from './zigbee2mqtt-device'; 15 | import mqtt from 'mqtt'; 16 | import DEBUG_FLAG from '../zb-debug'; 17 | 18 | function debug(): boolean { 19 | return DEBUG_FLAG.DEBUG_zigbee2mqtt; 20 | } 21 | 22 | export const WRITE_BIT = 0b010; 23 | export const READ_BIT = 0b100; 24 | 25 | export function parseType(expose: Expos): PropertyValueType { 26 | switch (expose.type) { 27 | case 'numeric': 28 | if (expose.value_step === 1) { 29 | return 'integer'; 30 | } else { 31 | return 'number'; 32 | } 33 | case 'enum': 34 | return 'string'; 35 | case 'binary': 36 | return 'boolean'; 37 | } 38 | 39 | return 'string'; 40 | } 41 | 42 | export function parseUnit(unit?: string): string | undefined { 43 | switch (unit) { 44 | case '°C': 45 | return 'degree celsius'; 46 | } 47 | 48 | return unit; 49 | } 50 | 51 | function isWritable(access: number): boolean { 52 | return (access & WRITE_BIT) != 0; 53 | } 54 | 55 | export function isReadable(access: number): boolean { 56 | return (access & READ_BIT) != 0; 57 | } 58 | 59 | export class Zigbee2MqttProperty extends Property { 60 | constructor( 61 | device: Zigbee2MqttDevice, 62 | name: string, 63 | protected expose: Expos, 64 | private client: mqtt.Client, 65 | private deviceTopic: string, 66 | additionalProperties?: PropertySchema 67 | ) { 68 | super(device, name, { 69 | title: expose.name, 70 | description: expose.description, 71 | type: parseType(expose), 72 | unit: parseUnit(expose.unit), 73 | enum: expose.values, 74 | minimum: expose.value_min, 75 | maximum: expose.value_max, 76 | multipleOf: expose.value_step, 77 | readOnly: !isWritable(expose.access ?? 0), 78 | ...additionalProperties, 79 | }); 80 | 81 | if (this.getUnit() == '%') { 82 | this.setAtType('LevelProperty'); 83 | } 84 | 85 | switch (name) { 86 | case 'occupancy': { 87 | const device = this.getDevice() as unknown as { '@type': string[] }; 88 | device['@type'].push('MotionSensor'); 89 | this.setAtType('MotionProperty'); 90 | break; 91 | } 92 | case 'power': { 93 | this.setAtType('InstantaneousPowerProperty'); 94 | break; 95 | } 96 | case 'voltage': { 97 | this.setAtType('VoltageProperty'); 98 | break; 99 | } 100 | case 'current': { 101 | this.setAtType('CurrentProperty'); 102 | break; 103 | } 104 | case 'local_temperature': { 105 | this.setTitle('Current temperature'); 106 | this.setAtType('TemperatureProperty'); 107 | break; 108 | } 109 | case 'occupied_heating_setpoint': { 110 | this.setTitle('Target temperature'); 111 | this.setAtType('TargetTemperatureProperty'); 112 | break; 113 | } 114 | case 'system_mode': { 115 | this.setTitle('Mode'); 116 | break; 117 | } 118 | case 'pi_heating_demand': { 119 | this.setTitle('Valve state'); 120 | break; 121 | } 122 | case 'battery': { 123 | this.setTitle('Battery'); 124 | break; 125 | } 126 | case 'temperature': { 127 | device['@type'].push('TemperatureSensor'); 128 | this.setTitle('Temperature'); 129 | this.setAtType('TemperatureProperty'); 130 | break; 131 | } 132 | case 'humidity': { 133 | device['@type'].push('HumiditySensor'); 134 | this.setTitle('Humidity'); 135 | this.setAtType('HumidityProperty'); 136 | break; 137 | } 138 | case 'pressure': { 139 | device['@type'].push('BarometricPressureSensor'); 140 | this.setTitle('Barometric pressure'); 141 | this.setAtType('BarometricPressureProperty'); 142 | break; 143 | } 144 | case 'smoke': { 145 | device['@type'].push('SmokeSensor'); 146 | this.setTitle('Smoke'); 147 | this.setAtType('SmokeProperty'); 148 | break; 149 | } 150 | case 'contact': { 151 | device['@type'].push('DoorSensor'); 152 | this.setTitle('Open'); 153 | this.setAtType('OpenProperty'); 154 | break; 155 | } 156 | } 157 | } 158 | 159 | isReadable(): boolean { 160 | console.log(`${this.getName()} ${this.expose.access} ${isReadable(this.expose.access ?? 0)}`); 161 | return isReadable(this.expose.access ?? 0); 162 | } 163 | 164 | update(value: unknown, _: Record): void { 165 | this.setCachedValueAndNotify(value as T); 166 | } 167 | 168 | async setValue(value: T): Promise { 169 | const newValue = await super.setValue(value); 170 | await this.sendValue(newValue); 171 | 172 | return Promise.resolve(newValue); 173 | } 174 | 175 | protected async sendValue(value: unknown): Promise { 176 | return new Promise((resolve, reject) => { 177 | const writeTopic = `${this.deviceTopic}/set`; 178 | const json = { [this.getName()]: value }; 179 | 180 | if (debug()) { 181 | console.log(`Sending ${JSON.stringify(json)} to ${writeTopic}`); 182 | } 183 | 184 | this.client.publish(writeTopic, JSON.stringify(json), (error) => { 185 | if (error) { 186 | reject(error); 187 | } else { 188 | resolve(); 189 | } 190 | }); 191 | }); 192 | } 193 | } 194 | 195 | export class OnOffProperty extends Zigbee2MqttProperty { 196 | constructor( 197 | device: Zigbee2MqttDevice, 198 | name: string, 199 | expose: Expos, 200 | client: mqtt.Client, 201 | deviceTopic: string 202 | ) { 203 | super(device, name, expose, client, deviceTopic, { 204 | '@type': 'OnOffProperty', 205 | title: 'On', 206 | type: parseType(expose), 207 | }); 208 | } 209 | 210 | update(value: string, update: Record): void { 211 | super.update(value === 'ON', update); 212 | } 213 | 214 | protected async sendValue(value: boolean): Promise { 215 | return super.sendValue(value ? 'ON' : 'OFF'); 216 | } 217 | } 218 | 219 | export class BrightnessProperty extends Zigbee2MqttProperty { 220 | constructor( 221 | device: Zigbee2MqttDevice, 222 | name: string, 223 | expose: Expos, 224 | client: mqtt.Client, 225 | deviceTopic: string 226 | ) { 227 | super(device, name, expose, client, deviceTopic, { 228 | '@type': 'BrightnessProperty', 229 | title: 'Brightness', 230 | minimum: 0, 231 | maximum: 100, 232 | type: 'number', 233 | unit: 'percent', 234 | }); 235 | } 236 | 237 | update(value: number, update: Record): void { 238 | const percent = Math.round((value / (this.expose.value_max ?? 100)) * 100); 239 | super.update(percent, update); 240 | } 241 | 242 | protected async sendValue(value: number): Promise { 243 | return super.sendValue(Math.round((value / 100) * (this.expose.value_max ?? 100))); 244 | } 245 | } 246 | 247 | function miredToKelvin(mired: number): number { 248 | return Math.round(1_000_000 / mired); 249 | } 250 | 251 | function kelvinToMiredd(kelvin: number): number { 252 | return Math.round(1_000_000 / kelvin); 253 | } 254 | 255 | export class ColorTemperatureProperty extends Zigbee2MqttProperty { 256 | constructor( 257 | device: Zigbee2MqttDevice, 258 | name: string, 259 | expose: Expos, 260 | client: mqtt.Client, 261 | deviceTopic: string 262 | ) { 263 | super(device, name, expose, client, deviceTopic, { 264 | '@type': 'ColorTemperatureProperty', 265 | title: 'Color temperature', 266 | type: 'number', 267 | minimum: miredToKelvin(expose.value_max!), 268 | maximum: miredToKelvin(expose.value_min!), 269 | unit: 'kelvin', 270 | }); 271 | } 272 | 273 | update(value: number, update: Record): void { 274 | super.update(miredToKelvin(value), update); 275 | } 276 | 277 | protected async sendValue(value: number): Promise { 278 | return super.sendValue(kelvinToMiredd(value)); 279 | } 280 | } 281 | 282 | export class ColorProperty extends Zigbee2MqttProperty { 283 | constructor( 284 | device: Zigbee2MqttDevice, 285 | name: string, 286 | expose: Expos, 287 | client: mqtt.Client, 288 | deviceTopic: string 289 | ) { 290 | super(device, name, expose, client, deviceTopic, { 291 | '@type': 'ColorProperty', 292 | title: 'Color', 293 | type: 'string', 294 | readOnly: false, 295 | }); 296 | } 297 | 298 | update(value: XY, update: Record): void { 299 | const rgb = xyBriToRgb(value.x, value.y, (update.brightness as number) ?? 255); 300 | const color = new Color(rgb); 301 | super.update(color.hex(), update); 302 | } 303 | 304 | protected async sendValue(value: string): Promise { 305 | const color = new Color(value); 306 | const rgb = color.object(); 307 | return super.sendValue(rgb); 308 | } 309 | } 310 | 311 | export interface XY { 312 | x: number; 313 | y: number; 314 | } 315 | 316 | export interface RGB { 317 | r: number; 318 | g: number; 319 | b: number; 320 | } 321 | 322 | /* 323 | I tried 324 | 325 | Color({ 326 | x: value.x * 100, 327 | y: value.y * 100, 328 | z: ((update.brightness as number) ?? 255) * 100 / 255, 329 | }).hex() 330 | 331 | but it seems to calculate the wrong color. 332 | If we send #00FF00 to zigbee2mqtt we get {"x":0.1721,"y":0.6905} as answer. 333 | The Color class translates this to #00FFF5. 334 | */ 335 | // https://stackoverflow.com/questions/22894498/philips-hue-convert-xy-from-api-to-hex-or-rgb 336 | function xyBriToRgb(x: number, y: number, bri: number): RGB { 337 | const z = 1.0 - x - y; 338 | 339 | const Y = bri / 255.0; 340 | const X = (Y / y) * x; 341 | const Z = (Y / y) * z; 342 | 343 | let r = X * 1.612 - Y * 0.203 - Z * 0.302; 344 | let g = -X * 0.509 + Y * 1.412 + Z * 0.066; 345 | let b = X * 0.026 - Y * 0.072 + Z * 0.962; 346 | 347 | r = r <= 0.0031308 ? 12.92 * r : (1.0 + 0.055) * Math.pow(r, 1.0 / 2.4) - 0.055; 348 | g = g <= 0.0031308 ? 12.92 * g : (1.0 + 0.055) * Math.pow(g, 1.0 / 2.4) - 0.055; 349 | b = b <= 0.0031308 ? 12.92 * b : (1.0 + 0.055) * Math.pow(b, 1.0 / 2.4) - 0.055; 350 | 351 | const maxValue = Math.max(r, g, b); 352 | 353 | r /= maxValue; 354 | g /= maxValue; 355 | b /= maxValue; 356 | 357 | r = limit(r * 255, 0, 255); 358 | g = limit(g * 255, 0, 255); 359 | b = limit(b * 255, 0, 255); 360 | 361 | return { r: Math.round(r), g: Math.round(g), b: Math.round(b) }; 362 | } 363 | 364 | function limit(value: number, min: number, max: number): number { 365 | return Math.max(Math.min(value, max), min); 366 | } 367 | 368 | function convertHeatingCoolingValues(value?: string[]): string[] | undefined { 369 | if (value) { 370 | return value.map((x) => convertHeatingCoolingValue(x)); 371 | } 372 | 373 | return value; 374 | } 375 | 376 | function convertHeatingCoolingValue(value: string): string { 377 | switch (value) { 378 | case 'idle': 379 | return 'off'; 380 | case 'heat': 381 | return 'heating'; 382 | case 'cool': 383 | return 'cooling'; 384 | default: 385 | throw new Error(`Invalid HeatingCoolingValue ${value}, expected idle, heat or cool`); 386 | } 387 | } 388 | 389 | export class HeatingCoolingProperty extends Zigbee2MqttProperty { 390 | constructor( 391 | device: Zigbee2MqttDevice, 392 | name: string, 393 | expose: Expos, 394 | client: mqtt.Client, 395 | deviceTopic: string 396 | ) { 397 | super(device, name, expose, client, deviceTopic, { 398 | '@type': 'HeatingCoolingProperty', 399 | title: 'Run Mode', 400 | type: 'string', 401 | enum: convertHeatingCoolingValues(expose.values), 402 | }); 403 | } 404 | 405 | update(value: string, update: Record): void { 406 | super.update(convertHeatingCoolingValue(value), update); 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2018", 8 | "dom" 9 | ], 10 | "declaration": true, 11 | "declarationMap": true, 12 | "sourceMap": true, 13 | "outDir": "lib", 14 | "rootDir": "src", 15 | "strict": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "strictFunctionTypes": true, 19 | "strictBindCallApply": true, 20 | "strictPropertyInitialization": true, 21 | "noImplicitThis": true, 22 | "alwaysStrict": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noImplicitReturns": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "esModuleInterop": true, 28 | "allowJs": true, 29 | "checkJs": false, 30 | "resolveJsonModule": true 31 | }, 32 | "exclude": [ 33 | "node_modules", 34 | "lib", 35 | "generate-config-interfaces.js" 36 | ] 37 | } 38 | --------------------------------------------------------------------------------