├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── auto-merge.yml ├── dependabot.yml ├── stale.yml └── workflows │ ├── dependabot-automerge.yml │ └── test-and-release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── __tests__ └── test-http-event-parser.js ├── examples ├── ble │ ├── add-pairing.js │ ├── discovery.js │ ├── get-accessories.js │ ├── get-characteristics.js │ ├── identify.js │ ├── list-pairings.js │ ├── pair-setup.js │ ├── remove-pairing.js │ ├── set-characteristics.js │ └── subscribe-characteristics.js └── ip │ ├── add-pairing.js │ ├── discovery.js │ ├── get-accessories.js │ ├── get-characteristics.js │ ├── identify.js │ ├── list-pairings.js │ ├── pair-setup-prompt.js │ ├── pair-setup.js │ ├── remove-pairing.js │ ├── set-characteristics.js │ └── subscribe-characteristics.js ├── global.d.ts ├── package-lock.json ├── package.json ├── src ├── index.ts ├── model │ ├── accessory.ts │ ├── category.ts │ ├── characteristic.ts │ ├── error.ts │ ├── service.ts │ └── tlv.ts ├── protocol │ └── pairing-protocol.ts ├── transport │ ├── ble │ │ ├── ble-discovery.ts │ │ ├── gatt-client.ts │ │ ├── gatt-connection.ts │ │ ├── gatt-constants.ts │ │ ├── gatt-protocol.ts │ │ └── gatt-utils.ts │ └── ip │ │ ├── http-client.ts │ │ ├── http-connection.ts │ │ ├── http-constants.ts │ │ ├── http-event-parser.ts │ │ └── ip-discovery.ts └── utils │ └── queue.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /.eslintrc.js 2 | /lib 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': false, 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 | ], 17 | 'parser': '@typescript-eslint/parser', 18 | 'parserOptions': { 19 | 'sourceType': 'module' 20 | }, 21 | 'plugins': [ 22 | '@typescript-eslint' 23 | ], 24 | 'rules': { 25 | 'arrow-parens': [ 26 | 'error', 27 | 'always' 28 | ], 29 | 'arrow-spacing': 'error', 30 | 'block-scoped-var': 'error', 31 | 'block-spacing': [ 32 | 'error', 33 | 'always' 34 | ], 35 | '@typescript-eslint/brace-style': [ 36 | 'error', 37 | '1tbs' 38 | ], 39 | '@typescript-eslint/comma-dangle': [ 40 | 'error', 41 | 'always-multiline' 42 | ], 43 | '@typescript-eslint/comma-spacing': 'error', 44 | 'comma-style': [ 45 | 'error', 46 | 'last' 47 | ], 48 | 'computed-property-spacing': [ 49 | 'error', 50 | 'never' 51 | ], 52 | 'curly': 'error', 53 | '@typescript-eslint/default-param-last': 'error', 54 | 'dot-notation': 'error', 55 | 'eol-last': 'error', 56 | '@typescript-eslint/explicit-module-boundary-types': [ 57 | 'warn', 58 | { 59 | 'allowArgumentsExplicitlyTypedAsAny': true 60 | } 61 | ], 62 | '@typescript-eslint/explicit-function-return-type': [ 63 | 'error', 64 | { 65 | 'allowExpressions': true 66 | } 67 | ], 68 | '@typescript-eslint/func-call-spacing': [ 69 | 'error', 70 | 'never' 71 | ], 72 | '@typescript-eslint/indent': [ 73 | 'error', 74 | 4, 75 | { 76 | 'ArrayExpression': 'first', 77 | 'CallExpression': { 78 | 'arguments': 'first' 79 | }, 80 | 'FunctionDeclaration': { 81 | 'parameters': 'first' 82 | }, 83 | 'FunctionExpression': { 84 | 'parameters': 'first' 85 | }, 86 | 'ObjectExpression': 'first', 87 | 'SwitchCase': 1 88 | } 89 | ], 90 | 'key-spacing': [ 91 | 'error', 92 | { 93 | 'afterColon': true, 94 | 'beforeColon': false, 95 | 'mode': 'strict' 96 | } 97 | ], 98 | '@typescript-eslint/keyword-spacing': 'off', 99 | 'linebreak-style': [ 100 | 'error', 101 | 'unix' 102 | ], 103 | '@typescript-eslint/lines-between-class-members': [ 104 | 'error', 105 | 'always' 106 | ], 107 | 'max-len': [ 108 | 'error', 109 | 120 110 | ], 111 | '@typescript-eslint/member-delimiter-style': [ 112 | 'error', 113 | { 114 | 'singleline': { 115 | 'delimiter': 'semi', 116 | 'requireLast': false 117 | }, 118 | 'multiline': { 119 | 'delimiter': 'semi', 120 | 'requireLast': true 121 | } 122 | } 123 | ], 124 | 'multiline-ternary': [ 125 | 'error', 126 | 'always-multiline' 127 | ], 128 | 'no-console': 0, 129 | 'no-eval': 'error', 130 | '@typescript-eslint/no-explicit-any': [ 131 | 'off', 132 | { 133 | 'ignoreRestArgs': true 134 | } 135 | ], 136 | 'no-floating-decimal': 'error', 137 | 'no-implicit-globals': 'error', 138 | 'no-implied-eval': 'error', 139 | 'no-lonely-if': 'error', 140 | 'no-multi-spaces': [ 141 | 'error', 142 | { 143 | 'ignoreEOLComments': true 144 | } 145 | ], 146 | 'no-multiple-empty-lines': 'error', 147 | '@typescript-eslint/no-namespace': [ 148 | 'error', 149 | { 150 | 'allowDeclarations': true 151 | } 152 | ], 153 | '@typescript-eslint/no-non-null-assertion': 'off', 154 | 'no-prototype-builtins': 'off', 155 | 'no-return-assign': 'error', 156 | 'no-script-url': 'error', 157 | 'no-self-compare': 'error', 158 | 'no-sequences': 'error', 159 | 'no-shadow-restricted-names': 'error', 160 | 'no-tabs': 'error', 161 | 'no-throw-literal': 'error', 162 | 'no-trailing-spaces': 'error', 163 | 'no-undefined': 'error', 164 | 'no-unmodified-loop-condition': 'error', 165 | '@typescript-eslint/no-unused-vars': [ 166 | 'error', 167 | { 168 | 'argsIgnorePattern': '^_', 169 | 'varsIgnorePattern': '^_' 170 | } 171 | ], 172 | 'no-useless-computed-key': 'error', 173 | 'no-useless-concat': 'error', 174 | '@typescript-eslint/no-useless-constructor': 'error', 175 | 'no-useless-return': 'error', 176 | 'no-var': 'error', 177 | 'no-void': 'error', 178 | 'no-whitespace-before-property': 'error', 179 | 'object-curly-newline': [ 180 | 'error', 181 | { 182 | 'consistent': true 183 | } 184 | ], 185 | 'object-curly-spacing': [ 186 | 'error', 187 | 'always' 188 | ], 189 | 'object-property-newline': [ 190 | 'error', 191 | { 192 | 'allowMultiplePropertiesPerLine': true 193 | } 194 | ], 195 | 'operator-linebreak': [ 196 | 'error', 197 | 'after', 198 | { 199 | 'overrides': { 200 | '?': 'before', 201 | ':': 'before' 202 | } 203 | } 204 | ], 205 | 'padded-blocks': [ 206 | 'error', 207 | { 208 | 'blocks': 'never' 209 | } 210 | ], 211 | 'prefer-const': 'error', 212 | '@typescript-eslint/prefer-for-of': 'error', 213 | 'prefer-template': 'error', 214 | 'quote-props': [ 215 | 'error', 216 | 'as-needed' 217 | ], 218 | '@typescript-eslint/quotes': [ 219 | 'error', 220 | 'single', 221 | { 222 | 'allowTemplateLiterals': true 223 | } 224 | ], 225 | '@typescript-eslint/semi': [ 226 | 'error', 227 | 'always' 228 | ], 229 | 'semi-spacing': [ 230 | 'error', 231 | { 232 | 'after': true, 233 | 'before': false 234 | } 235 | ], 236 | 'semi-style': [ 237 | 'error', 238 | 'last' 239 | ], 240 | 'space-before-blocks': [ 241 | 'error', 242 | 'always' 243 | ], 244 | '@typescript-eslint/space-before-function-paren': [ 245 | 'error', 246 | { 247 | 'anonymous': 'always', 248 | 'asyncArrow': 'always', 249 | 'named': 'never' 250 | } 251 | ], 252 | 'space-in-parens': [ 253 | 'error', 254 | 'never' 255 | ], 256 | '@typescript-eslint/space-infix-ops': 'error', 257 | 'space-unary-ops': [ 258 | 'error', 259 | { 260 | 'nonwords': false, 261 | 'words': true 262 | } 263 | ], 264 | 'spaced-comment': [ 265 | 'error', 266 | 'always', 267 | { 268 | 'block': { 269 | 'balanced': true, 270 | 'exceptions': [ 271 | '*' 272 | ] 273 | } 274 | } 275 | ], 276 | 'switch-colon-spacing': [ 277 | 'error', 278 | { 279 | 'after': true, 280 | 'before': false 281 | } 282 | ], 283 | 'template-curly-spacing': [ 284 | 'error', 285 | 'never' 286 | ], 287 | '@typescript-eslint/type-annotation-spacing': 'error', 288 | 'yoda': 'error' 289 | }, 290 | 'overrides': [ 291 | { 292 | 'files': [ 293 | '**/*.js' 294 | ], 295 | 'rules': { 296 | '@typescript-eslint/explicit-function-return-type': 'off', 297 | '@typescript-eslint/no-var-requires': 'off' 298 | } 299 | } 300 | ] 301 | }; 302 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: Apollon77 4 | patreon: Apollon77 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Something is not working as it should 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 1. Go to '...' 15 | 2. Click on '...' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots & Logfiles** 23 | If applicable, add screenshots and logfiles to help explain your problem. 24 | 25 | **Versions:** 26 | - Adapter version: 27 | - JS-Controller version: 28 | - Node version: 29 | - Operating system: 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/auto-merge.yml: -------------------------------------------------------------------------------- 1 | # Configure here which dependency updates should be merged automatically. 2 | # The recommended configuration is the following: 3 | - match: 4 | # Only merge patches for production dependencies 5 | dependency_type: production 6 | update_type: "semver:patch" 7 | - match: 8 | # Except for security fixes, here we allow minor patches 9 | dependency_type: production 10 | update_type: "security:minor" 11 | - match: 12 | # and development dependencies can have a minor update, too 13 | dependency_type: development 14 | update_type: "semver:minor" 15 | 16 | # The syntax is based on the legacy dependabot v1 automerged_updates syntax, see: 17 | # https://dependabot.com/docs/config-file/#automerged_updates -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: github-actions 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | time: "04:00" 9 | timezone: Europe/Berlin 10 | - package-ecosystem: npm 11 | directory: "/" 12 | schedule: 13 | interval: monthly 14 | time: "04:00" 15 | timezone: Europe/Berlin 16 | open-pull-requests-limit: 20 17 | versioning-strategy: increase -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 90 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 7 9 | 10 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) 11 | onlyLabels: [] 12 | 13 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 14 | exemptLabels: 15 | - enhancement 16 | - security 17 | 18 | # Set to true to ignore issues in a project (defaults to false) 19 | exemptProjects: true 20 | 21 | # Set to true to ignore issues in a milestone (defaults to false) 22 | exemptMilestones: true 23 | 24 | # Set to true to ignore issues with an assignee (defaults to false) 25 | exemptAssignees: false 26 | 27 | # Label to use when marking as stale 28 | staleLabel: wontfix 29 | 30 | # Comment to post when marking as stale. Set to `false` to disable 31 | markComment: > 32 | This issue has been automatically marked as stale because it has not had 33 | recent activity. It will be closed if no further activity occurs within the next 7 days. 34 | Please check if the issue is still relevant in the most current version of the adapter 35 | and tell us. Also check that all relevant details, logs and reproduction steps 36 | are included and update them if needed. 37 | Thank you for your contributions. 38 | 39 | Dieses Problem wurde automatisch als veraltet markiert, da es in letzter Zeit keine Aktivitäten gab. 40 | Es wird geschlossen, wenn nicht innerhalb der nächsten 7 Tage weitere Aktivitäten stattfinden. 41 | Bitte überprüft, ob das Problem auch in der aktuellsten Version des Adapters noch relevant ist, 42 | und teilt uns dies mit. Überprüft auch, ob alle relevanten Details, Logs und Reproduktionsschritte 43 | enthalten sind bzw. aktualisiert diese. 44 | Vielen Dank für Eure Unterstützung. 45 | 46 | # Comment to post when removing the stale label. 47 | # unmarkComment: > 48 | # Your comment here. 49 | 50 | # Comment to post when closing a stale Issue or Pull Request. 51 | closeComment: > 52 | This issue has been automatically closed because of inactivity. Please open a new 53 | issue if still relevant and make sure to include all relevant details, logs and 54 | reproduction steps. 55 | Thank you for your contributions. 56 | 57 | Dieses Problem wurde aufgrund von Inaktivität automatisch geschlossen. Bitte öffnet ein 58 | neues Issue, falls dies noch relevant ist und stellt sicher das alle relevanten Details, 59 | Logs und Reproduktionsschritte enthalten sind. 60 | Vielen Dank für Eure Unterstützung. 61 | 62 | # Limit the number of actions per hour, from 1-30. Default is 30 63 | limitPerRun: 30 64 | 65 | # Limit to only `issues` or `pulls` 66 | only: issues 67 | 68 | # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': 69 | # pulls: 70 | # daysUntilStale: 30 71 | # markComment: > 72 | # This pull request has been automatically marked as stale because it has not had 73 | # recent activity. It will be closed if no further activity occurs. Thank you 74 | # for your contributions. 75 | 76 | # issues: 77 | # exemptLabels: 78 | # - confirmed -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yml: -------------------------------------------------------------------------------- 1 | # Automatically merge Dependabot PRs when version comparison is within the range 2 | # that is configured in .github/auto-merge.yml 3 | 4 | name: Auto-Merge Dependabot PRs 5 | 6 | on: 7 | pull_request_target: 8 | 9 | jobs: 10 | auto-merge: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Check if PR should be auto-merged 17 | uses: ahmadnassri/action-dependabot-auto-merge@v2 18 | with: 19 | # This must be a personal access token with push access 20 | github-token: ${{ secrets.AUTO_MERGE_TOKEN }} 21 | # By default, squash and merge, so Github chooses nice commit messages 22 | command: squash and merge 23 | -------------------------------------------------------------------------------- /.github/workflows/test-and-release.yml: -------------------------------------------------------------------------------- 1 | # This is a composition of lint and test scripts 2 | # Make sure to update this file along with the others 3 | 4 | name: Test and Release 5 | 6 | # Run this job on all pushes and pull requests 7 | # as well as tags with a semantic version 8 | on: 9 | push: 10 | branches: 11 | - '*' 12 | tags: 13 | # normal versions 14 | - "v?[0-9]+.[0-9]+.[0-9]+" 15 | # pre-releases 16 | - "v?[0-9]+.[0-9]+.[0-9]+-**" 17 | pull_request: {} 18 | 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.ref }} 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Performs quick checks before the expensive test runs 25 | check-and-lint: 26 | if: contains(github.event.head_commit.message, '[skip ci]') == false 27 | 28 | runs-on: ubuntu-latest 29 | 30 | strategy: 31 | matrix: 32 | node-version: [20.x] 33 | 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | 41 | 42 | - name: Install Dependencies 43 | run: npm ci 44 | 45 | # Runs adapter tests on all supported node versions and OSes 46 | lib-tests: 47 | if: contains(github.event.head_commit.message, '[skip ci]') == false 48 | 49 | needs: [check-and-lint] 50 | 51 | runs-on: ${{ matrix.os }} 52 | strategy: 53 | matrix: 54 | node-version: [16.x, 18.x, 20.x, 22.x] 55 | os: [ubuntu-latest, macos-latest] 56 | 57 | steps: 58 | - uses: actions/checkout@v4 59 | - name: Use Python 3.11 60 | uses: actions/setup-python@v5 61 | if: matrix.os == 'macos-latest' 62 | with: 63 | python-version: 3.11 64 | - name: Use Node.js ${{ matrix.node-version }} 65 | uses: actions/setup-node@v4 66 | with: 67 | node-version: ${{ matrix.node-version }} 68 | 69 | - name: patch node gyp on windows to support Visual Studio 2019 70 | if: matrix.os == 'windows-latest' 71 | shell: powershell 72 | run: | 73 | npm install --global node-gyp@latest 74 | npm prefix -g | % {npm config set node_gyp "$_\node_modules\node-gyp\bin\node-gyp.js"} 75 | 76 | - name: Install Dependencies 77 | run: npm ci 78 | 79 | - name: Check formatting with prettier 80 | if: startsWith(runner.OS, 'windows') == false 81 | run: | 82 | npx prettier -u -c '*.ts' examples src __tests__ 83 | - name: Lint with eslint 84 | if: startsWith(runner.OS, 'windows') == false 85 | run: | 86 | npm run lint 87 | - name: Run build 88 | run: | 89 | npm run build 90 | - name: Run unit tests 91 | run: | 92 | npm run test 93 | 94 | # Deploys the final package to NPM 95 | deploy: 96 | needs: [lib-tests] 97 | 98 | # Trigger this step only when a commit on main is tagged with a version number 99 | if: | 100 | contains(github.event.head_commit.message, '[skip ci]') == false && 101 | github.event_name == 'push' && 102 | startsWith(github.ref, 'refs/tags/') 103 | runs-on: ubuntu-latest 104 | strategy: 105 | matrix: 106 | node-version: [20.x] 107 | 108 | steps: 109 | - name: Checkout code 110 | uses: actions/checkout@v4 111 | 112 | - name: Use Node.js ${{ matrix.node-version }} 113 | uses: actions/setup-node@v4 114 | with: 115 | node-version: ${{ matrix.node-version }} 116 | 117 | - name: Extract the version and commit body from the tag 118 | id: extract_release 119 | # The body may be multiline, therefore we need to escape some characters 120 | run: | 121 | VERSION="${{ github.ref }}" 122 | VERSION=${VERSION##*/} 123 | VERSION=${VERSION##*v} 124 | echo "::set-output name=VERSION::$VERSION" 125 | BODY=$(git show -s --format=%b) 126 | BODY="${BODY//'%'/'%25'}" 127 | BODY="${BODY//$'\n'/'%0A'}" 128 | BODY="${BODY//$'\r'/'%0D'}" 129 | echo "::set-output name=BODY::$BODY" 130 | 131 | - name: Install Dependencies 132 | run: npm ci 133 | 134 | - name: Run build 135 | run: | 136 | npm run build 137 | 138 | - name: Publish package to npm 139 | run: | 140 | npm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} 141 | npm whoami 142 | npm publish 143 | 144 | - name: Create Github Release 145 | id: create_release 146 | uses: actions/create-release@v1 147 | env: 148 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 149 | with: 150 | tag_name: ${{ github.ref }} 151 | release_name: Release v${{ steps.extract_release.outputs.VERSION }} 152 | draft: false 153 | # Prerelease versions create prereleases on Github 154 | prerelease: ${{ contains(steps.extract_release.outputs.VERSION, '-') }} 155 | body: ${{ steps.extract_release.outputs.BODY }} 156 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.tgz 3 | /lib/ 4 | /node_modules/ 5 | .idea 6 | /HAP-Specification-Non-Commercial-Version.pdf 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "tabWidth": 4, 5 | "useTabs": false 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hap-controller 2 | 3 | [![npm](https://img.shields.io/npm/v/hap-controller.svg)](https://www.npmjs.com/package/hap-controller) 4 | [![GitHub License](https://img.shields.io/github/license/Apollon77/hap-controller-node.svg)](LICENSE) 5 | 6 | This library allows you to build a HomeKit controller, capable of discovering and controlling both Wi-Fi and BLE devices. 7 | 8 | ## Installation 9 | 10 | Use `npm` to install the package: 11 | 12 | ```bash 13 | npm install hap-controller 14 | 15 | # OR 16 | 17 | yarn add hap-controller 18 | ``` 19 | 20 | ## Usage 21 | 22 | The IP and BLE APIs are very similar and only differ where it makes sense, given protocol differences. 23 | 24 | ### General notes about "concurrent requests" 25 | 26 | ### Device Discovery 27 | 28 | ```javascript 29 | const {BLEDiscovery, IPDiscovery} = require('hap-controller'); 30 | 31 | const ipDiscovery = new IPDiscovery(); 32 | ipDiscovery.on('serviceUp', (service) => { 33 | // ... 34 | }); 35 | ipDiscovery.start(); 36 | 37 | const bleDiscovery = new BLEDiscovery(); 38 | bleDiscovery.on('serviceUp', (service) => { 39 | // ... 40 | }); 41 | bleDiscovery.start(); // pass true if disconnected events are needed 42 | ``` 43 | 44 | ### Identify 45 | 46 | ```javascript 47 | const {GattClient, HttpClient} = require('hap-controller'); 48 | 49 | const ipClient = new HttpClient(id, address, port); 50 | ipClient.identify().then(() => { 51 | // ... 52 | }).catch((e) => console.error(e)); 53 | 54 | const bleClient = new GattClient(id, peripheral); 55 | bleClient.identify().then(() => { 56 | // ... 57 | }).catch((e) => console.error(e)); 58 | ``` 59 | 60 | ### Pair Setup 61 | 62 | ```javascript 63 | const {GattClient, HttpClient} = require('hap-controller'); 64 | 65 | const ipClient = new HttpClient(id, address, port); 66 | ipClient.pairSetup(pin).then(() => { 67 | // keep this data 68 | console.log(JSON.stringify(ipClient.getLongTermData(), null, 2)); 69 | }).catch((e) => console.error(e)); 70 | 71 | const bleClient = new GattClient(id, peripheral); 72 | bleClient.pairSetup(pin).then(() => { 73 | // keep this data 74 | console.log(JSON.stringify(bleClient.getLongTermData(), null, 2)); 75 | }).catch((e) => console.error(e)); 76 | ``` 77 | 78 | ### Manage Pairings 79 | 80 | ```javascript 81 | const {GattClient, HttpClient} = require('hap-controller'); 82 | 83 | const ipClient = new HttpClient(id, address, port, pairingData); 84 | ipClient.listPairings().then(() => { 85 | // ... 86 | }).catch((e) => console.error(e)); 87 | 88 | ipClient.removePairing(identifier).then(() => { 89 | // ... 90 | }).catch((e) => console.error(e)); 91 | 92 | const bleClient = new GattClient(id, peripheral, pairingData); 93 | bleClient.listPairings().then(() => { 94 | // ... 95 | }).catch((e) => console.error(e)); 96 | 97 | bleClient.removePairing(identifier).then(() => { 98 | // ... 99 | }).catch((e) => console.error(e)); 100 | ``` 101 | 102 | ### Accessory Database 103 | 104 | ```javascript 105 | const {GattClient, HttpClient} = require('hap-controller'); 106 | 107 | const ipClient = new HttpClient(id, address, port, pairingData); 108 | ipClient.getAccessories().then((accessories) => { 109 | // ... 110 | }).catch((e) => console.error(e)); 111 | 112 | const bleClient = new GattClient(id, peripheral, pairingData); 113 | bleClient.getAccessories().then((accessories) => { 114 | // ... 115 | }).catch((e) => console.error(e)); 116 | ``` 117 | 118 | ### Get/Set Characteristics 119 | 120 | ```javascript 121 | const {GattClient, GattUtils, HttpClient} = require('hap-controller'); 122 | 123 | const ipClient = new HttpClient(id, address, port, pairingData); 124 | ipClient.getCharacteristics( 125 | ['1.10'], 126 | { 127 | meta: true, 128 | perms: true, 129 | type: true, 130 | ev: true, 131 | } 132 | ).then((characteristics) => { 133 | // ... 134 | }).catch((e) => console.error(e)); 135 | 136 | ipClient.setCharacteristics({'1.10': true}).then(() => { 137 | // ... 138 | }).catch((e) => console.error(e)); 139 | 140 | const bleClient = new GattClient(id, peripheral, pairingData); 141 | bleClient.getCharacteristics( 142 | [ 143 | { 144 | serviceUuid: '...', // the "type" property 145 | characteristicUuid: '...', // the "type" property 146 | iid: 10, 147 | format: 'bool', // if known 148 | }, 149 | ], 150 | { 151 | meta: true, 152 | perms: true, 153 | type: true, 154 | ev: true, 155 | } 156 | ).then((characteristics) => { 157 | // ... 158 | }).catch((e) => console.error(e)); 159 | 160 | bleClient.setCharacteristics( 161 | [ 162 | { 163 | serviceUuid: '...', // the "type" property 164 | characteristicUuid: '...', // the "type" property 165 | iid: 10, 166 | value: GattUtils.valueToBuffer(true, 'bool'), 167 | }, 168 | ] 169 | ).then(() => { 170 | // ... 171 | }).catch((e) => console.error(e)); 172 | ``` 173 | 174 | ### Subscribe/Unsubscribe Characteristics 175 | 176 | ```javascript 177 | const {GattClient, HttpClient} = require('hap-controller'); 178 | 179 | const ipClient = new HttpClient(id, address, port, pairingData); 180 | 181 | ipClient.on('event', (ev) => { 182 | // ... 183 | }); 184 | 185 | ipClient.on('event-disconnect', (subscribedList) => { 186 | // ... 187 | }); 188 | 189 | let connection; 190 | ipClient.subscribeCharacteristics(['1.10']).then((conn) => { 191 | connection = conn; 192 | // ... 193 | }).catch((e) => console.error(e)); 194 | 195 | ipClient.unsubscribeCharacteristics(['1.10'], connection).then(() => { 196 | // ... 197 | }).catch((e) => console.error(e)); 198 | 199 | const bleClient = new GattClient(id, peripheral, pairingData); 200 | 201 | bleClient.on('event', (ev) => { 202 | // ... 203 | }); 204 | 205 | bleClient.on('event-disconnect', (subscribedList) => { 206 | // ... 207 | }); 208 | 209 | bleClient.subscribeCharacteristics( 210 | [ 211 | { 212 | serviceUuid: '...', // the "type" property 213 | characteristicUuid: '...', // the "type" property 214 | iid: 10, 215 | format: 'bool', // if known 216 | }, 217 | ] 218 | ).then(() => { 219 | // ... 220 | }).catch((e) => console.error(e)); 221 | 222 | bleClient.unsubscribeCharacteristics( 223 | [ 224 | { 225 | serviceUuid: '...', // the "type" property 226 | characteristicUuid: '...', // the "type" property 227 | }, 228 | ] 229 | ).then(() => { 230 | // ... 231 | }).catch((e) => console.error(e)); 232 | ``` 233 | 234 | ## Examples 235 | 236 | Examples of all of the APIs can be found in the [GitHub repo](https://github.com/Apollon77/hap-controller-node/tree/master/examples). 237 | 238 | ## Troubleshooting 239 | 240 | ### Known incompatible devices 241 | If you have issues pairing the device with this adapter please try to pair it with the normal iOS Apple Home App. If this do not work then something is weird with the device and then also this adapter can not help. Pot try a reset, but else there is not chance. 242 | 243 | This is currently that way for some Tado Door Locks as example. They need to be paired using the Tado App which is somehow registering the device into Apple Home, but not via an official pair process. 244 | 245 | Additional also Nuki 3 Locks (BLE) are not possible to pair because they use Hardware Authentication components that are not publicly documented by Apple. 246 | 247 | For Netatmo a user found out how pairing could be possible when it had issue. See https://github.com/Apollon77/ioBroker.homekit-controller/issues/233#issuecomment-1311983379 248 | 249 | ### For BLE issues 250 | * If you have issues that the BLE connection do not work our you get Errors when the adapter tries to initialize the BluetoothLE connection, please first check and follow https://github.com/noble/noble#running-on-linux 251 | * Does the device have a Pairing Mode or such that needs to be activated first? But also read the manual careful, maybe the Pairing mode is for some other legacy protocol or bridge but not Apple Home. 252 | * Please make sure that your system is up-to-date including kernel `apt update && apt dist-upgrade` 253 | * Try to reset the relevant BLE device with e.g. `sudo hciconfig hci0 reset` 254 | * For issues also provide the output of `uname -a` and `lsusb` 255 | * Low level BLE device log can be obtained using `sudo hcidump -t -x >log.txt` (in a second shell additionally to run the script) 256 | 257 | ### General advices 258 | * Basically if the error "pair-setup characteristic not found" pops up while trying to pair then the device do not support pairing via Homekit in it's current state. The adapter cn not do anything then! 259 | * Please make sure to enter the PIN mit Dashes in the form "XXX-XX-XXX". Other formats should be declined by the library already by an error, but just to make sure 260 | 261 | ## Debugging 262 | When you have issues and want to report an Issue (see below) then enhanced debug log is always helpful. 263 | 264 | Please start your application using 265 | 266 | `DEBUG=hap* node myprocess.js` 267 | 268 | and post the console log also in the issue. This will generate a log on protocol level. 269 | 270 | ## Contributing 271 | 272 | Please feel free to open an [issue](https://github.com/Apollon77/hap-controller-node/issues) or a [pull request](https://github.com/Apollon77/hap-controller-node/pulls) if you find something that could use improvement. 273 | For Issues please consider to directly provide debug loggins (see above). 274 | 275 | ## Changelog 276 | ### 0.10.2 (2024-10-31) 277 | * (dnicolson/Apollon77) Change noble to @stoprocent fork 278 | * (Apollon77) prevent crash on import/require when no BLE device is not connected 279 | 280 | ### 0.10.1 (2023-11-23) 281 | * (Apollon77) Remove duplicate entries in characteristic list 282 | 283 | ### 0.10.0 (2023-09-23) 284 | * (Apollon77) Also return all found addresses and not just the first one 285 | * (Apollon77) Update dependencies 286 | 287 | ### 0.9.3 (2023-02-27) 288 | * (Apollon77) Update Noble to fix CPU/Memory issue 289 | 290 | ### 0.9.2 (2023-01-10) 291 | * (Apollon77) Adjust some more places to finalize BigInt support 292 | 293 | ### 0.9.1 (2023-01-10) 294 | * (Apollon77) Return response body for getCharacteristics and subscribe/unsubscribe also when return code was 207 in IP mode 295 | 296 | ### 0.9.0 (2023-01-09) 297 | * (Apollon77) BREAKING: Returned data objects partially contain BigInt values (represented by bignumber.js instances) for iid/aid fields! 298 | 299 | ### 0.8.4 (2023-01-05) 300 | * (Apollon77) Upgrade noble 301 | 302 | ### 0.8.3 (2022-12-31) 303 | * (Apollon77) Downgrade noble again 304 | 305 | ### 0.8.2 (2022-12-22) 306 | * (Apollon77) Upgrade noble package 307 | 308 | ### 0.8.1 (2022-06-10) 309 | * (Apollon77) Make HTTP Client options optional 310 | 311 | ### 0.8.0 (2022-06-10) 312 | * (Apollon77) Initialize transaction ID randomly for BLE 313 | * (Apollon77) Add experimental flag options.subscriptionsUseSameConnection for HTTP Client to use the same connection for subscriptions and for all other calls to only have one connection from controller to the device. 314 | 315 | ### 0.7.4 (2022-05-06) 316 | * (Apollon77) Add Host header to all HTTP calls because some devices seem to require it 317 | * (Apollon77) Check that client was initialized before accessing the connected state 318 | 319 | ### 0.7.2 (2022-01-25) 320 | * (Apollon77) Add method "closePersistentConnection" to HTTPClient to allow a close of this connection (e.g. when commands get timeouts or such) 321 | 322 | ### 0.7.0 (2022-01-21) 323 | * (Apollon77) Introduce `close` method to tear down all connections that are potentially open 324 | * (Apollon77) Use a persistent connection by default to prevent the need to verify the pairing for each call. Can be disabled using a new `options` parameter in constructor. You must call `close()` if you do not need the instance any longer 325 | * (Apollon77) Make sure calls on a http connection are queued to not overlap 326 | * (Apollon77) check ble status before start a new scanning process 327 | * (Apollon77) remember that scanning was stopped when stop() is called 328 | * (Apollon77) Fix pot. hanging response if multiple subscriptions are done on same device 329 | * (Apollon77) Deprecate "GattConnection.iPeripheralConnected" in favor of a unified "isConnected" for BLE and HTTP connections 330 | * (Apollon77) Prevent parallel pair verify calls by queuing them 331 | * (Apollon77) Convert all examples to async/await for better readability and add close calls 332 | 333 | ### 0.6.1 (2021-10-18) 334 | * (Apollon77) move error class in own file and adjust some typings 335 | 336 | ### 0.6.0 (2021-10-17) 337 | * (Apollon77) Take over library (thanks to @mrstegeman for his great work so far and the ongoing consulting!) 338 | * (Apollon77) Add automatic detection for PairMethod to use based on discovery details (HTTP) or via extra method to read data from device (BLE) 339 | * (Apollon77) add getPairingMethod methods to the Client classes (as async for both because for BLE needs to be read from device while in Discovery response for IP devices, also adjust examples - so still not really "automatically resolved" but as method for the user to call and provide response to the pair method 340 | * (Apollon77) add debug lib and communication and tlv parsing/building logging 341 | * (Apollon77) add some convenient names to ble service definition to have same fields for "config version" ("c#") and "device id" ("id") for both service types 342 | * (Apollon77) add availableForPairing to both service objects and adjust the examples 343 | * (Apollon77) Adjust subscription handling for both transfers to keep the connection internally, so it is not returned anymore. Additionally the library keeps a list of all subscribed characteristics and will also check for duplicates 344 | * (Apollon77) The "disconnect" event is renamed to "event-disconnect" because it only notifies for disconnection of event subscriptions and returns the former list of subscribed characteristics as array that can directly be used to resubscribe 345 | * (Apollon77) Added "serviceChanged" events to both Discovery classes to be notified on changed advertisements - changed means that a relevant field of the service data has changed - mainly used to check GSN (for BLE) or "c#" (both) 346 | * (Apollon77) Added a "disconnect" and "connect" events to GattConnection to match HttpConnection 347 | * (Apollon77) Make sure getAccessories return UUIDs in all cases for HTTP and BLE 348 | * (Apollon77) enhance the perms returned from GattClient and add ev-connected, ev-disconnected and ev-broadcast if supported 349 | * (Apollon77) Define own Error class (HomekitControllerError) to transport enhanced info like statusCode and error body 350 | * (Apollon77) Allow also other properties to be set for the characteristic on write (like authData, remote and r) 351 | * (Apollon77) Update service and characteristic lists based on HapNodeJS because official specs are still from 2019 352 | * (Apollon77) adjustments to logic that was not working for me (mostly BLE things like flagging primary/hidden service in the data)) 353 | * (Apollon77) smaller adjustments and restructuring of docs and type definitions 354 | 355 | ### till 0.5.0 (16.08.2021) 356 | Former versions published by @mrstegeman 357 | -------------------------------------------------------------------------------- /__tests__/test-http-event-parser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test HTTP event parser. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const e2p = require('event-to-promise'); 8 | const HttpEventParser = require('../lib/transport/ip/http-event-parser').default; 9 | 10 | it('EVENT from spec', async () => { 11 | const message = 12 | 'EVENT/1.0 200 OK\r\n' + 13 | 'Content-Type: application/hap+json\r\n' + 14 | 'Content-Length: 128\r\n' + 15 | '\r\n' + 16 | '{\n' + 17 | ' "characteristics" : [\n' + 18 | ' {\n' + 19 | ' "aid" : 1,\n' + 20 | ' "iid" : 4,\n' + 21 | ' "value" : 23.0\n' + 22 | ' }\n' + 23 | ' ]\n' + 24 | '}'; 25 | 26 | const parser = new HttpEventParser(); 27 | const eventPromise = e2p(parser, 'event'); 28 | parser.execute(Buffer.from(message)); 29 | 30 | let event = await Promise.race([eventPromise, new Promise((resolve, reject) => setTimeout(reject, 2000))]); 31 | 32 | expect(typeof event).toBe('object'); 33 | 34 | event = JSON.parse(event.toString()); 35 | 36 | expect(typeof event).toBe('object'); 37 | expect(event.characteristics.length).toBe(1); 38 | expect(event.characteristics[0].aid).toBe(1); 39 | expect(event.characteristics[0].iid).toBe(4); 40 | expect(event.characteristics[0].value).toBeCloseTo(23.0); 41 | }); 42 | 43 | it('EVENT from spec, with missing Content-Length header', async () => { 44 | const message = 45 | 'EVENT/1.0 200 OK\r\n' + 46 | 'Content-Type: application/hap+json\r\n' + 47 | '\r\n' + 48 | '{\n' + 49 | ' "characteristics" : [\n' + 50 | ' {\n' + 51 | ' "aid" : 1,\n' + 52 | ' "iid" : 4,\n' + 53 | ' "value" : 23.0\n' + 54 | ' }\n' + 55 | ' ]\n' + 56 | '}'; 57 | 58 | const parser = new HttpEventParser(); 59 | const eventPromise = e2p(parser, 'event'); 60 | parser.execute(Buffer.from(message)); 61 | 62 | let event = await Promise.race([eventPromise, new Promise((resolve, reject) => setTimeout(reject, 2000))]); 63 | 64 | expect(typeof event).toBe('object'); 65 | 66 | event = JSON.parse(event.toString()); 67 | 68 | expect(typeof event).toBe('object'); 69 | expect(event.characteristics.length).toBe(1); 70 | expect(event.characteristics[0].aid).toBe(1); 71 | expect(event.characteristics[0].iid).toBe(4); 72 | expect(event.characteristics[0].value).toBeCloseTo(23.0); 73 | }); 74 | -------------------------------------------------------------------------------- /examples/ble/add-pairing.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery, GattClient } = require('hap-controller'); 2 | const sodium = require('libsodium-wrappers'); 3 | 4 | const discovery = new BLEDiscovery(); 5 | 6 | const pairingData = { 7 | AccessoryPairingID: '...', 8 | AccessoryLTPK: '...', 9 | iOSDevicePairingID: '...', 10 | iOSDeviceLTSK: '...', 11 | iOSDeviceLTPK: '...', 12 | }; 13 | 14 | const seed = Buffer.from(sodium.randombytes_buf(32)); 15 | const key = sodium.crypto_sign_seed_keypair(seed); 16 | const identifier = 'abcdefg'; 17 | const isAdmin = false; 18 | 19 | discovery.on('serviceUp', async (service) => { 20 | console.log(`Found device: ${service.name}`); 21 | 22 | const client = new GattClient(service.DeviceID, service.peripheral, pairingData); 23 | 24 | try { 25 | await client.addPairing(identifier, Buffer.from(key.publicKey), isAdmin); 26 | console.log(`${service.name}: Done!`); 27 | } catch (e) { 28 | console.error(`${service.name}:`, e); 29 | } 30 | }); 31 | 32 | discovery.start(); 33 | -------------------------------------------------------------------------------- /examples/ble/discovery.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery } = require('hap-controller'); 2 | 3 | const discovery = new BLEDiscovery(); 4 | 5 | discovery.on('serviceUp', (service) => { 6 | console.log('Found device:', service); 7 | }); 8 | 9 | discovery.start(); 10 | -------------------------------------------------------------------------------- /examples/ble/get-accessories.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery, GattClient } = require('hap-controller'); 2 | 3 | const discovery = new BLEDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | discovery.on('serviceUp', async (service) => { 14 | console.log(`Found device: ${service.name}`); 15 | 16 | const client = new GattClient(service.DeviceID, service.peripheral, pairingData); 17 | 18 | try { 19 | const acc = await client.getAccessories(); 20 | console.log(JSON.stringify(acc, null, 2)); 21 | } catch (e) { 22 | console.error(`${service.name}:`, e); 23 | } 24 | }); 25 | 26 | discovery.start(); 27 | -------------------------------------------------------------------------------- /examples/ble/get-characteristics.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery, GattClient } = require('hap-controller'); 2 | 3 | const discovery = new BLEDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | const characteristics = [ 14 | { 15 | serviceUuid: '...', // the "type" property 16 | characteristicUuid: '...', // the "type" property 17 | iid: 10, 18 | format: 'bool', // if known 19 | }, 20 | ]; 21 | 22 | discovery.on('serviceUp', async (service) => { 23 | console.log(`Found device: ${service.name}`); 24 | 25 | const client = new GattClient(service.DeviceID, service.peripheral, pairingData); 26 | 27 | try { 28 | const ch = await client.getCharacteristics(characteristics, { 29 | meta: true, 30 | perms: true, 31 | type: true, 32 | ev: true, 33 | }); 34 | console.log(JSON.stringify(ch, null, 2)); 35 | } catch (e) { 36 | console.error(`${service.name}:`, e); 37 | } 38 | }); 39 | 40 | discovery.start(); 41 | -------------------------------------------------------------------------------- /examples/ble/identify.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery, GattClient } = require('hap-controller'); 2 | 3 | const discovery = new BLEDiscovery(); 4 | 5 | discovery.on('serviceUp', async (service) => { 6 | console.log(`Found device: ${service.name}`); 7 | 8 | const client = new GattClient(service.DeviceID, service.peripheral); 9 | 10 | try { 11 | await client.identify(); 12 | console.log(`${service.name}: Done!`); 13 | } catch (e) { 14 | console.error(`${service.name}:`, e); 15 | } 16 | }); 17 | 18 | discovery.start(); 19 | -------------------------------------------------------------------------------- /examples/ble/list-pairings.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery, GattClient } = require('hap-controller'); 2 | 3 | const discovery = new BLEDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | discovery.on('serviceUp', async (service) => { 14 | console.log(`Found device: ${service.name}`); 15 | 16 | const client = new GattClient(service.DeviceID, service.peripheral, pairingData); 17 | 18 | try { 19 | const tlv = await client.listPairings(); 20 | console.log(JSON.stringify(tlv, null, 2)); 21 | } catch (e) { 22 | console.error(`${service.name}:`, e); 23 | } 24 | }); 25 | discovery.start(); 26 | -------------------------------------------------------------------------------- /examples/ble/pair-setup.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery, GattClient } = require('hap-controller'); 2 | 3 | const discovery = new BLEDiscovery(); 4 | 5 | const pin = 'XXX-YY-ZZZ'; 6 | 7 | discovery.on('serviceUp', async (service) => { 8 | console.log(`Found device: ${service.name}: Available for pairing: ${service.availableToPair}`); 9 | 10 | if (service.availableToPair) { 11 | try { 12 | const pairMethod = await discovery.getPairMethod(service); 13 | 14 | const client = new GattClient(service.DeviceID, service.peripheral); 15 | await client.pairSetup(pin, pairMethod); 16 | 17 | console.log('Paired! Keep the following pairing data safe:'); 18 | console.log(JSON.stringify(client.getLongTermData(), null, 2)); 19 | } catch (e) { 20 | console.error(`${service.name}: ${e}`); 21 | } 22 | } 23 | }); 24 | 25 | discovery.start(); 26 | -------------------------------------------------------------------------------- /examples/ble/remove-pairing.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery, GattClient } = require('hap-controller'); 2 | 3 | const discovery = new BLEDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | discovery.on('serviceUp', async (service) => { 14 | console.log(`Found device: ${service.name}`); 15 | 16 | const client = new GattClient(service.DeviceID, service.peripheral, pairingData); 17 | 18 | try { 19 | await client.removePairing(client.pairingProtocol.iOSDevicePairingID); 20 | console.log(`${service.name}: Done!`); 21 | } catch (e) { 22 | console.error(`${service.name}:`, e); 23 | } 24 | }); 25 | 26 | discovery.start(); 27 | -------------------------------------------------------------------------------- /examples/ble/set-characteristics.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery, GattClient, GattUtils } = require('hap-controller'); 2 | 3 | const discovery = new BLEDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | const characteristics = [ 14 | { 15 | serviceUuid: '...', // the "type" property 16 | characteristicUuid: '...', // the "type" property 17 | iid: 10, 18 | value: GattUtils.valueToBuffer(true, 'bool'), 19 | }, 20 | ]; 21 | 22 | discovery.on('serviceUp', async (service) => { 23 | console.log(`Found device: ${service.name}`); 24 | 25 | const client = new GattClient(service.DeviceID, service.peripheral, pairingData); 26 | 27 | try { 28 | await client.setCharacteristics(characteristics); 29 | console.log(`${service.name}: Done!`); 30 | } catch (e) { 31 | console.error(`${service.name}:`, e); 32 | } 33 | }); 34 | 35 | discovery.start(); 36 | -------------------------------------------------------------------------------- /examples/ble/subscribe-characteristics.js: -------------------------------------------------------------------------------- 1 | const { BLEDiscovery, GattClient } = require('hap-controller'); 2 | 3 | const discovery = new BLEDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | const characteristics = [ 14 | { 15 | serviceUuid: '...', // the "type" property 16 | characteristicUuid: '...', // the "type" property 17 | iid: 10, 18 | format: 'bool', 19 | }, 20 | ]; 21 | 22 | discovery.on('serviceUp', async (service) => { 23 | console.log(`Found device: ${service.name}`); 24 | 25 | const client = new GattClient(service.DeviceID, service.peripheral, pairingData); 26 | 27 | let count = 0; 28 | client.on('event', async (ev) => { 29 | console.log(JSON.stringify(ev, null, 2)); 30 | 31 | if (++count >= 2) { 32 | try { 33 | await client.unsubscribeCharacteristics(characteristics); 34 | console.log(`${service.name}: Unsubscribed!`); 35 | } catch (e) { 36 | console.error(`${service.name}:`, e); 37 | } 38 | } 39 | }); 40 | 41 | client.on('event-disconnect', (formerSubscribes) => { 42 | console.log(JSON.stringify(formerSubscribes, null, 2)); 43 | 44 | // resubscribe if wanted: 45 | // await client.subscribeCharacteristics(formerSubscribes); 46 | }); 47 | 48 | try { 49 | await client.subscribeCharacteristics(characteristics); 50 | console.log(`${service.name}: Subscribed!`); 51 | } catch (e) { 52 | console.error(`${service.name}:`, e); 53 | } 54 | }); 55 | discovery.start(); 56 | -------------------------------------------------------------------------------- /examples/ip/add-pairing.js: -------------------------------------------------------------------------------- 1 | const { HttpClient, IPDiscovery } = require('hap-controller'); 2 | const sodium = require('libsodium-wrappers'); 3 | 4 | const discovery = new IPDiscovery(); 5 | 6 | const pairingData = { 7 | AccessoryPairingID: '...', 8 | AccessoryLTPK: '...', 9 | iOSDevicePairingID: '...', 10 | iOSDeviceLTSK: '...', 11 | iOSDeviceLTPK: '...', 12 | }; 13 | 14 | const seed = Buffer.from(sodium.randombytes_buf(32)); 15 | const key = sodium.crypto_sign_seed_keypair(seed); 16 | const identifier = 'abcdefg'; 17 | const isAdmin = false; 18 | 19 | discovery.on('serviceUp', async (service) => { 20 | console.log(`Found device: ${service.name}`); 21 | 22 | const client = new HttpClient(service.id, service.address, service.port, pairingData); 23 | 24 | try { 25 | await client.addPairing(identifier, Buffer.from(key.publicKey), isAdmin); 26 | console.log(`${service.name}: Done!`); 27 | } catch (e) { 28 | console.error(`${service.name}:`, e); 29 | } 30 | client.close(); 31 | }); 32 | 33 | discovery.start(); 34 | -------------------------------------------------------------------------------- /examples/ip/discovery.js: -------------------------------------------------------------------------------- 1 | const { IPDiscovery } = require('hap-controller'); 2 | 3 | const discovery = new IPDiscovery(); 4 | 5 | discovery.on('serviceUp', (service) => { 6 | console.log('Found device:', service); 7 | }); 8 | 9 | discovery.start(); 10 | -------------------------------------------------------------------------------- /examples/ip/get-accessories.js: -------------------------------------------------------------------------------- 1 | const { HttpClient, IPDiscovery } = require('hap-controller'); 2 | 3 | const discovery = new IPDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | discovery.on('serviceUp', async (service) => { 14 | console.log(`Found device: ${service.name}`); 15 | 16 | const client = new HttpClient(service.id, service.address, service.port, pairingData, { 17 | usePersistentConnections: true, 18 | }); 19 | 20 | try { 21 | const acc = await client.getAccessories(); 22 | console.log(JSON.stringify(acc, null, 2)); 23 | } catch (e) { 24 | console.error(`${service.name}:`, e); 25 | } 26 | client.close(); 27 | }); 28 | 29 | discovery.start(); 30 | -------------------------------------------------------------------------------- /examples/ip/get-characteristics.js: -------------------------------------------------------------------------------- 1 | const { HttpClient, IPDiscovery } = require('hap-controller'); 2 | 3 | const discovery = new IPDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | const characteristics = [ 14 | '1.10', // aid.iid 15 | ]; 16 | 17 | discovery.on('serviceUp', async (service) => { 18 | console.log(`Found device: ${service.name}`); 19 | 20 | const client = new HttpClient(service.id, service.address, service.port, pairingData, { 21 | usePersistentConnections: true, 22 | }); 23 | 24 | try { 25 | const ch = await client.getCharacteristics(characteristics, { 26 | meta: true, 27 | perms: true, 28 | type: true, 29 | ev: true, 30 | }); 31 | client.close(); 32 | console.log(JSON.stringify(ch, null, 2)); 33 | } catch (e) { 34 | console.error(`${service.name}:`, e); 35 | } 36 | }); 37 | 38 | discovery.start(); 39 | -------------------------------------------------------------------------------- /examples/ip/identify.js: -------------------------------------------------------------------------------- 1 | const { HttpClient, IPDiscovery } = require('hap-controller'); 2 | const discovery = new IPDiscovery(); 3 | 4 | discovery.on('serviceUp', async (service) => { 5 | console.log(`Found device: ${service.name}`); 6 | 7 | const client = new HttpClient(service.id, service.address, service.port); 8 | 9 | try { 10 | await client.identify(); 11 | client.close(); // Not needed if only identify was called, else needed 12 | console.log(`${service.name}: Done!`); 13 | } catch (e) { 14 | console.error(`${service.name}:`, e); 15 | } 16 | }); 17 | discovery.start(); 18 | -------------------------------------------------------------------------------- /examples/ip/list-pairings.js: -------------------------------------------------------------------------------- 1 | const { HttpClient, IPDiscovery } = require('hap-controller'); 2 | 3 | const discovery = new IPDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | discovery.on('serviceUp', async (service) => { 14 | console.log(`Found device: ${service.name}`); 15 | 16 | const client = new HttpClient(service.id, service.address, service.port, pairingData, { 17 | usePersistentConnections: true, 18 | }); 19 | 20 | try { 21 | const tlv = await client.listPairings(); 22 | console.log(JSON.stringify(tlv, null, 2)); 23 | client.close(); 24 | } catch (e) { 25 | console.error(`${service.name}:`, e); 26 | } 27 | }); 28 | 29 | discovery.start(); 30 | -------------------------------------------------------------------------------- /examples/ip/pair-setup-prompt.js: -------------------------------------------------------------------------------- 1 | const readline = require('readline').createInterface({ 2 | input: process.stdin, 3 | output: process.stdout, 4 | }); 5 | 6 | const { HttpClient, IPDiscovery } = require('hap-controller'); 7 | 8 | const discovery = new IPDiscovery(); 9 | 10 | discovery.on('serviceUp', async (service) => { 11 | console.log(`Found device: ${service.name}: Available for pairing: ${service.availableToPair}`); 12 | 13 | if (service.availableToPair) { 14 | try { 15 | const pairMethod = await discovery.getPairMethod(service); 16 | 17 | const client = new HttpClient(service.id, service.address, service.port); 18 | 19 | const data = await client.startPairing(pairMethod); 20 | readline.question('Enter PIN: ', async (pin) => { 21 | try { 22 | await client.finishPairing(data, pin); 23 | console.log(`${service.name} paired! Keep the following pairing data safe:`); 24 | console.log(JSON.stringify(client.getLongTermData(), null, 2)); 25 | client.close(); 26 | } catch (e) { 27 | console.error(`${service.name}: Error`, e); 28 | } 29 | }); 30 | } catch (e) { 31 | console.error(`${service.name}: Error`, e); 32 | } 33 | } 34 | }); 35 | 36 | discovery.start(); 37 | -------------------------------------------------------------------------------- /examples/ip/pair-setup.js: -------------------------------------------------------------------------------- 1 | const { HttpClient, IPDiscovery } = require('hap-controller'); 2 | 3 | const discovery = new IPDiscovery(); 4 | 5 | const pin = 'XXX-YY-ZZZ'; 6 | 7 | discovery.on('serviceUp', async (service) => { 8 | console.log(`Found device: ${service.name}: Available for pairing: ${service.availableToPair}`); 9 | 10 | if (service.availableToPair) { 11 | try { 12 | const pairMethod = await discovery.getPairMethod(service); 13 | 14 | const client = new HttpClient(service.id, service.address, service.port); 15 | await client.pairSetup(pin, pairMethod); 16 | 17 | console.log(`${service.name} paired! Keep the following pairing data safe:`); 18 | console.log(JSON.stringify(client.getLongTermData(), null, 2)); 19 | client.close(); 20 | } catch (e) { 21 | console.error(`${service.name}: `, e); 22 | } 23 | } 24 | }); 25 | 26 | discovery.start(); 27 | -------------------------------------------------------------------------------- /examples/ip/remove-pairing.js: -------------------------------------------------------------------------------- 1 | const { HttpClient, IPDiscovery } = require('hap-controller'); 2 | 3 | const discovery = new IPDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | discovery.on('serviceUp', async (service) => { 14 | console.log(`Found device: ${service.name}`); 15 | 16 | const client = new HttpClient(service.id, service.address, service.port, pairingData); 17 | 18 | try { 19 | await client.removePairing(client.pairingProtocol.iOSDevicePairingID); 20 | client.close(); 21 | console.log(`${service.name}: done!`); 22 | } catch (e) { 23 | console.error(`${service.name}:`, e); 24 | } 25 | }); 26 | 27 | discovery.start(); 28 | -------------------------------------------------------------------------------- /examples/ip/set-characteristics.js: -------------------------------------------------------------------------------- 1 | const { HttpClient, IPDiscovery } = require('hap-controller'); 2 | 3 | const discovery = new IPDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | const characteristics = { 14 | '1.10': true, 15 | }; 16 | 17 | discovery.on('serviceUp', async (service) => { 18 | console.log(`Found device: ${service.name}`); 19 | 20 | const client = new HttpClient(service.id, service.address, service.port, pairingData, { 21 | usePersistentConnections: true, 22 | }); 23 | 24 | try { 25 | await client.setCharacteristics(characteristics); 26 | client.close(); 27 | console.log(`${service.name}: done!`); 28 | } catch (e) { 29 | console.error(`${service.name}:`, e); 30 | } 31 | }); 32 | 33 | discovery.start(); 34 | -------------------------------------------------------------------------------- /examples/ip/subscribe-characteristics.js: -------------------------------------------------------------------------------- 1 | const { HttpClient, IPDiscovery } = require('hap-controller'); 2 | 3 | const discovery = new IPDiscovery(); 4 | 5 | const pairingData = { 6 | AccessoryPairingID: '...', 7 | AccessoryLTPK: '...', 8 | iOSDevicePairingID: '...', 9 | iOSDeviceLTSK: '...', 10 | iOSDeviceLTPK: '...', 11 | }; 12 | 13 | const characteristics = [ 14 | '1.10', // aid.iid 15 | ]; 16 | 17 | discovery.on('serviceUp', async (service) => { 18 | console.log(`Found device: ${service.name}`); 19 | 20 | const client = new HttpClient(service.id, service.address, service.port, pairingData, { 21 | usePersistentConnections: true, 22 | }); 23 | 24 | let count = 0; 25 | client.on('event', async (ev) => { 26 | console.log(`Event: ${JSON.stringify(ev, null, 2)}`); 27 | 28 | if (++count >= 2) { 29 | try { 30 | await client.unsubscribeCharacteristics(characteristics); 31 | client.close(); 32 | console.log(`${service.name}: Unsubscribed!`); 33 | } catch (e) { 34 | console.error(`${service.name}:`, e); 35 | } 36 | } 37 | }); 38 | 39 | client.on('event-disconnect', async (formerSubscribes) => { 40 | console.log(`Disconnected: ${JSON.stringify(formerSubscribes, null, 2)}`); 41 | // resubscribe if wanted: 42 | try { 43 | // a disconnect can happen if the device was disconnected from the network 44 | // so you have to catch any network errors here 45 | await client.subscribeCharacteristics(formerSubscribes); 46 | } catch (e) { 47 | console.error('error while resubscribing', e); 48 | // if the discovery will detect the device again it will fire a new serviceUp event 49 | } 50 | }); 51 | 52 | try { 53 | await client.subscribeCharacteristics(characteristics); 54 | console.log(`${service.name}: Subscribed!`); 55 | } catch (e) { 56 | console.error(`${service.name}:`, e); 57 | } 58 | }); 59 | 60 | discovery.start(); 61 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'http-parser-js' { 2 | export class HTTPParser { 3 | static REQUEST: string; 4 | 5 | static RESPONSE: string; 6 | 7 | constructor(type?: string); 8 | 9 | info: { 10 | headers: string[]; 11 | upgrade: boolean; 12 | statusCode?: number; 13 | }; 14 | 15 | execute: (chunk: Buffer, start?: number, length?: number) => number; 16 | 17 | onHeadersComplete: (info: { 18 | versionMajor: string; 19 | versionMinor: string; 20 | headers: string[]; 21 | method: string | null; 22 | url: string | null; 23 | statusCode: number | null; 24 | statusMessage: string | null; 25 | upgrade: boolean; 26 | shouldKeepAlive: boolean; 27 | }) => void; 28 | 29 | onMessageComplete: () => void; 30 | 31 | onBody: (data: Buffer, offset: number, length: number) => void; 32 | } 33 | } 34 | 35 | declare module 'node-hkdf-sync' { 36 | export default class HKDF { 37 | constructor(hashAlg: string, salt: Buffer | string, ikm: Buffer | string); 38 | derive(info: Buffer | string, size: number): Buffer; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hap-controller", 3 | "version": "0.10.2", 4 | "description": "Library to implement a HAP (HomeKit) controller", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "prepare": "npm run build", 9 | "build": "tsc -p .", 10 | "lint": "eslint .", 11 | "prettier": "prettier -u -w '*.ts' examples src __tests__", 12 | "test": "jest", 13 | "release": "release-script" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/Apollon77/hap-controller-node.git" 18 | }, 19 | "keywords": [ 20 | "HomeKit", 21 | "HAP", 22 | "controller" 23 | ], 24 | "author": "Michael Stegeman", 25 | "license": "MPL-2.0", 26 | "bugs": { 27 | "url": "https://github.com/Apollon77/hap-controller-node/issues" 28 | }, 29 | "homepage": "https://github.com/Apollon77/hap-controller-node#readme", 30 | "dependencies": { 31 | "@stoprocent/noble": "^1.15.1", 32 | "bignumber.js": "^9.1.2", 33 | "debug": "^4.3.7", 34 | "dnssd": "^0.4.1", 35 | "fast-srp-hap": "^2.0.4", 36 | "http-parser-js": "^0.5.8", 37 | "json-bigint": "^1.0.0", 38 | "libsodium-wrappers": "^0.7.15", 39 | "node-hkdf-sync": "^1.0.0", 40 | "uuid": "^11.0.2" 41 | }, 42 | "devDependencies": { 43 | "@alcalzone/release-script": "^3.8.0", 44 | "@types/debug": "^4.1.12", 45 | "@types/dnssd": "^0.4.5", 46 | "@types/json-bigint": "^1.0.4", 47 | "@types/libsodium-wrappers": "^0.7.14", 48 | "@types/node": "^22.8.5", 49 | "@types/uuid": "^10.0.0", 50 | "@typescript-eslint/eslint-plugin": "^7.18.0", 51 | "@typescript-eslint/parser": "^7.18.0", 52 | "eslint": "^8.57.1", 53 | "eslint-config-prettier": "^9.1.0", 54 | "event-to-promise": "^0.8.0", 55 | "jest": "^29.7.0", 56 | "prettier": "^3.3.3", 57 | "typescript": "~5.6.3" 58 | }, 59 | "files": [ 60 | "LICENSE", 61 | "README.md", 62 | "lib", 63 | "examples" 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import BLEDiscovery, { HapServiceBle } from './transport/ble/ble-discovery'; 2 | import GattClient from './transport/ble/gatt-client'; 3 | import HttpClient from './transport/ip/http-client'; 4 | import { PairingData, PairingTypeFlags, PairMethods } from './protocol/pairing-protocol'; 5 | import IPDiscovery, { HapServiceIp } from './transport/ip/ip-discovery'; 6 | import * as Category from './model/category'; 7 | import * as Characteristic from './model/characteristic'; 8 | import * as GattConstants from './transport/ble/gatt-constants'; 9 | import * as GattUtils from './transport/ble/gatt-utils'; 10 | import * as HttpConstants from './transport/ip/http-constants'; 11 | import * as Service from './model/service'; 12 | import * as TLV from './model/tlv'; 13 | import HomekitControllerError from './model/error'; 14 | 15 | export { 16 | BLEDiscovery, 17 | HapServiceBle, 18 | Category, 19 | Characteristic, 20 | GattClient, 21 | GattConstants, 22 | GattUtils, 23 | HttpClient, 24 | HttpConstants, 25 | IPDiscovery, 26 | HapServiceIp, 27 | Service, 28 | TLV, 29 | PairMethods, 30 | PairingTypeFlags, 31 | PairingData, 32 | HomekitControllerError, 33 | }; 34 | -------------------------------------------------------------------------------- /src/model/accessory.ts: -------------------------------------------------------------------------------- 1 | import { ServiceObject } from './service'; 2 | 3 | /** 4 | * Accessory characteristic types. 5 | * 6 | * See Chapter 8 7 | */ 8 | export interface Accessories { 9 | accessories: AccessoryObject[]; 10 | } 11 | 12 | export interface AccessoryObject { 13 | aid: number; 14 | services: ServiceObject[]; 15 | } 16 | -------------------------------------------------------------------------------- /src/model/category.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Accessory categories. 3 | * 4 | * See Chapter 12.2 5 | */ 6 | 7 | const CategoryMapById = { 8 | 1: 'Other', 9 | 2: 'Bridge', 10 | 3: 'Fan', 11 | 4: 'Garage Door Opener', 12 | 5: 'Lighting', 13 | 6: 'Lock', 14 | 7: 'Outlet', 15 | 8: 'Switch', 16 | 9: 'Thermostat', 17 | 10: 'Sensor', 18 | 11: 'Security System', 19 | 12: 'Door', 20 | 13: 'Window', 21 | 14: 'Window Covering', 22 | 15: 'Programmable Switch', 23 | 16: 'Range Extender', 24 | 17: 'IP Camera', 25 | 18: 'Video Doorbell', 26 | 19: 'Air Purifier', 27 | 20: 'Heater', 28 | 21: 'Air Conditioner', 29 | 22: 'Humidifier', 30 | 23: 'Dehumidifier', 31 | 24: 'Apple TV', 32 | 25: 'HomePod', 33 | 26: 'Speaker', 34 | 27: 'AirPort', 35 | 28: 'Sprinkler', 36 | 29: 'Faucet', 37 | 30: 'Shower System', 38 | 31: 'Television', 39 | 32: 'Remote', 40 | 33: 'Router', 41 | }; 42 | 43 | const CategoryMapByCategory = Object.assign({}, ...Object.entries(CategoryMapById).map(([a, b]) => ({ [b]: a }))); 44 | 45 | /** 46 | * Get a category name from its Accessory Category Identifier. 47 | * 48 | * @param {number} id - Accessory Category Identifier 49 | * @returns {string} Category name 50 | */ 51 | export function categoryFromId(id: number): string { 52 | return CategoryMapById[id] || 'Unknown'; 53 | } 54 | 55 | /** 56 | * Get an Accessory Category Identifier from its name. 57 | * 58 | * @param {string} category - Category name 59 | * @returns {number} Accessory Category Identifier 60 | */ 61 | export function idFromCategory(category: string): number { 62 | return CategoryMapByCategory[category] || 1; 63 | } 64 | -------------------------------------------------------------------------------- /src/model/characteristic.ts: -------------------------------------------------------------------------------- 1 | export interface CharacteristicObject { 2 | serviceUuid?: string; // added for convenience 3 | aid?: number; // added for convenience 4 | iid?: number; 5 | type?: string; 6 | value?: unknown; 7 | perms?: string[]; 8 | ev?: boolean; 9 | description?: string; 10 | format?: string; 11 | unit?: string; 12 | minValue?: number; 13 | maxValue?: number; 14 | minStep?: number; 15 | maxLen?: number; 16 | maxDataLen?: number; 17 | 'valid-values'?: number[]; 18 | 'valid-values-range'?: number[]; 19 | TTL?: number; 20 | pid?: number; 21 | status?: number; 22 | } 23 | 24 | const UuidSuffix = '-0000-1000-8000-0026BB765291'; 25 | 26 | /* eslint-disable max-len */ 27 | const CharacteristicMapByUuid: { [key: string]: string } = { 28 | [`00000001${UuidSuffix}`]: 'public.hap.characteristic.administrator-only-access', 29 | [`00000005${UuidSuffix}`]: 'public.hap.characteristic.audio-feedback', 30 | [`00000008${UuidSuffix}`]: 'public.hap.characteristic.brightness', 31 | [`0000000D${UuidSuffix}`]: 'public.hap.characteristic.temperature.cooling-threshold', 32 | [`0000000E${UuidSuffix}`]: 'public.hap.characteristic.door-state.current', 33 | [`0000000F${UuidSuffix}`]: 'public.hap.characteristic.heating-cooling.current', 34 | [`00000010${UuidSuffix}`]: 'public.hap.characteristic.relative-humidity.current', 35 | [`00000011${UuidSuffix}`]: 'public.hap.characteristic.temperature.current', 36 | [`00000012${UuidSuffix}`]: 'public.hap.characteristic.temperature.heating-threshold', 37 | [`00000013${UuidSuffix}`]: 'public.hap.characteristic.hue', 38 | [`00000014${UuidSuffix}`]: 'public.hap.characteristic.identify', 39 | [`00000019${UuidSuffix}`]: 'public.hap.characteristic.lock-management.control-point', 40 | [`0000001A${UuidSuffix}`]: 'public.hap.characteristic.lock-management.auto-secure-timeout', 41 | [`0000001C${UuidSuffix}`]: 'public.hap.characteristic.lock-mechanism.last-known-action', 42 | [`0000001D${UuidSuffix}`]: 'public.hap.characteristic.lock-mechanism.current-state', 43 | [`0000001E${UuidSuffix}`]: 'public.hap.characteristic.lock-mechanism.target-state', 44 | [`0000001F${UuidSuffix}`]: 'public.hap.characteristic.logs', 45 | [`00000020${UuidSuffix}`]: 'public.hap.characteristic.manufacturer', 46 | [`00000021${UuidSuffix}`]: 'public.hap.characteristic.model', 47 | [`00000022${UuidSuffix}`]: 'public.hap.characteristic.motion-detected', 48 | [`00000023${UuidSuffix}`]: 'public.hap.characteristic.name', 49 | [`00000024${UuidSuffix}`]: 'public.hap.characteristic.obstruction-detected', 50 | [`00000025${UuidSuffix}`]: 'public.hap.characteristic.on', 51 | [`00000026${UuidSuffix}`]: 'public.hap.characteristic.outlet-in-use', 52 | [`00000028${UuidSuffix}`]: 'public.hap.characteristic.rotation.direction', 53 | [`00000029${UuidSuffix}`]: 'public.hap.characteristic.rotation.speed', 54 | [`0000002F${UuidSuffix}`]: 'public.hap.characteristic.saturation', 55 | [`00000030${UuidSuffix}`]: 'public.hap.characteristic.serial-number', 56 | [`00000032${UuidSuffix}`]: 'public.hap.characteristic.door-state.target', 57 | [`00000033${UuidSuffix}`]: 'public.hap.characteristic.heating-cooling.target', 58 | [`00000034${UuidSuffix}`]: 'public.hap.characteristic.relative-humidity.target', 59 | [`00000035${UuidSuffix}`]: 'public.hap.characteristic.temperature.target', 60 | [`00000036${UuidSuffix}`]: 'public.hap.characteristic.temperature.units', 61 | [`00000037${UuidSuffix}`]: 'public.hap.characteristic.version', 62 | [`0000004C${UuidSuffix}`]: 'public.hap.characteristic.pairing.pair-setup', 63 | [`0000004E${UuidSuffix}`]: 'public.hap.characteristic.pairing.pair-verify', 64 | [`0000004F${UuidSuffix}`]: 'public.hap.characteristic.pairing.features', 65 | [`00000050${UuidSuffix}`]: 'public.hap.characteristic.pairing.pairings', 66 | [`00000052${UuidSuffix}`]: 'public.hap.characteristic.firmware.revision', 67 | [`00000053${UuidSuffix}`]: 'public.hap.characteristic.hardware.revision', 68 | [`00000054${UuidSuffix}`]: 'public.hap.characteristic.software.revision', 69 | [`00000057${UuidSuffix}`]: 'public.hap.characteristic.accessory-identifier', 70 | [`00000058${UuidSuffix}`]: 'public.hap.characteristic.tunneled-accessory-state-number', 71 | [`00000059${UuidSuffix}`]: 'public.hap.characteristic.tunneled-accessory-connected', 72 | [`0000005B${UuidSuffix}`]: 'public.hap.characteristic.relay-enabled', 73 | [`0000005C${UuidSuffix}`]: 'public.hap.characteristic.relay-state', 74 | [`0000005E${UuidSuffix}`]: 'public.hap.characteristic.relay-control-point', 75 | [`00000060${UuidSuffix}`]: 'public.hap.characteristic.tunneled-accessory-advertising', 76 | [`00000061${UuidSuffix}`]: 'public.hap.characteristic.tunnel-connection-timeout', 77 | [`00000063${UuidSuffix}`]: 'public.hap.characteristic.reachable', // REMOVED 78 | [`00000064${UuidSuffix}`]: 'public.hap.characteristic.air-particulate.density', // since iOS 9 79 | [`00000065${UuidSuffix}`]: 'public.hap.characteristic.air-particulate.size', // since iOS 9 80 | [`00000066${UuidSuffix}`]: 'public.hap.characteristic.security-system-state.current', // since iOS 9 81 | [`00000067${UuidSuffix}`]: 'public.hap.characteristic.security-system-state.target', // since iOS 9 82 | [`00000068${UuidSuffix}`]: 'public.hap.characteristic.battery-level', // since iOS 9 83 | [`00000069${UuidSuffix}`]: 'public.hap.characteristic.carbon-monoxide.detected', // since iOS 9 84 | [`0000006A${UuidSuffix}`]: 'public.hap.characteristic.contact-state', // since iOS 9 85 | [`0000006B${UuidSuffix}`]: 'public.hap.characteristic.light-level.current', // since iOS 9 86 | [`0000006C${UuidSuffix}`]: 'public.hap.characteristic.horizontal-tilt.current', // since iOS 9 87 | [`0000006D${UuidSuffix}`]: 'public.hap.characteristic.position.current', // since iOS 9 88 | [`0000006E${UuidSuffix}`]: 'public.hap.characteristic.vertical-tilt.current', // since iOS 9 89 | [`0000006F${UuidSuffix}`]: 'public.hap.characteristic.position.hold', // since iOS 9 90 | [`00000070${UuidSuffix}`]: 'public.hap.characteristic.leak-detected', // since iOS 9 91 | [`00000071${UuidSuffix}`]: 'public.hap.characteristic.occupancy-detected', // since iOS 9 92 | [`00000072${UuidSuffix}`]: 'public.hap.characteristic.position.state', // since iOS 9 93 | [`00000073${UuidSuffix}`]: 'public.hap.characteristic.input-event', // since iOS 10.3 94 | [`00000074${UuidSuffix}`]: 'public.hap.characteristic.programmable-switch-output-state', 95 | [`00000075${UuidSuffix}`]: 'public.hap.characteristic.status-active', // since iOS 9 96 | [`00000076${UuidSuffix}`]: 'public.hap.characteristic.smoke-detected', // since iOS 9 97 | [`00000077${UuidSuffix}`]: 'public.hap.characteristic.status-fault', // since iOS 9 98 | [`00000078${UuidSuffix}`]: 'public.hap.characteristic.status-jammed', // since iOS 9 99 | [`00000079${UuidSuffix}`]: 'public.hap.characteristic.status-lo-batt', // since iOS 9 100 | [`0000007A${UuidSuffix}`]: 'public.hap.characteristic.status-tampered', // since iOS 9 101 | [`0000007B${UuidSuffix}`]: 'public.hap.characteristic.horizontal-tilt.target', // since iOS 9 102 | [`0000007C${UuidSuffix}`]: 'public.hap.characteristic.position.target', // since iOS 9 103 | [`0000007D${UuidSuffix}`]: 'public.hap.characteristic.vertical-tilt.target', // since iOS 9 104 | [`0000008E${UuidSuffix}`]: 'public.hap.characteristic.security-system.alarm-type', // since iOS 9 105 | [`0000008F${UuidSuffix}`]: 'public.hap.characteristic.charging-state', // since iOS 9 106 | [`00000090${UuidSuffix}`]: 'public.hap.characteristic.carbon-monoxide.level', // since iOS 9 107 | [`00000091${UuidSuffix}`]: 'public.hap.characteristic.carbon-monoxide.peak-level', // since iOS 9 108 | [`00000092${UuidSuffix}`]: 'public.hap.characteristic.carbon-dioxide.detected', // since iOS 9 109 | [`00000093${UuidSuffix}`]: 'public.hap.characteristic.carbon-dioxide.level', // since iOS 9 110 | [`00000094${UuidSuffix}`]: 'public.hap.characteristic.carbon-dioxide.peak-level', // since iOS 9 111 | [`00000095${UuidSuffix}`]: 'public.hap.characteristic.air-quality', // since iOS 9 112 | [`00000098${UuidSuffix}`]: 'public.hap.characteristic.day-of-the-week', // REMOVED 113 | [`0000009A${UuidSuffix}`]: 'public.hap.characteristic.time-update', // REMOVED 114 | [`0000009B${UuidSuffix}`]: 'public.hap.characteristic.current-time', // REMOVED 115 | [`0000009C${UuidSuffix}`]: 'public.hap.characteristic.link-quality', // REMOVED 116 | [`0000009D${UuidSuffix}`]: 'public.hap.characteristic.configure-bridged-accessory-status', // REMOVED 117 | [`0000009E${UuidSuffix}`]: 'public.hap.characteristic.discover-bridged-accessories', // REMOVED 118 | [`0000009F${UuidSuffix}`]: 'public.hap.characteristic.discovered-bridged-accessories', // REMOVED 119 | [`000000A0${UuidSuffix}`]: 'public.hap.characteristic.configure-bridged-accessory', 120 | [`000000A3${UuidSuffix}`]: 'public.hap.characteristic.category', // REMOVED 121 | [`000000A4${UuidSuffix}`]: 'public.hap.characteristic.app-matching-identifier', 122 | [`000000A6${UuidSuffix}`]: 'public.hap.characteristic.accessory-properties', 123 | [`000000A7${UuidSuffix}`]: 'public.hap.characteristic.lock-physical-controls', // since iOS 10.3 124 | [`000000A8${UuidSuffix}`]: 'public.hap.characteristic.air-purifier.state.target', // since iOS 10.3 125 | [`000000A9${UuidSuffix}`]: 'public.hap.characteristic.air-purifier.state.current', // since iOS 10.3 126 | [`000000AA${UuidSuffix}`]: 'public.hap.characteristic.slat.state.current', // since iOS 10.3 127 | [`000000AB${UuidSuffix}`]: 'public.hap.characteristic.filter.life-level', // since iOS 10.3 128 | [`000000AC${UuidSuffix}`]: 'public.hap.characteristic.filter.change-indication', // since iOS 10.3 129 | [`000000AD${UuidSuffix}`]: 'public.hap.characteristic.filter.reset-indication', // since iOS 10.3 130 | [`000000AE${UuidSuffix}`]: 'public.hap.characteristic.air-quality.target', // REMOVED 131 | [`000000AF${UuidSuffix}`]: 'public.hap.characteristic.fan.state.current', // since iOS 10.3 132 | [`000000B0${UuidSuffix}`]: 'public.hap.characteristic.active', // since iOS 10.3 133 | [`000000B1${UuidSuffix}`]: 'public.hap.characteristic.heater-cooler.state.current', // since iOS 11 134 | [`000000B2${UuidSuffix}`]: 'public.hap.characteristic.heater-cooler.state.target', // since iOS 11 135 | [`000000B3${UuidSuffix}`]: 'public.hap.characteristic.humidifier-dehumidifier.state.current', // since iOS 11 136 | [`000000B4${UuidSuffix}`]: 'public.hap.characteristic.humidifier-dehumidifier.state.target', // since iOS 11 137 | [`000000B5${UuidSuffix}`]: 'public.hap.characteristic.water-level', // since iOS 11 138 | [`000000B6${UuidSuffix}`]: 'public.hap.characteristic.swing-mode', // since iOS 10.3 139 | [`000000BE${UuidSuffix}`]: 'public.hap.characteristic.slat.state.target', // REMOVED 140 | [`000000BF${UuidSuffix}`]: 'public.hap.characteristic.fan.state.target', // since iOS 10.3 141 | [`000000C0${UuidSuffix}`]: 'public.hap.characteristic.type.slat', // since iOS 10.3 142 | [`000000C1${UuidSuffix}`]: 'public.hap.characteristic.tilt.current', // since iOS 10.3 143 | [`000000C2${UuidSuffix}`]: 'public.hap.characteristic.tilt.target', // since iOS 10.3 144 | [`000000C3${UuidSuffix}`]: 'public.hap.characteristic.density.ozone', // since iOS 10.3 145 | [`000000C4${UuidSuffix}`]: 'public.hap.characteristic.density.no2', // since iOS 10.3 146 | [`000000C5${UuidSuffix}`]: 'public.hap.characteristic.density.so2', // since iOS 10.3 147 | [`000000C6${UuidSuffix}`]: 'public.hap.characteristic.density.pm25', // since iOS 10.3 148 | [`000000C7${UuidSuffix}`]: 'public.hap.characteristic.density.pm10', // since iOS 10.3 149 | [`000000C8${UuidSuffix}`]: 'public.hap.characteristic.density.voc', // since iOS 10.3 150 | [`000000C9${UuidSuffix}`]: 'public.hap.characteristic.relative-humidity.dehumidifier-threshold', // since iOS 11 151 | [`000000CA${UuidSuffix}`]: 'public.hap.characteristic.relative-humidity.humidifier-threshold', // since iOS 11 152 | [`000000CB${UuidSuffix}`]: 'public.hap.characteristic.service-label-index', // since iOS 10.3 153 | [`000000CD${UuidSuffix}`]: 'public.hap.characteristic.service-label-namespace', // since iOS 10.3 154 | [`000000CE${UuidSuffix}`]: 'public.hap.characteristic.color-temperature', // since iOS 10.3 155 | [`000000D1${UuidSuffix}`]: 'public.hap.characteristic.program-mode', // since iOS 11.2 156 | [`000000D2${UuidSuffix}`]: 'public.hap.characteristic.in-use', // since iOS 11.2 157 | [`000000D3${UuidSuffix}`]: 'public.hap.characteristic.set-duration', // since iOS 11.2 158 | [`000000D4${UuidSuffix}`]: 'public.hap.characteristic.remaining-duration', // since iOS 11.2 159 | [`000000D5${UuidSuffix}`]: 'public.hap.characteristic.valve-type', // since iOS 11.2 160 | [`000000D6${UuidSuffix}`]: 'public.hap.characteristic.is-configured', // since iOS 12 161 | [`000000DB${UuidSuffix}`]: 'public.hap.characteristic.input-source-type', 162 | [`000000DC${UuidSuffix}`]: 'public.hap.characteristic.input-device-type', 163 | [`000000DD${UuidSuffix}`]: 'public.hap.characteristic.closed-captions', // REMOVED 164 | [`000000DF${UuidSuffix}`]: 'public.hap.characteristic.power-mode-selection', 165 | [`000000E0${UuidSuffix}`]: 'public.hap.characteristic.current-media-state', 166 | [`000000E1${UuidSuffix}`]: 'public.hap.characteristic.remote-key', 167 | [`000000E2${UuidSuffix}`]: 'public.hap.characteristic.picture-mode', 168 | [`000000E3${UuidSuffix}`]: 'public.hap.characteristic.configured-name', 169 | [`000000E4${UuidSuffix}`]: 'public.hap.characteristic.password-setting', 170 | [`000000E5${UuidSuffix}`]: 'public.hap.characteristic.access-control-level', 171 | [`000000E6${UuidSuffix}`]: 'public.hap.characteristic.identifier', 172 | [`000000E7${UuidSuffix}`]: 'public.hap.characteristic.active-identifier', // since iOS 12 173 | [`000000E8${UuidSuffix}`]: 'public.hap.characteristic.sleep-discovery-mode', 174 | [`000000E9${UuidSuffix}`]: 'public.hap.characteristic.volume-control-type', 175 | [`000000EA${UuidSuffix}`]: 'public.hap.characteristic.volume-selector', 176 | [`00000114${UuidSuffix}`]: 'public.hap.characteristic.supported-video-stream-configuration', // since iOS 10 177 | [`00000115${UuidSuffix}`]: 'public.hap.characteristic.supported-audio-configuration', // since iOS 10 178 | [`00000116${UuidSuffix}`]: 'public.hap.characteristic.supported-rtp-configuration', // since iOS 10 179 | [`00000117${UuidSuffix}`]: 'public.hap.characteristic.selected-rtp-stream-configuration', // since iOS 10 180 | [`00000118${UuidSuffix}`]: 'public.hap.characteristic.setup-endpoints', // since iOS 10 181 | [`00000119${UuidSuffix}`]: 'public.hap.characteristic.volume', // since iOS 10 182 | [`0000011A${UuidSuffix}`]: 'public.hap.characteristic.mute', // since iOS 10 183 | [`0000011B${UuidSuffix}`]: 'public.hap.characteristic.night-vision', // since iOS 10 184 | [`0000011C${UuidSuffix}`]: 'public.hap.characteristic.zoom-optical', // since iOS 10 185 | [`0000011D${UuidSuffix}`]: 'public.hap.characteristic.zoom-digital', // since iOS 10 186 | [`0000011E${UuidSuffix}`]: 'public.hap.characteristic.image-rotation', // since iOS 10 187 | [`0000011F${UuidSuffix}`]: 'public.hap.characteristic.image-mirror', // since iOS 10 - image-mirroring?? 188 | [`00000120${UuidSuffix}`]: 'public.hap.characteristic.streaming-status', // since iOS 10 189 | [`00000123${UuidSuffix}`]: 'public.hap.characteristic.supported-target-configuration', // since iOS 12 190 | [`00000124${UuidSuffix}`]: 'public.hap.characteristic.target-list', // since iOS 12 191 | [`00000126${UuidSuffix}`]: 'public.hap.characteristic.button-event', // since iOS 12 192 | [`00000128${UuidSuffix}`]: 'public.hap.characteristic.selected-audio-stream-configuration', // since iOS 12 193 | [`00000130${UuidSuffix}`]: 'public.hap.characteristic.supported-data-stream-transport-configuration', // since iOS 12 194 | [`00000131${UuidSuffix}`]: 'public.hap.characteristic.setup-data-stream-transport', // since iOS 12 195 | [`00000132${UuidSuffix}`]: 'public.hap.characteristic.siri.input-type', // since iOS 12 196 | [`00000134${UuidSuffix}`]: 'public.hap.characteristic.target-visibility-state', 197 | [`00000135${UuidSuffix}`]: 'public.hap.characteristic.current-visibility-state', 198 | [`00000136${UuidSuffix}`]: 'public.hap.characteristic.display-order', 199 | [`00000137${UuidSuffix}`]: 'public.hap.characteristic.target-media-state', 200 | [`00000138${UuidSuffix}`]: 'public.hap.characteristic.data-stream.hap-transport', // since iOS 14 201 | [`00000139${UuidSuffix}`]: 'public.hap.characteristic.data-stream.hap-transport-interrupt', // since iOS 14 202 | [`00000143${UuidSuffix}`]: 'public.hap.characteristic.characteristic-value-transition-control', // since iOS 14 203 | [`00000144${UuidSuffix}`]: 'public.hap.characteristic.supported-characteristic-value-transition-configuration', // since iOS 14 204 | [`00000201${UuidSuffix}`]: 'public.hap.characteristic.setup-transfer-transport', // since iOS 13.4 205 | [`00000202${UuidSuffix}`]: 'public.hap.characteristic.supported-transfer-transport-configuration', // since iOS 13.4 206 | [`00000205${UuidSuffix}`]: 'public.hap.characteristic.supported-camera-recording-configuration', 207 | [`00000206${UuidSuffix}`]: 'public.hap.characteristic.supported-video-recording-configuration', 208 | [`00000207${UuidSuffix}`]: 'public.hap.characteristic.supported-audio-recording-configuration', 209 | [`00000209${UuidSuffix}`]: 'public.hap.characteristic.selected-camera-recording-configuration', 210 | [`0000020C${UuidSuffix}`]: 'public.hap.characteristic.network-client-control', // network-client-profile-control?? 211 | [`0000020D${UuidSuffix}`]: 'public.hap.characteristic.network-client-status-control', 212 | [`0000020E${UuidSuffix}`]: 'public.hap.characteristic.router-status', 213 | [`00000210${UuidSuffix}`]: 'public.hap.characteristic.supported-router-configuration', 214 | [`00000211${UuidSuffix}`]: 'public.hap.characteristic.wan-configuration-list', 215 | [`00000212${UuidSuffix}`]: 'public.hap.characteristic.wan-status-list', 216 | [`00000215${UuidSuffix}`]: 'public.hap.characteristic.managed-network-enable', 217 | [`0000021B${UuidSuffix}`]: 'public.hap.characteristic.homekit-camera-active', 218 | [`0000021C${UuidSuffix}`]: 'public.hap.characteristic.third-party-camera-active', 219 | [`0000021D${UuidSuffix}`]: 'public.hap.characteristic.camera-operating-mode-indicator', 220 | [`0000021E${UuidSuffix}`]: 'public.hap.characteristic.wifi-satellite-status', 221 | [`0000021F${UuidSuffix}`]: 'public.hap.characteristic.network-access-violation-control', 222 | [`00000220${UuidSuffix}`]: 'public.hap.characteristic.product-data', 223 | [`00000222${UuidSuffix}`]: 'public.hap.characteristic.wake-configuration', // since iOS 13.2 224 | [`00000223${UuidSuffix}`]: 'public.hap.characteristic.event-snapshots-active', 225 | [`00000224${UuidSuffix}`]: 'public.hap.characteristic.diagonal-field-of-view', // since iOS 13.2 226 | [`00000225${UuidSuffix}`]: 'public.hap.characteristic.periodic-snapshots-active', 227 | [`00000226${UuidSuffix}`]: 'public.hap.characteristic.recording-audio-active', 228 | [`00000227${UuidSuffix}`]: 'public.hap.characteristic.manually-disabled', 229 | [`00000229${UuidSuffix}`]: 'public.hap.characteristic.video-analysis-active', // since iOS 14 230 | [`0000022B${UuidSuffix}`]: 'public.hap.characteristic.current-transport', // since iOS 14 231 | [`0000022C${UuidSuffix}`]: 'public.hap.characteristic.wifi-capabilities', // since iOS 14 232 | [`0000022D${UuidSuffix}`]: 'public.hap.characteristic.wifi-configuration-control', // since iOS 14 233 | [`00000232${UuidSuffix}`]: 'public.hap.characteristic.operating-state-response', // since iOS 14 234 | [`00000233${UuidSuffix}`]: 'public.hap.characteristic.supported-firmware-update-configuration', 235 | [`00000234${UuidSuffix}`]: 'public.hap.characteristic.firmware-update-readiness', 236 | [`00000235${UuidSuffix}`]: 'public.hap.characteristic.firmware-update-status', 237 | [`00000238${UuidSuffix}`]: 'public.hap.characteristic.supported-diagnostics-snapshot', // since iOS 14 238 | [`0000023A${UuidSuffix}`]: 'public.hap.characteristic.sleep-interval', // since iOS 14 239 | [`0000023B${UuidSuffix}`]: 'public.hap.characteristic.activity-interval', // since iOS 14 240 | [`0000023C${UuidSuffix}`]: 'public.hap.characteristic.ping', // since iOS 14 241 | [`0000023D${UuidSuffix}`]: 'public.hap.characteristic.event-retransmission-maximum', // since iOS 14 242 | [`0000023E${UuidSuffix}`]: 'public.hap.characteristic.event-transmission-counters', // since iOS 14 243 | [`0000023F${UuidSuffix}`]: 'public.hap.characteristic.received-signal-strength-indication', // since iOS 14 244 | [`00000241${UuidSuffix}`]: 'public.hap.characteristic.signal-to-noise-ratio', // since iOS 14 245 | [`00000242${UuidSuffix}`]: 'public.hap.characteristic.transmit-power', // since iOS 14 246 | [`00000243${UuidSuffix}`]: 'public.hap.characteristic.maximum-transmit-power', // since iOS 14 247 | [`00000244${UuidSuffix}`]: 'public.hap.characteristic.receiver-sensitivity', // since iOS 14 248 | [`00000245${UuidSuffix}`]: 'public.hap.characteristic.cca-signal-detect-threshold', // since iOS 14 249 | [`00000246${UuidSuffix}`]: 'public.hap.characteristic.cca-energy-detect-threshold', // since iOS 14 250 | [`00000247${UuidSuffix}`]: 'public.hap.characteristic.mac.retransmission-maximum', // since iOS 14 251 | [`00000248${UuidSuffix}`]: 'public.hap.characteristic.mac.transmission-counters', 252 | [`00000249${UuidSuffix}`]: 'public.hap.characteristic.staged-firmware-version', 253 | [`0000024A${UuidSuffix}`]: 'public.hap.characteristic.heart-beat', // since iOS 14, 254 | [`0000024B${UuidSuffix}`]: 'public.hap.characteristic.characteristic-value-active-transition-count', // since iOS 14 255 | [`0000024C${UuidSuffix}`]: 'public.hap.characteristic.supported-diagnostics-modes', 256 | [`0000024D${UuidSuffix}`]: 'public.hap.characteristic.selected-diagnostics-modes', 257 | [`00000254${UuidSuffix}`]: 'public.hap.characteristic.siri.endpoint-session-status', 258 | [`00000255${UuidSuffix}`]: 'public.hap.characteristic.siri.enable', 259 | [`00000256${UuidSuffix}`]: 'public.hap.characteristic.siri.listening', 260 | [`00000257${UuidSuffix}`]: 'public.hap.characteristic.siri.touch-to-use', 261 | [`00000258${UuidSuffix}`]: 'public.hap.characteristic.siri.light-on-use', 262 | [`0000025A${UuidSuffix}`]: 'public.hap.characteristic.siri.engine-version', 263 | [`0000025B${UuidSuffix}`]: 'public.hap.characteristic.air-play.enable', 264 | [`00000261${UuidSuffix}`]: 'public.hap.characteristic.access-code.supported-configuration', // since iOS 15 265 | [`00000262${UuidSuffix}`]: 'public.hap.characteristic.access-code.control-point', // since iOS 15 266 | [`00000263${UuidSuffix}`]: 'public.hap.characteristic.configuration-state', // since iOS 15 267 | [`00000264${UuidSuffix}`]: 'public.hap.characteristic.nfc-access-control-point', // since iOS 15 268 | [`00000265${UuidSuffix}`]: 'public.hap.characteristic.nfc-access-supported-configuration', // since iOS 15 269 | [`00000268${UuidSuffix}`]: 'public.hap.characteristic.supported-asset-types', 270 | [`00000269${UuidSuffix}`]: 'public.hap.characteristic.asset-update-readiness', 271 | [`0000026B${UuidSuffix}`]: 'public.hap.characteristic.multifunction-button', 272 | [`0000026C${UuidSuffix}`]: 'public.hap.characteristic.hardware.finish', // since iOS 15 273 | [`00000702${UuidSuffix}`]: 'public.hap.characteristic.thread.node-capabilities', 274 | [`00000703${UuidSuffix}`]: 'public.hap.characteristic.thread.status', 275 | [`00000704${UuidSuffix}`]: 'public.hap.characteristic.thread.control-point', 276 | [`00000706${UuidSuffix}`]: 'public.hap.characteristic.thread.open-thread-version', 277 | }; 278 | /* eslint-enable max-len */ 279 | 280 | const CharacteristicMapByCharacteristic = Object.assign( 281 | {}, 282 | ...Object.entries(CharacteristicMapByUuid).map(([a, b]) => ({ [b]: a })), 283 | ); 284 | 285 | /** 286 | * Ensure the type is a valid characteristic UUID, also when short representations is used 287 | * 288 | * @param {string} uuid - Characteristic UUID 289 | * @returns {string} Characteristic UUID as UUID 290 | */ 291 | export function ensureCharacteristicUuid(uuid: string): string { 292 | if (uuid.length <= 8) { 293 | uuid = `${uuid.padStart(8, '0')}${UuidSuffix}`; 294 | } 295 | 296 | uuid = uuid.toUpperCase(); 297 | 298 | return uuid; 299 | } 300 | 301 | /** 302 | * Get a characteristic name from its UUID. 303 | * 304 | * @param {string} uuid - Characteristic UUID 305 | * @returns {string} Characteristic name 306 | */ 307 | export function characteristicFromUuid(uuid: string): string { 308 | uuid = ensureCharacteristicUuid(uuid); 309 | 310 | return CharacteristicMapByUuid[uuid] || uuid; 311 | } 312 | 313 | /** 314 | * Get a characteristic UUID from its name. 315 | * 316 | * @param {string} characteristic - Characteristic name 317 | * @returns {string} Characteristic UUID 318 | */ 319 | export function uuidFromCharacteristic(characteristic: string): string { 320 | return CharacteristicMapByCharacteristic[characteristic] || characteristic; 321 | } 322 | -------------------------------------------------------------------------------- /src/model/error.ts: -------------------------------------------------------------------------------- 1 | import JSONBig from 'json-bigint'; 2 | 3 | class HomekitControllerError extends Error { 4 | public statusCode: number | undefined; 5 | 6 | public body: Record | undefined; 7 | 8 | constructor(message: string, statusCode?: number, body?: Record | Buffer) { 9 | super(message); 10 | // eslint-disable-next-line no-undefined 11 | if (statusCode !== undefined) { 12 | this.setStatusCode(statusCode); 13 | } 14 | // eslint-disable-next-line no-undefined 15 | if (body !== undefined) { 16 | this.setBody(body); 17 | } 18 | } 19 | 20 | setStatusCode(errorCode: number): void { 21 | this.statusCode = errorCode; 22 | } 23 | 24 | getStatusCode(): number | undefined { 25 | return this.statusCode; 26 | } 27 | 28 | setBody(body: Record | Buffer): void { 29 | if (Buffer.isBuffer(body)) { 30 | try { 31 | this.body = JSONBig.parse(body.toString('utf-8')); 32 | } catch (err) { 33 | this.body = { 34 | raw: body, 35 | }; 36 | } 37 | } else { 38 | this.body = body; 39 | } 40 | } 41 | 42 | getBody(): Record | undefined { 43 | return this.body; 44 | } 45 | } 46 | 47 | export default HomekitControllerError; 48 | -------------------------------------------------------------------------------- /src/model/service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service types. 3 | * 4 | * See Chapters 6 and 9. 5 | */ 6 | 7 | import { CharacteristicObject } from './characteristic'; 8 | 9 | export interface ServiceObject { 10 | iid: number; 11 | type: string; 12 | characteristics: CharacteristicObject[]; 13 | primary?: boolean; 14 | hidden?: boolean; 15 | linked?: number[]; 16 | } 17 | 18 | const UuidSuffix = '-0000-1000-8000-0026BB765291'; 19 | 20 | const ServiceMapByUuid: { [key: string]: string } = { 21 | [`0000003E${UuidSuffix}`]: 'public.hap.service.accessory-information', 22 | [`00000040${UuidSuffix}`]: 'public.hap.service.fan', 23 | [`00000041${UuidSuffix}`]: 'public.hap.service.garage-door-opener', 24 | [`00000043${UuidSuffix}`]: 'public.hap.service.lightbulb', 25 | [`00000044${UuidSuffix}`]: 'public.hap.service.lock-management', 26 | [`00000045${UuidSuffix}`]: 'public.hap.service.lock-mechanism', 27 | [`00000047${UuidSuffix}`]: 'public.hap.service.outlet', 28 | [`00000049${UuidSuffix}`]: 'public.hap.service.switch', 29 | [`0000004A${UuidSuffix}`]: 'public.hap.service.thermostat', 30 | [`00000055${UuidSuffix}`]: 'public.hap.service.pairing', 31 | [`00000056${UuidSuffix}`]: 'public.hap.service.tunneled-btle-accessory', 32 | [`0000005A${UuidSuffix}`]: 'public.hap.service.relay', 33 | [`00000062${UuidSuffix}`]: 'public.hap.service.bridging-state', 34 | [`0000007E${UuidSuffix}`]: 'public.hap.service.security-system', 35 | [`0000007F${UuidSuffix}`]: 'public.hap.service.sensor.carbon-monoxide', 36 | [`00000080${UuidSuffix}`]: 'public.hap.service.sensor.contact', 37 | [`00000081${UuidSuffix}`]: 'public.hap.service.door', 38 | [`00000082${UuidSuffix}`]: 'public.hap.service.sensor.humidity', 39 | [`00000083${UuidSuffix}`]: 'public.hap.service.sensor.leak', 40 | [`00000084${UuidSuffix}`]: 'public.hap.service.sensor.light', 41 | [`00000085${UuidSuffix}`]: 'public.hap.service.sensor.motion', 42 | [`00000086${UuidSuffix}`]: 'public.hap.service.sensor.occupancy', 43 | [`00000087${UuidSuffix}`]: 'public.hap.service.sensor.smoke', 44 | [`00000088${UuidSuffix}`]: 'public.hap.service.stateful-programmable-switch', 45 | [`00000089${UuidSuffix}`]: 'public.hap.service.stateless-programmable-switch', 46 | [`0000008A${UuidSuffix}`]: 'public.hap.service.sensor.temperature', 47 | [`0000008B${UuidSuffix}`]: 'public.hap.service.window', 48 | [`0000008C${UuidSuffix}`]: 'public.hap.service.window-covering', 49 | [`0000008D${UuidSuffix}`]: 'public.hap.service.sensor.air-quality', 50 | [`00000096${UuidSuffix}`]: 'public.hap.service.battery', 51 | [`00000097${UuidSuffix}`]: 'public.hap.service.sensor.carbon-dioxide', 52 | [`00000099${UuidSuffix}`]: 'public.hap.service.time-information', 53 | [`000000A1${UuidSuffix}`]: 'public.hap.service.bridge-configuration', 54 | [`000000A2${UuidSuffix}`]: 'public.hap.service.protocol.information.service', 55 | [`000000B7${UuidSuffix}`]: 'public.hap.service.fanv2', 56 | [`000000B9${UuidSuffix}`]: 'public.hap.service.vertical-slat', 57 | [`000000BA${UuidSuffix}`]: 'public.hap.service.filter-maintenance', 58 | [`000000BB${UuidSuffix}`]: 'public.hap.service.air-purifier', 59 | [`000000BC${UuidSuffix}`]: 'public.hap.service.heater-cooler', 60 | [`000000BD${UuidSuffix}`]: 'public.hap.service.humidifier-dehumidifier', 61 | [`000000CC${UuidSuffix}`]: 'public.hap.service.service-label', 62 | [`000000CF${UuidSuffix}`]: 'public.hap.service.irrigation-system', 63 | [`000000D0${UuidSuffix}`]: 'public.hap.service.valve', 64 | [`000000D7${UuidSuffix}`]: 'public.hap.service.faucet', 65 | [`000000D8${UuidSuffix}`]: 'public.hap.service.television', 66 | [`000000D9${UuidSuffix}`]: 'public.hap.service.input-source', 67 | [`000000DA${UuidSuffix}`]: 'public.hap.service.access-control', 68 | [`00000110${UuidSuffix}`]: 'public.hap.service.camera-rtp-stream-management', 69 | [`00000111${UuidSuffix}`]: 'public.hap.service.camera-control', 70 | [`00000112${UuidSuffix}`]: 'public.hap.service.microphone', 71 | [`00000113${UuidSuffix}`]: 'public.hap.service.speaker', 72 | [`00000121${UuidSuffix}`]: 'public.hap.service.doorbell', 73 | [`00000122${UuidSuffix}`]: 'public.hap.service.target-control-management', 74 | [`00000125${UuidSuffix}`]: 'public.hap.service.target-control', 75 | [`00000127${UuidSuffix}`]: 'public.hap.service.audio-stream-management', 76 | [`00000129${UuidSuffix}`]: 'public.hap.service.data-stream-transport-management', 77 | [`00000133${UuidSuffix}`]: 'public.hap.service.siri', 78 | [`00000203${UuidSuffix}`]: 'public.hap.service.transfer-transport-management', 79 | [`00000204${UuidSuffix}`]: 'public.hap.service.camera-recording-management', 80 | [`0000020A${UuidSuffix}`]: 'public.hap.service.wifi-router', 81 | [`0000020F${UuidSuffix}`]: 'public.hap.service.wifi-satellite', 82 | [`0000021A${UuidSuffix}`]: 'public.hap.service.camera-operating-mode', 83 | [`00000221${UuidSuffix}`]: 'public.hap.service.power-management', 84 | [`00000228${UuidSuffix}`]: 'public.hap.service.smart-speaker', 85 | [`0000022A${UuidSuffix}`]: 'public.hap.service.wifi-transport', 86 | [`00000237${UuidSuffix}`]: 'public.hap.service.diagnostics', 87 | [`00000239${UuidSuffix}`]: 'public.hap.service.accessory-runtime-information', 88 | [`00000253${UuidSuffix}`]: 'public.hap.service.siri-endpoint', 89 | [`00000260${UuidSuffix}`]: 'public.hap.service.access-code', // since iOS 15 90 | [`00000266${UuidSuffix}`]: 'public.hap.service.nfc-access', // since iOS 15 91 | [`00000267${UuidSuffix}`]: 'public.hap.service.asset-update', 92 | [`0000026A${UuidSuffix}`]: 'public.hap.service.assistant', 93 | [`00000270${UuidSuffix}`]: 'public.hap.service.accessory-metrics', 94 | [`00000701${UuidSuffix}`]: 'public.hap.service.thread-transport', 95 | }; 96 | 97 | const ServiceMapByService = Object.assign({}, ...Object.entries(ServiceMapByUuid).map(([a, b]) => ({ [b]: a }))); 98 | 99 | /** 100 | * Ensure the type is a valid service UUID, also when short representations is used 101 | * 102 | * @param {string} uuid - Service UUID 103 | * @returns {string} Service UUID as UUID 104 | */ 105 | export function ensureServiceUuid(uuid: string): string { 106 | if (uuid.length <= 8) { 107 | uuid = `${uuid.padStart(8, '0')}${UuidSuffix}`; 108 | } 109 | 110 | uuid = uuid.toUpperCase(); 111 | 112 | return uuid; 113 | } 114 | 115 | /** 116 | * Get a service name from its UUID. 117 | * 118 | * @param {string} uuid - Service UUID 119 | * @returns {string} Service name 120 | */ 121 | export function serviceFromUuid(uuid: string): string { 122 | uuid = ensureServiceUuid(uuid); 123 | 124 | return ServiceMapByUuid[uuid] || uuid; 125 | } 126 | 127 | /** 128 | * Get a service UUID from its name. 129 | * 130 | * @param {string} service - Service name 131 | * @returns {string} Service UUID 132 | */ 133 | export function uuidFromService(service: string): string { 134 | return ServiceMapByService[service] || service; 135 | } 136 | -------------------------------------------------------------------------------- /src/model/tlv.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class for dealing with HAP TLV data. 3 | */ 4 | 5 | import Debug from 'debug'; 6 | 7 | const debug = Debug('hap-controller:tlv'); 8 | 9 | const kTLVType_Separator = 255; 10 | 11 | export type TLV = Map; 12 | 13 | /** 14 | * Decode a buffer into a TLV object. 15 | * 16 | * See Chapter 12.1 17 | * 18 | * @param {Buffer} buffer - Buffer to decode 19 | * @returns {TLV} TLV object 20 | */ 21 | export function decodeBuffer(buffer: Buffer): TLV { 22 | let position = 0; 23 | let lastTag = -1; 24 | const result = new Map(); 25 | 26 | if (!Buffer.isBuffer(buffer)) { 27 | return result; 28 | } 29 | 30 | while (position < buffer.length) { 31 | const tag = buffer.readUInt8(position++); 32 | const length = buffer.readUInt8(position++); 33 | const value = buffer.slice(position, position + length); 34 | 35 | debug(`Read ${length} bytes for tag ${tag}: ${value.toString('hex')}`); 36 | 37 | if (result.has(tag)) { 38 | const existingValue = result.get(tag); 39 | if (Array.isArray(existingValue) && tag === lastTag) { 40 | const idx = existingValue.length - 1; 41 | const newValue = Buffer.allocUnsafe(existingValue[idx].length + length); 42 | existingValue[idx].copy(newValue, 0); 43 | value.copy(newValue, existingValue[idx].length); 44 | existingValue[idx] = newValue; 45 | } else if (Array.isArray(existingValue)) { 46 | existingValue.push(value); 47 | } else if (tag === lastTag) { 48 | const newValue = Buffer.allocUnsafe(existingValue.length + length); 49 | existingValue.copy(newValue, 0); 50 | value.copy(newValue, existingValue.length); 51 | result.set(tag, newValue); 52 | } else { 53 | result.set(tag, [existingValue, value]); 54 | } 55 | } else { 56 | result.set(tag, value); 57 | } 58 | 59 | position += length; 60 | lastTag = tag; 61 | } 62 | 63 | return result; 64 | } 65 | 66 | /** 67 | * Encode a TLV object into a buffer. 68 | * 69 | * See Chapter 12.1 70 | * 71 | * @param {TLV} obj - TLV object to encode 72 | * @returns {Buffer} Encoded buffer 73 | */ 74 | export function encodeObject(obj: TLV): Buffer { 75 | const tlvs = []; 76 | 77 | // eslint-disable-next-line prefer-const 78 | for (let [tag, value] of obj) { 79 | if (tag < 0 || tag > 255) { 80 | continue; 81 | } 82 | 83 | if (tag === kTLVType_Separator) { 84 | debug('Add separator to data'); 85 | tlvs.push(Buffer.from([kTLVType_Separator, 0])); 86 | continue; 87 | } 88 | 89 | let values; 90 | if (Array.isArray(value)) { 91 | values = value; 92 | } else { 93 | values = [value]; 94 | } 95 | 96 | let valueIdx = 0; 97 | while (valueIdx < values.length) { 98 | let position = 0; 99 | while (values[valueIdx].length - position > 0) { 100 | const length = Math.min(values[valueIdx].length - position, 255); 101 | 102 | debug( 103 | `Add ${length} bytes for tag ${tag}: ${values[valueIdx].toString( 104 | 'hex', 105 | position, 106 | position + length, 107 | )}`, 108 | ); 109 | const tlv = Buffer.allocUnsafe(length + 2); 110 | tlv.writeUInt8(tag, 0); 111 | tlv.writeUInt8(length, 1); 112 | values[valueIdx].copy(tlv, 2, position, position + length); 113 | 114 | tlvs.push(tlv); 115 | position += length; 116 | } 117 | 118 | if (++valueIdx < values.length) { 119 | debug('Add separator to data'); 120 | tlvs.push(Buffer.from([kTLVType_Separator, 0])); 121 | } 122 | } 123 | } 124 | 125 | return Buffer.concat(tlvs); 126 | } 127 | -------------------------------------------------------------------------------- /src/transport/ble/ble-discovery.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * BLE discovery wrappers for finding HAP devices. 3 | */ 4 | 5 | import { EventEmitter } from 'events'; 6 | import { Peripheral } from '@stoprocent/noble'; 7 | import GattClient from './gatt-client'; 8 | import Debug from 'debug'; 9 | 10 | let noble: typeof import('@stoprocent/noble') | null = null; 11 | try { 12 | noble = require('@stoprocent/noble'); 13 | if (typeof noble?.on !== 'function') { 14 | // The following commit broke the default exported instance of noble: 15 | // https://github.com/abandonware/noble/commit/b67eea246f719947fc45b1b52b856e61637a8a8e 16 | noble = (noble as any)({ extended: false }); 17 | } 18 | } catch (error) { 19 | // Ignore 20 | } 21 | const debug = Debug('hap-controller:gatt-client'); 22 | 23 | /** 24 | * See Table 7-43 25 | */ 26 | const DiscoveryPairingStatusFlags = { 27 | AccessoryNotPaired: 0x01, 28 | }; 29 | 30 | export { DiscoveryPairingStatusFlags }; 31 | 32 | export interface HapServiceBle { 33 | name: string; 34 | /** 35 | * CoID: Company Identifier code, 0x004C (Apple,Inc.) ,in little endian format. 36 | */ 37 | CoID: number; 38 | /** 39 | * TY: 8 bits for Type,which shall be set to 0x11 40 | */ 41 | TY: number; 42 | /** 43 | * STL: 8 bits for Sub Type and Length 44 | * 45 | * From Specs: 46 | * The 3 significant bits specify the HomeKit advertising 47 | * format Sub Type and shall be set to 1, and the remaining 5 bits is the length of the remaining 48 | * bytes in the manufacturer specific data which shall be set to the value 17. 49 | */ 50 | AIL: number; 51 | /** 52 | * SF: 8 bits for Status Flags 53 | * 54 | * From Specs: 55 | * Bits 1-7 are reserved and shall be set to 0, Bit 0 shall reflect the value of the HAP Pairing 56 | * Status Flag. 57 | * see DiscoveryPairingStatusFlags 58 | */ 59 | SF: number; 60 | /** 61 | * Device-ID: 48-bit Device ID (”5.4 DeviceID” (page31)) of the accessory. 62 | */ 63 | DeviceID: string; 64 | /** 65 | * ACID: Accessory Category Identifier 66 | * 67 | * From Specs: 68 | * 16-bit little endian unsigned Accessory Category Identifier,which indicates the category that 69 | * best describes the primary function of the accessory. This must have a range of 1-65535. This 70 | * must take one of the values defined in the ”13-1 Accessory Categories” (page 252). 71 | * The Category Identifier must not change except during a firmware update. 72 | */ 73 | ACID: number; 74 | /** 75 | * GSN: 16-bit little endian unsigned Global State Number. 76 | * 77 | * From Specs: 78 | * The Global State Number represents the state at which a required change on the accessory was 79 | * last notified to the HomeKit controller. Accessories shall maintain a 16 bit monotonically 80 | * increasing GSN value. This value must have a range of 1-65535 and wrap to 1 when it overflows. 81 | * This value must persist across reboots, power cycles, etc. This value must be reset back to 1 82 | * when factory reset or a firmware update occurs on the accessory. For more details see 83 | * ”7.4.6 HAP Notifications” (page 127) 84 | */ 85 | GSN: number; 86 | /** 87 | * CN: Configuration Number 88 | * 89 | * From Specs: 90 | * 8 bits for Configuration Number, with a default starting value of 1. Accessories must 91 | * increment the config number after a firmware update. This value must have a range of 1-255 92 | * and wrap to 1 when it overflows. This value must persist across reboots, power cycles and 93 | * firmware updates. 94 | */ 95 | CN: number; 96 | /** 97 | * CV: 8bit little endian Compatible Version 98 | * 99 | * From Specs: 100 | * This value shall be set to 0x02 for this version of the HAP BLE. 101 | */ 102 | CV: number; 103 | /** 104 | * Added in v2 of the HAP specifications but no details known 105 | * SH: 4byte little endian Setup Hash to support enhanced setup payload information 106 | * (see”????”(page??)) 107 | */ 108 | // SH: string; 109 | /** 110 | * Peripheral object used for all communication to this device 111 | */ 112 | peripheral: Peripheral; 113 | /** 114 | * c#: the configuration number, same value as CN for convenient reasons with IP 115 | */ 116 | 'c#': number; 117 | /** 118 | * id: the deviceId, same value as deviceId for convenient reasons with IP 119 | */ 120 | id: string; 121 | /** 122 | * ci: the category identifier, same value as ACID for convenient reasons with IP 123 | */ 124 | ci: number; 125 | /** 126 | * availableToPair: is the device available for pairing? 127 | */ 128 | availableToPair: boolean; 129 | } 130 | 131 | /** 132 | * Handle discovery of IP devices 133 | * 134 | * @fires BLEDiscovery#serviceUp 135 | * @fires BLEDiscovery#serviceChanged 136 | */ 137 | export default class BLEDiscovery extends EventEmitter { 138 | private scanEnabled: boolean; 139 | 140 | private allowDuplicates: boolean; 141 | 142 | private services: Map; 143 | 144 | private handleStateChange: (state: string) => void; 145 | 146 | private handleDiscover: (peripheral: Peripheral) => void; 147 | 148 | private handleScanStart: () => void; 149 | 150 | private handleScanStop: () => void; 151 | 152 | constructor() { 153 | super(); 154 | 155 | this.scanEnabled = false; 156 | this.allowDuplicates = false; 157 | 158 | this.services = new Map(); 159 | 160 | this.handleStateChange = this._handleStateChange.bind(this); 161 | this.handleDiscover = this._handleDiscover.bind(this); 162 | this.handleScanStart = this._handleScanStart.bind(this); 163 | this.handleScanStop = this._handleScanStop.bind(this); 164 | } 165 | 166 | /** 167 | * Start searching for BLE HAP devices. 168 | * 169 | * @param {boolean} allowDuplicates - Deprecated, use new serviceChanged event instead. 170 | * Allow duplicate serviceUp events. This 171 | * is needed for disconnected events, where the GSN is 172 | * updated in the advertisement. 173 | */ 174 | start(allowDuplicates = false): void { 175 | if (!noble) { 176 | throw new Error('BLE could not be enabled or no device found'); 177 | } 178 | this.scanEnabled = true; 179 | this.allowDuplicates = allowDuplicates; 180 | 181 | noble.on('stateChange', this.handleStateChange); 182 | noble.on('scanStart', this.handleScanStart); 183 | noble.on('scanStop', this.handleScanStop); 184 | noble.on('discover', this.handleDiscover); 185 | 186 | // Only manually start if powered on already. Otherwise, wait for state 187 | // change and handle it there. 188 | if (noble._state === 'poweredOn') { 189 | noble.startScanning([], true); 190 | } 191 | } 192 | 193 | /** 194 | * Get PairMethod to use for pairing from the data received during discovery 195 | * 196 | * @param {HapServiceBle} service Discovered service object to check 197 | * @returns {Promise} Promise which resolves with the PairMethod to use 198 | */ 199 | public async getPairMethod(service: HapServiceBle): Promise { 200 | const client = new GattClient(service.DeviceID, service.peripheral); 201 | return client.getPairingMethod(); 202 | } 203 | 204 | /** 205 | * List the currently known services. 206 | * 207 | * @returns {Object[]} Array of services 208 | */ 209 | list(): HapServiceBle[] { 210 | return Array.from(this.services.values()); 211 | } 212 | 213 | /** 214 | * Stop an ongoing discovery process. 215 | */ 216 | stop(): void { 217 | if (!noble) { 218 | throw new Error('BLE could not be enabled or no device found'); 219 | } 220 | this.scanEnabled = false; 221 | noble.stopScanning(); 222 | noble.removeListener('stateChange', this.handleStateChange); 223 | noble.removeListener('scanStart', this.handleScanStart); 224 | noble.removeListener('scanStop', this.handleScanStop); 225 | noble.removeListener('discover', this.handleDiscover); 226 | } 227 | 228 | private _handleStateChange(state: string): void { 229 | if (!noble) { 230 | return; 231 | } 232 | if (state === 'poweredOn' && this.scanEnabled) { 233 | noble.startScanning([], true); 234 | } else { 235 | noble.stopScanning(); 236 | } 237 | } 238 | 239 | private _handleScanStart(): void { 240 | if (!noble) { 241 | return; 242 | } 243 | if (!this.scanEnabled) { 244 | noble.stopScanning(); 245 | } 246 | } 247 | 248 | private _handleScanStop(): void { 249 | if (!noble) { 250 | return; 251 | } 252 | if (this.scanEnabled && noble._state === 'poweredOn') { 253 | noble.startScanning([], true); 254 | } 255 | } 256 | 257 | private _handleDiscover(peripheral: Peripheral): void { 258 | const advertisement = peripheral.advertisement; 259 | const manufacturerData = advertisement.manufacturerData; 260 | 261 | if (!advertisement || !advertisement.localName || !manufacturerData || manufacturerData.length < 17) { 262 | return; 263 | } 264 | 265 | // See Chapter 6.4.2.2 266 | const localName = advertisement.localName; 267 | const CoID = manufacturerData.readUInt16LE(0); 268 | const TY = manufacturerData.readUInt8(2); 269 | const AIL = manufacturerData.readUInt8(3); 270 | const SF = manufacturerData.readUInt8(4); 271 | const deviceID = manufacturerData.slice(5, 11); 272 | const ACID = manufacturerData.readUInt16LE(11); 273 | const GSN = manufacturerData.readUInt16LE(13); 274 | const CN = manufacturerData.readUInt8(15); 275 | const CV = manufacturerData.readUInt8(16); 276 | // const SH = manufacturerData.length > 17 ? manufacturerData.slice(17, 21) : Buffer.alloc(0); 277 | 278 | if (TY === 0x11) { 279 | debug(`Encrypted Broadcast detected ... ignoring for now: ${manufacturerData}`); 280 | } 281 | if (CoID !== 0x4c || TY !== 0x06 || CV !== 0x02) { 282 | return; 283 | } 284 | 285 | let formattedId = ''; 286 | for (const b of deviceID) { 287 | formattedId += `${b.toString(16).padStart(2, '0')}:`; 288 | } 289 | formattedId = formattedId.substr(0, 17); 290 | 291 | const service = { 292 | name: localName, 293 | CoID, 294 | TY, 295 | AIL, 296 | SF, 297 | DeviceID: formattedId, 298 | ACID, 299 | GSN, 300 | CN, 301 | CV, 302 | peripheral, 303 | // SH, 304 | 'c#': CN, 305 | id: formattedId, 306 | ci: ACID, 307 | availableToPair: !!(SF & DiscoveryPairingStatusFlags.AccessoryNotPaired), 308 | }; 309 | 310 | const formerService = this.services.get(service.DeviceID); 311 | this.services.set(service.DeviceID, service); 312 | if (formerService && !this.allowDuplicates) { 313 | for (const el of Object.keys(service) as (keyof HapServiceBle)[]) { 314 | if (el !== 'peripheral' && el !== 'name' && formerService[el] !== service[el]) { 315 | /** 316 | * Device data changed event 317 | * 318 | * @event BLEDiscovery#serviceChanged 319 | * @type HapServiceBle 320 | */ 321 | this.emit('serviceChanged', service); 322 | break; 323 | } 324 | } 325 | } else { 326 | /** 327 | * New device discovered event 328 | * 329 | * @event BLEDiscovery#serviceUp 330 | * @type HapServiceBle 331 | */ 332 | this.emit('serviceUp', service); 333 | } 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/transport/ble/gatt-connection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class to represent a multi-request GATT connection. 3 | */ 4 | 5 | import { EventEmitter } from 'events'; 6 | import { Watcher } from './gatt-utils'; 7 | import sodium from 'libsodium-wrappers'; 8 | import { Characteristic, Peripheral } from '@stoprocent/noble'; 9 | import { SessionKeys } from '../../protocol/pairing-protocol'; 10 | import Debug from 'debug'; 11 | import { OpQueue } from '../../utils/queue'; 12 | 13 | const debug = Debug('hap-controller:gatt-connection'); 14 | 15 | export default class GattConnection extends EventEmitter { 16 | private peripheral: Peripheral; 17 | 18 | private sessionKeys: SessionKeys | null; 19 | 20 | private a2cCounter: number; 21 | 22 | private c2aCounter: number; 23 | 24 | private queue: OpQueue; 25 | 26 | /** 27 | * Initialize the GattConnection object. 28 | * 29 | * @param {Object} peripheral - Peripheral object from noble 30 | */ 31 | constructor(peripheral: Peripheral) { 32 | super(); 33 | this.peripheral = peripheral; 34 | this.sessionKeys = null; 35 | this.a2cCounter = 0; 36 | this.c2aCounter = 0; 37 | this.queue = new OpQueue(); 38 | } 39 | 40 | /** 41 | * Queue an operation for the connection. 42 | * 43 | * @param {function} op - Function to add to the queue 44 | * @returns {Promise} Promise which resolves when the function is called. 45 | */ 46 | private _queueOperation(op: () => Promise): Promise { 47 | return this.queue.queue(op); 48 | } 49 | 50 | /** 51 | * Set the session keys for the connection. 52 | * 53 | * @param {Object} keys - The session key object obtained from PairingProtocol 54 | */ 55 | setSessionKeys(keys: SessionKeys): void { 56 | this.sessionKeys = keys; 57 | } 58 | 59 | /** 60 | * Get the State of the peripheral connection 61 | * Deprecated, please change to "isConnected" 62 | * 63 | * @returns {Boolean} Connection State 64 | * @deprecated 65 | */ 66 | isPeripheralConnected(): boolean { 67 | return this.isConnected(); 68 | } 69 | 70 | /** 71 | * Get the State of the peripheral connection 72 | * 73 | * @returns {Boolean} Connection State 74 | */ 75 | isConnected(): boolean { 76 | return this.peripheral?.state === 'connected'; 77 | } 78 | 79 | /** 80 | * Connect to the peripheral if necessary. 81 | * 82 | * @returns {Promise} Promise which resolves when the connection is 83 | * established. 84 | */ 85 | async connect(): Promise { 86 | if (this.peripheral.state === 'connected') { 87 | return; 88 | } 89 | 90 | if (this.peripheral.state !== 'disconnected') { 91 | debug('disconnect peripheral to reconnect'); 92 | await new Watcher(this.peripheral, this.peripheral.disconnectAsync()).getPromise(); 93 | } 94 | 95 | debug('connect peripheral'); 96 | await new Watcher(this.peripheral, this.peripheral.connectAsync()).getPromise(); 97 | this.emit('connected'); 98 | this.peripheral.once('disconnect', () => { 99 | debug('Peripheral disconnected'); 100 | this.emit('disconnected'); 101 | }); 102 | } 103 | 104 | /** 105 | * Disconnect from the peripheral if necessary. 106 | * 107 | * @returns {Promise} Promise which resolves when the connection is destroyed. 108 | */ 109 | async disconnect(): Promise { 110 | if (this.peripheral.state !== 'disconnected') { 111 | debug('disconnect peripheral'); 112 | await new Watcher(this.peripheral, this.peripheral.disconnectAsync()).getPromise(); 113 | } 114 | } 115 | 116 | /** 117 | * Encrypt a series of PDUs. 118 | * 119 | * @param {Buffer[]} pdus - List of PDUs to encrypt 120 | * @returns {Buffer[]} List of encrypted PDUs. 121 | */ 122 | private _encryptPdus(pdus: Buffer[]): Buffer[] { 123 | const encryptedPdus = []; 124 | 125 | for (const pdu of pdus) { 126 | let position = 0; 127 | 128 | while (position < pdu.length) { 129 | const writeNonce = Buffer.alloc(12); 130 | writeNonce.writeUInt32LE(this.c2aCounter++, 4); 131 | 132 | const frameLength = Math.min(pdu.length - position, 496); 133 | 134 | const frame = Buffer.from( 135 | sodium.crypto_aead_chacha20poly1305_ietf_encrypt( 136 | pdu.slice(position, position + frameLength), 137 | null, 138 | null, 139 | writeNonce, 140 | this.sessionKeys!.ControllerToAccessoryKey, 141 | ), 142 | ); 143 | 144 | encryptedPdus.push(frame); 145 | position += frameLength; 146 | } 147 | } 148 | 149 | return encryptedPdus; 150 | } 151 | 152 | /** 153 | * Decrypt a series of PDUs. 154 | * 155 | * @param {Buffer} pdu - PDU to decrypt 156 | * @returns {Buffer} Decrypted PDU. 157 | */ 158 | private _decryptPdu(pdu: Buffer): Buffer { 159 | const readNonce = Buffer.alloc(12); 160 | readNonce.writeUInt32LE(this.a2cCounter++, 4); 161 | 162 | try { 163 | const decryptedData = Buffer.from( 164 | sodium.crypto_aead_chacha20poly1305_ietf_decrypt( 165 | null, 166 | pdu, 167 | null, 168 | readNonce, 169 | this.sessionKeys!.AccessoryToControllerKey, 170 | ), 171 | ); 172 | 173 | return decryptedData; 174 | } catch (e) { 175 | return pdu; 176 | } 177 | } 178 | 179 | /** 180 | * Write a series of PDUs to a characteristic. 181 | * 182 | * @param {Object} characteristic - Characteristic object to write to 183 | * @param {Buffer[]} pdus - List of PDUs to send 184 | * @returns {Promise} Promise which resolves to a list of responses when all 185 | * writes are sent. 186 | */ 187 | writeCharacteristic(characteristic: Characteristic, pdus: Buffer[]): Promise { 188 | return this._queueOperation(async () => { 189 | await sodium.ready; 190 | await this.connect(); 191 | 192 | const queue = new OpQueue(); 193 | let lastOp: Promise = Promise.resolve(); 194 | 195 | if (this.sessionKeys) { 196 | pdus = this._encryptPdus(pdus); 197 | } 198 | 199 | for (const pdu of pdus) { 200 | lastOp = queue.queue(async () => { 201 | debug( 202 | `${this.peripheral.id}/${this.peripheral.address} Write for characteristic ${ 203 | characteristic.uuid 204 | } ${pdu.toString('hex')}`, 205 | ); 206 | await new Watcher(this.peripheral, characteristic.writeAsync(pdu, false)).getPromise(); 207 | }); 208 | } 209 | 210 | await lastOp; 211 | return await this._readCharacteristicInner(characteristic, []); 212 | }); 213 | } 214 | 215 | /** 216 | * Read a series of PDUs from a characteristic. 217 | * 218 | * @param {Object} characteristic - Characteristic object to write to 219 | * @param {Buffer[]} pdus - List of PDUs already read 220 | * @returns {Promise} Promise which resolves to a list of PDUs. 221 | */ 222 | private async _readCharacteristicInner(characteristic: Characteristic, pdus: Buffer[] = []): Promise { 223 | let data = await new Watcher(this.peripheral, characteristic.readAsync()).getPromise(); 224 | 225 | if (this.sessionKeys) { 226 | data = this._decryptPdu(data); 227 | } 228 | 229 | debug( 230 | `${this.peripheral.id}/${this.peripheral.address} Received data for characteristic ${ 231 | characteristic.uuid 232 | } ${data.toString('hex')}`, 233 | ); 234 | 235 | pdus.push(data); 236 | 237 | let complete = false; 238 | if (!data || data.length === 0) { 239 | complete = true; 240 | } else { 241 | const controlField = data.readUInt8(0); 242 | if ((controlField & 0x80) === 0) { 243 | // not fragmented or first pdu 244 | if (data.length >= 5) { 245 | const length = data.readUInt16LE(3); 246 | if (length <= data.length - 5) { 247 | complete = true; 248 | } 249 | } else { 250 | complete = true; 251 | } 252 | } else if (pdus.length > 1) { 253 | const length = pdus[0].readUInt16LE(3); 254 | let totalRead = pdus[0].length - 5; 255 | if (pdus.length > 1) { 256 | pdus.slice(1).forEach((pdu) => { 257 | totalRead += pdu.length - 2; 258 | }); 259 | } 260 | 261 | if (totalRead >= length) { 262 | complete = true; 263 | } 264 | } 265 | } 266 | 267 | if (!complete) { 268 | return await this._readCharacteristicInner(characteristic, pdus); 269 | } 270 | 271 | return pdus; 272 | } 273 | 274 | /** 275 | * Read a series of PDUs from a characteristic. 276 | * 277 | * @param {Object} characteristic - Characteristic object to write to 278 | * @returns {Promise} Promise which resolves to a list of PDUs. 279 | */ 280 | readCharacteristic(characteristic: Characteristic): Promise { 281 | return this._queueOperation(async () => { 282 | await this.connect(); 283 | return this._readCharacteristicInner(characteristic, []); 284 | }); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/transport/ble/gatt-constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * See Table 6-7 3 | */ 4 | export const Opcodes = { 5 | 'HAP-Characteristic-Signature-Read': 1, 6 | 'HAP-Characteristic-Write': 2, 7 | 'HAP-Characteristic-Read': 3, 8 | 'HAP-Characteristic-Timed-Write': 4, 9 | 'HAP-Characteristic-Execute-Write': 5, 10 | 'HAP-Service-Signature-Read': 6, 11 | 'HAP-Characteristic-Configuration': 7, 12 | 'HAP-Protocol-Configuration': 8, 13 | }; 14 | 15 | /** 16 | * See Table 6-9 17 | */ 18 | export const Types = { 19 | 'HAP-Param-Value': 1, 20 | 'HAP-Param-Additional-Authorization-Data': 2, 21 | 'HAP-Param-Origin (local vs remote)': 3, 22 | 'HAP-Param-Characteristic-Type': 4, 23 | 'HAP-Param-Characteristic-Instance-ID': 5, 24 | 'HAP-Param-Service-Type': 6, 25 | 'HAP-Param-Service-Instance-ID': 7, 26 | 'HAP-Param-TTL': 8, 27 | 'HAP-Param-Return-Response': 9, 28 | 'HAP-Param-HAP-Characteristic-Properties-Descriptor': 10, 29 | 'HAP-Param-GATT-User-Description-Descriptor': 11, 30 | 'HAP-Param-GATT-Presentation-Format-Descriptor': 12, 31 | 'HAP-Param-GATT-Valid-Range': 13, 32 | 'HAP-Param-HAP-Step-Value-Descriptor': 14, 33 | 'HAP-Param-HAP-Service-Properties': 15, 34 | 'HAP-Param-HAP-Linked-Services': 16, 35 | 'HAP-Param-HAP-Valid-Values-Descriptor': 17, 36 | 'HAP-Param-HAP-Valid-Values-Range-Descriptor': 18, 37 | 38 | 'HAP-Characteristic-Configuration-Param-Properties': 1, 39 | 'HAP-Characteristic-Configuration-Param-Broadcast-Interval': 2, 40 | 41 | 'HAP-Param-Current-State-Number': 1, 42 | 'HAP-Param-Current-Config-Number': 2, 43 | 'HAP-Param-Accessory-Advertising-Identifier': 3, 44 | 'HAP-Param-Broadcast-Encryption-Key': 4, 45 | }; 46 | 47 | /** 48 | * See Table 6-26 49 | */ 50 | /* eslint-disable max-len */ 51 | export const HapStatusCodes = { 52 | 0: { 53 | definition: 'Success', 54 | description: 'The request was successful.', 55 | }, 56 | 1: { 57 | definition: 'Unsupported-PDU', 58 | description: 'The request failed as the HAP PDU was not recognized or supported.', 59 | }, 60 | 2: { 61 | definition: 'Max-Procedures', 62 | description: 63 | 'The request failed as the accessory has reached the the limit on the simultaneous procedures it can handle.', 64 | }, 65 | 3: { 66 | definition: 'Insufficient Authorization', 67 | description: 'Characteristic requires additional authorization data.', 68 | }, 69 | 4: { 70 | definition: 'Invalid Instance ID', 71 | description: 72 | // eslint-disable-next-line @typescript-eslint/quotes 73 | "The HAP Request's characteristic Instance id did not match the addressed characteristic's instance id.", 74 | }, 75 | 5: { 76 | definition: 'Insufficient Authentication', 77 | description: 'Characteristic access required a secure session to be established.', 78 | }, 79 | 6: { 80 | definition: 'Invalid Request', 81 | description: 'Accessory was not able to perform the requested operation.', 82 | }, 83 | }; 84 | /* eslint-enable max-len */ 85 | 86 | /** 87 | * See Table 6-34 88 | */ 89 | export const ServiceProperties = { 90 | 1: 'Primary Service', 91 | 2: 'Hidden Service', 92 | }; 93 | 94 | /** 95 | * See Table 6-35 96 | */ 97 | export const CharacteristicDescriptions = { 98 | 1: 'Characteristic Supports Read', 99 | 2: 'Characteristic Supports Write', 100 | 4: 'Characteristic Supports Additional Authorization Data', 101 | 8: 'Characteristic Requires HAP Characteristic Timed Write Procedure', 102 | 16: 'Characteristics Supports Secure Reads', 103 | 32: 'Characteristics Supports Secure Writes', 104 | 64: 'Characteristic Hidden from User', 105 | 128: 'Characteristic Notifies Events in Connected State', 106 | 256: 'Characteristic Notifies Events in Disconnected State', 107 | }; 108 | 109 | /** 110 | * See Table 6-36 111 | */ 112 | export const BTSigToHapFormat = new Map([ 113 | [0x01, 'bool'], 114 | [0x04, 'uint8'], 115 | [0x06, 'uint16'], 116 | [0x08, 'uint32'], 117 | [0x0a, 'uint64'], 118 | [0x10, 'int'], 119 | [0x14, 'float'], 120 | [0x19, 'string'], 121 | [0x1b, 'data'], 122 | ]); 123 | 124 | /** 125 | * See Table 6-37 126 | */ 127 | export const BTSigToHapUnit = new Map([ 128 | [0x2700, 'unitless'], 129 | [0x2703, 'seconds'], 130 | [0x272f, 'celsius'], 131 | [0x2731, 'lux'], 132 | [0x2763, 'arcdegrees'], 133 | [0x27ad, 'percentage'], 134 | ]); 135 | 136 | /** 137 | * See Chapter 6.4.4.3 138 | */ 139 | export const ServiceInstanceIdUuid = 'E604E95D-A759-4817-87D3-AA005083A0D1'; 140 | 141 | /** 142 | * See Chapter 6.4.4.5.2 143 | */ 144 | export const CharacteristicInstanceIdUuid = 'DC46F0FE-81D2-4616-B5D9-6ABDD796939A'; 145 | export const CharacteristicInstanceIdShortUuid = '939A'; 146 | 147 | /** 148 | * See Chapter 6.4.4.5.4 149 | */ 150 | export const ServiceSignatureUuid = '000000A5-0000-1000-8000-0026BB765291'; 151 | -------------------------------------------------------------------------------- /src/transport/ble/gatt-protocol.ts: -------------------------------------------------------------------------------- 1 | import * as GattConstants from './gatt-constants'; 2 | import { decodeBuffer, encodeObject, TLV } from '../../model/tlv'; 3 | 4 | export interface GattResponse { 5 | controlField: number; 6 | tid: number; 7 | status: number; 8 | length?: number; 9 | tlv?: TLV; 10 | } 11 | 12 | export default class BLEProtocol { 13 | buildCharacteristicSignatureReadRequest(tid: number, iid: number): Buffer { 14 | const buf = Buffer.alloc(5); 15 | buf.writeUInt8(GattConstants.Opcodes['HAP-Characteristic-Signature-Read'], 1); 16 | buf.writeUInt8(tid, 2); 17 | buf.writeUInt16LE(iid, 3); 18 | return buf; 19 | } 20 | 21 | parseCharacteristicSignatureReadResponse(buf: Buffer): GattResponse { 22 | return { 23 | controlField: buf.readUInt8(0), 24 | tid: buf.readUInt8(1), 25 | status: buf.readUInt8(2), 26 | length: buf.readUInt16LE(3), 27 | tlv: decodeBuffer(buf.slice(5, buf.length)), 28 | }; 29 | } 30 | 31 | buildCharacteristicWriteRequest(tid: number, iid: number, tlv: TLV): Buffer { 32 | let body; 33 | if (Buffer.isBuffer(tlv)) { 34 | body = tlv; 35 | } else { 36 | body = encodeObject(tlv); 37 | } 38 | 39 | const buf = Buffer.alloc(7 + body.length); 40 | buf.writeUInt8(GattConstants.Opcodes['HAP-Characteristic-Write'], 1); 41 | buf.writeUInt8(tid, 2); 42 | buf.writeUInt16LE(iid, 3); 43 | buf.writeUInt16LE(body.length, 5); 44 | body.copy(buf, 7); 45 | return buf; 46 | } 47 | 48 | parseCharacteristicWriteResponse(buf: Buffer): GattResponse { 49 | return { 50 | controlField: buf.readUInt8(0), 51 | tid: buf.readUInt8(1), 52 | status: buf.readUInt8(2), 53 | }; 54 | } 55 | 56 | buildCharacteristicReadRequest(tid: number, iid: number): Buffer { 57 | const buf = Buffer.alloc(5); 58 | buf.writeUInt8(GattConstants.Opcodes['HAP-Characteristic-Read'], 1); 59 | buf.writeUInt8(tid, 2); 60 | buf.writeUInt16LE(iid, 3); 61 | return buf; 62 | } 63 | 64 | parseCharacteristicReadResponse(buf: Buffer): GattResponse { 65 | return { 66 | controlField: buf.readUInt8(0), 67 | tid: buf.readUInt8(1), 68 | status: buf.readUInt8(2), 69 | length: buf.readUInt16LE(3), 70 | tlv: decodeBuffer(buf.slice(5, buf.length)), 71 | }; 72 | } 73 | 74 | buildCharacteristicTimedWriteRequest(tid: number, iid: number, tlv: TLV): Buffer { 75 | const body = encodeObject(tlv); 76 | 77 | const buf = Buffer.alloc(7 + body.length); 78 | buf.writeUInt8(GattConstants.Opcodes['HAP-Characteristic-Timed-Write'], 1); 79 | buf.writeUInt8(tid, 2); 80 | buf.writeUInt16LE(iid, 3); 81 | buf.writeUInt16LE(body.length, 5); 82 | body.copy(buf, 7); 83 | return buf; 84 | } 85 | 86 | parseCharacteristicTimedWriteResponse(buf: Buffer): GattResponse { 87 | return { 88 | controlField: buf.readUInt8(0), 89 | tid: buf.readUInt8(1), 90 | status: buf.readUInt8(2), 91 | }; 92 | } 93 | 94 | buildCharacteristicExecuteWriteRequest(tid: number, iid: number): Buffer { 95 | const buf = Buffer.alloc(5); 96 | buf.writeUInt8(GattConstants.Opcodes['HAP-Characteristic-Execute-Write'], 1); 97 | buf.writeUInt8(tid, 2); 98 | buf.writeUInt16LE(iid, 3); 99 | return buf; 100 | } 101 | 102 | parseCharacteristicExecuteWriteResponse(buf: Buffer): GattResponse { 103 | return { 104 | controlField: buf.readUInt8(0), 105 | tid: buf.readUInt8(1), 106 | status: buf.readUInt8(2), 107 | }; 108 | } 109 | 110 | buildServiceSignatureReadRequest(tid: number, sid: number): Buffer { 111 | const buf = Buffer.alloc(5); 112 | buf.writeUInt8(GattConstants.Opcodes['HAP-Service-Signature-Read'], 1); 113 | buf.writeUInt8(tid, 2); 114 | buf.writeUInt16LE(sid, 3); 115 | return buf; 116 | } 117 | 118 | parseServiceSignatureReadResponse(buf: Buffer): GattResponse { 119 | return { 120 | controlField: buf.readUInt8(0), 121 | tid: buf.readUInt8(1), 122 | status: buf.readUInt8(2), 123 | length: buf.readUInt16LE(3), 124 | tlv: decodeBuffer(buf.slice(5, buf.length)), 125 | }; 126 | } 127 | 128 | buildCharacteristicConfigurationRequest(tid: number, iid: number, tlv: TLV): Buffer { 129 | const body = encodeObject(tlv); 130 | 131 | const buf = Buffer.alloc(7 + body.length); 132 | buf.writeUInt8(GattConstants.Opcodes['HAP-Characteristic-Configuration'], 1); 133 | buf.writeUInt8(tid, 2); 134 | buf.writeUInt16LE(iid, 3); 135 | buf.writeUInt16LE(body.length, 5); 136 | body.copy(buf, 7); 137 | return buf; 138 | } 139 | 140 | parseCharacteristicConfigurationResponse(buf: Buffer): GattResponse { 141 | return { 142 | controlField: buf.readUInt8(0), 143 | tid: buf.readUInt8(1), 144 | status: buf.readUInt8(2), 145 | length: buf.readUInt16LE(3), 146 | tlv: decodeBuffer(buf.slice(5, buf.length)), 147 | }; 148 | } 149 | 150 | buildProtocolConfigurationRequest(tid: number, svcID: number, tlv: TLV): Buffer { 151 | const body = encodeObject(tlv); 152 | 153 | const buf = Buffer.alloc(7 + body.length); 154 | buf.writeUInt8(GattConstants.Opcodes['HAP-Protocol-Configuration'], 1); 155 | buf.writeUInt8(tid, 2); 156 | buf.writeUInt16LE(svcID, 3); 157 | buf.writeUInt16LE(body.length, 5); 158 | body.copy(buf, 7); 159 | return buf; 160 | } 161 | 162 | parseProtocolConfigurationResponse(buf: Buffer): GattResponse { 163 | return { 164 | controlField: buf.readUInt8(0), 165 | tid: buf.readUInt8(1), 166 | status: buf.readUInt8(2), 167 | length: buf.readUInt16LE(3), 168 | tlv: decodeBuffer(buf.slice(5, buf.length)), 169 | }; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/transport/ble/gatt-utils.ts: -------------------------------------------------------------------------------- 1 | import type { Peripheral } from '@stoprocent/noble'; 2 | 3 | /** 4 | * Convert a proper UUID to noble's format. 5 | * 6 | * @param {string} uuid - UUID to convert 7 | * @returns {string} UUID 8 | */ 9 | export function uuidToNobleUuid(uuid: string): string { 10 | return uuid.toLowerCase().replace(/-/g, ''); 11 | } 12 | 13 | /** 14 | * Convert a UUID in noble's format to a proper UUID. 15 | * 16 | * @param {string} uuid - UUID to convert 17 | * @returns {string} UUID 18 | */ 19 | export function nobleUuidToUuid(uuid: string): string { 20 | uuid = uuid.toUpperCase(); 21 | 22 | if (uuid.length !== 32) { 23 | return uuid; 24 | } 25 | 26 | const parts = [ 27 | uuid.substring(0, 8), 28 | uuid.substring(8, 12), 29 | uuid.substring(12, 16), 30 | uuid.substring(16, 20), 31 | uuid.substring(20, 32), 32 | ]; 33 | 34 | return parts.join('-'); 35 | } 36 | 37 | /** 38 | * Unpack a HAP value from a buffer. 39 | * 40 | * @param {Buffer} buffer - Buffer to unpack 41 | * @param {string} format - HAP data format 42 | * @returns {*} Unpacked value. 43 | */ 44 | export function bufferToValue(buffer: Buffer, format: string): unknown { 45 | switch (format) { 46 | case 'bool': 47 | return buffer.readUInt8(0) !== 0; 48 | case 'uint8': 49 | return buffer.readUInt8(0); 50 | case 'uint16': 51 | return buffer.readUInt16LE(0); 52 | case 'uint32': 53 | return buffer.readUInt32LE(0); 54 | case 'uint64': 55 | return buffer.readUInt32LE(0) || buffer.readUInt32LE(4) << 32; 56 | case 'int': 57 | return buffer.readInt32LE(0); 58 | case 'float': 59 | return buffer.readFloatLE(0); 60 | case 'string': 61 | return buffer.toString(); 62 | case 'data': 63 | return buffer.toString('base64'); 64 | default: 65 | throw new Error(`Unknown format type: ${format}`); 66 | } 67 | } 68 | 69 | /** 70 | * Pack a HAP value into a buffer. 71 | * 72 | * @param {*} value - Value to pack 73 | * @param {string} format - HAP data format 74 | * @returns {Buffer} Packed buffer 75 | */ 76 | export function valueToBuffer(value: unknown, format: string): Buffer { 77 | switch (format) { 78 | case 'bool': 79 | return Buffer.from([value ? 1 : 0]); 80 | case 'uint8': 81 | return Buffer.from([(value) & 0xff]); 82 | case 'uint16': { 83 | const b = Buffer.alloc(2); 84 | b.writeUInt16LE(value, 0); 85 | return b; 86 | } 87 | case 'uint32': { 88 | const b = Buffer.alloc(4); 89 | b.writeUInt32LE(value, 0); 90 | return b; 91 | } 92 | case 'uint64': { 93 | const b = Buffer.alloc(8); 94 | b.writeUInt32LE((value) & 0xffffffff, 0); 95 | b.writeUInt32LE((value) >> 32, 4); 96 | return b; 97 | } 98 | case 'int': { 99 | const b = Buffer.alloc(4); 100 | b.writeInt32LE(value); 101 | return b; 102 | } 103 | case 'float': { 104 | const b = Buffer.alloc(4); 105 | b.writeFloatLE(value); 106 | return b; 107 | } 108 | case 'string': 109 | return Buffer.from(value); 110 | case 'data': 111 | if (typeof value === 'string') { 112 | return Buffer.from(value, 'base64'); 113 | } 114 | 115 | return value; 116 | default: 117 | throw new Error(`Unknown format type: ${format}`); 118 | } 119 | } 120 | 121 | /** 122 | * This should be used when doing any communication with a BLE device, since 123 | * noble doesn't provide any timeout functionality. 124 | */ 125 | export class Watcher { 126 | rejected: boolean; 127 | 128 | stopped: boolean; 129 | 130 | private peripheral: Peripheral; 131 | 132 | private rejectFn?: (reason: string) => void; 133 | 134 | private reject: (reason?: string) => void; 135 | 136 | private timer?: NodeJS.Timeout; 137 | 138 | private promise: Promise; 139 | 140 | /** 141 | * Initialize the Watcher object. 142 | * 143 | * @param {Object} peripheral - The noble peripheral object 144 | * @param {Promise} watch - The Promise to set a timeout on 145 | * @param {number?} timeout - Timeout 146 | */ 147 | constructor(peripheral: Peripheral, watch: Promise, timeout = 30000) { 148 | this.rejected = false; 149 | this.stopped = false; 150 | this.peripheral = peripheral; 151 | this.reject = this._reject.bind(this); 152 | 153 | this.peripheral.once('disconnect', this.reject); 154 | 155 | const watchPromise = watch.finally(() => this.stop()); 156 | 157 | const timeoutPromise = new Promise((_resolve, reject) => { 158 | this.rejectFn = reject; 159 | this.timer = setTimeout(() => { 160 | this._reject('Timeout'); 161 | }, timeout); 162 | }); 163 | 164 | this.promise = >Promise.race([watchPromise, timeoutPromise]); 165 | } 166 | 167 | /** 168 | * Get the promise associated with this watcher. 169 | * 170 | * @returns {Promise} The promise. 171 | */ 172 | getPromise(): Promise { 173 | return this.promise; 174 | } 175 | 176 | /** 177 | * Call the reject function with the provided reason. 178 | * 179 | * @param {string?} reason - Reject reason 180 | */ 181 | private _reject(reason = 'Disconnected'): void { 182 | if (this.rejected || this.stopped) { 183 | return; 184 | } 185 | 186 | this.rejected = true; 187 | this.rejectFn!(reason); 188 | } 189 | 190 | /** 191 | * Stop the watcher. 192 | */ 193 | stop(): void { 194 | this.stopped = true; 195 | 196 | if (this.timer) { 197 | clearTimeout(this.timer); 198 | } 199 | 200 | this.peripheral.removeListener('disconnect', this.reject); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/transport/ip/http-connection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class to represent a multi-request HTTP connection. 3 | */ 4 | 5 | import { EventEmitter } from 'events'; 6 | import HttpEventParser from './http-event-parser'; 7 | import net from 'net'; 8 | import sodium from 'libsodium-wrappers'; 9 | import { HTTPParser } from 'http-parser-js'; 10 | import { SessionKeys } from '../../protocol/pairing-protocol'; 11 | import Debug from 'debug'; 12 | import { OpQueue } from '../../utils/queue'; 13 | 14 | const debug = Debug('hap-controller:http-connection'); 15 | 16 | /** 17 | * Internal socket state. 18 | */ 19 | enum State { 20 | CLOSED, 21 | OPENING, 22 | READY, 23 | CLOSING, 24 | } 25 | 26 | export interface HttpResponse { 27 | statusCode: number; 28 | headers: Record; 29 | body: Buffer; 30 | } 31 | 32 | export default class HttpConnection extends EventEmitter { 33 | private address: string; 34 | 35 | private port: number; 36 | 37 | private state: State; 38 | 39 | private socket: net.Socket | null; 40 | 41 | private sessionKeys: SessionKeys | null; 42 | 43 | private a2cCounter: number; 44 | 45 | private c2aCounter: number; 46 | 47 | private queue: OpQueue; 48 | 49 | /** 50 | * Initialize the HttpConnection object. 51 | * 52 | * @param {string} address - IP address of the device 53 | * @param {number} port - HTTP port 54 | */ 55 | constructor(address: string, port: number) { 56 | super(); 57 | this.address = address; 58 | this.port = port; 59 | this.state = State.CLOSED; 60 | this.socket = null; 61 | this.sessionKeys = null; 62 | this.a2cCounter = 0; 63 | this.c2aCounter = 0; 64 | this.queue = new OpQueue(); 65 | } 66 | 67 | /** 68 | * Set the session keys for the connection. 69 | * 70 | * @param {Object} keys - The session key object obtained from PairingProtocol 71 | */ 72 | setSessionKeys(keys: SessionKeys): void { 73 | this.sessionKeys = keys; 74 | } 75 | 76 | /** 77 | * Get the State of the connection 78 | * 79 | * @returns {Boolean} Connection State 80 | */ 81 | isConnected(): boolean { 82 | return this.state === State.READY; 83 | } 84 | 85 | /** 86 | * Queue an operation for the connection. 87 | * 88 | * @param {function} op - Function to add to the queue 89 | * @returns {Promise} Promise which resolves when the function is called. 90 | */ 91 | private _queueOperation(op: () => Promise): Promise { 92 | return this.queue.queue(op); 93 | } 94 | 95 | /** 96 | * Open a socket if necessary. 97 | * 98 | * @returns {Promise} Promise which resolves when the socket is open and 99 | * ready. 100 | */ 101 | private async _open(): Promise { 102 | if (this.state === State.READY) { 103 | return; 104 | } else if (this.state !== State.CLOSED && this.socket) { 105 | this.socket!.end(); 106 | } 107 | 108 | return new Promise((resolve, reject) => { 109 | this.state = State.CLOSED; 110 | try { 111 | this.socket = net.createConnection(this.port, this.address); 112 | this.socket!.setKeepAlive(true); 113 | 114 | this.socket!.on('close', () => { 115 | this.socket = null; 116 | this.state = State.CLOSED; 117 | this.emit('disconnect', {}); 118 | }); 119 | this.socket!.on('end', () => { 120 | this.state = State.CLOSING; 121 | this.socket?.end(); 122 | }); 123 | this.socket!.on('timeout', () => { 124 | this.state = State.CLOSING; 125 | this.socket?.end(); 126 | }); 127 | this.socket!.on('error', (err) => { 128 | reject(err); 129 | }); 130 | this.socket!.on('connect', () => { 131 | this.state = State.READY; 132 | resolve(); 133 | }); 134 | } catch (err) { 135 | reject(err); 136 | } 137 | }); 138 | } 139 | 140 | /** 141 | * Send a GET request. 142 | * 143 | * @param {string} path - Path to request 144 | * @returns {Promise} Promise which resolves to a buffer containing the 145 | * response body. 146 | */ 147 | get(path: string): Promise { 148 | debug(`${this.address}:${this.port} GET ${path}`); 149 | 150 | const data = Buffer.concat([ 151 | Buffer.from(`GET ${path} HTTP/1.1\r\n`), 152 | Buffer.from(`Host: ${this.address}:${this.port}\r\n`), 153 | Buffer.from(`\r\n`), 154 | ]); 155 | return this.request(data); 156 | } 157 | 158 | /** 159 | * Send a POST request. 160 | * 161 | * @param {string} path - Path to request 162 | * @param {Buffer|string} body - Request body 163 | * @param {string?} contentType - Request content type 164 | * @returns {Promise} Promise which resolves to a buffer containing the 165 | * response body. 166 | */ 167 | post(path: string, body: Buffer | string, contentType = 'application/hap+json'): Promise { 168 | if (typeof body === 'string') { 169 | body = Buffer.from(body); 170 | } 171 | debug( 172 | `${this.address}:${this.port} POST ${path} ${body.toString('hex')} (${ 173 | body.length ? contentType : 'no content' 174 | })`, 175 | ); 176 | 177 | const data = Buffer.concat([ 178 | Buffer.from(`POST ${path} HTTP/1.1\r\n`), 179 | Buffer.from(`Host: ${this.address}:${this.port}\r\n`), 180 | body.length ? Buffer.from(`Content-Type: ${contentType}\r\n`) : Buffer.from(''), 181 | Buffer.from(`Content-Length: ${body.length}\r\n`), 182 | Buffer.from(`\r\n`), 183 | body, 184 | ]); 185 | return this.request(data); 186 | } 187 | 188 | /** 189 | * Send a PUT request. 190 | * 191 | * @param {string} path - Path to request 192 | * @param {Buffer|string} body - Request body 193 | * @param {string?} contentType - Request content type 194 | * @param {boolean?} readEvents - Whether or not to read EVENT messages after 195 | * initial request 196 | * @returns {Promise} Promise which resolves to a buffer containing the 197 | * response body. 198 | */ 199 | put( 200 | path: string, 201 | body: Buffer | string, 202 | contentType = 'application/hap+json', 203 | readEvents = false, 204 | ): Promise { 205 | if (typeof body === 'string') { 206 | body = Buffer.from(body); 207 | } 208 | debug(`${this.address}:${this.port} PUT ${path} ${body.toString('hex')}`); 209 | 210 | const data = Buffer.concat([ 211 | Buffer.from(`PUT ${path} HTTP/1.1\r\n`), 212 | Buffer.from(`Host: ${this.address}:${this.port}\r\n`), 213 | Buffer.from(`Content-Type: ${contentType}\r\n`), 214 | Buffer.from(`Content-Length: ${body.length}\r\n`), 215 | Buffer.from(`\r\n`), 216 | body, 217 | ]); 218 | return this.request(data, readEvents); 219 | } 220 | 221 | /** 222 | * Send a request. 223 | * 224 | * @param {Buffer} body - Request body 225 | * @param {boolean?} readEvents - Whether or not to read EVENT messages after 226 | * initial request 227 | * @returns {Promise} Promise which resolves to a buffer containing the 228 | * response body. 229 | */ 230 | request(body: Buffer, readEvents = false): Promise { 231 | return this._queueOperation(async () => { 232 | if (this.sessionKeys) { 233 | return this._requestEncrypted(body, readEvents); 234 | } 235 | 236 | return this._requestClear(body, readEvents); 237 | }); 238 | } 239 | 240 | /** 241 | * Encrypt request data. 242 | * 243 | * @param {Buffer} data - Data to encrypt 244 | * @returns {Buffer} Encrypted data. 245 | */ 246 | private _encryptData(data: Buffer): Buffer { 247 | const encryptedData = []; 248 | let position = 0; 249 | 250 | while (position < data.length) { 251 | const writeNonce = Buffer.alloc(12); 252 | writeNonce.writeUInt32LE(this.c2aCounter++, 4); 253 | 254 | const frameLength = Math.min(data.length - position, 1024); 255 | const aad = Buffer.alloc(2); 256 | aad.writeUInt16LE(frameLength, 0); 257 | 258 | const frame = Buffer.from( 259 | sodium.crypto_aead_chacha20poly1305_ietf_encrypt( 260 | data.slice(position, position + frameLength), 261 | aad, 262 | null, 263 | writeNonce, 264 | this.sessionKeys!.ControllerToAccessoryKey, 265 | ), 266 | ); 267 | 268 | encryptedData.push(aad); 269 | encryptedData.push(frame); 270 | position += frameLength; 271 | } 272 | 273 | return Buffer.concat(encryptedData); 274 | } 275 | 276 | /** 277 | * Create an HTTP response parser. 278 | * 279 | * @param {(response: HttpResponse) => void} resolve - Function to call with response 280 | * @returns {Object} HTTPParser object. 281 | */ 282 | private _buildHttpResponseParser(resolve: (response: HttpResponse) => void): HTTPParser { 283 | const parser = new HTTPParser(HTTPParser.RESPONSE); 284 | 285 | const headers: Record = {}; 286 | parser.onHeadersComplete = (res) => { 287 | for (let i = 0; i < res.headers.length; i += 2) { 288 | headers[res.headers[i]] = res.headers[i + 1]; 289 | } 290 | }; 291 | 292 | let body = Buffer.alloc(0); 293 | parser.onBody = (chunk, start, len) => { 294 | body = Buffer.concat([body, chunk.slice(start, start + len)]); 295 | }; 296 | 297 | parser.onMessageComplete = () => { 298 | resolve({ 299 | statusCode: parser.info.statusCode!, 300 | headers, 301 | body, 302 | }); 303 | }; 304 | 305 | return parser; 306 | } 307 | 308 | /** 309 | * Send an encrypted request. 310 | * 311 | * @param {Buffer} data - Request body 312 | * @param {boolean?} readEvents - Whether or not to read EVENT messages after 313 | * initial request 314 | * @returns {Promise} Promise which resolves to a buffer containing the 315 | * response body. 316 | */ 317 | private async _requestEncrypted(data: Buffer, readEvents = false): Promise { 318 | await sodium.ready; 319 | await this._open(); 320 | 321 | return new Promise((resolve, reject) => { 322 | const oldListeners = <((...args: any[]) => void)[]>this.socket!.listeners('data'); 323 | this.socket!.removeAllListeners('data'); 324 | 325 | try { 326 | this.socket!.write(this._encryptData(data)); 327 | } catch (err) { 328 | return reject(err); 329 | } 330 | let message = Buffer.alloc(0); 331 | 332 | // eslint-disable-next-line prefer-const 333 | let parser: HTTPParser | HttpEventParser; 334 | 335 | const bodyParser = (chunk: Buffer): void => { 336 | message = Buffer.concat([message, chunk]); 337 | while (message.length >= 18) { 338 | const frameLength = message.readUInt16LE(0); 339 | if (message.length < frameLength + 18) { 340 | return; 341 | } 342 | 343 | const aad = message.slice(0, 2); 344 | const data = message.slice(2, 18 + frameLength); 345 | const readNonce = Buffer.alloc(12); 346 | readNonce.writeUInt32LE(this.a2cCounter, 4); 347 | 348 | try { 349 | const decryptedData = Buffer.from( 350 | sodium.crypto_aead_chacha20poly1305_ietf_decrypt( 351 | null, 352 | data, 353 | aad, 354 | readNonce, 355 | this.sessionKeys!.AccessoryToControllerKey, 356 | ), 357 | ); 358 | 359 | message = message.slice(18 + frameLength, message.length); 360 | ++this.a2cCounter; 361 | parser.execute(decryptedData); 362 | } catch (e) { 363 | // pass 364 | } 365 | } 366 | }; 367 | 368 | parser = this._buildHttpResponseParser((response) => { 369 | this.socket!.removeListener('data', bodyParser); 370 | 371 | for (const l of oldListeners) { 372 | this.socket!.on('data', l); 373 | } 374 | 375 | if (readEvents) { 376 | parser = new HttpEventParser(); 377 | parser.on('event', (ev) => this.emit('event', ev)); 378 | this.socket!.on('data', bodyParser); 379 | } 380 | 381 | debug( 382 | `${this.address}:${this.port} ` + 383 | `Response ${response.statusCode} with ${response.body.length} byte data`, 384 | ); 385 | resolve(response); 386 | }); 387 | 388 | this.socket!.on('data', bodyParser); 389 | }); 390 | } 391 | 392 | /** 393 | * Send a clear-text request. 394 | * 395 | * @param {Buffer} data - Request body 396 | * @param {boolean?} readEvents - Whether or not to read EVENT messages after 397 | * initial request 398 | * @returns {Promise} Promise which resolves to a buffer containing the 399 | * response body. 400 | */ 401 | private async _requestClear(data: Buffer, readEvents = false): Promise { 402 | await this._open(); 403 | 404 | return new Promise((resolve, reject) => { 405 | const oldListeners = <((...args: any[]) => void)[]>this.socket!.listeners('data'); 406 | this.socket!.removeAllListeners('data'); 407 | 408 | try { 409 | this.socket!.write(data); 410 | } catch (err) { 411 | return reject(err); 412 | } 413 | 414 | // eslint-disable-next-line prefer-const 415 | let parser: HTTPParser | HttpEventParser; 416 | 417 | const bodyParser = (chunk: Buffer): void => { 418 | parser.execute(chunk); 419 | }; 420 | 421 | parser = this._buildHttpResponseParser((response) => { 422 | this.socket!.removeListener('data', bodyParser); 423 | 424 | for (const l of oldListeners) { 425 | this.socket!.on('data', l); 426 | } 427 | 428 | if (readEvents) { 429 | parser = new HttpEventParser(); 430 | parser.on('event', (ev) => this.emit('event', ev)); 431 | this.socket!.on('data', bodyParser); 432 | } 433 | 434 | debug( 435 | `${this.address}:${this.port} ` + 436 | `Response ${response.statusCode} with ${response.body.length} byte data`, 437 | ); 438 | resolve(response); 439 | }); 440 | 441 | this.socket!.on('data', bodyParser); 442 | }); 443 | } 444 | 445 | /** 446 | * Close the socket. 447 | */ 448 | close(): void { 449 | this.socket?.end(); 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /src/transport/ip/http-constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants used for HTTP procedures. 3 | */ 4 | 5 | /** 6 | * See Table 5-12 7 | */ 8 | /* eslint-disable max-len */ 9 | export const HapStatusCodes = { 10 | 0: 'This specifies a success for the request.', 11 | '-70401': 'Request denied due to insufficient privileges.', 12 | '-70402': 'Unable to communicate with requested service, e.g. the power to the accessory was turned off.', 13 | '-70403': 'Resource is busy, try again.', 14 | '-70404': 'Cannot write to read only characteristic.', 15 | '-70405': 'Cannot read from a write only characteristic.', 16 | '-70406': 'Notification is not supported for characteristic.', 17 | '-70407': 'Out of resources to process request.', 18 | '-70408': 'Operation timed out.', 19 | '-70409': 'Resource does not exist.', 20 | '-70410': 'Accessory received an invalid value in a write request.', 21 | '-70411': 'Insufficient Authorization.', 22 | }; 23 | 24 | export const HttpStatusCodes = { 25 | 200: 'OK. This specifies a success for the request.', 26 | 207: 'Multi-Status. Request was not processed completely, e.g. only some of the provided characteristics could be written.', 27 | 400: 'Bad Request. Generic error for a problem with the request, e.g. bad TLV, state error, etc.', 28 | 404: 'Not Found. The requested URL was not found', 29 | 405: 'Method Not Allowed. Wrong HTTP request method, e.g. GET when expecting POST.', 30 | 422: 'Unprocessable Entity. for a well-formed request that contains invalid HTTP parameters.', 31 | 429: 'Too Many Requests. Server cannot handle any more requests of this type, e.g. attempt to pair while already pairing.', 32 | 470: 'Connection Authorization Required. Request to secure resource made without establishing security, e.g. didnʼt perform the Pair Verify procedure.', 33 | 500: 'Internal Server Error. Server had a problem, e.g. ran out of memory.', 34 | 503: 'Service Unavailable. If the accessory server is too busy to service the request, e.g. reached its maximum number of connections.', 35 | }; 36 | /* eslint-enable max-len */ 37 | -------------------------------------------------------------------------------- /src/transport/ip/http-event-parser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Basic parser for HTTP-based HAP event messages. 3 | */ 4 | import { EventEmitter } from 'events'; 5 | 6 | enum State { 7 | EMPTY, 8 | REQUEST_LINE_COMPLETE, 9 | HEADERS_COMPLETE, 10 | } 11 | 12 | const HEADER_SUFFIX = '\r\n\r\n'; 13 | 14 | export default class HttpEventParser extends EventEmitter { 15 | private _pending = Buffer.alloc(0); 16 | 17 | private _state = State.EMPTY; 18 | 19 | private headers: Record = {}; 20 | 21 | private protocol: string | null = null; 22 | 23 | version: string | null = null; 24 | 25 | statusCode: number | null = null; 26 | 27 | statusMessage: string | null = null; 28 | 29 | body = Buffer.alloc(0); 30 | 31 | /** 32 | * Initialize the HttpEventParser object. 33 | */ 34 | constructor() { 35 | super(); 36 | this._reset(); 37 | } 38 | 39 | /** 40 | * Execute parser on a chunk of data. 41 | * 42 | * @param {Buffer} data - Chunk of data 43 | */ 44 | execute(data: Buffer): void { 45 | this._pending = Buffer.concat([this._pending, data]); 46 | 47 | while (this._pending.length > 0) { 48 | switch (this._state) { 49 | case State.EMPTY: { 50 | const crlf = this._pending.indexOf('\r\n'); 51 | if (crlf < 0) { 52 | return; 53 | } 54 | 55 | const requestLine = this._pending.slice(0, crlf).toString(); 56 | this._pending = this._pending.slice(crlf + 2, this._pending.length); 57 | 58 | let parts = requestLine.split(' '); 59 | this.statusCode = parseInt(parts[1], 10); 60 | this.statusMessage = parts.slice(2).join(' '); 61 | 62 | parts = parts[0].split('/', 2); 63 | this.protocol = parts[0]; 64 | this.version = parts[1]; 65 | 66 | this._state = State.REQUEST_LINE_COMPLETE; 67 | break; 68 | } 69 | case State.REQUEST_LINE_COMPLETE: { 70 | const end = this._pending.indexOf(HEADER_SUFFIX); 71 | if (end < 0) { 72 | return; 73 | } 74 | 75 | const headers = this._pending.slice(0, end).toString(); 76 | this._pending = this._pending.slice(end + 4, this._pending.length); 77 | 78 | const lines = headers.split('\r\n'); 79 | for (const line of lines) { 80 | const idx = line.indexOf(':'); 81 | if (idx > 0) { 82 | const name = line.substring(0, idx).trim(); 83 | const value = line.substring(idx + 1, line.length).trim(); 84 | 85 | this.headers[name.toLowerCase()] = value; 86 | } 87 | } 88 | 89 | if (parseInt(this.headers['content-length'], 10) === 0) { 90 | this._reset(); 91 | } else { 92 | this._state = State.HEADERS_COMPLETE; 93 | } 94 | break; 95 | } 96 | case State.HEADERS_COMPLETE: { 97 | if (typeof this.headers['content-length'] !== 'undefined') { 98 | const contentLength = parseInt(this.headers['content-length'], 10); 99 | const toCopy = Math.min(contentLength - this.body.length, this._pending.length); 100 | this.body = Buffer.concat([this.body, this._pending.slice(0, toCopy)]); 101 | this._pending = this._pending.slice(toCopy, this._pending.length); 102 | if (this.body.length === contentLength && this.protocol === 'EVENT') { 103 | this.emit('event', this.body); 104 | this._reset(); 105 | } 106 | } else if (typeof this.headers['content-type'] !== 'undefined') { 107 | this.body = Buffer.from(this._pending); 108 | const firstPosition = this.body.indexOf('{'); 109 | const lastPosition = this.body.lastIndexOf('}'); 110 | this.body = this.body.slice(firstPosition, lastPosition + 1); 111 | this._pending = this._pending.slice(this.body.length, this._pending.length); 112 | if (this.body.length > 0 && this.protocol === 'EVENT') { 113 | this.emit('event', this.body); 114 | this._reset(); 115 | } 116 | } 117 | 118 | break; 119 | } 120 | } 121 | } 122 | } 123 | 124 | /** 125 | * Reset the internal parser state. 126 | */ 127 | private _reset(): void { 128 | this.protocol = null; 129 | this.version = null; 130 | this.statusCode = null; 131 | this.statusMessage = null; 132 | this.headers = {}; 133 | this.body = Buffer.alloc(0); 134 | this._state = State.EMPTY; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/transport/ip/ip-discovery.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Zeroconf wrappers for finding HAP devices. 3 | */ 4 | 5 | import { EventEmitter } from 'events'; 6 | import { Browser, Options, Service, ServiceType } from 'dnssd'; 7 | import { PairMethods } from '../../protocol/pairing-protocol'; 8 | 9 | /** 10 | * Service data structure returned by discovery methods 11 | */ 12 | export interface HapServiceIp { 13 | /** 14 | * name: the Bonjour name of the HomeKit accessory (i.e. Testsensor1._hap._tcp.local.) 15 | */ 16 | name: string; 17 | /** 18 | * address: the first discovered IP address of the accessory 19 | */ 20 | address: string; 21 | /** 22 | * allAddresses: all discovered IP addresses of the accessory 23 | */ 24 | allAddresses: string[]; 25 | /** 26 | * port: the used port 27 | */ 28 | port: number; 29 | /** 30 | * c#: the configuration number (required), 31 | * monitor that value and refresh device definition if it increases 32 | * 33 | * From Specs: 34 | * Must update when an accessory, service, or characteristic is added or removed 35 | * on the accessory server. Accessories must increment the config number after a firmware update. 36 | * This must have a range of 1-65535 and wrap to 1 when it overflows. 37 | * This value must persist across reboots, power cycles, etc. 38 | */ 39 | 'c#': number; 40 | /** 41 | * ff / flags: the numerical and human readable version of the feature flags 42 | * 43 | * From Specs: 44 | * Pairing Feature flags (e.g. ”0x3” for bits 0 and 1). Required if non-zero. 45 | * See Table 5-4 (page 49). 46 | * See PairingFeatureFlags 47 | */ 48 | ff: number; 49 | /** 50 | * id: Device ID (”5.4 Device ID” (page 31)) of the accessory. 51 | * 52 | * From Specs: 53 | * The Device ID must be formatted as ”XX:XX:XX:XX:XX:XX”, where ”XX” is a hexadecimal string 54 | * representing a byte. Required. 55 | * This value is also used as the accessoryʼs Pairing Identifier. 56 | */ 57 | id: string; 58 | /** 59 | * md: the Model name of the accessory (e.g. ”Device1,1”). Required. 60 | */ 61 | md: string; 62 | /** 63 | * pv: the protocol version 64 | * 65 | * From Specs: 66 | * Protocol version string ”X.Y” (e.g. ”1.0”). Required if value is not ”1.0”. 67 | * (see ”6.6.3 IP Protocol Version” (page 61)) 68 | * It must be set to ”1.1” for this version of HAP IP. 69 | */ 70 | pv: string; 71 | /** 72 | * s#: Current state number. Required. 73 | * 74 | * From Specs: 75 | * This must have a value of ”1”. 76 | */ 77 | 's#': number; 78 | /** 79 | * sf / statusflags: Status flags. Required 80 | * 81 | * From Specs: 82 | * (e.g. ”0x04” for bit 3). Value should be an unsigned integer. 83 | * See Table 6-8 (page 58) 84 | * See DiscoveryStatusFlags 85 | */ 86 | sf: number; 87 | /** 88 | * ci / category: the category identifier in numerical and human readable form. Required. 89 | * 90 | * From Specs: 91 | * Indicates the category that best describes the primary function of the accessory. 92 | * This must have a range of 1-65535. This must take values defined in 93 | * ”13-1 Accessory Categories” (page 252). 94 | * This must persist across reboots, power cycles, etc. 95 | */ 96 | ci: number; 97 | /** 98 | * Added in v2 of the HAP specifications but no details known 99 | * sh: Setup Hash. 100 | * 101 | * From Specs: 102 | * See (”?? ??” (page ??)) Required if the accessory supports enhanced setup payload information. 103 | */ 104 | // sh: string; 105 | /** 106 | * availableToPair: is the device available for pairing? 107 | */ 108 | availableToPair: boolean; 109 | } 110 | 111 | /** 112 | * See Table 6-8 113 | */ 114 | const DiscoveryPairingStatusFlags = { 115 | AccessoryNotPaired: 0x01, 116 | AccessoryNotConfiguredToJoinWifi: 0x02, 117 | AccessoryHasProblems: 0x04, 118 | }; 119 | 120 | /** 121 | * See Table 5-4 122 | */ 123 | const DiscoveryPairingFeatureFlags = { 124 | SupportsAppleAuthenticationCoprocessor: 0x01, // MFi HW 125 | SupportsSoftwareAuthentication: 0x02, // MFi SW 126 | }; 127 | 128 | export { DiscoveryPairingStatusFlags, DiscoveryPairingFeatureFlags }; 129 | 130 | /** 131 | * Handle discovery of IP devices 132 | * 133 | * @fires IPDiscovery#serviceUp 134 | * @fires IPDiscovery#serviceDown 135 | * @fires IPDiscovery#serviceChanged 136 | */ 137 | export default class IPDiscovery extends EventEmitter { 138 | private browser: Browser | null; 139 | 140 | private iface?: string; 141 | 142 | private services: Map; 143 | 144 | /** 145 | * Initialize the IPDiscovery object. 146 | * 147 | * @param {string?} iface - Optional interface to bind to 148 | */ 149 | constructor(iface?: string) { 150 | super(); 151 | this.browser = null; 152 | 153 | if (iface) { 154 | this.iface = iface; 155 | } 156 | 157 | this.services = new Map(); 158 | } 159 | 160 | /** 161 | * Convert a DNS-SD service record to a HAP service object. 162 | * 163 | * See Table 5-7 164 | * 165 | * @param {Object} service - Service record to convert 166 | */ 167 | private static _serviceToHapService(service: Service): HapServiceIp { 168 | const sf = parseInt(service.txt.sf, 10); 169 | return { 170 | name: service.name, 171 | address: service.addresses[0], 172 | allAddresses: service.addresses, 173 | port: service.port, 174 | 'c#': parseInt(service.txt['c#']), 175 | ff: parseInt(service.txt.ff || '0', 10), 176 | id: service.txt.id, 177 | md: service.txt.md, 178 | pv: service.txt.pv || '1.0', 179 | 's#': parseInt(service.txt['s#'], 10), 180 | sf, 181 | ci: parseInt(service.txt.ci, 10), 182 | // sh: service.txt.sh, 183 | availableToPair: !!(sf & DiscoveryPairingStatusFlags.AccessoryNotPaired), 184 | }; 185 | } 186 | 187 | /** 188 | * Get PairMethod to use for pairing from the data received during discovery 189 | * 190 | * @param {HapServiceIp} service Discovered service object to check 191 | * @returns {Promise} Promise which resolves with the PairMethod to use 192 | */ 193 | public async getPairMethod(service: HapServiceIp): Promise { 194 | // async to be compatible with the BLE variant 195 | return service.ff & DiscoveryPairingFeatureFlags.SupportsAppleAuthenticationCoprocessor 196 | ? PairMethods.PairSetupWithAuth 197 | : PairMethods.PairSetup; 198 | } 199 | 200 | /** 201 | * Start searching for HAP devices on the network. 202 | */ 203 | start(): void { 204 | if (this.browser) { 205 | this.browser.stop(); 206 | } 207 | 208 | const options: Options = {}; 209 | if (this.iface) { 210 | options.interface = this.iface; 211 | } 212 | 213 | this.browser = new Browser(new ServiceType('_hap._tcp'), options); 214 | this.browser.on('serviceUp', (service) => { 215 | const hapService = IPDiscovery._serviceToHapService(service); 216 | this.services.set(hapService.id, hapService); 217 | /** 218 | * New device discovered event 219 | * 220 | * @event IPDiscovery#serviceUp 221 | * @type HapServiceIp 222 | */ 223 | this.emit('serviceUp', hapService); 224 | }); 225 | this.browser.on('serviceDown', (service) => { 226 | const hapService = IPDiscovery._serviceToHapService(service); 227 | this.services.delete(hapService.id); 228 | /** 229 | * Device offline event 230 | * 231 | * @event IPDiscovery#serviceDown 232 | * @type HapServiceIp 233 | */ 234 | this.emit('serviceDown', hapService); 235 | }); 236 | this.browser.on('serviceChanged', (service) => { 237 | const hapService = IPDiscovery._serviceToHapService(service); 238 | this.services.set(hapService.id, hapService); 239 | /** 240 | * Device data changed event 241 | * 242 | * @event IPDiscovery#serviceChanged 243 | * @type HapServiceIp 244 | */ 245 | this.emit('serviceChanged', hapService); 246 | }); 247 | this.browser.start(); 248 | } 249 | 250 | /** 251 | * List the currently known services. 252 | * 253 | * @returns {HapServiceIp[]} Array of services 254 | */ 255 | list(): HapServiceIp[] { 256 | return Array.from(this.services.values()); 257 | } 258 | 259 | /** 260 | * Stop an ongoing discovery process. 261 | */ 262 | stop(): void { 263 | if (this.browser) { 264 | this.browser.stop(); 265 | this.browser = null; 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/utils/queue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Queue used for serializing BLE operations. 3 | */ 4 | export class OpQueue { 5 | private _current: Promise; 6 | 7 | /** 8 | * Create the queue. 9 | */ 10 | constructor() { 11 | this._current = Promise.resolve(); 12 | } 13 | 14 | /** 15 | * Queue a new operation. 16 | * 17 | * @param {function} op - Function to queue 18 | * @returns {Promise} Promise which resolves when the function has executed. 19 | */ 20 | queue(op: () => Promise): Promise { 21 | const ret = new Promise((resolve, reject) => { 22 | this._current.then(() => { 23 | op().then(resolve, reject); 24 | }); 25 | }); 26 | // eslint-disable-next-line @typescript-eslint/no-empty-function 27 | this._current = ret.catch(() => {}); 28 | return ret; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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 | "resolveJsonModule": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------