├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── built-dev.yaml │ ├── built-main.yaml │ ├── ci.yaml │ ├── release.yaml │ ├── stale.yaml │ └── typedoc.yaml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── biome.json ├── docker ├── Dockerfile.dev └── compose.dev.yaml ├── package-lock.json ├── package.json ├── src ├── dev │ ├── cli.ts │ ├── conf.json │ ├── minimal-adapter.ts │ ├── wireshark.ts │ ├── z2mdata-to-zohsave.ts │ └── zohsave-to-readable.ts ├── drivers │ ├── descriptors.ts │ ├── ot-rcp-driver.ts │ ├── ot-rcp-parser.ts │ └── ot-rcp-writer.ts ├── spinel │ ├── commands.ts │ ├── hdlc.ts │ ├── properties.ts │ ├── spinel.ts │ └── statuses.ts ├── utils │ └── logger.ts └── zigbee │ ├── mac.ts │ ├── zigbee-aps.ts │ ├── zigbee-nwk.ts │ ├── zigbee-nwkgp.ts │ └── zigbee.ts ├── test ├── data.ts ├── ot-rcp-driver.test.ts ├── spinel.test.ts ├── tsconfig.json ├── vitest.config.mts ├── wireshark.test.ts ├── zigbee.bench.ts └── zigbee.test.ts ├── tsconfig.json └── tsconfig.prod.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: Nerivec 4 | buy_me_a_coffee: Nerivec -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Do this 16 | 2. Do that 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Setup (please complete the following information):** 23 | - Machine: [e.g. Intel NUC, RPI] 24 | - OS: [e.g. debian 12.9] 25 | - Container: [e.g. no, Docker 27] 26 | - Zigbee2MQTT: [e.g. 2.1.3-dev] 27 | 28 | 35 | 36 | **Coordinator/Adapter (please complete the following information):** 37 | - Manufacturer: [e.g. Silabs, TI] 38 | - Firmware Version (or link to firmware file): [e.g. 2.5.2.0_GitHub-1fceb225b] 39 | 40 | **Additional context** 41 | 42 | **Logs** 43 | Add relevant `debug` logs 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 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/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | production-dependencies: 9 | applies-to: version-updates 10 | dependency-type: "production" 11 | update-types: 12 | - "minor" 13 | - "patch" 14 | development-dependencies: 15 | applies-to: version-updates 16 | dependency-type: "development" 17 | update-types: 18 | - "minor" 19 | - "patch" 20 | 21 | - package-ecosystem: github-actions 22 | directory: "/" 23 | schedule: 24 | interval: weekly 25 | -------------------------------------------------------------------------------- /.github/workflows/built-dev.yaml: -------------------------------------------------------------------------------- 1 | name: Maintain built dev branch 2 | 3 | on: 4 | workflow_run: 5 | workflows: [CI] 6 | types: [completed] 7 | branches: [dev] 8 | 9 | permissions: 10 | contents: read 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | build: 18 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - run: git fetch --unshallow 26 | 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version-file: 'package.json' 30 | 31 | - run: git switch --force-create built-dev 32 | 33 | - run: npm ci 34 | - run: npm run build 35 | 36 | - name: Setup git 37 | run: | 38 | git config --global user.name 'github-actions[bot]' 39 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 40 | 41 | - name: Commit changes 42 | run: | 43 | git add --force ./dist 44 | git commit -m "Rebuild" || echo 'Nothing to commit' 45 | git push --force --set-upstream origin built-dev 46 | -------------------------------------------------------------------------------- /.github/workflows/built-main.yaml: -------------------------------------------------------------------------------- 1 | name: Maintain built main branch 2 | 3 | on: 4 | workflow_run: 5 | workflows: [CI] 6 | types: [completed] 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | build: 18 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - run: git fetch --unshallow 26 | 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version-file: 'package.json' 30 | 31 | - run: git switch --force-create built-main 32 | 33 | - run: npm ci 34 | - run: npm run build:prod 35 | 36 | - name: Setup git 37 | run: | 38 | git config --global user.name 'github-actions[bot]' 39 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 40 | 41 | - name: Commit changes 42 | run: | 43 | git add --force ./dist 44 | git commit -m "Rebuild" || echo 'Nothing to commit' 45 | git push --force --set-upstream origin built-main 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - built-main 7 | - built-dev 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | checks: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version-file: 'package.json' 21 | 22 | - run: npm ci 23 | 24 | - run: npm run build:prod 25 | 26 | - run: npm run check:ci 27 | 28 | tests: 29 | strategy: 30 | matrix: 31 | os: [ubuntu-latest, macos-latest, windows-latest] 32 | node: [20, 22] 33 | runs-on: ${{ matrix.os }} 34 | continue-on-error: true 35 | steps: 36 | - uses: actions/checkout@v4 37 | 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: ${{ matrix.node }} 41 | 42 | - run: npm ci 43 | 44 | - run: npm run build:prod 45 | 46 | - run: npm run test:cov 47 | 48 | # get some "in-workflow" reference numbers for future comparison 49 | # TODO: send results to PR as needed 50 | - run: npm run bench 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Publish package to npmjs 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | id-token: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | # Setup .npmrc file to publish to npm 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version-file: 'package.json' 23 | registry-url: 'https://registry.npmjs.org' 24 | 25 | - run: npm ci 26 | 27 | - run: npm publish --provenance --access public 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 4 * * *' 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | stale: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | # contents: write # only for delete-branch option 14 | issues: write 15 | pull-requests: write 16 | steps: 17 | - uses: actions/stale@v9 18 | with: 19 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 20 | stale-pr-message: 'This pull request is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 21 | days-before-stale: 30 22 | days-before-close: 5 23 | exempt-issue-labels: dont-stale 24 | exempt-pr-labels: dont-stale 25 | -------------------------------------------------------------------------------- /.github/workflows/typedoc.yaml: -------------------------------------------------------------------------------- 1 | name: Publish typedoc on Github Pages 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version-file: 'package.json' 23 | 24 | - run: npm ci 25 | 26 | - run: npm install -g typedoc 27 | 28 | - run: typedoc --gitRevision "$(git describe --tag --abbrev=0)" --tsconfig tsconfig.json --excludePrivate --excludeProtected --excludeExternals --entryPointStrategy expand ./src --sourceLinkTemplate "https://github.com/Nerivec/zigbee-on-host/blob/{gitRevision}/{path}#L{line}" -out typedoc 29 | 30 | - uses: actions/upload-pages-artifact@v3 31 | with: 32 | name: github-pages 33 | # typedoc "out" path 34 | path: ./typedoc 35 | 36 | deploy: 37 | needs: build 38 | runs-on: ubuntu-latest 39 | permissions: 40 | pages: write # to deploy to Pages 41 | id-token: write # to verify the deployment originates from an appropriate source 42 | environment: 43 | name: github-pages 44 | url: ${{ steps.deployment.outputs.page_url }} 45 | steps: 46 | - uses: actions/deploy-pages@v4 47 | id: deployment 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # ZoH 133 | *.save 134 | /temp 135 | /data-temp 136 | temp_* -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome", "vitest.explorer"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "notebook.defaultFormatter": "biomejs.biome", 4 | "editor.tabSize": 4, 5 | "editor.insertSpaces": true, 6 | "files.defaultLanguage": "typescript", 7 | "files.eol": "\n" 8 | } 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 119 | 120 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 121 | enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | https://www.contributor-covenant.org/faq. Translations are available at 127 | https://www.contributor-covenant.org/translations. 128 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Guidelines 2 | 3 | Some quick guidelines to keep the codebase maintainable: 4 | 5 | - No external production dependencies 6 | - Mark `TODO` / `XXX` / `@deprecated` in code as needed for quick access 7 | - Performance in mind (with the goal to eventually bring the appropriate layers to a lower language as needed) 8 | - No expensive calls (stringify, etc.) 9 | - Bail as early as possible (no unnecessary parsing, holding waiters, etc.) 10 | - Ability to no-op expensive "optional" features 11 | - And the usuals... 12 | - Keep MAC/ZigBee property naming mostly in line with Wireshark for easier debugging 13 | - Keep in line with the ZigBee 3.0 specification, but allow optimization due to the host-driven nature and removal of unnecessary features that won't impact compatibility 14 | - Focus on "Centralized Trust Center" implementation (at least at first) 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZigBee on Host 2 | 3 | [![Version](https://img.shields.io/npm/v/zigbee-on-host.svg)](https://npmjs.org/package/zigbee-on-host) 4 | [![CI](https://github.com/Nerivec/zigbee-on-host/actions/workflows/ci.yaml/badge.svg)](https://github.com/Nerivec/zigbee-on-host/actions/workflows/ci.yaml) 5 | [![CodeQL](https://github.com/Nerivec/zigbee-on-host/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/Nerivec/zigbee-on-host/actions/workflows/github-code-scanning/codeql) 6 | 7 | Open Source ZigBee stack designed to run on a host and communicate with a radio co-processor (RCP). 8 | 9 | Current implementation aims for compatibility with OpenThread RCP firmware. That base provides compatibility with any chip manufacturer that supports it (Silabs, TI, etc.) with the only requirements being proper implementation of the `STREAM_RAW` mechanism of the `Spinel` protocol (which allows to send raw 802.15.4 frames, including... ZigBee!) and hardware MAC ACKing. 10 | 11 | _This library can also serve as a base for pentesting ZigBee networks thanks to the ability to easily craft various payloads at any layer of the specification and send them through the raw stream using any network parameters._ 12 | 13 | > [!IMPORTANT] 14 | > Work in progress! Expect breaking changes without backwards compatibility for a while! 15 | 16 | ## Development 17 | 18 | [CONTRIBUTING](./CONTRIBUTING.md) 19 | 20 | ### Current status 21 | 22 | > [~] Partial feature, [?] Uncertain feature 23 | 24 | - [x] Encoding/decoding of Spinel & HDLC protocols 25 | - [x] Encoding/decoding of MAC frames 26 | - [x] Encoding/decoding of ZigBee NWK frames 27 | - [ ] Lacking reference sniffs for multicast (group) 28 | - [x] Encoding/decoding of ZigBee NWK GP frames 29 | - [x] Encoding/decoding of ZigBee NWK APS frames 30 | - [x] Network forming 31 | - [x] Network state saving (de facto backups) 32 | - [x] Network state reset 33 | - [x] Joining/Rejoining 34 | - [x] APS TC link key update mechanism (global) 35 | - [x] Direct child router 36 | - [x] Direct child end device 37 | - [x] Nested device 38 | - [x] Indirect transmission mechanism 39 | - [x] Source routing 40 | - [ ] Route repairing 41 | - [x] Coordinator LQI/Routing tables (for network map data on coordinator side) 42 | - [x] LQI reporting 43 | - [ ] Refining 44 | - [ ] Install codes 45 | - [?] APS APP link keys 46 | - [ ] InterPAN / Touchlink 47 | - [ ] R23 (need reference sniffs...) 48 | - [~] Security 49 | - [ ] Metrics/Statistics 50 | - [ ] Big cleanup of unused / never will use! 51 | 52 | And likely more, and of course a bunch of `TODO`s in the code! 53 | 54 | You can also contribute by submitting sniffs/captures. [More information here](https://github.com/Nerivec/zigbee-on-host/discussions/14). 55 | 56 | ### OpenThread RCP firmware notes 57 | 58 | - [Texas Instruments] Does not currently implement `PHY_CCA_THRESHOLD` (cannot read or write value) 59 | 60 | ## Testing 61 | 62 | #### Current Status 63 | 64 | - CI: ~70% coverage 65 | - Stress-testing: pending 66 | - Firmware stability: 67 | - Silicon Labs: ongoing 68 | - Texas Instruments: ongoing 69 | - Nordic Semiconductor: [pending](https://github.com/Nerivec/zigbee-on-host/discussions/18) 70 | - Usage in test networks: ongoing 71 | - Usage in live networks: pending 72 | 73 | ### Firmware 74 | 75 | Use the appropriate OpenThread RCP firmware for your adapter: 76 | - Silicon Labs: https://github.com/Nerivec/silabs-firmware-builder/releases 77 | - Texas Instruments: https://github.com/Koenkk/OpenThread-TexasInstruments-firmware/releases 78 | - Nordic Semiconductor: https://github.com/Nerivec/zigbee-on-host/discussions/18 79 | 80 | ### Zigbee2MQTT 81 | 82 | Zigbee2MQTT 2.1.3-dev (after [PR #26742](https://github.com/Koenkk/zigbee2mqtt/pull/26742)) and later versions should allow the use of the `zoh` adapter. 83 | Make sure you followed the above steps to get the proper firmware, then configure your `configuration.yaml`, including: 84 | 85 | > [!TIP] 86 | > It is currently recommended you use Zigbee2MQTT `latest-dev` (`edge`) to get the latest fixes when testing this implementation! 87 | 88 | ```yaml 89 | serial: 90 | port: /dev/serial/by-id/my-device-id-here 91 | adapter: zoh 92 | # unused for TCP-based coordinator 93 | baudrate: 460800 94 | # as appropriate for your coordinator/firmware, unused for TCP-based coordinator 95 | rtscts: true 96 | ``` 97 | 98 | > [!TIP] 99 | > ZigBee on Host saves the current state of the network in the file `zoh.save`. _It is similar to the NVRAM of an NCP coordinator._ 100 | > This file contains everything needed to re-establish the network on start, hence, a `coordinator_backup.json` is never created by Zigbee2MQTT. It is located alongside the `database.db` in the `data` folder. 101 | 102 | > [!TIP] 103 | > The EUI64 (IEEE address) in the firmware of the coordinator is ignored in this mode. A static one is used instead (set by Zigbee2MQTT), allowing you to change coordinators at will on the same network (although you may encounter device-related troubles when radio specs vary wildly). 104 | 105 | ### CLI & Utils 106 | 107 | Clone the repository. 108 | 109 | ```bash 110 | git clone https://github.com/Nerivec/zigbee-on-host 111 | cd zigbee-on-host 112 | ``` 113 | 114 | Install dev dependencies and build: 115 | 116 | ```bash 117 | npm ci 118 | npm run build 119 | ``` 120 | 121 | > [!IMPORTANT] 122 | > Running `npm run build:prod` omits the `src/dev` directory (for production). If you do, you will not be able to use `dev:*` commands. 123 | 124 | > [!TIP] 125 | > If having issues with building, try removing the `*.tsbuildinfo` incremental compilation files (or run `npm run clean` first). 126 | 127 | #### Utils 128 | 129 | ##### Create a 'zoh.save' from the content of a Zigbee2MQTT data folder 130 | 131 | ```bash 132 | npm run dev:z2z ./path/to/data/ 133 | ``` 134 | 135 | > [!TIP] 136 | > This allows you to quickly take over a network created with `zstack` or `ember`. You then just need to change the `configuration.yaml` to `adapter: zoh` and `baudrate: 460800` (and `port` as appropriate). 137 | 138 | ##### Print and save the content of the 'zoh.save' in the given directory in human-readable format (as JSON, in same directory) 139 | 140 | ```bash 141 | npm run dev:z2r ./path/to/data/ 142 | ``` 143 | 144 | ##### CLI 145 | 146 | Get a list of supported commands with: 147 | 148 | ```bash 149 | npm run dev:cli help 150 | ``` 151 | 152 | > [!TIP] 153 | > `dev:cli` commands can be configured in more details using the file `dist/dev/conf.json`. Some environment variables are also available to quickly configure the adapter & wireshark. _The effective config is printed at the start of every command (`help` included)._ 154 | 155 | ##### Using 'Docker.dev' and 'compose.dev.yaml' 156 | 157 | ###### Prerequisites 158 | 159 | ```bash 160 | git clone https://github.com/Nerivec/zigbee-on-host 161 | cd zigbee-on-host 162 | docker compose -f docker/compose.dev.yaml up -d --pull never 163 | docker compose -f docker/compose.dev.yaml exec zigbee-on-host npm ci 164 | docker compose -f docker/compose.dev.yaml exec zigbee-on-host npm run build 165 | ``` 166 | 167 | ###### Running util commands 168 | 169 | Create 'zoh.save' (details above): 170 | 171 | ```bash 172 | docker compose -f docker/compose.dev.yaml exec zigbee-on-host npm run dev:z2z ./path/to/data 173 | ``` 174 | 175 | Print readable 'zoh.save' content (details above): 176 | 177 | ```bash 178 | docker compose -f docker/compose.dev.yaml exec zigbee-on-host npm run dev:z2r ./path/to/data 179 | ``` 180 | 181 | CLI: 182 | 183 | ```bash 184 | docker compose -f docker/compose.dev.yaml exec zigbee-on-host npm run dev:cli help 185 | ``` 186 | 187 | > [!TIP] 188 | > `dev:cli` commands can be configured in more details using the file `dist/dev/conf.json`. Some environment variables are also available to configure the adapter & wireshark from the compose file. _The effective config is printed at the start of every command (`help` included)._ 189 | 190 | ###### Stopping & removing the container 191 | 192 | ```bash 193 | docker compose -f docker/compose.dev.yaml down 194 | ``` 195 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["package.json", "package-lock.json"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 4, 16 | "lineWidth": 150, 17 | "lineEnding": "lf" 18 | }, 19 | "organizeImports": { 20 | "enabled": true 21 | }, 22 | "linter": { 23 | "enabled": true, 24 | "rules": { 25 | "recommended": true, 26 | "style": { 27 | "noNonNullAssertion": "off", 28 | "noParameterAssign": "off", 29 | "useThrowNewError": "error", 30 | "useThrowOnlyError": "error", 31 | "useNamingConvention": { 32 | "level": "error", 33 | "options": { 34 | "strictCase": false, 35 | "requireAscii": true, 36 | "enumMemberCase": "CONSTANT_CASE" 37 | } 38 | } 39 | }, 40 | "correctness": { 41 | "noUnusedImports": "error", 42 | "noUnusedVariables": { 43 | "level": "warn", 44 | "fix": "none" 45 | } 46 | }, 47 | "performance": { 48 | "noBarrelFile": "error", 49 | "noReExportAll": "error" 50 | }, 51 | "suspicious": { 52 | "noConstEnum": "off", 53 | "useAwait": "error" 54 | } 55 | } 56 | }, 57 | "javascript": { 58 | "formatter": { 59 | "quoteStyle": "double" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | ARG TARGETPLATFORM 2 | 3 | # Need to use Alpine 3.18.4 which uses Node 18 for arm/v6 and arm/v7, otherwise the build hangs. 4 | # See https://github.com/nodejs/docker-node/issues/2077 5 | FROM alpine:3.18.4 AS linux-arm-alpine 6 | FROM alpine:3.21 AS linux-arm64-alpine 7 | FROM alpine:3.21 AS linux-amd64-alpine 8 | FROM alpine:3.21 AS linux-riscv64-alpine 9 | FROM alpine:3.21 AS linux-386-alpine 10 | 11 | FROM linux-${TARGETARCH}-alpine AS base 12 | 13 | ENV NODE_ENV=development 14 | WORKDIR /app 15 | 16 | RUN apk add --no-cache tzdata eudev nodejs npm git 17 | 18 | COPY . ./ 19 | 20 | ARG DATE 21 | ARG VERSION 22 | LABEL org.opencontainers.image.authors="Nerivec" 23 | LABEL org.opencontainers.image.title="ZigBee on Host - Dev" 24 | LABEL org.opencontainers.image.description="Open Source ZigBee stack designed to run on a host and communicate with a radio co-processor (RCP)" 25 | LABEL org.opencontainers.image.url="https://github.com/Nerivec/zigbee-on-host" 26 | LABEL org.opencontainers.image.documentation="https://github.com/Nerivec/zigbee-on-host" 27 | LABEL org.opencontainers.image.source="https://github.com/Nerivec/zigbee-on-host" 28 | LABEL org.opencontainers.image.licenses="GPL-3.0-or-later" 29 | LABEL org.opencontainers.image.created=${DATE} 30 | LABEL org.opencontainers.image.version=${VERSION} 31 | 32 | ENTRYPOINT ["tail", "-f", "/dev/null"] 33 | -------------------------------------------------------------------------------- /docker/compose.dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | zigbee-on-host: 3 | container_name: zigbee-on-host 4 | image: nerivec/zigbee-on-host:dev 5 | build: 6 | context: ../ 7 | dockerfile: docker/Dockerfile.dev 8 | volumes: 9 | - ../:/app 10 | environment: 11 | # used by `dev:cli` commands 12 | ADAPTER_PATH: /dev/ttyACM0 13 | ADAPTER_BAUDRATE: 460800 14 | ADAPTER_RTSCTS: false 15 | # WIRESHARK_ZEP_PORT: 17754 16 | # WIRESHARK_ADDRESS: 127.0.0.1 17 | # devices: 18 | # - /dev/serial/by-id/:/dev/ttyACM0 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zigbee-on-host", 3 | "version": "0.1.12", 4 | "description": "ZigBee stack designed to run on a host and communicate with a radio co-processor (RCP)", 5 | "engines": { 6 | "node": ">=20.17.0" 7 | }, 8 | "files": [ 9 | "./dist" 10 | ], 11 | "types": "./dist/drivers/ot-rcp-driver.d.ts", 12 | "main": "./dist/drivers/ot-rcp-driver.js", 13 | "scripts": { 14 | "build": "tsc", 15 | "build:prod": "tsc --project tsconfig.prod.json", 16 | "test": "vitest run --config ./test/vitest.config.mts", 17 | "test:cov": "vitest run --config ./test/vitest.config.mts --coverage", 18 | "bench": "vitest bench --run --config ./test/vitest.config.mts", 19 | "check": "biome check --write .", 20 | "check:ci": "biome check .", 21 | "clean": "rm -rf dist *.tsbuildinfo", 22 | "dev:cli": "node dist/dev/cli.js", 23 | "dev:z2z": "node dist/dev/z2mdata-to-zohsave.js", 24 | "dev:z2r": "node dist/dev/zohsave-to-readable.js", 25 | "prepack": "npm run clean && npm run build:prod" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/Nerivec/zigbee-on-host.git" 30 | }, 31 | "keywords": [ 32 | "zigbee", 33 | "host", 34 | "stack", 35 | "rcp" 36 | ], 37 | "author": "Nerivec", 38 | "license": "GPL-3.0-or-later", 39 | "bugs": { 40 | "url": "https://github.com/Nerivec/zigbee-on-host/issues" 41 | }, 42 | "homepage": "https://github.com/Nerivec/zigbee-on-host#readme", 43 | "devDependencies": { 44 | "@biomejs/biome": "1.9.4", 45 | "@types/node": "^22.15.24", 46 | "@vitest/coverage-v8": "^3.1.4", 47 | "serialport": "^13.0.0", 48 | "typescript": "^5.8.3", 49 | "vitest": "^3.0.8" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/dev/cli.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | import { join } from "node:path"; 3 | import type { StreamRawConfig } from "../spinel/spinel.js"; 4 | import { MinimalAdapter, type PortOptions, type ResetType, type StartOptions } from "./minimal-adapter.js"; 5 | 6 | type Conf = { 7 | adapter: PortOptions; 8 | streamRaw: StreamRawConfig; 9 | network: { 10 | tcKey: number[]; 11 | tcKeyFrameCounter: number; 12 | networkKey: number[]; 13 | networkKeyFrameCounter: number; 14 | networkKeySequenceNumber: number; 15 | panId: number; 16 | extendedPANId: number[]; 17 | channel: number; 18 | eui64: number[]; 19 | nwkUpdateId: number; 20 | txPower: number; 21 | }; 22 | sendMACToZEP: boolean; 23 | }; 24 | 25 | function argToBool(arg: string): boolean { 26 | arg = arg.toLowerCase(); 27 | 28 | return arg === "1" || arg === "true" || arg === "yes" || arg === "on"; 29 | } 30 | 31 | function printHelp(shouldThrow: boolean): void { 32 | console.log("\nForm:"); 33 | console.log(" dev:cli form "); 34 | 35 | // console.log("\nScan:"); 36 | // console.log(" dev:cli scan [tx_power min=-128 max=127]"); 37 | 38 | console.log("\nSniff:"); 39 | console.log(" dev:cli sniff "); 40 | 41 | console.log("\nReset:"); 42 | console.log(" dev:cli reset "); 43 | 44 | console.log("\n- Boolean 'yes' can take any of the following forms (any other will be considered no/false): 1, true, yes, on"); 45 | console.log("- CSVs are expected without spaces"); 46 | console.log("- Following ENV vars will override 'conf.json': ADAPTER_PATH, ADAPTER_BAUDRATE, ADAPTER_RTSCTS"); 47 | console.log( 48 | "- If you have any trouble starting a command after completing another, try to unplug/replug the adapter or the 'reset stack' command", 49 | ); 50 | 51 | if (shouldThrow) { 52 | throw new Error("Invalid parameters"); 53 | } 54 | } 55 | 56 | if (require.main === module) { 57 | const confPath = join(__dirname, "conf.json"); 58 | const conf = JSON.parse(readFileSync(confPath, "utf8")) as Conf; 59 | 60 | if (process.env.ADAPTER_PATH) { 61 | conf.adapter.path = process.env.ADAPTER_PATH; 62 | } 63 | 64 | if (process.env.ADAPTER_BAUDRATE) { 65 | conf.adapter.baudRate = Number.parseInt(process.env.ADAPTER_BAUDRATE, 10); 66 | } 67 | 68 | if (process.env.ADAPTER_RTSCTS) { 69 | conf.adapter.rtscts = argToBool(process.env.ADAPTER_RTSCTS); 70 | } 71 | 72 | console.log("Starting with conf:", JSON.stringify(conf)); 73 | 74 | if (process.argv[2] === "help") { 75 | // after above log to be able to see conf without side-effect 76 | printHelp(false); 77 | } else { 78 | if (process.argv.length <= 2) { 79 | printHelp(true); 80 | } 81 | 82 | const mode = process.argv[2] as StartOptions["mode"]; // typing validated below 83 | 84 | if (mode !== "form" && mode !== "scan" && mode !== "sniff" && mode !== "reset") { 85 | printHelp(true); 86 | } 87 | 88 | const adapter = new MinimalAdapter( 89 | conf.adapter, 90 | conf.streamRaw, 91 | // NOTE: this information is overwritten on `start` if a save exists 92 | { 93 | eui64: Buffer.from(conf.network.eui64).readBigUInt64LE(0), 94 | panId: conf.network.panId, 95 | extendedPANId: Buffer.from(conf.network.extendedPANId).readBigUInt64LE(0), 96 | channel: conf.network.channel, 97 | nwkUpdateId: conf.network.nwkUpdateId, 98 | txPower: conf.network.txPower, 99 | networkKey: Buffer.from(conf.network.networkKey), 100 | networkKeyFrameCounter: conf.network.networkKeyFrameCounter, 101 | networkKeySequenceNumber: conf.network.networkKeySequenceNumber, 102 | tcKey: Buffer.from(conf.network.tcKey), 103 | tcKeyFrameCounter: conf.network.tcKeyFrameCounter, 104 | }, 105 | conf.sendMACToZEP || mode === "sniff", 106 | ); 107 | 108 | const onStop = async () => { 109 | switch (mode) { 110 | case "form": { 111 | break; 112 | } 113 | // case "scan": { 114 | // // await adapter.driver.stopEnergyScan(); 115 | // break; 116 | // } 117 | case "sniff": { 118 | await adapter.driver.stopSniffer(); 119 | break; 120 | } 121 | case "reset": { 122 | break; 123 | } 124 | } 125 | 126 | await adapter.stop(); 127 | }; 128 | 129 | process.on("SIGINT", onStop); 130 | process.on("SIGTERM", onStop); 131 | 132 | switch (mode) { 133 | case "form": { 134 | if (process.argv.length !== 4) { 135 | printHelp(true); 136 | } 137 | 138 | const allowJoins = argToBool(process.argv[3]); 139 | 140 | console.log(`Starting 'form' mode with allowJoins=${allowJoins} (advanced configs loaded from ${confPath})`); 141 | 142 | void adapter.start({ mode: "form", allowJoins }); 143 | break; 144 | } 145 | 146 | // TODO: not stable... 147 | // case "scan": { 148 | // if (process.argv.length !== 5 && process.argv.length !== 6) { 149 | // printHelp(); 150 | // } 151 | 152 | // const channels = process.argv[3].split(",").map((v) => { 153 | // const channel = Number.parseInt(v, 10); 154 | 155 | // if (channel > 26 || channel < 11) { 156 | // throw new Error("Invalid channel: [11..26]"); 157 | // } 158 | 159 | // return channel; 160 | // }); 161 | 162 | // const period = Math.min(Math.max(Number.parseInt(process.argv[4], 10), 50), 500); 163 | // const txPower = process.argv[5] ? Math.min(Math.max(Number.parseInt(process.argv[5], 10), -128), 127) : conf.network.txPower; 164 | 165 | // console.log(`Starting 'scan' mode with channels=${channels} period=${period} txPower=${txPower}`); 166 | 167 | // void adapter.start({ mode: "scan", channels, period, txPower }); 168 | // break; 169 | // } 170 | 171 | case "sniff": { 172 | if (process.argv.length !== 4) { 173 | printHelp(true); 174 | } 175 | 176 | const channel = Number.parseInt(process.argv[3], 10); 177 | 178 | if (channel > 26 || channel < 11) { 179 | throw new Error("Invalid channel: [11..26]"); 180 | } 181 | 182 | console.log(`Starting 'sniff' mode with channel=${channel}`); 183 | 184 | void adapter.start({ mode: "sniff", channel }); 185 | break; 186 | } 187 | 188 | case "reset": { 189 | if (process.argv.length !== 3 && process.argv.length !== 4) { 190 | printHelp(true); 191 | } 192 | 193 | const type = process.argv[3] as ResetType; // typing validated below 194 | 195 | if (type && type !== "stack" && type !== "bootloader") { 196 | printHelp(true); 197 | } 198 | 199 | console.log("Starting 'reset' mode"); 200 | 201 | void adapter.start({ mode: "reset", type: type ?? "stack" }); 202 | break; 203 | } 204 | } 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/dev/conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "adapter": { 3 | "path": "/dev/ttyACM0", 4 | "baudRate": 460800, 5 | "rtscts": false 6 | }, 7 | "streamRaw": { 8 | "txChannel": 25, 9 | "ccaBackoffAttempts": 1, 10 | "ccaRetries": 4, 11 | "enableCSMACA": true, 12 | "headerUpdated": true, 13 | "reTx": false, 14 | "securityProcessed": true, 15 | "txDelay": 0, 16 | "txDelayBaseTime": 0, 17 | "rxChannelAfterTxDone": 25 18 | }, 19 | "network": { 20 | "tcKey": [90, 105, 103, 66, 101, 101, 65, 108, 108, 105, 97, 110, 99, 101, 48, 57], 21 | "tcKeyFrameCounter": 0, 22 | "networkKey": [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13], 23 | "networkKeyFrameCounter": 0, 24 | "networkKeySequenceNumber": 0, 25 | "panId": 26979, 26 | "extendedPANId": [17, 221, 34, 221, 51, 221, 68, 221], 27 | "channel": 25, 28 | "eui64": [99, 108, 105, 99, 108, 105, 99, 108], 29 | "nwkUpdateId": 0, 30 | "txPower": 19 31 | }, 32 | "sendMACToZEP": false 33 | } 34 | -------------------------------------------------------------------------------- /src/dev/minimal-adapter.ts: -------------------------------------------------------------------------------- 1 | import { type Socket as DgramSocket, createSocket } from "node:dgram"; 2 | import { Socket } from "node:net"; 3 | import { setTimeout } from "node:timers/promises"; 4 | import { SerialPort } from "serialport"; 5 | import { type NetworkParameters, OTRCPDriver } from "../drivers/ot-rcp-driver.js"; 6 | import type { StreamRawConfig } from "../spinel/spinel.js"; 7 | import { logger } from "../utils/logger.js"; 8 | import type { ZigbeeAPSHeader, ZigbeeAPSPayload } from "../zigbee/zigbee-aps.js"; 9 | import { DEFAULT_WIRESHARK_IP, DEFAULT_ZEP_UDP_PORT, createWiresharkZEPFrame } from "./wireshark.js"; 10 | 11 | const NS = "minimal-adapter"; 12 | 13 | export function isTcpPath(path: string): boolean { 14 | // tcp path must be: tcp://: 15 | return /^(?:tcp:\/\/)[\w.-]+[:][\d]+$/gm.test(path); 16 | } 17 | 18 | /** 19 | * Example: 20 | * ```ts 21 | * { 22 | * path: 'COM4', 23 | * baudRate: 460800, 24 | * rtscts: true, 25 | * } 26 | * ``` 27 | */ 28 | export type PortOptions = { 29 | path: string; 30 | //---- serial only 31 | baudRate?: number; 32 | rtscts?: boolean; 33 | }; 34 | 35 | export type ResetType = "stack" | "bootloader"; 36 | 37 | export type StartOptions = 38 | | { 39 | mode: "form"; 40 | allowJoins: boolean; 41 | } 42 | | { 43 | mode: "scan"; 44 | channels: number[]; 45 | period: number; 46 | txPower: number; 47 | } 48 | | { 49 | mode: "sniff"; 50 | channel: number; 51 | } 52 | | { 53 | mode: "reset"; 54 | type: ResetType; 55 | }; 56 | 57 | /** 58 | * Minimal adapter using the OT RCP Driver that can be started via `cli.ts` and outputs to both the console, and Wireshark (MAC frames). 59 | */ 60 | export class MinimalAdapter { 61 | public readonly driver: OTRCPDriver; 62 | readonly #portOptions: PortOptions; 63 | #serialPort?: SerialPort; 64 | #socketPort?: Socket; 65 | /** True when serial/socket is currently closing */ 66 | #closing: boolean; 67 | 68 | #wiresharkSeqNum: number; 69 | #wiresharkPort: number; 70 | #wiresharkAddress: string; 71 | readonly #wiresharkSocket: DgramSocket; 72 | 73 | constructor(portOptions: PortOptions, streamRawConfig: StreamRawConfig, netParams: NetworkParameters, sendMACToZEP: boolean) { 74 | this.#wiresharkSeqNum = 0; // start at 1 75 | this.#wiresharkSocket = createSocket("udp4"); 76 | this.#wiresharkPort = process.env.WIRESHARK_ZEP_PORT ? Number.parseInt(process.env.WIRESHARK_ZEP_PORT) : DEFAULT_ZEP_UDP_PORT; 77 | this.#wiresharkAddress = process.env.WIRESHARK_ADDRESS ? process.env.WIRESHARK_ADDRESS : DEFAULT_WIRESHARK_IP; 78 | this.#wiresharkSocket.bind(this.#wiresharkPort); 79 | 80 | this.driver = new OTRCPDriver(streamRawConfig, netParams, ".", sendMACToZEP); 81 | 82 | this.#portOptions = portOptions; 83 | this.#closing = false; 84 | 85 | if (sendMACToZEP) { 86 | this.driver.on("macFrame", (payload, rssi) => { 87 | const wsZEPFrame = createWiresharkZEPFrame(this.driver.netParams.channel, 1, 0, rssi ?? 0, this.nextWiresharkSeqNum(), payload); 88 | 89 | this.#wiresharkSocket.send(wsZEPFrame, this.#wiresharkPort, this.#wiresharkAddress); 90 | }); 91 | } 92 | 93 | // noop logger as needed 94 | // setLogger({ debug: () => {}, info: () => {}, warning: () => {}, error: () => {}}); 95 | } 96 | 97 | /** 98 | * Check if port is valid, open, and not closing. 99 | */ 100 | get portOpen(): boolean { 101 | if (this.#closing) { 102 | return false; 103 | } 104 | 105 | if (isTcpPath(this.#portOptions.path!)) { 106 | return this.#socketPort ? !this.#socketPort.closed : false; 107 | } 108 | 109 | return this.#serialPort ? this.#serialPort.isOpen : false; 110 | } 111 | 112 | private nextWiresharkSeqNum(): number { 113 | this.#wiresharkSeqNum = (this.#wiresharkSeqNum + 1) & 0xffffffff; 114 | 115 | return this.#wiresharkSeqNum + 1; 116 | } 117 | 118 | /** 119 | * Init the serial or socket port and hook parser/writer. 120 | */ 121 | public async initPort(): Promise { 122 | await this.closePort(); // will do nothing if nothing's open 123 | 124 | if (isTcpPath(this.#portOptions.path!)) { 125 | const pathUrl = new URL(this.#portOptions.path!); 126 | const hostname = pathUrl.hostname; 127 | const port = Number.parseInt(pathUrl.port, 10); 128 | 129 | logger.debug(() => `Opening TCP socket with ${hostname}:${port}`, NS); 130 | 131 | this.#socketPort = new Socket(); 132 | 133 | this.#socketPort.setNoDelay(true); 134 | this.#socketPort.setKeepAlive(true, 15000); 135 | this.driver.writer.pipe(this.#socketPort); 136 | this.#socketPort.pipe(this.driver.parser); 137 | this.driver.parser.on("data", this.driver.onFrame.bind(this.driver)); 138 | 139 | return await new Promise((resolve, reject): void => { 140 | const openError = async (err: Error): Promise => { 141 | await this.stop(); 142 | 143 | reject(err); 144 | }; 145 | 146 | this.#socketPort!.on("connect", () => { 147 | logger.debug(() => "Socket connected", NS); 148 | }); 149 | this.#socketPort!.on("ready", (): void => { 150 | logger.info("Socket ready", NS); 151 | this.#socketPort!.removeListener("error", openError); 152 | this.#socketPort!.once("close", this.onPortClose.bind(this)); 153 | this.#socketPort!.on("error", this.onPortError.bind(this)); 154 | 155 | resolve(); 156 | }); 157 | this.#socketPort!.once("error", openError); 158 | 159 | this.#socketPort!.connect(port, hostname); 160 | }); 161 | } 162 | 163 | const serialOpts = { 164 | path: this.#portOptions.path!, 165 | baudRate: typeof this.#portOptions.baudRate === "number" ? this.#portOptions.baudRate : 460800, 166 | rtscts: typeof this.#portOptions.rtscts === "boolean" ? this.#portOptions.rtscts : false, 167 | autoOpen: false, 168 | parity: "none" as const, 169 | stopBits: 1 as const, 170 | xon: false, 171 | xoff: false, 172 | }; 173 | 174 | // enable software flow control if RTS/CTS not enabled in config 175 | if (!serialOpts.rtscts) { 176 | logger.info("RTS/CTS config is off, enabling software flow control.", NS); 177 | serialOpts.xon = true; 178 | serialOpts.xoff = true; 179 | } 180 | 181 | logger.debug(() => `Opening serial port with [path=${serialOpts.path} baudRate=${serialOpts.baudRate} rtscts=${serialOpts.rtscts}]`, NS); 182 | this.#serialPort = new SerialPort(serialOpts); 183 | 184 | this.driver.writer.pipe(this.#serialPort); 185 | this.#serialPort.pipe(this.driver.parser); 186 | this.driver.parser.on("data", this.driver.onFrame.bind(this.driver)); 187 | 188 | try { 189 | await new Promise((resolve, reject): void => { 190 | this.#serialPort!.open((err) => (err ? reject(err) : resolve())); 191 | }); 192 | 193 | logger.info("Serial port opened", NS); 194 | 195 | this.#serialPort.once("close", this.onPortClose.bind(this)); 196 | this.#serialPort.on("error", this.onPortError.bind(this)); 197 | } catch (error) { 198 | logger.error(`Error opening port (${(error as Error).message})`, NS); 199 | 200 | await this.stop(); 201 | 202 | throw error; 203 | } 204 | } 205 | 206 | /** 207 | * Handle port closing 208 | * @param err A boolean for Socket, an Error for serialport 209 | */ 210 | private onPortClose(error: boolean | Error): void { 211 | if (error) { 212 | logger.error("Port closed unexpectedly.", NS); 213 | } else { 214 | logger.info("Port closed.", NS); 215 | } 216 | } 217 | 218 | /** 219 | * Handle port error 220 | * @param error 221 | */ 222 | private onPortError(error: Error): void { 223 | logger.error(`Port ${error}`, NS); 224 | 225 | throw new Error("Port error"); 226 | } 227 | 228 | public async start(options: StartOptions): Promise { 229 | await this.initPort(); 230 | 231 | if (!this.portOpen) { 232 | throw new Error("Invalid call to start"); 233 | } 234 | 235 | if (this.#serialPort) { 236 | // try clearing read/write buffers 237 | try { 238 | await new Promise((resolve, reject): void => { 239 | this.#serialPort!.flush((err) => (err ? reject(err) : resolve())); 240 | }); 241 | } catch (err) { 242 | logger.error(`Error while flushing serial port before start: ${err}`, NS); 243 | } 244 | } 245 | 246 | switch (options.mode) { 247 | case "form": { 248 | await this.driver.start(); 249 | await this.driver.formNetwork(); 250 | 251 | if (options.allowJoins) { 252 | // allow joins on start for 254 seconds 253 | this.driver.allowJoins(0xfe, true); 254 | this.driver.gpEnterCommissioningMode(0xfe); 255 | } 256 | 257 | break; 258 | } 259 | case "scan": { 260 | try { 261 | for (const channel of options.channels) { 262 | // NOTE: using multiple channels in the prop at once doesn't seem to work (INVALID_ARGUMENT) 263 | await this.driver.startEnergyScan([channel], options.period, options.txPower); 264 | await setTimeout(options.period * 1.25); 265 | await this.driver.stopEnergyScan(); 266 | await setTimeout(1000); 267 | } 268 | } catch (error) { 269 | logger.error(`Failed to scan (${(error as Error).message})`, NS); 270 | 271 | await this.driver.stopEnergyScan(); 272 | } 273 | 274 | await this.stop(); 275 | 276 | break; 277 | } 278 | case "sniff": { 279 | await this.driver.startSniffer(options.channel); 280 | 281 | break; 282 | } 283 | case "reset": { 284 | if (options.type === "stack") { 285 | await this.driver.resetStack(); 286 | } else if (options.type === "bootloader") { 287 | await this.driver.resetIntoBootloader(); 288 | } 289 | 290 | await this.stop(); 291 | 292 | break; 293 | } 294 | } 295 | 296 | this.driver.on("frame", this.onFrame.bind(this)); 297 | this.driver.on("deviceJoined", this.onDeviceJoined.bind(this)); 298 | this.driver.on("deviceRejoined", this.onDeviceRejoined.bind(this)); 299 | this.driver.on("deviceLeft", this.onDeviceLeft.bind(this)); 300 | } 301 | 302 | public async stop(): Promise { 303 | this.#closing = true; 304 | 305 | this.driver.removeAllListeners(); 306 | 307 | if (this.#serialPort?.isOpen || (this.#socketPort != null && !this.#socketPort.closed)) { 308 | try { 309 | await this.driver.stop(); 310 | } catch (error) { 311 | console.error(`Failed to stop cleanly (${(error as Error).message}). You may need to power-cycle your adapter.`); 312 | } 313 | 314 | try { 315 | await this.closePort(); 316 | } catch (error) { 317 | console.error(`Failed to close port cleanly (${(error as Error).message}). You may need to power-cycle your adapter.`); 318 | } 319 | } 320 | 321 | this.#wiresharkSocket.close(); 322 | } 323 | 324 | public async closePort(): Promise { 325 | if (this.#serialPort?.isOpen) { 326 | try { 327 | await new Promise((resolve, reject): void => { 328 | this.#serialPort!.flush((err) => (err ? reject(err) : resolve())); 329 | }); 330 | 331 | await new Promise((resolve, reject): void => { 332 | this.#serialPort!.close((err) => (err ? reject(err) : resolve())); 333 | }); 334 | } catch (err) { 335 | logger.error(`Failed to close serial port ${err}.`, NS); 336 | } 337 | 338 | this.#serialPort.removeAllListeners(); 339 | 340 | this.#serialPort = undefined; 341 | } else if (this.#socketPort != null && !this.#socketPort.closed) { 342 | this.#socketPort.destroy(); 343 | this.#socketPort.removeAllListeners(); 344 | 345 | this.#socketPort = undefined; 346 | } 347 | } 348 | 349 | private onFrame(_sender16: number | undefined, _sender64: bigint | undefined, _apsHeader: ZigbeeAPSHeader, _apsPayload: ZigbeeAPSPayload): void { 350 | // as needed for testing 351 | } 352 | 353 | private onDeviceJoined(_source16: number, _source64: bigint | undefined): void { 354 | // as needed for testing 355 | } 356 | 357 | private onDeviceRejoined(_source16: number, _source64: bigint | undefined): void { 358 | // as needed for testing 359 | } 360 | 361 | private onDeviceLeft(_source16: number, _source64: bigint): void { 362 | // as needed for testing 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/dev/wireshark.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_WIRESHARK_IP = "127.0.0.1"; 2 | export const DEFAULT_ZEP_UDP_PORT = 17754; 3 | 4 | /** 5 | * @see https://github.com/wireshark/wireshark/blob/master/epan/dissectors/packet-zep.c 6 | * @see https://github.com/wireshark/wireshark/blob/master/epan/dissectors/packet-ieee802154.c 7 | * @see https://github.com/wireshark/wireshark/blob/master/epan/dissectors/packet-zbee-nwk.c 8 | *------------------------------------------------------------ 9 | * 10 | * ZEP Packets must be received in the following format: 11 | * |UDP Header| ZEP Header |IEEE 802.15.4 Packet| 12 | * | 8 bytes | 16/32 bytes | <= 127 bytes | 13 | *------------------------------------------------------------ 14 | * 15 | * ZEP v1 Header will have the following format: 16 | * |Preamble|Version|Channel ID|Device ID|CRC/LQI Mode|LQI Val|Reserved|Length| 17 | * |2 bytes |1 byte | 1 byte | 2 bytes | 1 byte |1 byte |7 bytes |1 byte| 18 | * 19 | * ZEP v2 Header will have the following format (if type=1/Data): 20 | * |Preamble|Version| Type |Channel ID|Device ID|CRC/LQI Mode|LQI Val|NTP Timestamp|Sequence#|Reserved|Length| 21 | * |2 bytes |1 byte |1 byte| 1 byte | 2 bytes | 1 byte |1 byte | 8 bytes | 4 bytes |10 bytes|1 byte| 22 | * 23 | * ZEP v2 Header will have the following format (if type=2/Ack): 24 | * |Preamble|Version| Type |Sequence#| 25 | * |2 bytes |1 byte |1 byte| 4 bytes | 26 | *------------------------------------------------------------ 27 | */ 28 | const ZEP_PREAMBLE_NUM = 0x5845; // 'EX' 29 | const ZEP_PROTOCOL_VERSION = 2; 30 | const ZEP_PROTOCOL_TYPE = 1; 31 | /** Baseline NTP time if bit-0=0 -> 7-Feb-2036 @ 06:28:16 UTC */ 32 | const NTP_MSB_0_BASE_TIME = 2085978496000n; 33 | /** Baseline NTP time if bit-0=1 -> 1-Jan-1900 @ 01:00:00 UTC */ 34 | const NTP_MSB_1_BASE_TIME = -2208988800000n; 35 | 36 | const getZepTimestamp = (): bigint => { 37 | const now = BigInt(Date.now()); 38 | const useBase1 = now < NTP_MSB_0_BASE_TIME; // time < Feb-2036 39 | // MSB_1_BASE_TIME: dates <= Feb-2036, MSB_0_BASE_TIME: if base0 needed for dates >= Feb-2036 40 | const baseTime = now - (useBase1 ? NTP_MSB_1_BASE_TIME : NTP_MSB_0_BASE_TIME); 41 | let seconds = baseTime / 1000n; 42 | const fraction = ((baseTime % 1000n) * 0x100000000n) / 1000n; 43 | 44 | if (useBase1) { 45 | seconds |= 0x80000000n; // set high-order bit if MSB_1_BASE_TIME 1900 used 46 | } 47 | 48 | return BigInt.asIntN(64, (seconds << 32n) | fraction); 49 | }; 50 | 51 | export const createWiresharkZEPFrame = ( 52 | channelId: number, 53 | deviceId: number, 54 | lqi: number, 55 | rssi: number, 56 | sequence: number, 57 | data: Buffer, 58 | lqiMode = false, 59 | ): Buffer => { 60 | const payload = Buffer.alloc(32 + data.byteLength); 61 | let offset = 0; 62 | 63 | // The IEEE 802.15.4 packet encapsulated in the ZEP frame must have the "TI CC24xx" format 64 | // See figure 21 on page 24 of the CC2420 datasheet: https://www.ti.com/lit/ds/symlink/cc2420.pdf 65 | // So, two bytes must be added at the end: 66 | // * First byte: RSSI value as a signed 8 bits integer (range -128 to 127) 67 | // * Second byte: 68 | // - the most significant bit is set to 1 if the CRC of the frame is correct 69 | // - the 7 least significant bits contain the LQI value as a unsigned 7 bits integer (range 0 to 127) 70 | data[data.length - 2] = rssi; 71 | data[data.length - 1] = 0x80 | ((lqi >> 1) & 0x7f); 72 | 73 | // Protocol ID String | Character string | 2.0.3 to 4.2.5 74 | payload.writeUInt16LE(ZEP_PREAMBLE_NUM, offset); 75 | offset += 2; 76 | // Protocol Version | Unsigned integer (8 bits) | 1.2.0 to 4.2.5 77 | payload.writeUInt8(ZEP_PROTOCOL_VERSION, offset); 78 | offset += 1; 79 | // Type | Unsigned integer (8 bits) | 1.2.0 to 1.8.15, 1.12.0 to 4.2.5 80 | payload.writeUInt8(ZEP_PROTOCOL_TYPE, offset); 81 | offset += 1; 82 | // Channel ID | Unsigned integer (8 bits) | 1.2.0 to 4.2.5 83 | payload.writeUInt8(channelId, offset); 84 | offset += 1; 85 | // Device ID | Unsigned integer (16 bits) | 1.2.0 to 4.2.5 86 | payload.writeUint16BE(deviceId, offset); 87 | offset += 2; 88 | 89 | // LQI/CRC Mode | Boolean | 1.2.0 to 4.2.5 90 | payload.writeUInt8(lqiMode ? 1 : 0, offset); 91 | offset += 1; 92 | // Link Quality Indication | Unsigned integer (8 bits) | 1.2.0 to 4.2.5 93 | payload.writeUInt8(lqi, offset); 94 | offset += 1; 95 | 96 | // Timestamp | Date and time | 1.2.0 to 4.2.5 97 | payload.writeBigInt64BE(getZepTimestamp(), offset); 98 | offset += 8; 99 | 100 | // Sequence Number | Unsigned integer (32 bits) | 1.2.0 to 4.2.5 101 | payload.writeUInt32BE(sequence, offset); 102 | offset += 4; 103 | 104 | // Reserved Fields | Byte sequence | 2.0.0 to 4.2.5 105 | offset += 10; 106 | 107 | // Length | Unsigned integer (8 bits) | 1.2.0 to 4.2.5 108 | payload.writeUInt8(data.byteLength, offset); 109 | offset += 1; 110 | 111 | payload.set(data, offset); 112 | offset += data.length; 113 | 114 | return payload; 115 | }; 116 | -------------------------------------------------------------------------------- /src/dev/z2mdata-to-zohsave.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { existsSync } from "node:fs"; 3 | import { readFile } from "node:fs/promises"; 4 | import { join } from "node:path"; 5 | import { OTRCPDriver } from "../drivers/ot-rcp-driver"; 6 | 7 | type DeviceDatabaseEntry = { 8 | id: number; 9 | type: "Coordinator" | "Router" | "EndDevice" | "Unknown" | "GreenPower"; 10 | nwkAddr: number; 11 | ieeeAddr: string; 12 | powerSource?: 13 | | "Unknown" 14 | | "Mains (single phase)" 15 | | "Mains (3 phase)" 16 | | "Battery" 17 | | "DC Source" 18 | | "Emergency mains constantly powered" 19 | | "Emergency mains and transfer switch"; 20 | interviewCompleted: boolean; 21 | }; 22 | type GroupDatabaseEntry = { 23 | id: number; 24 | type: "Group"; 25 | members: { deviceIeeeAddr: string; endpointID: number }[]; 26 | groupID: number; 27 | }; 28 | 29 | type DatabaseEntry = DeviceDatabaseEntry | GroupDatabaseEntry; 30 | 31 | async function openDb(path: string): Promise<[devices: DeviceDatabaseEntry[], groups: GroupDatabaseEntry[]]> { 32 | const devices: DeviceDatabaseEntry[] = []; 33 | const groups: GroupDatabaseEntry[] = []; 34 | 35 | if (existsSync(path)) { 36 | const file = await readFile(path, "utf-8"); 37 | 38 | for (const row of file.split("\n")) { 39 | if (!row) { 40 | continue; 41 | } 42 | 43 | try { 44 | const json = JSON.parse(row) as DatabaseEntry; 45 | 46 | switch (json.type) { 47 | case "Group": { 48 | groups.push(json); 49 | break; 50 | } 51 | case "EndDevice": 52 | case "Router": 53 | case "GreenPower": 54 | case "Unknown": { 55 | devices.push(json); 56 | break; 57 | } 58 | } 59 | } catch (error) { 60 | console.error(`Corrupted database line, ignoring. ${error}`); 61 | } 62 | } 63 | } else { 64 | console.error(`Invalid DB path ${path}`); 65 | } 66 | 67 | return [devices, groups]; 68 | } 69 | 70 | export interface UnifiedBackupStorage { 71 | metadata: { 72 | format: "zigpy/open-coordinator-backup"; 73 | version: 1; 74 | source: string; 75 | internal: { 76 | /* zigbee-herdsman specific data */ 77 | date: string; 78 | znpVersion?: number; 79 | ezspVersion?: number; 80 | 81 | [key: string]: unknown; 82 | }; 83 | }; 84 | // biome-ignore lint/style/useNamingConvention: out of control 85 | stack_specific?: { 86 | zstack?: { 87 | // biome-ignore lint/style/useNamingConvention: out of control 88 | tclk_seed?: string; 89 | }; 90 | ezsp?: { 91 | // biome-ignore lint/style/useNamingConvention: out of control 92 | hashed_tclk?: string; 93 | }; 94 | }; 95 | // biome-ignore lint/style/useNamingConvention: out of control 96 | coordinator_ieee: string; 97 | // biome-ignore lint/style/useNamingConvention: out of control 98 | pan_id: string; 99 | // biome-ignore lint/style/useNamingConvention: out of control 100 | extended_pan_id: string; 101 | // biome-ignore lint/style/useNamingConvention: out of control 102 | security_level: number; 103 | // biome-ignore lint/style/useNamingConvention: out of control 104 | nwk_update_id: number; 105 | channel: number; 106 | // biome-ignore lint/style/useNamingConvention: out of control 107 | channel_mask: number[]; 108 | // biome-ignore lint/style/useNamingConvention: out of control 109 | network_key: { 110 | key: string; 111 | // biome-ignore lint/style/useNamingConvention: out of control 112 | sequence_number: number; 113 | // biome-ignore lint/style/useNamingConvention: out of control 114 | frame_counter: number; 115 | }; 116 | devices: { 117 | // biome-ignore lint/style/useNamingConvention: out of control 118 | nwk_address: string | null; 119 | // biome-ignore lint/style/useNamingConvention: out of control 120 | ieee_address: string; 121 | // biome-ignore lint/style/useNamingConvention: out of control 122 | is_child: boolean; 123 | // biome-ignore lint/style/useNamingConvention: out of control 124 | link_key: { key: string; rx_counter: number; tx_counter: number } | undefined; 125 | }[]; 126 | } 127 | 128 | function findDeviceInBackup(backup: UnifiedBackupStorage, ieeeAddress: string): UnifiedBackupStorage["devices"][number] | undefined { 129 | return backup.devices.find((d) => d.ieee_address === ieeeAddress.slice(2 /* 0x */)); 130 | } 131 | 132 | async function convert(dataPath: string): Promise { 133 | const backup = JSON.parse(await readFile(join(dataPath, "coordinator_backup.json"), "utf8")) as UnifiedBackupStorage; 134 | 135 | if (backup.metadata.version !== 1) { 136 | throw new Error(`Coordinator Backup of version ${backup.metadata.version} not supported`); 137 | } 138 | 139 | if (!backup.metadata.source.includes("zigbee-herdsman")) { 140 | throw new Error("Coordinator Backup not from Zigbee2MQTT not supported"); 141 | } 142 | 143 | const isEmber = Boolean(backup.stack_specific?.ezsp?.hashed_tclk); 144 | const isZstack = Boolean(backup.stack_specific?.zstack?.tclk_seed); 145 | 146 | if (!isEmber && !isZstack) { 147 | throw new Error("Coordinator Backup not from [ember, zstack] drivers not supported"); 148 | } 149 | 150 | let txPower = 5; 151 | 152 | if (existsSync(join(dataPath, "configuration.yaml"))) { 153 | const conf = await readFile(join(dataPath, "configuration.yaml"), "utf8"); 154 | const txPowerMatch = conf.match(/transmit_power: (\d*)$/m); 155 | 156 | if (txPowerMatch) { 157 | txPower = Number.parseInt(txPowerMatch[1]); 158 | } 159 | } 160 | 161 | const eui64Buf = Buffer.from(backup.coordinator_ieee, "hex"); 162 | const eui64 = isEmber ? eui64Buf.readBigUInt64LE(0) : /* isZstack */ eui64Buf.readBigUInt64BE(0); 163 | const panId = Number.parseInt(backup.pan_id, 16); 164 | const extendedPANId = Buffer.from(backup.extended_pan_id, "hex").readBigUInt64LE(0); 165 | const channel = backup.channel; 166 | const nwkUpdateId = backup.nwk_update_id; 167 | const networkKey = Buffer.from(backup.network_key.key, "hex"); 168 | const networkKeyFrameCounter = backup.network_key.frame_counter; 169 | const networkKeySequenceNumber = backup.network_key.sequence_number; 170 | 171 | let driver = new OTRCPDriver( 172 | // @ts-expect-error not needed here 173 | {}, 174 | { 175 | eui64, 176 | panId, 177 | extendedPANId, 178 | channel, 179 | nwkUpdateId, 180 | txPower, 181 | // ZigBeeAlliance09 182 | tcKey: Buffer.from([0x5a, 0x69, 0x67, 0x42, 0x65, 0x65, 0x41, 0x6c, 0x6c, 0x69, 0x61, 0x6e, 0x63, 0x65, 0x30, 0x39]), 183 | tcKeyFrameCounter: 0, 184 | networkKey, 185 | networkKeyFrameCounter, 186 | networkKeySequenceNumber, 187 | }, 188 | dataPath, 189 | ); 190 | const [devices /*, group*/] = await openDb(join(dataPath, "database.db")); 191 | 192 | for (const device of devices) { 193 | const backupDevice = findDeviceInBackup(backup, device.ieeeAddr); 194 | 195 | driver.deviceTable.set(BigInt(device.ieeeAddr), { 196 | address16: device.nwkAddr, 197 | // this could be... wrong, devices not always use this properly 198 | capabilities: { 199 | alternatePANCoordinator: false, 200 | deviceType: device.type === "Router" ? 0x01 : 0x00, 201 | powerSource: device.powerSource !== "Unknown" && device.powerSource !== "Battery" ? 0x01 : 0x00, 202 | rxOnWhenIdle: device.type === "Router" && device.powerSource !== "Unknown" && device.powerSource !== "Battery", 203 | securityCapability: false, 204 | allocateAddress: true, 205 | }, 206 | // technically not correct, but reasonable expectation 207 | authorized: device.interviewCompleted === true, 208 | // add support for not knowing this in driver (re-evaluation) 209 | neighbor: backupDevice?.is_child !== true, 210 | recentLQAs: [], 211 | }); 212 | } 213 | 214 | // for (const group of groups) {} 215 | 216 | await driver.saveState(); 217 | 218 | driver = new OTRCPDriver( 219 | // @ts-expect-error not needed here 220 | {}, 221 | { 222 | eui64: 0n, 223 | panId: 0, 224 | extendedPANId: 0n, 225 | channel: 0, 226 | nwkUpdateId: -1, 227 | txPower: 5, 228 | // ZigBeeAlliance09 229 | tcKey: Buffer.from([0x5a, 0x69, 0x67, 0x42, 0x65, 0x65, 0x41, 0x6c, 0x6c, 0x69, 0x61, 0x6e, 0x63, 0x65, 0x30, 0x39]), 230 | tcKeyFrameCounter: 0, 231 | networkKey: Buffer.alloc(16), 232 | networkKeyFrameCounter: 0, 233 | networkKeySequenceNumber: 0, 234 | }, 235 | dataPath, 236 | ); 237 | 238 | await driver.loadState(); 239 | 240 | assert(driver.netParams.eui64 === eui64); 241 | assert(driver.netParams.panId === panId); 242 | assert(driver.netParams.extendedPANId === extendedPANId); 243 | assert(driver.netParams.nwkUpdateId === nwkUpdateId); 244 | assert(driver.netParams.networkKey.equals(networkKey)); 245 | assert(driver.netParams.networkKeyFrameCounter === networkKeyFrameCounter + 1024); 246 | assert(driver.netParams.networkKeySequenceNumber === networkKeySequenceNumber); 247 | 248 | for (const device of devices) { 249 | assert(driver.deviceTable.get(BigInt(device.ieeeAddr))); 250 | } 251 | } 252 | 253 | if (require.main === module) { 254 | const dataPath = process.argv[2]; 255 | 256 | if (!dataPath || dataPath === "help") { 257 | console.log("Create a 'zoh.save' from the content of a Zigbee2MQTT data folder."); 258 | console.log("The presence and validity of these files is required for this operation:"); 259 | console.log(" - coordinator_backup.json"); 260 | console.log(" - database.db"); 261 | console.log("Allows to quickly take over a network created by another Zigbee2MQTT driver ('ember', 'zstack')."); 262 | console.log("Usage:"); 263 | console.log("node ./dist/dev/z2mdata-to-zohsave.js ../path/to/data/"); 264 | } else { 265 | console.log("Using: ", dataPath); 266 | 267 | if (!existsSync(join(dataPath, "coordinator_backup.json"))) { 268 | throw new Error(`No 'coordinator_backup.json' exists at ${dataPath}`); 269 | } 270 | 271 | if (!existsSync(join(dataPath, "database.db"))) { 272 | throw new Error(`No 'database.db' exists at ${dataPath}`); 273 | } 274 | 275 | void convert(dataPath); 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/dev/zohsave-to-readable.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "node:fs"; 2 | import { writeFile } from "node:fs/promises"; 3 | import { join } from "node:path"; 4 | import { OTRCPDriver } from "../drivers/ot-rcp-driver"; 5 | 6 | async function printSave(dataPath: string): Promise { 7 | const driver = new OTRCPDriver( 8 | // @ts-expect-error not needed here 9 | {}, 10 | { 11 | eui64: 0n, 12 | panId: 0, 13 | extendedPANId: 0n, 14 | channel: 0, 15 | nwkUpdateId: -1, 16 | txPower: 5, 17 | // ZigBeeAlliance09 18 | tcKey: Buffer.from([0x5a, 0x69, 0x67, 0x42, 0x65, 0x65, 0x41, 0x6c, 0x6c, 0x69, 0x61, 0x6e, 0x63, 0x65, 0x30, 0x39]), 19 | tcKeyFrameCounter: 0, 20 | networkKey: Buffer.alloc(16), 21 | networkKeyFrameCounter: 0, 22 | networkKeySequenceNumber: 0, 23 | }, 24 | dataPath, 25 | ); 26 | 27 | await driver.loadState(); 28 | 29 | // @ts-expect-error workaround 30 | BigInt.prototype.toJSON = function (): string { 31 | return `0x${this.toString(16).padStart(16, "0")}`; 32 | }; 33 | Buffer.prototype.toJSON = function (): string { 34 | return this.toString("hex"); 35 | }; 36 | 37 | const netParamsJson = JSON.stringify(driver.netParams, undefined, 2); 38 | 39 | console.log(netParamsJson); 40 | 41 | await writeFile(join(dataPath, "zohsave-netparams.json"), netParamsJson, "utf8"); 42 | 43 | const devices = []; 44 | 45 | for (const [addr64, device] of driver.deviceTable) { 46 | devices.push({ addr64, ...device }); 47 | } 48 | 49 | const devicesJson = JSON.stringify(devices, undefined, 2); 50 | 51 | console.log(devicesJson); 52 | 53 | await writeFile(join(dataPath, "zohsave-devices.json"), devicesJson, "utf8"); 54 | 55 | const routes = []; 56 | 57 | for (const [addr16, entries] of driver.sourceRouteTable) { 58 | routes.push({ addr16, entries }); 59 | } 60 | 61 | const routesJson = JSON.stringify(routes, undefined, 2); 62 | 63 | console.log(routesJson); 64 | 65 | await writeFile(join(dataPath, "zohsave-routes.json"), routesJson, "utf8"); 66 | } 67 | 68 | if (require.main === module) { 69 | const dataPath = process.argv[2]; 70 | 71 | if (!dataPath || dataPath === "help") { 72 | console.log("Print and save the content of the 'zoh.save' in the given directory in human-readable format (as JSON, in same directory)."); 73 | console.log("Usage:"); 74 | console.log("node ./dist/dev/zohsave-to-readable.js ../path/to/data/"); 75 | } else { 76 | console.log("Using: ", dataPath); 77 | 78 | if (!existsSync(join(dataPath, "zoh.save"))) { 79 | throw new Error(`No 'zoh.save' exists at ${dataPath}`); 80 | } 81 | 82 | void printSave(dataPath); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/drivers/descriptors.ts: -------------------------------------------------------------------------------- 1 | import { ZigbeeMACConsts } from "../zigbee/mac.js"; 2 | import { ZigbeeConsts } from "../zigbee/zigbee.js"; 3 | 4 | const MAC_CAPABILITIES = 5 | (1 & 0x01) | // alternatePANCoordinator 6 | ((1 << 1) & 0x02) | // deviceType 7 | ((1 << 2) & 0x04) | // powerSource 8 | ((1 << 3) & 0x08) | // rxOnWhenIdle 9 | ((0 << 4) & 0x10) | // reserved1 10 | ((0 << 5) & 0x20) | // reserved2 11 | ((0 << 6) & 0x40) | // securityCapability 12 | ((1 << 7) & 0x80); // allocateAddress 13 | const MANUFACTURER_CODE = 0xc5a0; // CONNECTIVITY_STANDARDS_ALLIANCE 14 | const SERVER_MASK = 15 | (1 & 0x01) | // primaryTrustCenter 16 | ((0 << 1) & 0x02) | // backupTrustCenter 17 | ((0 << 2) & 0x04) | // deprecated1 18 | ((0 << 3) & 0x08) | // deprecated2 19 | ((0 << 4) & 0x10) | // deprecated3 20 | ((0 << 5) & 0x20) | // deprecated4 21 | ((1 << 6) & 0x40) | // networkManager 22 | ((0 << 7) & 0x80) | // reserved1 23 | ((0 << 8) & 0x100) | // reserved2 24 | ((22 << 9) & 0xfe00); // stackComplianceRevision TODO: update to 23 once properly supported 25 | 26 | const EP_HA = 1; 27 | const EP_HA_PROFILE_ID = 0x0104; 28 | const EP_HA_DEVICE_ID = 0x65; 29 | const EP_HA_INPUT_CLUSTERS = [ 30 | 0x0000, // Basic 31 | 0x0003, // Identify 32 | 0x0006, // On/off 33 | 0x0008, // Level Control 34 | 0x000a, // Time 35 | 0x0019, // Over the Air Bootloading 36 | // Cluster.genPowerProfile.ID,// 0x001A, // Power Profile XXX: missing ZCL cluster def in Z2M? 37 | 0x0300, // Color Control 38 | ]; 39 | const EP_HA_OUTPUT_CLUSTERS = [ 40 | 0x0000, // Basic 41 | 0x0003, // Identify 42 | 0x0004, // Groups 43 | 0x0005, // Scenes 44 | 0x0006, // On/off 45 | 0x0008, // Level Control 46 | 0x0020, // Poll Control 47 | 0x0300, // Color Control 48 | 0x0400, // Illuminance Measurement 49 | 0x0402, // Temperature Measurement 50 | 0x0405, // Relative Humidity Measurement 51 | 0x0406, // Occupancy Sensing 52 | 0x0500, // IAS Zone 53 | 0x0702, // Simple Metering 54 | 0x0b01, // Meter Identification 55 | 0x0b03, // Appliance Statistics 56 | 0x0b04, // Electrical Measurement 57 | 0x1000, // touchlink 58 | ]; 59 | const EP_GP = 242; 60 | const EP_GP_PROFILE_ID = 0xa1e0; 61 | const EP_GP_DEVICE_ID = 0x66; 62 | const EP_GP_INPUT_CLUSTERS = [ 63 | 0x0021, // Green Power 64 | ]; 65 | const EP_GP_OUTPUT_CLUSTERS = [ 66 | 0x0021, // Green Power 67 | ]; 68 | 69 | const ACTIVE_ENDPOINTS_RESPONSE = [0x00, 0x00, 0x00, 0x00, 2, EP_HA, EP_GP]; 70 | 71 | export function encodeCoordinatorDescriptors(eui64: bigint): [address: Buffer, node: Buffer, power: Buffer, simple: Buffer, activeEndpoints: Buffer] { 72 | // works for both NETWORK & IEEE response 73 | const address = Buffer.alloc(12); 74 | let offset = 2; // skip seqNum (set on use), status 75 | 76 | address.writeBigUInt64LE(eui64, offset); 77 | offset += 8; 78 | address.writeUInt16LE(ZigbeeConsts.COORDINATOR_ADDRESS, offset); 79 | offset += 2; 80 | 81 | const node = Buffer.alloc(17); 82 | offset = 4; // skip seqNum (set on use), status, nwkAddress 83 | 84 | node.writeUInt8( 85 | (0 & 0x07) | // logicalType 86 | ((0 << 5) & 0x20), // fragmentationSupported 87 | offset, 88 | ); 89 | offset += 1; 90 | node.writeUInt8( 91 | (0 & 0x07) | // apsFlags 92 | ((8 << 3) & 0xf8), // frequencyBand 93 | offset, 94 | ); 95 | offset += 1; 96 | node.writeUInt8(MAC_CAPABILITIES, offset); 97 | offset += 1; 98 | node.writeUInt16LE(MANUFACTURER_CODE, offset); 99 | offset += 2; 100 | node.writeUInt8(0x7f, offset); 101 | offset += 1; 102 | node.writeUInt16LE(ZigbeeMACConsts.FRAME_MAX_SIZE, offset); 103 | offset += 2; 104 | node.writeUInt16LE(SERVER_MASK, offset); 105 | offset += 2; 106 | node.writeUInt16LE(ZigbeeMACConsts.FRAME_MAX_SIZE, offset); 107 | offset += 2; 108 | // skip deprecated 109 | offset += 1; 110 | 111 | const power = Buffer.alloc(6); 112 | offset = 4; // skip seqNum (set on use), status, nwkAddress 113 | 114 | power.writeUInt8( 115 | (0 & 0xf) | // currentPowerMode 116 | ((0 & 0xf) << 4), // availPowerSources 117 | offset, 118 | ); 119 | offset += 1; 120 | power.writeUInt8( 121 | (0 & 0xf) | // currentPowerSource 122 | ((0b1100 & 0xf) << 4), // currentPowerSourceLevel 123 | offset, 124 | ); 125 | offset += 1; 126 | 127 | const simple = Buffer.alloc( 128 | 21 + 129 | (EP_HA_INPUT_CLUSTERS.length + EP_HA_OUTPUT_CLUSTERS.length + EP_GP_INPUT_CLUSTERS.length + EP_GP_OUTPUT_CLUSTERS.length) * 130 | 2 /* uint16_t */, 131 | ); 132 | offset = 4; // skip seqNum (set on use), status, nwkAddress 133 | 134 | simple.writeUInt8( 135 | 16 + 136 | (EP_HA_INPUT_CLUSTERS.length + EP_HA_OUTPUT_CLUSTERS.length + EP_GP_INPUT_CLUSTERS.length + EP_GP_OUTPUT_CLUSTERS.length) * 137 | 2 /* uint16_t */, 138 | offset, 139 | ); 140 | offset += 1; 141 | // HA endpoint 142 | simple.writeUInt8(EP_HA, offset); 143 | offset += 1; 144 | simple.writeUInt16LE(EP_HA_PROFILE_ID, offset); 145 | offset += 2; 146 | simple.writeUInt16LE(EP_HA_DEVICE_ID, offset); 147 | offset += 2; 148 | simple.writeUInt8(1, offset); 149 | offset += 1; 150 | simple.writeUInt8(EP_HA_INPUT_CLUSTERS.length, offset); 151 | offset += 1; 152 | 153 | for (const haInCluster of EP_HA_INPUT_CLUSTERS) { 154 | simple.writeUInt16LE(haInCluster, offset); 155 | offset += 2; 156 | } 157 | 158 | simple.writeUInt8(EP_HA_OUTPUT_CLUSTERS.length, offset); 159 | offset += 1; 160 | 161 | for (const haOutCluster of EP_HA_OUTPUT_CLUSTERS) { 162 | simple.writeUInt16LE(haOutCluster, offset); 163 | offset += 2; 164 | } 165 | 166 | // GP endpoint 167 | simple.writeUInt8(EP_GP, offset); 168 | offset += 1; 169 | simple.writeUInt16LE(EP_GP_PROFILE_ID, offset); 170 | offset += 2; 171 | simple.writeUInt16LE(EP_GP_DEVICE_ID, offset); 172 | offset += 2; 173 | simple.writeUInt8(1, offset); 174 | offset += 1; 175 | simple.writeUInt8(EP_GP_INPUT_CLUSTERS.length, offset); 176 | offset += 1; 177 | 178 | for (const gpInCluster of EP_GP_INPUT_CLUSTERS) { 179 | simple.writeUInt16LE(gpInCluster, offset); 180 | offset += 2; 181 | } 182 | 183 | simple.writeUInt8(EP_GP_OUTPUT_CLUSTERS.length, offset); 184 | offset += 1; 185 | 186 | for (const gpOutCluster of EP_GP_OUTPUT_CLUSTERS) { 187 | simple.writeUInt16LE(gpOutCluster, offset); 188 | offset += 2; 189 | } 190 | 191 | const activeEndpoints = Buffer.from(ACTIVE_ENDPOINTS_RESPONSE); 192 | 193 | return [address, node, power, simple, activeEndpoints]; 194 | } 195 | -------------------------------------------------------------------------------- /src/drivers/ot-rcp-parser.ts: -------------------------------------------------------------------------------- 1 | import { Transform, type TransformCallback, type TransformOptions } from "node:stream"; 2 | 3 | import { HdlcReservedByte } from "../spinel/hdlc.js"; 4 | import { logger } from "../utils/logger.js"; 5 | 6 | const NS = "ot-rcp-driver:parser"; 7 | 8 | export class OTRCPParser extends Transform { 9 | #buffer: Buffer; 10 | 11 | public constructor(opts?: TransformOptions) { 12 | super(opts); 13 | 14 | this.#buffer = Buffer.alloc(0); 15 | } 16 | 17 | override _transform(chunk: Buffer, _encoding: BufferEncoding, cb: TransformCallback): void { 18 | let data = Buffer.concat([this.#buffer, chunk]); 19 | 20 | if (data[0] !== HdlcReservedByte.FLAG) { 21 | // discard data before FLAG 22 | data = data.subarray(data.indexOf(HdlcReservedByte.FLAG)); 23 | } 24 | 25 | let position: number = data.indexOf(HdlcReservedByte.FLAG, 1); 26 | 27 | while (position !== -1) { 28 | const endPosition = position + 1; 29 | 30 | // ignore repeated successive flags 31 | if (position > 1) { 32 | const frame = data.subarray(0, endPosition); 33 | 34 | logger.debug(() => `<<< FRAME[${frame.toString("hex")}]`, NS); 35 | 36 | this.push(frame); 37 | 38 | // remove the frame from internal buffer (set below) 39 | data = data.subarray(endPosition); 40 | } else { 41 | data = data.subarray(position); 42 | } 43 | 44 | position = data.indexOf(HdlcReservedByte.FLAG, 1); 45 | } 46 | 47 | this.#buffer = data; 48 | 49 | cb(); 50 | } 51 | 52 | /* v8 ignore start */ 53 | override _flush(cb: TransformCallback): void { 54 | if (this.#buffer.byteLength > 0) { 55 | this.push(this.#buffer); 56 | 57 | this.#buffer = Buffer.alloc(0); 58 | } 59 | 60 | cb(); 61 | } 62 | /* v8 ignore stop */ 63 | } 64 | -------------------------------------------------------------------------------- /src/drivers/ot-rcp-writer.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from "node:stream"; 2 | 3 | import { logger } from "../utils/logger.js"; 4 | 5 | const NS = "ot-rcp-driver:writer"; 6 | 7 | export class OTRCPWriter extends Readable { 8 | public writeBuffer(buffer: Buffer): void { 9 | logger.debug(() => `>>> FRAME[${buffer.toString("hex")}]`, NS); 10 | 11 | // this.push(buffer); 12 | this.emit("data", buffer); // XXX: this is faster 13 | } 14 | 15 | /* v8 ignore next */ 16 | public override _read(): void {} 17 | } 18 | -------------------------------------------------------------------------------- /src/spinel/commands.ts: -------------------------------------------------------------------------------- 1 | export const enum SpinelCommandId { 2 | /** 3 | * No-Operation command (Host -> NCP) 4 | * 5 | * Encoding: Empty 6 | * 7 | * Induces the NCP to send a success status back to the host. This is 8 | * primarily used for liveliness checks. The command payload for this 9 | * command SHOULD be empty. 10 | * 11 | * There is no error condition for this command. 12 | */ 13 | NOOP = 0, 14 | 15 | /** 16 | * Reset NCP command (Host -> NCP) 17 | * 18 | * Encoding: Empty or `C` 19 | * 20 | * Causes the NCP to perform a software reset. Due to the nature of 21 | * this command, the TID is ignored. The host should instead wait 22 | * for a `CMD_PROP_VALUE_IS` command from the NCP indicating 23 | * `PROP_LAST_STATUS` has been set to `STATUS_RESET_SOFTWARE`. 24 | * 25 | * The optional command payload specifies the reset type, can be 26 | * `SPINEL_RESET_PLATFORM`, `SPINEL_RESET_STACK`, or 27 | * `SPINEL_RESET_BOOTLOADER`. 28 | * 29 | * Defaults to stack reset if unspecified. 30 | * 31 | * If an error occurs, the value of `PROP_LAST_STATUS` will be emitted 32 | * instead with the value set to the generated status code for the error. 33 | */ 34 | RESET = 1, 35 | 36 | /** 37 | * Get property value command (Host -> NCP) 38 | * 39 | * Encoding: `i` 40 | * `i` : Property Id 41 | * 42 | * Causes the NCP to emit a `CMD_PROP_VALUE_IS` command for the 43 | * given property identifier. 44 | * 45 | * The payload for this command is the property identifier encoded 46 | * in the packed unsigned integer format `i`. 47 | * 48 | * If an error occurs, the value of `PROP_LAST_STATUS` will be emitted 49 | * instead with the value set to the generated status code for the error. 50 | */ 51 | PROP_VALUE_GET = 2, 52 | 53 | /** 54 | * Set property value command (Host -> NCP) 55 | * 56 | * Encoding: `iD` 57 | * `i` : Property Id 58 | * `D` : Value (encoding depends on the property) 59 | * 60 | * Instructs the NCP to set the given property to the specific given 61 | * value, replacing any previous value. 62 | * 63 | * The payload for this command is the property identifier encoded in the 64 | * packed unsigned integer format, followed by the property value. The 65 | * exact format of the property value is defined by the property. 66 | * 67 | * On success a `CMD_PROP_VALUE_IS` command is emitted either for the 68 | * given property identifier with the set value, or for `PROP_LAST_STATUS` 69 | * with value `LAST_STATUS_OK`. 70 | * 71 | * If an error occurs, the value of `PROP_LAST_STATUS` will be emitted 72 | * with the value set to the generated status code for the error. 73 | */ 74 | PROP_VALUE_SET = 3, 75 | 76 | /** 77 | * Insert value into property command (Host -> NCP) 78 | * 79 | * Encoding: `iD` 80 | * `i` : Property Id 81 | * `D` : Value (encoding depends on the property) 82 | * 83 | * Instructs the NCP to insert the given value into a list-oriented 84 | * property without removing other items in the list. The resulting order 85 | * of items in the list is defined by the individual property being 86 | * operated on. 87 | * 88 | * The payload for this command is the property identifier encoded in the 89 | * packed unsigned integer format, followed by the value to be inserted. 90 | * The exact format of the value is defined by the property. 91 | * 92 | * If the type signature of the property consists of a single structure 93 | * enclosed by an array `A(t(...))`, then the contents of value MUST 94 | * contain the contents of the structure (`...`) rather than the 95 | * serialization of the whole item (`t(...)`). Specifically, the length 96 | * of the structure MUST NOT be prepended to value. This helps to 97 | * eliminate redundant data. 98 | * 99 | * On success, either a `CMD_PROP_VALUE_INSERTED` command is emitted for 100 | * the given property, or a `CMD_PROP_VALUE_IS` command is emitted of 101 | * property `PROP_LAST_STATUS` with value `LAST_STATUS_OK`. 102 | * 103 | * If an error occurs, the value of `PROP_LAST_STATUS` will be emitted 104 | * with the value set to the generated status code for the error. 105 | */ 106 | PROP_VALUE_INSERT = 4, 107 | 108 | /** 109 | * Remove value from property command (Host -> NCP) 110 | * 111 | * Encoding: `iD` 112 | * `i` : Property Id 113 | * `D` : Value (encoding depends on the property) 114 | 115 | * Instructs the NCP to remove the given value from a list-oriented property, 116 | * without affecting other items in the list. The resulting order of items 117 | * in the list is defined by the individual property being operated on. 118 | * 119 | * Note that this command operates by value, not by index! 120 | * 121 | * The payload for this command is the property identifier encoded in the 122 | * packed unsigned integer format, followed by the value to be removed. The 123 | * exact format of the value is defined by the property. 124 | * 125 | * If the type signature of the property consists of a single structure 126 | * enclosed by an array `A(t(...))`, then the contents of value MUST contain 127 | * the contents of the structure (`...`) rather than the serialization of the 128 | * whole item (`t(...)`). Specifically, the length of the structure MUST NOT 129 | * be prepended to `VALUE`. This helps to eliminate redundant data. 130 | * 131 | * On success, either a `CMD_PROP_VALUE_REMOVED` command is emitted for the 132 | * given property, or a `CMD_PROP_VALUE_IS` command is emitted of property 133 | * `PROP_LAST_STATUS` with value `LAST_STATUS_OK`. 134 | * 135 | * If an error occurs, the value of `PROP_LAST_STATUS` will be emitted 136 | * with the value set to the generated status code for the error. 137 | */ 138 | PROP_VALUE_REMOVE = 5, 139 | 140 | /** 141 | * Property value notification command (NCP -> Host) 142 | * 143 | * Encoding: `iD` 144 | * `i` : Property Id 145 | * `D` : Value (encoding depends on the property) 146 | * 147 | * This command can be sent by the NCP in response to a previous command 148 | * from the host, or it can be sent by the NCP in an unsolicited fashion 149 | * to notify the host of various state changes asynchronously. 150 | * 151 | * The payload for this command is the property identifier encoded in the 152 | * packed unsigned integer format, followed by the current value of the 153 | * given property. 154 | */ 155 | PROP_VALUE_IS = 6, 156 | 157 | /** 158 | * Property value insertion notification command (NCP -> Host) 159 | * 160 | * Encoding:`iD` 161 | * `i` : Property Id 162 | * `D` : Value (encoding depends on the property) 163 | * 164 | * This command can be sent by the NCP in response to the 165 | * `CMD_PROP_VALUE_INSERT` command, or it can be sent by the NCP in an 166 | * unsolicited fashion to notify the host of various state changes 167 | * asynchronously. 168 | * 169 | * The payload for this command is the property identifier encoded in the 170 | * packed unsigned integer format, followed by the value that was inserted 171 | * into the given property. 172 | * 173 | * If the type signature of the property specified by property id consists 174 | * of a single structure enclosed by an array (`A(t(...))`), then the 175 | * contents of value MUST contain the contents of the structure (`...`) 176 | * rather than the serialization of the whole item (`t(...)`). Specifically, 177 | * the length of the structure MUST NOT be prepended to `VALUE`. This 178 | * helps to eliminate redundant data. 179 | * 180 | * The resulting order of items in the list is defined by the given 181 | * property. 182 | */ 183 | PROP_VALUE_INSERTED = 7, 184 | 185 | /** 186 | * Property value removal notification command (NCP -> Host) 187 | * 188 | * Encoding: `iD` 189 | * `i` : Property Id 190 | * `D` : Value (encoding depends on the property) 191 | * 192 | * This command can be sent by the NCP in response to the 193 | * `CMD_PROP_VALUE_REMOVE` command, or it can be sent by the NCP in an 194 | * unsolicited fashion to notify the host of various state changes 195 | * asynchronously. 196 | * 197 | * Note that this command operates by value, not by index! 198 | * 199 | * The payload for this command is the property identifier encoded in the 200 | * packed unsigned integer format described in followed by the value that 201 | * was removed from the given property. 202 | * 203 | * If the type signature of the property specified by property id consists 204 | * of a single structure enclosed by an array (`A(t(...))`), then the 205 | * contents of value MUST contain the contents of the structure (`...`) 206 | * rather than the serialization of the whole item (`t(...)`). Specifically, 207 | * the length of the structure MUST NOT be prepended to `VALUE`. This 208 | * helps to eliminate redundant data. 209 | * 210 | * The resulting order of items in the list is defined by the given 211 | * property. 212 | */ 213 | PROP_VALUE_REMOVED = 8, 214 | 215 | NET_SAVE = 9, // Deprecated 216 | 217 | /** 218 | * Clear saved network settings command (Host -> NCP) 219 | * 220 | * Encoding: Empty 221 | * 222 | * Erases all network credentials and state from non-volatile memory. 223 | * 224 | * This operation affects non-volatile memory only. The current network 225 | * information stored in volatile memory is unaffected. 226 | * 227 | * The response to this command is always a `CMD_PROP_VALUE_IS` for 228 | * `PROP_LAST_STATUS`, indicating the result of the operation. 229 | */ 230 | NET_CLEAR = 10, 231 | 232 | NET_RECALL = 11, // Deprecated 233 | 234 | /** 235 | * Host buffer offload is an optional NCP capability that, when 236 | * present, allows the NCP to store data buffers on the host processor 237 | * that can be recalled at a later time. 238 | * 239 | * The presence of this feature can be detected by the host by 240 | * checking for the presence of the `CAP_HBO` 241 | * capability in `PROP_CAPS`. 242 | * 243 | * This feature is not currently supported on OpenThread. 244 | */ 245 | 246 | HBO_OFFLOAD = 12, 247 | HBO_RECLAIM = 13, 248 | HBO_DROP = 14, 249 | HBO_OFFLOADED = 15, 250 | HBO_RECLAIMED = 16, 251 | HBO_DROPPED = 17, 252 | 253 | /** 254 | * Peek command (Host -> NCP) 255 | * 256 | * Encoding: `LU` 257 | * `L` : The address to peek 258 | * `U` : Number of bytes to read 259 | * 260 | * This command allows the NCP to fetch values from the RAM of the NCP 261 | * for debugging purposes. Upon success, `CMD_PEEK_RET` is sent from the 262 | * NCP to the host. Upon failure, `PROP_LAST_STATUS` is emitted with 263 | * the appropriate error indication. 264 | * 265 | * The NCP MAY prevent certain regions of memory from being accessed. 266 | * 267 | * This command requires the capability `CAP_PEEK_POKE` to be present. 268 | */ 269 | PEEK = 18, 270 | 271 | /** 272 | * Peek return command (NCP -> Host) 273 | * 274 | * Encoding: `LUD` 275 | * `L` : The address peeked 276 | * `U` : Number of bytes read 277 | * `D` : Memory content 278 | * 279 | * This command contains the contents of memory that was requested by 280 | * a previous call to `CMD_PEEK`. 281 | * 282 | * This command requires the capability `CAP_PEEK_POKE` to be present. 283 | */ 284 | PEEK_RET = 19, 285 | 286 | /** 287 | * Poke command (Host -> NCP) 288 | * 289 | * Encoding: `LUD` 290 | * `L` : The address to be poked 291 | * `U` : Number of bytes to write 292 | * `D` : Content to write 293 | * 294 | * This command writes the bytes to the specified memory address 295 | * for debugging purposes. 296 | * 297 | * This command requires the capability `CAP_PEEK_POKE` to be present. 298 | */ 299 | POKE = 20, 300 | 301 | PROP_VALUE_MULTI_GET = 21, 302 | PROP_VALUE_MULTI_SET = 22, 303 | PROP_VALUES_ARE = 23, 304 | } 305 | -------------------------------------------------------------------------------- /src/spinel/hdlc.ts: -------------------------------------------------------------------------------- 1 | export const enum HdlcReservedByte { 2 | XON = 0x11, 3 | XOFF = 0x13, 4 | FLAG = 0x7e, 5 | ESCAPE = 0x7d, 6 | FLAG_SPECIAL = 0xf8, 7 | } 8 | 9 | /** Initial FCS value */ 10 | const HDLC_INIT_FCS = 0xffff; 11 | /** Good FCS value. */ 12 | export const HDLC_GOOD_FCS = 0xf0b8; 13 | /** FCS size (number of bytes) */ 14 | const HDLC_FCS_SIZE = 2; 15 | const HDLC_ESCAPE_XOR = 0x20; 16 | 17 | export const HDLC_TX_CHUNK_SIZE = 2048; 18 | 19 | export type HdlcFrame = { 20 | data: Buffer; 21 | /** For decoded frames, this stops before FCS+FLAG */ 22 | length: number; 23 | /** Final value should match HDLC_GOOD_FCS */ 24 | fcs: number; 25 | }; 26 | 27 | export function hdlcByteNeedsEscape(aByte: number): boolean { 28 | return ( 29 | aByte === HdlcReservedByte.XON || 30 | aByte === HdlcReservedByte.XOFF || 31 | aByte === HdlcReservedByte.ESCAPE || 32 | aByte === HdlcReservedByte.FLAG || 33 | aByte === HdlcReservedByte.FLAG_SPECIAL 34 | ); 35 | } 36 | 37 | const HDLC_FCS_TABLE = [ 38 | 0x0000, 0x1189, 0x2312, 0x329b, 0x4624, 0x57ad, 0x6536, 0x74bf, 0x8c48, 0x9dc1, 0xaf5a, 0xbed3, 0xca6c, 0xdbe5, 0xe97e, 0xf8f7, 0x1081, 0x0108, 39 | 0x3393, 0x221a, 0x56a5, 0x472c, 0x75b7, 0x643e, 0x9cc9, 0x8d40, 0xbfdb, 0xae52, 0xdaed, 0xcb64, 0xf9ff, 0xe876, 0x2102, 0x308b, 0x0210, 0x1399, 40 | 0x6726, 0x76af, 0x4434, 0x55bd, 0xad4a, 0xbcc3, 0x8e58, 0x9fd1, 0xeb6e, 0xfae7, 0xc87c, 0xd9f5, 0x3183, 0x200a, 0x1291, 0x0318, 0x77a7, 0x662e, 41 | 0x54b5, 0x453c, 0xbdcb, 0xac42, 0x9ed9, 0x8f50, 0xfbef, 0xea66, 0xd8fd, 0xc974, 0x4204, 0x538d, 0x6116, 0x709f, 0x0420, 0x15a9, 0x2732, 0x36bb, 42 | 0xce4c, 0xdfc5, 0xed5e, 0xfcd7, 0x8868, 0x99e1, 0xab7a, 0xbaf3, 0x5285, 0x430c, 0x7197, 0x601e, 0x14a1, 0x0528, 0x37b3, 0x263a, 0xdecd, 0xcf44, 43 | 0xfddf, 0xec56, 0x98e9, 0x8960, 0xbbfb, 0xaa72, 0x6306, 0x728f, 0x4014, 0x519d, 0x2522, 0x34ab, 0x0630, 0x17b9, 0xef4e, 0xfec7, 0xcc5c, 0xddd5, 44 | 0xa96a, 0xb8e3, 0x8a78, 0x9bf1, 0x7387, 0x620e, 0x5095, 0x411c, 0x35a3, 0x242a, 0x16b1, 0x0738, 0xffcf, 0xee46, 0xdcdd, 0xcd54, 0xb9eb, 0xa862, 45 | 0x9af9, 0x8b70, 0x8408, 0x9581, 0xa71a, 0xb693, 0xc22c, 0xd3a5, 0xe13e, 0xf0b7, 0x0840, 0x19c9, 0x2b52, 0x3adb, 0x4e64, 0x5fed, 0x6d76, 0x7cff, 46 | 0x9489, 0x8500, 0xb79b, 0xa612, 0xd2ad, 0xc324, 0xf1bf, 0xe036, 0x18c1, 0x0948, 0x3bd3, 0x2a5a, 0x5ee5, 0x4f6c, 0x7df7, 0x6c7e, 0xa50a, 0xb483, 47 | 0x8618, 0x9791, 0xe32e, 0xf2a7, 0xc03c, 0xd1b5, 0x2942, 0x38cb, 0x0a50, 0x1bd9, 0x6f66, 0x7eef, 0x4c74, 0x5dfd, 0xb58b, 0xa402, 0x9699, 0x8710, 48 | 0xf3af, 0xe226, 0xd0bd, 0xc134, 0x39c3, 0x284a, 0x1ad1, 0x0b58, 0x7fe7, 0x6e6e, 0x5cf5, 0x4d7c, 0xc60c, 0xd785, 0xe51e, 0xf497, 0x8028, 0x91a1, 49 | 0xa33a, 0xb2b3, 0x4a44, 0x5bcd, 0x6956, 0x78df, 0x0c60, 0x1de9, 0x2f72, 0x3efb, 0xd68d, 0xc704, 0xf59f, 0xe416, 0x90a9, 0x8120, 0xb3bb, 0xa232, 50 | 0x5ac5, 0x4b4c, 0x79d7, 0x685e, 0x1ce1, 0x0d68, 0x3ff3, 0x2e7a, 0xe70e, 0xf687, 0xc41c, 0xd595, 0xa12a, 0xb0a3, 0x8238, 0x93b1, 0x6b46, 0x7acf, 51 | 0x4854, 0x59dd, 0x2d62, 0x3ceb, 0x0e70, 0x1ff9, 0xf78f, 0xe606, 0xd49d, 0xc514, 0xb1ab, 0xa022, 0x92b9, 0x8330, 0x7bc7, 0x6a4e, 0x58d5, 0x495c, 52 | 0x3de3, 0x2c6a, 0x1ef1, 0x0f78, 53 | ]; 54 | 55 | export function updateFcs(aFcs: number, aByte: number): number { 56 | return ((aFcs >> 8) ^ HDLC_FCS_TABLE[(aFcs ^ aByte) & 0xff]) & 0xffff; 57 | } 58 | 59 | export function decodeHdlcFrame(buffer: Buffer): HdlcFrame { 60 | // sanity check 61 | if (buffer.byteLength > HDLC_TX_CHUNK_SIZE) { 62 | throw new Error("HDLC frame too long"); 63 | } 64 | 65 | const hdlcFrame: HdlcFrame = { 66 | // data can only be smaller than incoming buffer (removed flags/escapes) 67 | data: Buffer.alloc(buffer.byteLength), 68 | length: 0, 69 | fcs: HDLC_INIT_FCS, 70 | }; 71 | 72 | let lastWasEscape = false; 73 | 74 | for (let i = 0; i < buffer.byteLength; i++) { 75 | const aByte = buffer[i]; 76 | 77 | if (aByte === HdlcReservedByte.FLAG) { 78 | if (i > 0) { 79 | if (hdlcFrame.length >= HDLC_FCS_SIZE && hdlcFrame.fcs === HDLC_GOOD_FCS) { 80 | // walk back the FCS writes by ignoring them from data length 81 | hdlcFrame.length -= 2; 82 | } else { 83 | throw new Error("HDLC parsing error"); 84 | } 85 | } 86 | } else if (aByte === HdlcReservedByte.ESCAPE) { 87 | lastWasEscape = true; 88 | } else { 89 | if (lastWasEscape) { 90 | const newByte = aByte ^ HDLC_ESCAPE_XOR; 91 | hdlcFrame.fcs = updateFcs(hdlcFrame.fcs, newByte); 92 | 93 | hdlcFrame.data[hdlcFrame.length] = newByte; 94 | hdlcFrame.length += 1; 95 | 96 | lastWasEscape = false; 97 | } else { 98 | hdlcFrame.fcs = updateFcs(hdlcFrame.fcs, aByte); 99 | 100 | hdlcFrame.data[hdlcFrame.length] = aByte; 101 | hdlcFrame.length += 1; 102 | } 103 | } 104 | } 105 | 106 | return hdlcFrame; 107 | } 108 | 109 | /** 110 | * @returns The new offset after encoded byte is added 111 | */ 112 | export function encodeByte(hdlcFrame: HdlcFrame, aByte: number, dataOffset: number): number { 113 | if (hdlcByteNeedsEscape(aByte)) { 114 | hdlcFrame.data[dataOffset] = HdlcReservedByte.ESCAPE; 115 | dataOffset += 1; 116 | hdlcFrame.data[dataOffset] = aByte ^ HDLC_ESCAPE_XOR; 117 | dataOffset += 1; 118 | } else { 119 | hdlcFrame.data[dataOffset] = aByte; 120 | dataOffset += 1; 121 | } 122 | 123 | hdlcFrame.fcs = updateFcs(hdlcFrame.fcs, aByte); 124 | 125 | return dataOffset; 126 | } 127 | 128 | export function encodeHdlcFrame(buffer: Buffer): HdlcFrame { 129 | // sanity check 130 | if (buffer.byteLength > HDLC_TX_CHUNK_SIZE) { 131 | throw new Error("HDLC frame would be too long"); 132 | } 133 | 134 | const hdlcFrame: HdlcFrame = { 135 | // alloc to max possible size (as if each byte needs escaping) 136 | data: Buffer.alloc(Math.min(buffer.byteLength * 2 + 6, HDLC_TX_CHUNK_SIZE)), 137 | length: 0, 138 | fcs: HDLC_INIT_FCS, 139 | }; 140 | hdlcFrame.data[hdlcFrame.length] = HdlcReservedByte.FLAG; 141 | hdlcFrame.length += 1; 142 | 143 | for (const aByte of buffer) { 144 | hdlcFrame.length = encodeByte(hdlcFrame, aByte, hdlcFrame.length); 145 | } 146 | 147 | let fcs = hdlcFrame.fcs; 148 | fcs ^= HDLC_INIT_FCS; 149 | 150 | hdlcFrame.length = encodeByte(hdlcFrame, fcs & 0xff, hdlcFrame.length); 151 | hdlcFrame.length = encodeByte(hdlcFrame, (fcs >> 8) & 0xff, hdlcFrame.length); 152 | 153 | hdlcFrame.data[hdlcFrame.length] = HdlcReservedByte.FLAG; 154 | hdlcFrame.length += 1; 155 | 156 | return hdlcFrame; 157 | } 158 | -------------------------------------------------------------------------------- /src/spinel/statuses.ts: -------------------------------------------------------------------------------- 1 | export enum SpinelStatus { 2 | /** Operation has completed successfully. */ 3 | OK = 0, 4 | /** Operation has failed for some undefined reason. */ 5 | FAILURE = 1, 6 | /** Given operation has not been implemented. */ 7 | UNIMPLEMENTED = 2, 8 | /** An argument to the operation is invalid. */ 9 | INVALID_ARGUMENT = 3, 10 | /** This operation is invalid for the current device state. */ 11 | INVALID_STATE = 4, 12 | /** This command is not recognized. */ 13 | INVALID_COMMAND = 5, 14 | /** This interface is not supported. */ 15 | INVALID_INTERFACE = 6, 16 | /** An internal runtime error has occurred. */ 17 | INTERNAL_ERROR = 7, 18 | /** A security/authentication error has occurred. */ 19 | SECURITY_ERROR = 8, 20 | /** A error has occurred while parsing the command. */ 21 | PARSE_ERROR = 9, 22 | /** This operation is in progress. */ 23 | IN_PROGRESS = 10, 24 | /** Operation prevented due to memory pressure. */ 25 | NOMEM = 11, 26 | /** The device is currently performing a mutually exclusive operation */ 27 | BUSY = 12, 28 | /** The given property is not recognized. */ 29 | PROP_NOT_FOUND = 13, 30 | /** A/The packet was dropped. */ 31 | DROPPED = 14, 32 | /** The result of the operation is empty. */ 33 | EMPTY = 15, 34 | /** The command was too large to fit in the internal buffer. */ 35 | CMD_TOO_BIG = 16, 36 | /** The packet was not acknowledged. */ 37 | NO_ACK = 17, 38 | /** The packet was not sent due to a CCA failure. */ 39 | CCA_FAILURE = 18, 40 | /** The operation is already in progress. */ 41 | ALREADY = 19, 42 | /** The given item could not be found. */ 43 | ITEM_NOT_FOUND = 20, 44 | /** The given command cannot be performed on this property. */ 45 | INVALID_COMMAND_FOR_PROP = 21, 46 | /** The neighbor is unknown. */ 47 | UNKNOWN_NEIGHBOR = 22, 48 | /** The target is not capable of handling requested operation. */ 49 | NOT_CAPABLE = 23, 50 | /** No response received from remote node */ 51 | RESPONSE_TIMEOUT = 24, 52 | /** Radio interface switch completed successfully (SPINEL_PROP_MULTIPAN_ACTIVE_INTERFACE) */ 53 | SWITCHOVER_DONE = 25, 54 | /** Radio interface switch failed (SPINEL_PROP_MULTIPAN_ACTIVE_INTERFACE) */ 55 | SWITCHOVER_FAILED = 26, 56 | 57 | /** 58 | * Generic failure to associate with other peers. 59 | * 60 | * This status error should not be used by implementers if 61 | * enough information is available to determine that one of the 62 | * later join failure status codes would be more accurate. 63 | * 64 | * \sa SPINEL_PROP_NET_REQUIRE_JOIN_EXISTING 65 | * \sa SPINEL_PROP_MESHCOP_JOINER_COMMISSIONING 66 | */ 67 | JOIN_FAILURE = 104, 68 | 69 | /** 70 | * The node found other peers but was unable to decode their packets. 71 | * 72 | * Typically this error code indicates that the network 73 | * key has been set incorrectly. 74 | * 75 | * \sa SPINEL_PROP_NET_REQUIRE_JOIN_EXISTING 76 | * \sa SPINEL_PROP_MESHCOP_JOINER_COMMISSIONING 77 | */ 78 | JOIN_SECURITY = 105, 79 | 80 | /** 81 | * The node was unable to find any other peers on the network. 82 | * 83 | * \sa SPINEL_PROP_NET_REQUIRE_JOIN_EXISTING 84 | * \sa SPINEL_PROP_MESHCOP_JOINER_COMMISSIONING 85 | */ 86 | JOIN_NO_PEERS = 106, 87 | 88 | /** 89 | * The only potential peer nodes found are incompatible. 90 | * 91 | * \sa SPINEL_PROP_NET_REQUIRE_JOIN_EXISTING 92 | */ 93 | JOIN_INCOMPATIBLE = 107, 94 | 95 | /** 96 | * No response in expecting time. 97 | * 98 | * \sa SPINEL_PROP_MESHCOP_JOINER_COMMISSIONING 99 | */ 100 | JOIN_RSP_TIMEOUT = 108, 101 | 102 | /** 103 | * The node succeeds in commissioning and get the network credentials. 104 | * 105 | * \sa SPINEL_PROP_MESHCOP_JOINER_COMMISSIONING 106 | */ 107 | JOIN_SUCCESS = 109, 108 | 109 | RESET_POWER_ON = 112, 110 | RESET_EXTERNAL = 113, 111 | RESET_SOFTWARE = 114, 112 | RESET_FAULT = 115, 113 | RESET_CRASH = 116, 114 | RESET_ASSERT = 117, 115 | RESET_OTHER = 118, 116 | RESET_UNKNOWN = 119, 117 | RESET_WATCHDOG = 120, 118 | } 119 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | debug: (messageOrLambda: () => string, namespace: string) => void; 3 | info: (messageOrLambda: string | (() => string), namespace: string) => void; 4 | warning: (messageOrLambda: string | (() => string), namespace: string) => void; 5 | error: (messageOrLambda: string, namespace: string) => void; 6 | } 7 | 8 | export let logger: Logger = { 9 | debug: (messageOrLambda, namespace) => console.debug(`[${new Date().toISOString()}] ${namespace}: ${messageOrLambda()}`), 10 | info: (messageOrLambda, namespace) => 11 | console.info(`[${new Date().toISOString()}] ${namespace}: ${typeof messageOrLambda === "function" ? messageOrLambda() : messageOrLambda}`), 12 | warning: (messageOrLambda, namespace) => 13 | console.warn(`[${new Date().toISOString()}] ${namespace}: ${typeof messageOrLambda === "function" ? messageOrLambda() : messageOrLambda}`), 14 | error: (message, namespace) => console.error(`[${new Date().toISOString()}] ${namespace}: ${message}`), 15 | }; 16 | 17 | export function setLogger(l: Logger): void { 18 | logger = l; 19 | } 20 | -------------------------------------------------------------------------------- /src/zigbee/zigbee-aps.ts: -------------------------------------------------------------------------------- 1 | import { type ZigbeeSecurityHeader, decryptZigbeePayload, encryptZigbeePayload } from "./zigbee.js"; 2 | 3 | /** 4 | * const enum with sole purpose of avoiding "magic numbers" in code for well-known values 5 | */ 6 | export const enum ZigbeeAPSConsts { 7 | HEADER_MIN_SIZE = 8, 8 | HEADER_MAX_SIZE = 21, 9 | FRAME_MAX_SIZE = 108, 10 | PAYLOAD_MIN_SIZE = 65, 11 | /** no NWK security */ 12 | PAYLOAD_MAX_SIZE = 100, 13 | 14 | CMD_KEY_TC_MASTER = 0x00, 15 | CMD_KEY_STANDARD_NWK = 0x01, 16 | CMD_KEY_APP_MASTER = 0x02, 17 | CMD_KEY_APP_LINK = 0x03, 18 | CMD_KEY_TC_LINK = 0x04, 19 | CMD_KEY_HIGH_SEC_NWK = 0x05, 20 | CMD_KEY_LENGTH = 16, 21 | 22 | CMD_REQ_NWK_KEY = 0x01, 23 | CMD_REQ_APP_KEY = 0x02, 24 | 25 | CMD_UPDATE_STANDARD_SEC_REJOIN = 0x00, 26 | CMD_UPDATE_STANDARD_UNSEC_JOIN = 0x01, 27 | CMD_UPDATE_LEAVE = 0x02, 28 | CMD_UPDATE_STANDARD_UNSEC_REJOIN = 0x03, 29 | CMD_UPDATE_HIGH_SEC_REJOIN = 0x04, 30 | CMD_UPDATE_HIGH_UNSEC_JOIN = 0x05, 31 | CMD_UPDATE_HIGH_UNSEC_REJOIN = 0x07, 32 | 33 | FCF_FRAME_TYPE = 0x03, 34 | FCF_DELIVERY_MODE = 0x0c, 35 | /** ZigBee 2004 and earlier. */ 36 | // FCF_INDIRECT_MODE = 0x10, 37 | /** ZigBee 2007 and later. */ 38 | FCF_ACK_FORMAT = 0x10, 39 | FCF_SECURITY = 0x20, 40 | FCF_ACK_REQ = 0x40, 41 | FCF_EXT_HEADER = 0x80, 42 | 43 | EXT_FCF_FRAGMENT = 0x03, 44 | } 45 | 46 | export const enum ZigbeeAPSFrameType { 47 | DATA = 0x00, 48 | CMD = 0x01, 49 | ACK = 0x02, 50 | INTERPAN = 0x03, 51 | } 52 | 53 | export const enum ZigbeeAPSDeliveryMode { 54 | UNICAST = 0x00, 55 | // INDIRECT = 0x01, /** removed in Zigbee 2006 and later */ 56 | BCAST = 0x02, 57 | /** ZigBee 2006 and later */ 58 | GROUP = 0x03, 59 | } 60 | 61 | export const enum ZigbeeAPSFragmentation { 62 | NONE = 0x00, 63 | FIRST = 0x01, 64 | MIDDLE = 0x02, 65 | } 66 | 67 | export const enum ZigbeeAPSCommandId { 68 | TRANSPORT_KEY = 0x05, 69 | UPDATE_DEVICE = 0x06, 70 | REMOVE_DEVICE = 0x07, 71 | REQUEST_KEY = 0x08, 72 | SWITCH_KEY = 0x09, 73 | TUNNEL = 0x0e, 74 | VERIFY_KEY = 0x0f, 75 | CONFIRM_KEY = 0x10, 76 | RELAY_MESSAGE_DOWNSTREAM = 0x11, 77 | RELAY_MESSAGE_UPSTREAM = 0x12, 78 | } 79 | 80 | /** 81 | * Frame Control Field: Ack (0x02) 82 | * .... ..10 = Frame Type: Ack (0x2) 83 | * .... 00.. = Delivery Mode: Unicast (0x0) 84 | * ...0 .... = Acknowledgement Format: False 85 | * ..0. .... = Security: False 86 | * .0.. .... = Acknowledgement Request: False 87 | * 0... .... = Extended Header: False 88 | */ 89 | export type ZigbeeAPSFrameControl = { 90 | frameType: ZigbeeAPSFrameType; 91 | deliveryMode: ZigbeeAPSDeliveryMode; 92 | // indirectMode: ZigbeeAPSIndirectMode; 93 | ackFormat: boolean; 94 | security: boolean; 95 | ackRequest: boolean; 96 | extendedHeader: boolean; 97 | }; 98 | 99 | export type ZigbeeAPSHeader = { 100 | /** uint8_t */ 101 | frameControl: ZigbeeAPSFrameControl; 102 | /** uint8_t */ 103 | destEndpoint?: number; 104 | /** uint16_t */ 105 | group?: number; 106 | /** uint16_t */ 107 | clusterId?: number; 108 | /** uint16_t */ 109 | profileId?: number; 110 | /** uint8_t */ 111 | sourceEndpoint?: number; 112 | /** uint8_t */ 113 | counter?: number; 114 | /** uint8_t */ 115 | fragmentation?: ZigbeeAPSFragmentation; 116 | /** uint8_t */ 117 | fragBlockNumber?: number; 118 | /** uint8_t */ 119 | fragACKBitfield?: number; 120 | securityHeader?: ZigbeeSecurityHeader; 121 | }; 122 | 123 | export type ZigbeeAPSPayload = Buffer; 124 | 125 | export function decodeZigbeeAPSFrameControl(data: Buffer, offset: number): [ZigbeeAPSFrameControl, offset: number] { 126 | const fcf = data.readUInt8(offset); 127 | offset += 1; 128 | 129 | return [ 130 | { 131 | frameType: fcf & ZigbeeAPSConsts.FCF_FRAME_TYPE, 132 | deliveryMode: (fcf & ZigbeeAPSConsts.FCF_DELIVERY_MODE) >> 2, 133 | // indirectMode = (fcf & ZigbeeAPSConsts.FCF_INDIRECT_MODE) >> 4, 134 | ackFormat: Boolean((fcf & ZigbeeAPSConsts.FCF_ACK_FORMAT) >> 4), 135 | security: Boolean((fcf & ZigbeeAPSConsts.FCF_SECURITY) >> 5), 136 | ackRequest: Boolean((fcf & ZigbeeAPSConsts.FCF_ACK_REQ) >> 6), 137 | extendedHeader: Boolean((fcf & ZigbeeAPSConsts.FCF_EXT_HEADER) >> 7), 138 | }, 139 | offset, 140 | ]; 141 | } 142 | 143 | function encodeZigbeeAPSFrameControl(data: Buffer, offset: number, fcf: ZigbeeAPSFrameControl): number { 144 | data.writeUInt8( 145 | (fcf.frameType & ZigbeeAPSConsts.FCF_FRAME_TYPE) | 146 | ((fcf.deliveryMode << 2) & ZigbeeAPSConsts.FCF_DELIVERY_MODE) | 147 | // ((fcf.indirectMode << 4) & ZigbeeAPSConsts.FCF_INDIRECT_MODE) | 148 | (((fcf.ackFormat ? 1 : 0) << 4) & ZigbeeAPSConsts.FCF_ACK_FORMAT) | 149 | (((fcf.security ? 1 : 0) << 5) & ZigbeeAPSConsts.FCF_SECURITY) | 150 | (((fcf.ackRequest ? 1 : 0) << 6) & ZigbeeAPSConsts.FCF_ACK_REQ) | 151 | (((fcf.extendedHeader ? 1 : 0) << 7) & ZigbeeAPSConsts.FCF_EXT_HEADER), 152 | offset, 153 | ); 154 | offset += 1; 155 | 156 | return offset; 157 | } 158 | 159 | export function decodeZigbeeAPSHeader(data: Buffer, offset: number, frameControl: ZigbeeAPSFrameControl): [ZigbeeAPSHeader, offset: number] { 160 | let hasEndpointAddressing = true; 161 | let destPresent = false; 162 | let sourcePresent = false; 163 | let destEndpoint: number | undefined; 164 | let group: number | undefined; 165 | let clusterId: number | undefined; 166 | let profileId: number | undefined; 167 | let sourceEndpoint: number | undefined; 168 | 169 | switch (frameControl.frameType) { 170 | case ZigbeeAPSFrameType.DATA: { 171 | break; 172 | } 173 | case ZigbeeAPSFrameType.ACK: { 174 | if (frameControl.ackFormat) { 175 | hasEndpointAddressing = false; 176 | } 177 | break; 178 | } 179 | case ZigbeeAPSFrameType.INTERPAN: { 180 | destPresent = false; 181 | sourcePresent = false; 182 | break; 183 | } 184 | case ZigbeeAPSFrameType.CMD: { 185 | hasEndpointAddressing = false; 186 | break; 187 | } 188 | } 189 | 190 | if (hasEndpointAddressing) { 191 | if (frameControl.frameType !== ZigbeeAPSFrameType.INTERPAN) { 192 | if (frameControl.deliveryMode === ZigbeeAPSDeliveryMode.UNICAST || frameControl.deliveryMode === ZigbeeAPSDeliveryMode.BCAST) { 193 | destPresent = true; 194 | sourcePresent = true; 195 | } else if (frameControl.deliveryMode === ZigbeeAPSDeliveryMode.GROUP) { 196 | destPresent = false; 197 | sourcePresent = true; 198 | } else { 199 | throw new Error(`Invalid APS delivery mode ${frameControl.deliveryMode}`); 200 | } 201 | 202 | if (destPresent) { 203 | destEndpoint = data.readUInt8(offset); 204 | offset += 1; 205 | } 206 | } 207 | 208 | if (frameControl.deliveryMode === ZigbeeAPSDeliveryMode.GROUP) { 209 | group = data.readUInt16LE(offset); 210 | offset += 2; 211 | } 212 | 213 | clusterId = data.readUInt16LE(offset); 214 | offset += 2; 215 | 216 | profileId = data.readUInt16LE(offset); 217 | offset += 2; 218 | 219 | if (sourcePresent) { 220 | sourceEndpoint = data.readUInt8(offset); 221 | offset += 1; 222 | } 223 | } 224 | 225 | let counter: number | undefined; 226 | 227 | if (frameControl.frameType !== ZigbeeAPSFrameType.INTERPAN) { 228 | counter = data.readUInt8(offset); 229 | offset += 1; 230 | } 231 | 232 | let fragmentation = undefined; 233 | let fragBlockNumber: number | undefined; 234 | let fragACKBitfield: number | undefined; 235 | 236 | if (frameControl.extendedHeader) { 237 | const fcf = data.readUInt8(offset); 238 | offset += 1; 239 | fragmentation = fcf & ZigbeeAPSConsts.EXT_FCF_FRAGMENT; 240 | 241 | if (fragmentation !== ZigbeeAPSFragmentation.NONE) { 242 | fragBlockNumber = data.readUInt8(offset); 243 | offset += 1; 244 | } 245 | 246 | if (fragmentation !== ZigbeeAPSFragmentation.NONE && frameControl.frameType === ZigbeeAPSFrameType.ACK) { 247 | fragACKBitfield = data.readUInt8(offset); 248 | offset += 1; 249 | } 250 | } 251 | 252 | if (fragmentation !== undefined && fragmentation !== ZigbeeAPSFragmentation.NONE) { 253 | // TODO 254 | throw new Error("APS fragmentation not supported"); 255 | } 256 | 257 | return [ 258 | { 259 | frameControl, 260 | destEndpoint: destEndpoint, 261 | group, 262 | clusterId, 263 | profileId, 264 | sourceEndpoint: sourceEndpoint, 265 | counter, 266 | fragmentation, 267 | fragBlockNumber, 268 | fragACKBitfield, 269 | securityHeader: undefined, // set later, or not 270 | }, 271 | offset, 272 | ]; 273 | } 274 | 275 | export function encodeZigbeeAPSHeader(data: Buffer, offset: number, header: ZigbeeAPSHeader): number { 276 | offset = encodeZigbeeAPSFrameControl(data, offset, header.frameControl); 277 | let hasEndpointAddressing = true; 278 | let destPresent = false; 279 | let sourcePresent = false; 280 | 281 | switch (header.frameControl.frameType) { 282 | case ZigbeeAPSFrameType.DATA: { 283 | break; 284 | } 285 | case ZigbeeAPSFrameType.ACK: { 286 | if (header.frameControl.ackFormat) { 287 | hasEndpointAddressing = false; 288 | } 289 | break; 290 | } 291 | case ZigbeeAPSFrameType.INTERPAN: { 292 | destPresent = false; 293 | sourcePresent = false; 294 | break; 295 | } 296 | case ZigbeeAPSFrameType.CMD: { 297 | hasEndpointAddressing = false; 298 | break; 299 | } 300 | } 301 | 302 | if (hasEndpointAddressing) { 303 | if (header.frameControl.frameType !== ZigbeeAPSFrameType.INTERPAN) { 304 | if ( 305 | header.frameControl.deliveryMode === ZigbeeAPSDeliveryMode.UNICAST || 306 | header.frameControl.deliveryMode === ZigbeeAPSDeliveryMode.BCAST 307 | ) { 308 | destPresent = true; 309 | sourcePresent = true; 310 | } else if (header.frameControl.deliveryMode === ZigbeeAPSDeliveryMode.GROUP) { 311 | destPresent = false; 312 | sourcePresent = true; 313 | } else { 314 | throw new Error(`Invalid APS delivery mode ${header.frameControl.deliveryMode}`); 315 | } 316 | 317 | if (destPresent) { 318 | data.writeUInt8(header.destEndpoint!, offset); 319 | offset += 1; 320 | } 321 | } 322 | 323 | if (header.frameControl.deliveryMode === ZigbeeAPSDeliveryMode.GROUP) { 324 | data.writeUInt16LE(header.group!, offset); 325 | offset += 2; 326 | } 327 | 328 | data.writeUInt16LE(header.clusterId!, offset); 329 | offset += 2; 330 | 331 | data.writeUInt16LE(header.profileId!, offset); 332 | offset += 2; 333 | 334 | if (sourcePresent) { 335 | data.writeUInt8(header.sourceEndpoint!, offset); 336 | offset += 1; 337 | } 338 | } 339 | 340 | if (header.frameControl.frameType !== ZigbeeAPSFrameType.INTERPAN) { 341 | data.writeUInt8(header.counter!, offset); 342 | offset += 1; 343 | } 344 | 345 | if (header.frameControl.extendedHeader) { 346 | const fcf = header.fragmentation! & ZigbeeAPSConsts.EXT_FCF_FRAGMENT; 347 | 348 | data.writeUInt8(fcf, offset); 349 | offset += 1; 350 | 351 | if (header.fragmentation! !== ZigbeeAPSFragmentation.NONE) { 352 | data.writeUInt8(header.fragBlockNumber!, offset); 353 | offset += 1; 354 | } 355 | 356 | if (header.fragmentation! !== ZigbeeAPSFragmentation.NONE && header.frameControl.frameType === ZigbeeAPSFrameType.ACK) { 357 | data.writeUInt8(header.fragACKBitfield!, offset); 358 | offset += 1; 359 | } 360 | } 361 | 362 | return offset; 363 | } 364 | 365 | /** 366 | * @param data 367 | * @param offset 368 | * @param decryptKey If undefined, use default pre-hashed 369 | * @param nwkSource64 370 | * @param frameControl 371 | * @param header 372 | */ 373 | export function decodeZigbeeAPSPayload( 374 | data: Buffer, 375 | offset: number, 376 | decryptKey: Buffer | undefined, 377 | nwkSource64: bigint | undefined, 378 | frameControl: ZigbeeAPSFrameControl, 379 | header: ZigbeeAPSHeader, 380 | ): ZigbeeAPSPayload { 381 | if (frameControl.security) { 382 | const [payload, securityHeader, dOutOffset] = decryptZigbeePayload(data, offset, decryptKey, nwkSource64); 383 | offset = dOutOffset; 384 | header.securityHeader = securityHeader; 385 | 386 | return payload; 387 | } 388 | 389 | return data.subarray(offset); 390 | } 391 | 392 | /** 393 | * @param header 394 | * @param payload 395 | * @param securityHeader 396 | * @param encryptKey If undefined, and security=true, use default pre-hashed 397 | */ 398 | export function encodeZigbeeAPSFrame( 399 | header: ZigbeeAPSHeader, 400 | payload: ZigbeeAPSPayload, 401 | securityHeader?: ZigbeeSecurityHeader, 402 | encryptKey?: Buffer, 403 | ): Buffer { 404 | let offset = 0; 405 | const data = Buffer.alloc(ZigbeeAPSConsts.FRAME_MAX_SIZE); 406 | 407 | offset = encodeZigbeeAPSHeader(data, offset, header); 408 | 409 | if (header.frameControl.security) { 410 | // the octet string `a` SHALL be the string ApsHeader || Auxiliary-Header and the octet string `m` SHALL be the string Payload 411 | const [cryptedPayload, authTag, eOutOffset] = encryptZigbeePayload(data, offset, payload, securityHeader!, encryptKey); 412 | offset = eOutOffset; 413 | 414 | data.set(cryptedPayload, offset); 415 | offset += cryptedPayload.byteLength; 416 | 417 | data.set(authTag, offset); 418 | offset += authTag.byteLength; 419 | 420 | return data.subarray(0, offset); 421 | } 422 | 423 | data.set(payload, offset); 424 | offset += payload.byteLength; 425 | 426 | // TODO: auth tag? 427 | // the octet string `a` SHALL be the string ApsHeader || AuxiliaryHeader || Payload and the octet string `m` SHALL be a string of length zero 428 | 429 | return data.subarray(0, offset); 430 | } 431 | -------------------------------------------------------------------------------- /src/zigbee/zigbee-nwk.ts: -------------------------------------------------------------------------------- 1 | import { type ZigbeeSecurityHeader, decryptZigbeePayload, encryptZigbeePayload } from "./zigbee.js"; 2 | 3 | /** 4 | * const enum with sole purpose of avoiding "magic numbers" in code for well-known values 5 | */ 6 | export const enum ZigbeeNWKConsts { 7 | FRAME_MAX_SIZE = 116, 8 | /** no security */ 9 | HEADER_MIN_SIZE = 8, 10 | HEADER_MAX_SIZE = 30, 11 | PAYLOAD_MIN_SIZE = 86, 12 | PAYLOAD_MAX_SIZE = 108, 13 | 14 | //---- ZigBee version numbers. 15 | /** Re: 053474r06ZB_TSC-ZigBeeSpecification.pdf */ 16 | // VERSION_2004 = 1, 17 | /** Re: 053474r17ZB_TSC-ZigBeeSpecification.pdf */ 18 | VERSION_2007 = 2, 19 | VERSION_GREEN_POWER = 3, 20 | 21 | //---- ZigBee NWK Route Options Flags 22 | /** ZigBee 2004 only. */ 23 | // ROUTE_OPTION_REPAIR = 0x80, 24 | /** ZigBee 2006 and later */ 25 | ROUTE_OPTION_MCAST = 0x40, 26 | /** ZigBee 2007 and later (route request only). */ 27 | ROUTE_OPTION_DEST_EXT = 0x20, 28 | /** ZigBee 2007 and later (route request only). */ 29 | ROUTE_OPTION_MANY_MASK = 0x18, 30 | /** ZigBee 2007 and layer (route reply only). */ 31 | ROUTE_OPTION_RESP_EXT = 0x20, 32 | /** ZigBee 2007 and later (route reply only). */ 33 | ROUTE_OPTION_ORIG_EXT = 0x10, 34 | /* Many-to-One modes, ZigBee 2007 and later (route request only). */ 35 | ROUTE_OPTION_MANY_NONE = 0x00, 36 | /* Many-to-One modes, ZigBee 2007 and later (route request only). */ 37 | ROUTE_OPTION_MANY_REC = 0x01, 38 | /* Many-to-One modes, ZigBee 2007 and later (route request only). */ 39 | ROUTE_OPTION_MANY_NOREC = 0x02, 40 | 41 | //---- ZigBee NWK Route Options Flags 42 | // CMD_ROUTE_OPTION_REPAIR = 0x80, /* ZigBee 2004 only. */ 43 | // CMD_ROUTE_OPTION_MCAST = 0x40, /* ZigBee 2006 and later, @deprecated */ 44 | CMD_ROUTE_OPTION_DEST_EXT = 0x20 /* ZigBee 2007 and later (route request only). */, 45 | CMD_ROUTE_OPTION_MANY_MASK = 0x18 /* ZigBee 2007 and later (route request only). */, 46 | CMD_ROUTE_OPTION_RESP_EXT = 0x20 /* ZigBee 2007 and layer (route reply only). */, 47 | CMD_ROUTE_OPTION_ORIG_EXT = 0x10 /* ZigBee 2007 and later (route reply only). */, 48 | 49 | //---- Many-to-One modes, ZigBee 2007 and later (route request only) 50 | CMD_ROUTE_OPTION_MANY_NONE = 0x00, 51 | CMD_ROUTE_OPTION_MANY_REC = 0x01, 52 | CMD_ROUTE_OPTION_MANY_NOREC = 0x02, 53 | 54 | //---- ZigBee NWK Leave Options Flags 55 | CMD_LEAVE_OPTION_REMOVE_CHILDREN = 0x80, 56 | CMD_LEAVE_OPTION_REQUEST = 0x40, 57 | CMD_LEAVE_OPTION_REJOIN = 0x20, 58 | 59 | //---- ZigBee NWK Link Status Options 60 | CMD_LINK_OPTION_LAST_FRAME = 0x40, 61 | CMD_LINK_OPTION_FIRST_FRAME = 0x20, 62 | CMD_LINK_OPTION_COUNT_MASK = 0x1f, 63 | 64 | //---- ZigBee NWK Link Status cost fields 65 | CMD_LINK_INCOMING_COST_MASK = 0x07, 66 | CMD_LINK_OUTGOING_COST_MASK = 0x70, 67 | 68 | //---- ZigBee NWK Report Options 69 | CMD_NWK_REPORT_COUNT_MASK = 0x1f, 70 | CMD_NWK_REPORT_ID_MASK = 0xe0, 71 | CMD_NWK_REPORT_ID_PAN_CONFLICT = 0x00, 72 | 73 | //---- ZigBee NWK Update Options 74 | CMD_NWK_UPDATE_COUNT_MASK = 0x1f, 75 | CMD_NWK_UPDATE_ID_MASK = 0xe0, 76 | CMD_NWK_UPDATE_ID_PAN_UPDATE = 0x00, 77 | 78 | //---- ZigBee NWK Values of the Parent Information Bitmask (Table 3.47) 79 | CMD_ED_TIMEO_RSP_PRNT_INFO_MAC_DATA_POLL_KEEPAL_SUPP = 0x01, 80 | CMD_ED_TIMEO_RSP_PRNT_INFO_ED_TIMOU_REQ_KEEPAL_SUPP = 0x02, 81 | CMD_ED_TIMEO_RSP_PRNT_INFO_PWR_NEG_SUPP = 0x04, 82 | 83 | //---- ZigBee NWK Link Power Delta Options 84 | CMD_NWK_LINK_PWR_DELTA_TYPE_MASK = 0x03, 85 | 86 | //---- MAC Association Status extension 87 | ASSOC_STATUS_ADDR_CONFLICT = 0xf0, 88 | 89 | //---- ZigBee NWK FCF fields 90 | FCF_FRAME_TYPE = 0x0003, 91 | FCF_VERSION = 0x003c, 92 | FCF_DISCOVER_ROUTE = 0x00c0, 93 | /** ZigBee 2006 and Later */ 94 | FCF_MULTICAST = 0x0100, 95 | FCF_SECURITY = 0x0200, 96 | /** ZigBee 2006 and Later */ 97 | FCF_SOURCE_ROUTE = 0x0400, 98 | /** ZigBee 2006 and Later */ 99 | FCF_EXT_DEST = 0x0800, 100 | /** ZigBee 2006 and Later */ 101 | FCF_EXT_SOURCE = 0x1000, 102 | /** ZigBee PRO r21 */ 103 | FCF_END_DEVICE_INITIATOR = 0x2000, 104 | 105 | //---- ZigBee NWK Multicast Control fields - ZigBee 2006 and later 106 | MCAST_MODE = 0x03, 107 | MCAST_RADIUS = 0x1c, 108 | MCAST_MAX_RADIUS = 0xe0, 109 | } 110 | 111 | /** ZigBee NWK FCF Frame Types */ 112 | export const enum ZigbeeNWKFrameType { 113 | DATA = 0x00, 114 | CMD = 0x01, 115 | INTERPAN = 0x03, 116 | } 117 | 118 | /** ZigBee NWK Discovery Modes. */ 119 | export const enum ZigbeeNWKRouteDiscovery { 120 | SUPPRESS = 0x0000, 121 | ENABLE = 0x0001, 122 | FORCE = 0x0003, 123 | } 124 | 125 | export const enum ZigbeeNWKMulticastMode { 126 | NONMEMBER = 0x00, 127 | MEMBER = 0x01, 128 | } 129 | 130 | export const enum ZigbeeNWKRelayType { 131 | NO_RELAY = 0, 132 | RELAY_UPSTREAM = 1, 133 | RELAY_DOWNSTREAM = 2, 134 | } 135 | 136 | /** ZigBee NWK Command Types */ 137 | export const enum ZigbeeNWKCommandId { 138 | /* Route Request Command. */ 139 | ROUTE_REQ = 0x01, 140 | /* Route Reply Command. */ 141 | ROUTE_REPLY = 0x02, 142 | /* Network Status Command. */ 143 | NWK_STATUS = 0x03, 144 | /* Leave Command. ZigBee 2006 and Later */ 145 | LEAVE = 0x04, 146 | /* Route Record Command. ZigBee 2006 and later */ 147 | ROUTE_RECORD = 0x05, 148 | /* Rejoin Request Command. ZigBee 2006 and later */ 149 | REJOIN_REQ = 0x06, 150 | /* Rejoin Response Command. ZigBee 2006 and later */ 151 | REJOIN_RESP = 0x07, 152 | /* Link Status Command. ZigBee 2007 and later */ 153 | LINK_STATUS = 0x08, 154 | /* Network Report Command. ZigBee 2007 and later */ 155 | NWK_REPORT = 0x09, 156 | /* Network Update Command. ZigBee 2007 and later */ 157 | NWK_UPDATE = 0x0a, 158 | /* Network End Device Timeout Request Command. r21 */ 159 | ED_TIMEOUT_REQUEST = 0x0b, 160 | /* Network End Device Timeout Response Command. r21 */ 161 | ED_TIMEOUT_RESPONSE = 0x0c, 162 | /* Link Power Delta Command. r22 */ 163 | LINK_PWR_DELTA = 0x0d, 164 | /* Network Commissioning Request Command. r23 */ 165 | COMMISSIONING_REQUEST = 0x0e, 166 | /* Network Commissioning Response Command. r23 */ 167 | COMMISSIONING_RESPONSE = 0x0f, 168 | } 169 | 170 | /** Network Status Code Definitions. */ 171 | export enum ZigbeeNWKStatus { 172 | /** @deprecated in R23, should no longer be sent, but still processed (same as @see LINK_FAILURE ) */ 173 | LEGACY_NO_ROUTE_AVAILABLE = 0x00, 174 | /** @deprecated in R23, should no longer be sent, but still processed (same as @see LINK_FAILURE ) */ 175 | LEGACY_LINK_FAILURE = 0x01, 176 | /** This link code indicates a failure to route across a link. */ 177 | LINK_FAILURE = 0x02, 178 | // LOW_BATTERY = 0x03, // deprecated 179 | // NO_ROUTING = 0x04, // deprecated 180 | // NO_INDIRECT = 0x05, // deprecated 181 | // INDIRECT_EXPIRE = 0x06, // deprecated 182 | // DEVICE_UNAVAIL = 0x07, // deprecated 183 | // ADDR_UNAVAIL = 0x08, // deprecated 184 | /** 185 | * The failure occurred as a result of a failure in the RF link to the device’s parent. 186 | * This status is only used locally on a device to indicate loss of communication with the parent. 187 | */ 188 | PARENT_LINK_FAILURE = 0x09, 189 | // VALIDATE_ROUTE = 0x0a, // deprecated 190 | /** Source routing has failed, probably indicating a link failure in one of the source route’s links. */ 191 | SOURCE_ROUTE_FAILURE = 0x0b, 192 | /** A route established as a result of a many-to-one route request has failed. */ 193 | MANY_TO_ONE_ROUTE_FAILURE = 0x0c, 194 | /** The address in the destination address field has been determined to be in use by two or more devices. */ 195 | ADDRESS_CONFLICT = 0x0d, 196 | // VERIFY_ADDRESS = 0x0e, // deprecated 197 | /** The operational network PAN identifier of the device has been updated. */ 198 | PANID_UPDATE = 0x0f, 199 | /** The network address of the local device has been updated. */ 200 | NETWORK_ADDRESS_UPDATE = 0x10, 201 | // BAD_FRAME_COUNTER = 0x11, // XXX: not in spec 202 | // BAD_KEY_SEQNO = 0x12, // XXX: not in spec 203 | /** The NWK command ID is not known to the device. */ 204 | UNKNOWN_COMMAND = 0x13, 205 | /** Notification to the local application that a PAN ID Conflict Report has been received by the local Network Manager. */ 206 | PANID_CONFLICT_REPORT = 0x14, 207 | // RESERVED = 0x15-0xff, 208 | } 209 | 210 | export const enum ZigbeeNWKManyToOne { 211 | /** The route request is not a many-to-one route request. */ 212 | DISABLED = 0, 213 | /** The route request is a many-to-one route request and the sender supports a route record table. */ 214 | WITH_SOURCE_ROUTING = 1, 215 | /** The route request is a many-to-one route request and the sender does not support a route record table. */ 216 | WITHOUT_SOURCE_ROUTING = 2, 217 | // RESERVED = 3, 218 | } 219 | 220 | export const enum ZigbeeNWKRouteStatus { 221 | ACTIVE = 0x0, 222 | DISCOVERY_UNDERWAY = 0x1, 223 | DISCOVERY_FAILED = 0x2, 224 | INACTIVE = 0x3, 225 | // RESERVED = 0x4-0x7, 226 | } 227 | 228 | export type ZigbeeNWKLinkStatus = { 229 | /** uint16_t */ 230 | address: number; 231 | /** LB uint8_t */ 232 | incomingCost: number; 233 | /** HB uint8_t */ 234 | outgoingCost: number; 235 | }; 236 | 237 | /** 238 | * Frame Control Field: 0x0248, Frame Type: Data, Discover Route: Enable, Security Data 239 | * .... .... .... ..00 = Frame Type: Data (0x0) 240 | * .... .... ..00 10.. = Protocol Version: 2 241 | * .... .... 01.. .... = Discover Route: Enable (0x1) 242 | * .... ...0 .... .... = Multicast: False 243 | * .... ..1. .... .... = Security: True 244 | * .... .0.. .... .... = Source Route: False 245 | * .... 0... .... .... = Destination: False 246 | * ...0 .... .... .... = Extended Source: False 247 | * ..0. .... .... .... = End Device Initiator: False 248 | */ 249 | export type ZigbeeNWKFrameControl = { 250 | frameType: ZigbeeNWKFrameType; 251 | protocolVersion: number; 252 | discoverRoute: ZigbeeNWKRouteDiscovery; 253 | /** ZigBee 2006 and Later @deprecated */ 254 | multicast?: boolean; 255 | security: boolean; 256 | /** ZigBee 2006 and Later */ 257 | sourceRoute: boolean; 258 | /** ZigBee 2006 and Later */ 259 | extendedDestination: boolean; 260 | /** ZigBee 2006 and Later */ 261 | extendedSource: boolean; 262 | /** ZigBee PRO r21 */ 263 | endDeviceInitiator: boolean; 264 | }; 265 | 266 | export type ZigbeeNWKHeader = { 267 | frameControl: ZigbeeNWKFrameControl; 268 | destination16?: number; 269 | source16?: number; 270 | radius?: number; 271 | seqNum?: number; 272 | destination64?: bigint; 273 | source64?: bigint; 274 | relayIndex?: number; 275 | relayAddresses?: number[]; 276 | securityHeader?: ZigbeeSecurityHeader; 277 | }; 278 | 279 | /** 280 | * if the security subfield is set to 1 in the frame control field, the frame payload is protected as defined by the security suite selected for that relationship. 281 | * 282 | * Octets: variable 283 | */ 284 | export type ZigbeeNWKPayload = Buffer; 285 | 286 | export function decodeZigbeeNWKFrameControl(data: Buffer, offset: number): [ZigbeeNWKFrameControl, offset: number] { 287 | const fcf = data.readUInt16LE(offset); 288 | offset += 2; 289 | 290 | return [ 291 | { 292 | frameType: fcf & ZigbeeNWKConsts.FCF_FRAME_TYPE, 293 | protocolVersion: (fcf & ZigbeeNWKConsts.FCF_VERSION) >> 2, 294 | discoverRoute: (fcf & ZigbeeNWKConsts.FCF_DISCOVER_ROUTE) >> 6, 295 | multicast: Boolean((fcf & ZigbeeNWKConsts.FCF_MULTICAST) >> 8), 296 | security: Boolean((fcf & ZigbeeNWKConsts.FCF_SECURITY) >> 9), 297 | sourceRoute: Boolean((fcf & ZigbeeNWKConsts.FCF_SOURCE_ROUTE) >> 10), 298 | extendedDestination: Boolean((fcf & ZigbeeNWKConsts.FCF_EXT_DEST) >> 11), 299 | extendedSource: Boolean((fcf & ZigbeeNWKConsts.FCF_EXT_SOURCE) >> 12), 300 | endDeviceInitiator: Boolean((fcf & ZigbeeNWKConsts.FCF_END_DEVICE_INITIATOR) >> 13), 301 | }, 302 | offset, 303 | ]; 304 | } 305 | 306 | function encodeZigbeeNWKFrameControl(view: Buffer, offset: number, fcf: ZigbeeNWKFrameControl): number { 307 | view.writeUInt16LE( 308 | (fcf.frameType & ZigbeeNWKConsts.FCF_FRAME_TYPE) | 309 | ((fcf.protocolVersion << 2) & ZigbeeNWKConsts.FCF_VERSION) | 310 | ((fcf.discoverRoute << 6) & ZigbeeNWKConsts.FCF_DISCOVER_ROUTE) | 311 | (((fcf.multicast ? 1 : 0) << 8) & ZigbeeNWKConsts.FCF_MULTICAST) | 312 | (((fcf.security ? 1 : 0) << 9) & ZigbeeNWKConsts.FCF_SECURITY) | 313 | (((fcf.sourceRoute ? 1 : 0) << 10) & ZigbeeNWKConsts.FCF_SOURCE_ROUTE) | 314 | (((fcf.extendedDestination ? 1 : 0) << 11) & ZigbeeNWKConsts.FCF_EXT_DEST) | 315 | (((fcf.extendedSource ? 1 : 0) << 12) & ZigbeeNWKConsts.FCF_EXT_SOURCE) | 316 | (((fcf.endDeviceInitiator ? 1 : 0) << 13) & ZigbeeNWKConsts.FCF_END_DEVICE_INITIATOR), 317 | offset, 318 | ); 319 | offset += 2; 320 | 321 | return offset; 322 | } 323 | 324 | export function decodeZigbeeNWKHeader(data: Buffer, offset: number, frameControl: ZigbeeNWKFrameControl): [ZigbeeNWKHeader, offset: number] { 325 | let destination16: number | undefined; 326 | let source16: number | undefined; 327 | let radius: number | undefined; 328 | let seqNum: number | undefined; 329 | let destination64: bigint | undefined; 330 | let source64: bigint | undefined; 331 | let relayIndex: number | undefined; 332 | let relayAddresses: number[] | undefined; 333 | 334 | if (frameControl.frameType !== ZigbeeNWKFrameType.INTERPAN) { 335 | destination16 = data.readUInt16LE(offset); 336 | offset += 2; 337 | source16 = data.readUInt16LE(offset); 338 | offset += 2; 339 | radius = data.readUInt8(offset); 340 | offset += 1; 341 | seqNum = data.readUInt8(offset); 342 | offset += 1; 343 | 344 | if (frameControl.extendedDestination) { 345 | destination64 = data.readBigUInt64LE(offset); 346 | offset += 8; 347 | } 348 | 349 | if (frameControl.extendedSource) { 350 | source64 = data.readBigUInt64LE(offset); 351 | offset += 8; 352 | } 353 | 354 | if (frameControl.multicast) { 355 | offset += 1; 356 | } 357 | 358 | if (frameControl.sourceRoute) { 359 | const relayCount = data.readUInt8(offset); 360 | offset += 1; 361 | relayIndex = data.readUInt8(offset); 362 | offset += 1; 363 | relayAddresses = []; 364 | 365 | for (let i = 0; i < relayCount; i++) { 366 | relayAddresses.push(data.readUInt16LE(offset)); 367 | offset += 2; 368 | } 369 | } 370 | } 371 | 372 | if (offset >= data.byteLength) { 373 | throw new Error("Invalid NWK frame: no payload"); 374 | } 375 | 376 | return [ 377 | { 378 | frameControl, 379 | destination16, 380 | source16, 381 | radius, 382 | seqNum, 383 | destination64, 384 | source64, 385 | relayIndex, 386 | relayAddresses, 387 | securityHeader: undefined, // set later, or not 388 | }, 389 | offset, 390 | ]; 391 | } 392 | 393 | function encodeZigbeeNWKHeader(data: Buffer, offset: number, header: ZigbeeNWKHeader): number { 394 | offset = encodeZigbeeNWKFrameControl(data, offset, header.frameControl); 395 | 396 | if (header.frameControl.frameType !== ZigbeeNWKFrameType.INTERPAN) { 397 | data.writeUInt16LE(header.destination16!, offset); 398 | offset += 2; 399 | data.writeUInt16LE(header.source16!, offset); 400 | offset += 2; 401 | data.writeUInt8(header.radius!, offset); 402 | offset += 1; 403 | data.writeUInt8(header.seqNum!, offset); 404 | offset += 1; 405 | 406 | if (header.frameControl.extendedDestination) { 407 | data.writeBigUInt64LE(header.destination64!, offset); 408 | offset += 8; 409 | } 410 | 411 | if (header.frameControl.extendedSource) { 412 | data.writeBigUInt64LE(header.source64!, offset); 413 | offset += 8; 414 | } 415 | 416 | if (header.frameControl.sourceRoute) { 417 | data.writeUInt8(header.relayAddresses!.length, offset); 418 | offset += 1; 419 | data.writeUInt8(header.relayIndex!, offset); 420 | offset += 1; 421 | 422 | for (const relayAddress of header.relayAddresses!) { 423 | data.writeUInt16LE(relayAddress, offset); 424 | offset += 2; 425 | } 426 | } 427 | } 428 | 429 | return offset; 430 | } 431 | 432 | /** 433 | * 434 | * @param data 435 | * @param offset 436 | * @param decryptKey If undefined, use default pre-hashed 437 | * @param macSource64 438 | * @param frameControl 439 | * @param header 440 | */ 441 | export function decodeZigbeeNWKPayload( 442 | data: Buffer, 443 | offset: number, 444 | decryptKey: Buffer | undefined, 445 | macSource64: bigint | undefined, 446 | frameControl: ZigbeeNWKFrameControl, 447 | header: ZigbeeNWKHeader, 448 | ): ZigbeeNWKPayload { 449 | if (frameControl.security) { 450 | const [payload, securityHeader, dOutOffset] = decryptZigbeePayload(data, offset, decryptKey, macSource64); 451 | offset = dOutOffset; 452 | header.securityHeader = securityHeader; 453 | 454 | return payload; 455 | } 456 | 457 | return data.subarray(offset); 458 | } 459 | 460 | /** 461 | * @param header 462 | * @param payload 463 | * @param securityHeader 464 | * @param encryptKey If undefined, and security=true, use default pre-hashed 465 | */ 466 | export function encodeZigbeeNWKFrame( 467 | header: ZigbeeNWKHeader, 468 | payload: ZigbeeNWKPayload, 469 | securityHeader?: ZigbeeSecurityHeader, 470 | encryptKey?: Buffer, 471 | ): Buffer { 472 | let offset = 0; 473 | const data = Buffer.alloc(ZigbeeNWKConsts.FRAME_MAX_SIZE); 474 | 475 | offset = encodeZigbeeNWKHeader(data, offset, header); 476 | 477 | if (header.frameControl.security) { 478 | const [cryptedPayload, authTag, eOutOffset] = encryptZigbeePayload(data, offset, payload, securityHeader!, encryptKey); 479 | offset = eOutOffset; 480 | 481 | data.set(cryptedPayload, offset); 482 | offset += cryptedPayload.byteLength; 483 | 484 | data.set(authTag, offset); 485 | offset += authTag.byteLength; 486 | 487 | return data.subarray(0, offset); 488 | } 489 | 490 | data.set(payload, offset); 491 | offset += payload.byteLength; 492 | 493 | return data.subarray(0, offset); 494 | } 495 | -------------------------------------------------------------------------------- /src/zigbee/zigbee-nwkgp.ts: -------------------------------------------------------------------------------- 1 | import { ZigbeeConsts, aes128CcmStar, computeAuthTag } from "./zigbee.js"; 2 | 3 | /** 4 | * const enum with sole purpose of avoiding "magic numbers" in code for well-known values 5 | */ 6 | export const enum ZigbeeNWKGPConsts { 7 | // TODO: get actual values, these are just copied from NWK 8 | FRAME_MAX_SIZE = 116, 9 | /** no security */ 10 | HEADER_MIN_SIZE = 8, 11 | HEADER_MAX_SIZE = 30, 12 | PAYLOAD_MIN_SIZE = 86, 13 | PAYLOAD_MAX_SIZE = 108, 14 | 15 | //---- ZigBee NWK GP FCF fields 16 | FCF_AUTO_COMMISSIONING = 0x40, 17 | FCF_CONTROL_EXTENSION = 0x80, 18 | FCF_FRAME_TYPE = 0x03, 19 | FCF_VERSION = 0x3c, 20 | 21 | //---- Extended NWK Frame Control field 22 | FCF_EXT_APP_ID = 0x07, // 0 - 2 b. 23 | FCF_EXT_SECURITY_LEVEL = 0x18, // 3 - 4 b. 24 | FCF_EXT_SECURITY_KEY = 0x20, // 5 b. 25 | FCF_EXT_RX_AFTER_TX = 0x40, // 6 b. 26 | FCF_EXT_DIRECTION = 0x80, // 7 b. 27 | } 28 | 29 | /** ZigBee NWK GP FCF frame types. */ 30 | export const enum ZigbeeNWKGPFrameType { 31 | DATA = 0x00, 32 | MAINTENANCE = 0x01, 33 | } 34 | 35 | /** Definitions for application IDs. */ 36 | export const enum ZigbeeNWKGPAppId { 37 | DEFAULT = 0x00, 38 | LPED = 0x01, 39 | ZGP = 0x02, 40 | } 41 | 42 | /** Definitions for GP directions. */ 43 | export const enum ZigbeeNWKGPDirection { 44 | // DIRECTION_DEFAULT = 0x00, 45 | DIRECTION_FROM_ZGPD = 0x00, 46 | DIRECTION_FROM_ZGPP = 0x01, 47 | } 48 | 49 | /** Security level values. */ 50 | export const enum ZigbeeNWKGPSecurityLevel { 51 | /** No Security */ 52 | NO = 0x00, 53 | /** Reserved? */ 54 | ONELSB = 0x01, 55 | /** 4 Byte Frame Counter and 4 Byte MIC */ 56 | FULL = 0x02, 57 | /** 4 Byte Frame Counter and 4 Byte MIC with encryption */ 58 | FULLENCR = 0x03, 59 | } 60 | 61 | /** GP Security key types. */ 62 | export const enum ZigbeeNWKGPSecurityKeyType { 63 | NO_KEY = 0x00, 64 | ZB_NWK_KEY = 0x01, 65 | GPD_GROUP_KEY = 0x02, 66 | NWK_KEY_DERIVED_GPD_KEY_GROUP_KEY = 0x03, 67 | PRECONFIGURED_INDIVIDUAL_GPD_KEY = 0x04, 68 | DERIVED_INDIVIDUAL_GPD_KEY = 0x07, 69 | } 70 | 71 | export const enum ZigbeeNWKGPCommandId { 72 | IDENTIFY = 0x00, 73 | RECALL_SCENE0 = 0x10, 74 | RECALL_SCENE1 = 0x11, 75 | RECALL_SCENE2 = 0x12, 76 | RECALL_SCENE3 = 0x13, 77 | RECALL_SCENE4 = 0x14, 78 | RECALL_SCENE5 = 0x15, 79 | RECALL_SCENE6 = 0x16, 80 | RECALL_SCENE7 = 0x17, 81 | STORE_SCENE0 = 0x18, 82 | STORE_SCENE1 = 0x19, 83 | STORE_SCENE2 = 0x1a, 84 | STORE_SCENE3 = 0x1b, 85 | STORE_SCENE4 = 0x1c, 86 | STORE_SCENE5 = 0x1d, 87 | STORE_SCENE6 = 0x1e, 88 | STORE_SCENE7 = 0x1f, 89 | OFF = 0x20, 90 | ON = 0x21, 91 | TOGGLE = 0x22, 92 | RELEASE = 0x23, 93 | MOVE_UP = 0x30, 94 | MOVE_DOWN = 0x31, 95 | STEP_UP = 0x32, 96 | STEP_DOWN = 0x33, 97 | LEVEL_CONTROL_STOP = 0x34, 98 | MOVE_UP_WITH_ON_OFF = 0x35, 99 | MOVE_DOWN_WITH_ON_OFF = 0x36, 100 | STEP_UP_WITH_ON_OFF = 0x37, 101 | STEP_DOWN_WITH_ON_OFF = 0x38, 102 | MOVE_HUE_STOP = 0x40, 103 | MOVE_HUE_UP = 0x41, 104 | MOVE_HUE_DOWN = 0x42, 105 | STEP_HUE_UP = 0x43, 106 | STEP_HUW_DOWN = 0x44, 107 | MOVE_SATURATION_STOP = 0x45, 108 | MOVE_SATURATION_UP = 0x46, 109 | MOVE_SATURATION_DOWN = 0x47, 110 | STEP_SATURATION_UP = 0x48, 111 | STEP_SATURATION_DOWN = 0x49, 112 | MOVE_COLOR = 0x4a, 113 | STEP_COLOR = 0x4b, 114 | LOCK_DOOR = 0x50, 115 | UNLOCK_DOOR = 0x51, 116 | PRESS11 = 0x60, 117 | RELEASE11 = 0x61, 118 | PRESS12 = 0x62, 119 | RELEASE12 = 0x63, 120 | PRESS22 = 0x64, 121 | RELEASE22 = 0x65, 122 | SHORT_PRESS11 = 0x66, 123 | SHORT_PRESS12 = 0x67, 124 | SHORT_PRESS22 = 0x68, 125 | PRESS_8BIT_VECTOR = 0x69, 126 | RELEASE_8BIT_VECTOR = 0x6a, 127 | ATTRIBUTE_REPORTING = 0xa0, 128 | MANUFACTURE_SPECIFIC_ATTR_REPORTING = 0xa1, 129 | MULTI_CLUSTER_REPORTING = 0xa2, 130 | MANUFACTURER_SPECIFIC_MCLUSTER_REPORTING = 0xa3, 131 | REQUEST_ATTRIBUTES = 0xa4, 132 | READ_ATTRIBUTES_RESPONSE = 0xa5, 133 | ZCL_TUNNELING = 0xa6, 134 | COMPACT_ATTRIBUTE_REPORTING = 0xa8, 135 | ANY_SENSOR_COMMAND_A0_A3 = 0xaf, 136 | COMMISSIONING = 0xe0, 137 | DECOMMISSIONING = 0xe1, 138 | SUCCESS = 0xe2, 139 | CHANNEL_REQUEST = 0xe3, 140 | APPLICATION_DESCRIPTION = 0xe4, 141 | //-- sent to GPD 142 | COMMISSIONING_REPLY = 0xf0, 143 | WRITE_ATTRIBUTES = 0xf1, 144 | READ_ATTRIBUTES = 0xf2, 145 | CHANNEL_CONFIGURATION = 0xf3, 146 | ZCL_TUNNELING_TO_GPD = 0x6, 147 | } 148 | 149 | /** 150 | * Frame Control Field: 0x8c, Frame Type: Data, NWK Frame Extension Data 151 | * .... ..00 = Frame Type: Data (0x0) 152 | * ..00 11.. = Protocol Version: 3 153 | * .0.. .... = Auto Commissioning: False 154 | * 1... .... = NWK Frame Extension: True 155 | */ 156 | export type ZigbeeNWKGPFrameControl = { 157 | frameType: number; 158 | protocolVersion: number; 159 | autoCommissioning: boolean; 160 | nwkFrameControlExtension: boolean; 161 | }; 162 | 163 | /** 164 | * Extended NWK Frame Control Field: 0x30, Application ID: Unknown, Security Level: Full frame counter and full MIC only, Security Key, Direction: From ZGPD 165 | * .... .000 = Application ID: Unknown (0x0) 166 | * ...1 0... = Security Level: Full frame counter and full MIC only (0x2) 167 | * ..1. .... = Security Key: True 168 | * .0.. .... = Rx After Tx: False 169 | * 0... .... = Direction: From ZGPD (0x0) 170 | */ 171 | export type ZigbeeNWKGPFrameControlExt = { 172 | appId: ZigbeeNWKGPAppId; 173 | securityLevel: ZigbeeNWKGPSecurityLevel; 174 | securityKey: boolean; 175 | rxAfterTx: boolean; 176 | direction: ZigbeeNWKGPDirection; 177 | }; 178 | 179 | export type ZigbeeNWKGPHeader = { 180 | frameControl: ZigbeeNWKGPFrameControl; 181 | frameControlExt?: ZigbeeNWKGPFrameControlExt; 182 | sourceId?: number; 183 | endpoint?: number; 184 | /** (utility, not part of the spec) */ 185 | micSize: 0 | 2 | 4; 186 | securityFrameCounter?: number; 187 | payloadLength: number; 188 | mic?: number; 189 | }; 190 | 191 | export type ZigbeeNWKGPPayload = Buffer; 192 | 193 | export function decodeZigbeeNWKGPFrameControl(data: Buffer, offset: number): [ZigbeeNWKGPFrameControl, offset: number] { 194 | const fcf = data.readUInt8(offset); 195 | offset += 1; 196 | 197 | return [ 198 | { 199 | frameType: fcf & ZigbeeNWKGPConsts.FCF_FRAME_TYPE, 200 | protocolVersion: (fcf & ZigbeeNWKGPConsts.FCF_VERSION) >> 2, 201 | autoCommissioning: Boolean((fcf & ZigbeeNWKGPConsts.FCF_AUTO_COMMISSIONING) >> 6), 202 | nwkFrameControlExtension: Boolean((fcf & ZigbeeNWKGPConsts.FCF_CONTROL_EXTENSION) >> 7), 203 | }, 204 | offset, 205 | ]; 206 | } 207 | 208 | function encodeZigbeeNWKGPFrameControl(data: Buffer, offset: number, fcf: ZigbeeNWKGPFrameControl): number { 209 | data.writeUInt8( 210 | (fcf.frameType & ZigbeeNWKGPConsts.FCF_FRAME_TYPE) | 211 | ((fcf.protocolVersion << 2) & ZigbeeNWKGPConsts.FCF_VERSION) | 212 | (((fcf.autoCommissioning ? 1 : 0) << 6) & ZigbeeNWKGPConsts.FCF_AUTO_COMMISSIONING) | 213 | (((fcf.nwkFrameControlExtension ? 1 : 0) << 7) & ZigbeeNWKGPConsts.FCF_CONTROL_EXTENSION), 214 | offset, 215 | ); 216 | offset += 1; 217 | 218 | return offset; 219 | } 220 | 221 | function decodeZigbeeNWKGPFrameControlExt(data: Buffer, offset: number): [ZigbeeNWKGPFrameControlExt, offset: number] { 222 | const fcf = data.readUInt8(offset); 223 | offset += 1; 224 | 225 | return [ 226 | { 227 | appId: fcf & ZigbeeNWKGPConsts.FCF_EXT_APP_ID, 228 | securityLevel: (fcf & ZigbeeNWKGPConsts.FCF_EXT_SECURITY_LEVEL) >> 3, 229 | securityKey: Boolean((fcf & ZigbeeNWKGPConsts.FCF_EXT_SECURITY_KEY) >> 5), 230 | rxAfterTx: Boolean((fcf & ZigbeeNWKGPConsts.FCF_EXT_RX_AFTER_TX) >> 6), 231 | direction: (fcf & ZigbeeNWKGPConsts.FCF_EXT_DIRECTION) >> 7, 232 | }, 233 | offset, 234 | ]; 235 | } 236 | 237 | function encodeZigbeeNWKGPFrameControlExt(data: Buffer, offset: number, fcExt: ZigbeeNWKGPFrameControlExt): number { 238 | data.writeUInt8( 239 | (fcExt.appId & ZigbeeNWKGPConsts.FCF_EXT_APP_ID) | 240 | ((fcExt.securityLevel << 3) & ZigbeeNWKGPConsts.FCF_EXT_SECURITY_LEVEL) | 241 | (((fcExt.securityKey ? 1 : 0) << 5) & ZigbeeNWKGPConsts.FCF_EXT_SECURITY_KEY) | 242 | (((fcExt.rxAfterTx ? 1 : 0) << 6) & ZigbeeNWKGPConsts.FCF_EXT_RX_AFTER_TX) | 243 | ((fcExt.direction << 7) & ZigbeeNWKGPConsts.FCF_EXT_DIRECTION), 244 | offset, 245 | ); 246 | offset += 1; 247 | 248 | return offset; 249 | } 250 | 251 | export function decodeZigbeeNWKGPHeader(data: Buffer, offset: number, frameControl: ZigbeeNWKGPFrameControl): [ZigbeeNWKGPHeader, offset: number] { 252 | let frameControlExt: ZigbeeNWKGPFrameControlExt | undefined; 253 | 254 | if (frameControl.nwkFrameControlExtension) { 255 | [frameControlExt, offset] = decodeZigbeeNWKGPFrameControlExt(data, offset); 256 | } 257 | 258 | let sourceId: number | undefined; 259 | let endpoint: number | undefined; 260 | let micSize: ZigbeeNWKGPHeader["micSize"] = 0; 261 | let securityFrameCounter: number | undefined; 262 | let mic: number | undefined; 263 | 264 | if ( 265 | (frameControl.frameType === ZigbeeNWKGPFrameType.DATA && !frameControl.nwkFrameControlExtension) || 266 | (frameControl.frameType === ZigbeeNWKGPFrameType.DATA && 267 | frameControl.nwkFrameControlExtension && 268 | frameControlExt!.appId === ZigbeeNWKGPAppId.DEFAULT) || 269 | (frameControl.frameType === ZigbeeNWKGPFrameType.MAINTENANCE && 270 | frameControl.nwkFrameControlExtension && 271 | frameControlExt!.appId === ZigbeeNWKGPAppId.DEFAULT && 272 | data.readUInt8(offset) !== ZigbeeNWKGPCommandId.CHANNEL_CONFIGURATION) 273 | ) { 274 | sourceId = data.readUInt32LE(offset); 275 | offset += 4; 276 | } 277 | 278 | if (frameControl.nwkFrameControlExtension && frameControlExt!.appId === ZigbeeNWKGPAppId.ZGP) { 279 | endpoint = data.readUInt8(offset); 280 | offset += 1; 281 | } 282 | 283 | if ( 284 | frameControl.nwkFrameControlExtension && 285 | (frameControlExt!.appId === ZigbeeNWKGPAppId.DEFAULT || 286 | frameControlExt!.appId === ZigbeeNWKGPAppId.ZGP || 287 | frameControlExt!.appId === ZigbeeNWKGPAppId.LPED) 288 | ) { 289 | if (frameControlExt!.securityLevel === ZigbeeNWKGPSecurityLevel.ONELSB && frameControlExt!.appId !== ZigbeeNWKGPAppId.LPED) { 290 | micSize = 2; 291 | } else if ( 292 | frameControlExt!.securityLevel === ZigbeeNWKGPSecurityLevel.FULL || 293 | frameControlExt!.securityLevel === ZigbeeNWKGPSecurityLevel.FULLENCR 294 | ) { 295 | micSize = 4; 296 | securityFrameCounter = data.readUInt32LE(offset); 297 | offset += 4; 298 | } 299 | } 300 | 301 | //-- here `offset` is "start of payload" 302 | 303 | const payloadLength = data.byteLength - offset - micSize; 304 | 305 | if (payloadLength <= 0) { 306 | throw new Error("Zigbee NWK GP frame without payload"); 307 | } 308 | 309 | if (micSize === 2) { 310 | mic = data.readUInt16LE(offset + payloadLength); // at end 311 | } else if (micSize === 4) { 312 | mic = data.readUInt32LE(offset + payloadLength); // at end 313 | } 314 | 315 | return [ 316 | { 317 | frameControl, 318 | frameControlExt, 319 | sourceId, 320 | endpoint, 321 | micSize, 322 | securityFrameCounter, 323 | payloadLength, 324 | mic, 325 | }, 326 | offset, 327 | ]; 328 | } 329 | 330 | function encodeZigbeeNWKGPHeader(data: Buffer, offset: number, header: ZigbeeNWKGPHeader): number { 331 | offset = encodeZigbeeNWKGPFrameControl(data, offset, header.frameControl); 332 | 333 | if (header.frameControl.nwkFrameControlExtension) { 334 | offset = encodeZigbeeNWKGPFrameControlExt(data, offset, header.frameControlExt!); 335 | } 336 | 337 | if ( 338 | (header.frameControl.frameType === ZigbeeNWKGPFrameType.DATA && !header.frameControl.nwkFrameControlExtension) || 339 | (header.frameControl.frameType === ZigbeeNWKGPFrameType.DATA && 340 | header.frameControl.nwkFrameControlExtension && 341 | header.frameControlExt!.appId === ZigbeeNWKGPAppId.DEFAULT) || 342 | (header.frameControl.frameType === ZigbeeNWKGPFrameType.MAINTENANCE && 343 | header.frameControl.nwkFrameControlExtension && 344 | header.frameControlExt!.appId === ZigbeeNWKGPAppId.DEFAULT && 345 | data.readUInt8(offset) !== ZigbeeNWKGPCommandId.CHANNEL_CONFIGURATION) 346 | ) { 347 | data.writeUInt32LE(header.sourceId!, offset); 348 | offset += 4; 349 | } 350 | 351 | if (header.frameControl.nwkFrameControlExtension && header.frameControlExt!.appId === ZigbeeNWKGPAppId.ZGP) { 352 | data.writeUInt8(header.endpoint!, offset); 353 | offset += 1; 354 | } 355 | 356 | if ( 357 | header.frameControl.nwkFrameControlExtension && 358 | (header.frameControlExt!.appId === ZigbeeNWKGPAppId.DEFAULT || 359 | header.frameControlExt!.appId === ZigbeeNWKGPAppId.ZGP || 360 | header.frameControlExt!.appId === ZigbeeNWKGPAppId.LPED) 361 | ) { 362 | if ( 363 | header.frameControlExt!.securityLevel === ZigbeeNWKGPSecurityLevel.FULL || 364 | header.frameControlExt!.securityLevel === ZigbeeNWKGPSecurityLevel.FULLENCR 365 | ) { 366 | data.writeUInt32LE(header.securityFrameCounter!, offset); 367 | offset += 4; 368 | } 369 | } 370 | 371 | //-- here `offset` is "start of payload" 372 | 373 | return offset; 374 | } 375 | 376 | function makeGPNonce(header: ZigbeeNWKGPHeader, macSource64: bigint | undefined): Buffer { 377 | const nonce = Buffer.alloc(ZigbeeConsts.SEC_NONCE_LEN); 378 | let offset = 0; 379 | 380 | if (header.frameControlExt!.appId === ZigbeeNWKGPAppId.DEFAULT) { 381 | if (header.frameControlExt!.direction === ZigbeeNWKGPDirection.DIRECTION_FROM_ZGPD) { 382 | nonce.writeUInt32LE(header.sourceId!, offset); 383 | offset += 4; 384 | } 385 | 386 | nonce.writeUInt32LE(header.sourceId!, offset); 387 | offset += 4; 388 | } else if (header.frameControlExt!.appId === ZigbeeNWKGPAppId.ZGP) { 389 | nonce.writeBigUInt64LE(macSource64!, offset); 390 | offset += 8; 391 | } 392 | 393 | nonce.writeUInt32LE(header.securityFrameCounter!, offset); 394 | offset += 4; 395 | 396 | if (header.frameControlExt!.appId === ZigbeeNWKGPAppId.ZGP && header.frameControlExt!.direction === ZigbeeNWKGPDirection.DIRECTION_FROM_ZGPD) { 397 | // Security level = 0b101, Key Identifier = 0x00, Extended nonce = 0b0, Reserved = 0b00 398 | nonce.writeUInt8(0xc5, offset); 399 | offset += 1; 400 | } else { 401 | // Security level = 0b101, Key Identifier = 0x00, Extended nonce = 0b0, Reserved = 0b11 402 | nonce.writeUInt8(0x05, offset); 403 | offset += 1; 404 | } 405 | 406 | return nonce; 407 | } 408 | 409 | export function decodeZigbeeNWKGPPayload( 410 | data: Buffer, 411 | offset: number, 412 | decryptKey: Buffer, 413 | macSource64: bigint | undefined, 414 | _frameControl: ZigbeeNWKGPFrameControl, 415 | header: ZigbeeNWKGPHeader, 416 | ): ZigbeeNWKGPPayload { 417 | let authTag: Buffer | undefined; 418 | let decryptedPayload: ZigbeeNWKGPPayload | undefined; 419 | 420 | if (header.frameControlExt?.securityLevel === ZigbeeNWKGPSecurityLevel.FULLENCR) { 421 | const nonce = makeGPNonce(header, macSource64); 422 | [authTag, decryptedPayload] = aes128CcmStar(header.micSize, decryptKey, nonce, data.subarray(offset)); 423 | 424 | const computedAuthTag = computeAuthTag(data.subarray(0, offset), header.micSize!, decryptKey, nonce, decryptedPayload); 425 | 426 | if (!computedAuthTag.equals(authTag)) { 427 | throw new Error("Auth tag mismatch while decrypting Zigbee NWK GP payload with FULLENCR security level"); 428 | } 429 | } else if (header.frameControlExt?.securityLevel === ZigbeeNWKGPSecurityLevel.FULL) { 430 | // TODO: Works against spec test vectors but not actual sniffed frame... 431 | // const nonce = makeGPNonce(header, macSource64); 432 | // [authTag] = aes128CcmStar(header.micSize, decryptKey, nonce, data.subarray(offset)); 433 | // const computedAuthTag = computeAuthTag(data.subarray(0, offset + header.payloadLength), header.micSize!, decryptKey, nonce, Buffer.alloc(0)); 434 | 435 | // if (!computedAuthTag.equals(authTag)) { 436 | // throw new Error("Auth tag mismatch while decrypting Zigbee NWK GP payload with FULL security level"); 437 | // } 438 | 439 | decryptedPayload = data.subarray(offset, offset + header.payloadLength); // no MIC 440 | } else { 441 | decryptedPayload = data.subarray(offset, offset + header.payloadLength); // no MIC 442 | 443 | // TODO mic/authTag? 444 | } 445 | 446 | if (!decryptedPayload) { 447 | throw new Error("Unable to decrypt Zigbee NWK GP payload"); 448 | } 449 | 450 | return decryptedPayload; 451 | } 452 | 453 | export function encodeZigbeeNWKGPFrame( 454 | header: ZigbeeNWKGPHeader, 455 | payload: ZigbeeNWKGPPayload, 456 | decryptKey: Buffer, 457 | macSource64: bigint | undefined, 458 | ): Buffer { 459 | let offset = 0; 460 | const data = Buffer.alloc(ZigbeeNWKGPConsts.FRAME_MAX_SIZE); 461 | 462 | offset = encodeZigbeeNWKGPHeader(data, offset, header); 463 | 464 | if (header.frameControlExt?.securityLevel === ZigbeeNWKGPSecurityLevel.FULLENCR) { 465 | const nonce = makeGPNonce(header, macSource64); 466 | const decryptedData = Buffer.alloc(payload.byteLength + header.micSize!); // payload + auth tag 467 | decryptedData.set(payload, 0); 468 | 469 | const computedAuthTag = computeAuthTag(data.subarray(0, offset), header.micSize!, decryptKey, nonce, payload); 470 | decryptedData.set(computedAuthTag, payload.byteLength); 471 | 472 | const [authTag, encryptedPayload] = aes128CcmStar(header.micSize!, decryptKey, nonce, decryptedData); 473 | 474 | data.set(encryptedPayload, offset); 475 | offset += encryptedPayload.byteLength; 476 | 477 | data.set(authTag, offset); // at end 478 | offset += header.micSize!; 479 | } else if (header.frameControlExt?.securityLevel === ZigbeeNWKGPSecurityLevel.FULL) { 480 | const nonce = makeGPNonce(header, macSource64); 481 | const decryptedData = Buffer.alloc(payload.byteLength + header.micSize!); // payload + auth tag 482 | decryptedData.set(payload, 0); 483 | 484 | data.set(payload, offset); 485 | offset += payload.byteLength; 486 | 487 | const computedAuthTag = computeAuthTag(data.subarray(0, offset), header.micSize!, decryptKey, nonce, Buffer.alloc(0)); 488 | decryptedData.set(computedAuthTag, payload.byteLength); 489 | 490 | const [authTag] = aes128CcmStar(header.micSize!, decryptKey, nonce, decryptedData); 491 | 492 | data.set(authTag, offset); // at end 493 | offset += header.micSize!; 494 | } else { 495 | data.set(payload, offset); 496 | offset += payload.byteLength; 497 | } 498 | 499 | return data.subarray(0, offset); 500 | } 501 | -------------------------------------------------------------------------------- /src/zigbee/zigbee.ts: -------------------------------------------------------------------------------- 1 | import { createCipheriv } from "node:crypto"; 2 | 3 | /** 4 | * const enum with sole purpose of avoiding "magic numbers" in code for well-known values 5 | */ 6 | export const enum ZigbeeConsts { 7 | COORDINATOR_ADDRESS = 0x0000, 8 | /** min reserved address for broacasts */ 9 | BCAST_MIN = 0xfff8, 10 | /** Low power routers only */ 11 | BCAST_LOW_POWER_ROUTERS = 0xfffb, 12 | /** All routers and coordinator */ 13 | BCAST_DEFAULT = 0xfffc, 14 | /** macRxOnWhenIdle = TRUE (all non-sleepy devices) */ 15 | BCAST_RX_ON_WHEN_IDLE = 0xfffd, 16 | /** All devices in PAN (including sleepy end devices) */ 17 | BCAST_SLEEPY = 0xffff, 18 | /** The amount of time after which a broadcast is considered propagated throughout the network */ 19 | BCAST_TIME_WINDOW = 9000, 20 | /** The maximum amount of time that the MAC will hold a message for indirect transmission to a child. (7.68sec for ZigBee Pro) */ 21 | MAC_INDIRECT_TRANSMISSION_TIMEOUT = 7680, 22 | 23 | //---- HA 24 | HA_ENDPOINT = 0x01, 25 | HA_PROFILE_ID = 0x0104, 26 | 27 | //---- ZDO 28 | ZDO_ENDPOINT = 0x00, 29 | ZDO_PROFILE_ID = 0x0000, 30 | NETWORK_ADDRESS_REQUEST = 0x0000, 31 | IEEE_ADDRESS_REQUEST = 0x0001, 32 | NODE_DESCRIPTOR_REQUEST = 0x0002, 33 | POWER_DESCRIPTOR_REQUEST = 0x0003, 34 | SIMPLE_DESCRIPTOR_REQUEST = 0x0004, 35 | ACTIVE_ENDPOINTS_REQUEST = 0x0005, 36 | END_DEVICE_ANNOUNCE = 0x0013, 37 | LQI_TABLE_REQUEST = 0x0031, 38 | ROUTING_TABLE_REQUEST = 0x0032, 39 | NWK_UPDATE_REQUEST = 0x0038, 40 | 41 | //---- Green Power 42 | GP_ENDPOINT = 0xf2, 43 | GP_PROFILE_ID = 0xa1e0, 44 | GP_GROUP_ID = 0x0b84, 45 | GP_CLUSTER_ID = 0x0021, 46 | 47 | //---- Touchlink 48 | TOUCHLINK_PROFILE_ID = 0xc05e, 49 | 50 | //---- ZigBee Security Constants 51 | SEC_L = 2, 52 | SEC_BLOCKSIZE = 16, 53 | SEC_NONCE_LEN = 16 - 2 - 1, 54 | SEC_KEYSIZE = 16, 55 | 56 | SEC_CONTROL_VERIFIED_FC = 0x40, 57 | 58 | //---- CCM* Flags 59 | /** 3-bit encoding of (L-1) */ 60 | SEC_CCM_FLAG_L = 0x01, 61 | 62 | SEC_IPAD = 0x36, 63 | SEC_OPAD = 0x5c, 64 | 65 | //---- Bit masks for the Security Control Field 66 | SEC_CONTROL_LEVEL = 0x07, 67 | SEC_CONTROL_KEY = 0x18, 68 | SEC_CONTROL_NONCE = 0x20, 69 | } 70 | 71 | /* ZigBee security levels. */ 72 | export const enum ZigbeeSecurityLevel { 73 | NONE = 0x00, 74 | MIC32 = 0x01, 75 | MIC64 = 0x02, 76 | MIC128 = 0x03, 77 | ENC = 0x04, 78 | /** ZigBee 3.0 */ 79 | ENC_MIC32 = 0x05, 80 | ENC_MIC64 = 0x06, 81 | ENC_MIC128 = 0x07, 82 | } 83 | 84 | /* ZigBee Key Types */ 85 | export const enum ZigbeeKeyType { 86 | LINK = 0x00, 87 | NWK = 0x01, 88 | TRANSPORT = 0x02, 89 | LOAD = 0x03, 90 | } 91 | 92 | export type ZigbeeSecurityControl = { 93 | level: ZigbeeSecurityLevel; 94 | keyId: ZigbeeKeyType; 95 | nonce: boolean; 96 | }; 97 | 98 | export type ZigbeeSecurityHeader = { 99 | /** uint8_t (same as above) */ 100 | control: ZigbeeSecurityControl; 101 | /** uint32_t */ 102 | frameCounter: number; 103 | /** uint64_t */ 104 | source64?: bigint; 105 | /** uint8_t */ 106 | keySeqNum?: number; 107 | /** (utility, not part of the spec) */ 108 | micLen?: 0 | 4 | 8 | 16; 109 | /** uint32_t */ 110 | // mic?: number; 111 | }; 112 | 113 | function aes128MmoHashUpdate(result: Buffer, data: Buffer, dataSize: number): void { 114 | while (dataSize >= ZigbeeConsts.SEC_BLOCKSIZE) { 115 | const cipher = createCipheriv("aes-128-ecb", result, null); 116 | const block = data.subarray(0, ZigbeeConsts.SEC_BLOCKSIZE); 117 | const u = cipher.update(block); 118 | const f = cipher.final(); 119 | const encryptedBlock = Buffer.alloc(u.byteLength + f.byteLength); 120 | 121 | encryptedBlock.set(u, 0); 122 | encryptedBlock.set(f, u.byteLength); 123 | 124 | // XOR encrypted and plaintext 125 | for (let i = 0; i < ZigbeeConsts.SEC_BLOCKSIZE; i++) { 126 | result[i] = encryptedBlock[i] ^ block[i]; 127 | } 128 | 129 | data = data.subarray(ZigbeeConsts.SEC_BLOCKSIZE); 130 | dataSize -= ZigbeeConsts.SEC_BLOCKSIZE; 131 | } 132 | } 133 | 134 | /** 135 | * See B.1.3 Cryptographic Hash Function 136 | * 137 | * AES-128-MMO (Matyas-Meyer-Oseas) hashing (using node 'crypto' built-in with 'aes-128-ecb') 138 | * 139 | * Used for Install Codes - see Document 13-0402-13 - 10.1 140 | */ 141 | export function aes128MmoHash(data: Buffer): Buffer { 142 | const hashResult = Buffer.alloc(ZigbeeConsts.SEC_BLOCKSIZE); 143 | let remainingLength = data.byteLength; 144 | let position = 0; 145 | 146 | for (position; remainingLength >= ZigbeeConsts.SEC_BLOCKSIZE; ) { 147 | const chunk = data.subarray(position, position + ZigbeeConsts.SEC_BLOCKSIZE); 148 | 149 | aes128MmoHashUpdate(hashResult, chunk, chunk.byteLength); 150 | 151 | position += ZigbeeConsts.SEC_BLOCKSIZE; 152 | remainingLength -= ZigbeeConsts.SEC_BLOCKSIZE; 153 | } 154 | 155 | const temp = Buffer.alloc(ZigbeeConsts.SEC_BLOCKSIZE); 156 | 157 | temp.set(data.subarray(position, position + remainingLength), 0); 158 | 159 | // per the spec, concatenate a 1 bit followed by all zero bits 160 | temp[remainingLength] = 0x80; 161 | 162 | // if appending the bit string will push us beyond the 16-byte boundary, hash that block and append another 16-byte block 163 | if (ZigbeeConsts.SEC_BLOCKSIZE - remainingLength < 3) { 164 | aes128MmoHashUpdate(hashResult, temp, ZigbeeConsts.SEC_BLOCKSIZE); 165 | temp.fill(0); 166 | } 167 | 168 | temp[ZigbeeConsts.SEC_BLOCKSIZE - 2] = (data.byteLength >> 5) & 0xff; 169 | temp[ZigbeeConsts.SEC_BLOCKSIZE - 1] = (data.byteLength << 3) & 0xff; 170 | 171 | aes128MmoHashUpdate(hashResult, temp, ZigbeeConsts.SEC_BLOCKSIZE); 172 | 173 | return hashResult.subarray(0, ZigbeeConsts.SEC_BLOCKSIZE); 174 | } 175 | 176 | /** 177 | * See A CCM* MODE OF OPERATION 178 | * 179 | * Used for Zigbee NWK layer encryption/decryption 180 | */ 181 | export function aes128CcmStar(M: 0 | 2 | 4 | 8 | 16, key: Buffer, nonce: Buffer, data: Buffer): [authTag: Buffer, ciphertext: Buffer] { 182 | const payloadLengthNoM = data.byteLength - M; 183 | const blockCount = 1 + Math.ceil(payloadLengthNoM / ZigbeeConsts.SEC_BLOCKSIZE); 184 | const plaintext = Buffer.alloc(blockCount * ZigbeeConsts.SEC_BLOCKSIZE); 185 | 186 | plaintext.set(data.subarray(-M), 0); 187 | plaintext.set(data.subarray(0, -M), ZigbeeConsts.SEC_BLOCKSIZE); 188 | 189 | const cipher = createCipheriv("aes-128-ecb", key, null); 190 | const buffer = Buffer.alloc(blockCount * ZigbeeConsts.SEC_BLOCKSIZE); 191 | const counter = Buffer.alloc(ZigbeeConsts.SEC_BLOCKSIZE); 192 | counter[0] = ZigbeeConsts.SEC_CCM_FLAG_L; 193 | 194 | counter.set(nonce, 1); 195 | 196 | for (let blockNum = 0; blockNum < blockCount; blockNum++) { 197 | // big endian of size ZigbeeConsts.SEC_L 198 | counter[counter.byteLength - 2] = (blockNum >> 8) & 0xff; 199 | counter[counter.byteLength - 1] = blockNum & 0xff; 200 | const plaintextBlock = plaintext.subarray(ZigbeeConsts.SEC_BLOCKSIZE * blockNum, ZigbeeConsts.SEC_BLOCKSIZE * (blockNum + 1)); 201 | const cipherU = cipher.update(counter); 202 | 203 | // XOR cipher and plaintext 204 | for (let i = 0; i < cipherU.byteLength; i++) { 205 | cipherU[i] ^= plaintextBlock[i]; 206 | } 207 | 208 | buffer.set(cipherU, ZigbeeConsts.SEC_BLOCKSIZE * blockNum); 209 | } 210 | 211 | cipher.final(); 212 | const authTag = buffer.subarray(0, M); 213 | const ciphertext = buffer.subarray(ZigbeeConsts.SEC_BLOCKSIZE, ZigbeeConsts.SEC_BLOCKSIZE + payloadLengthNoM); 214 | 215 | return [authTag, ciphertext]; 216 | } 217 | 218 | /** 219 | * aes-128-cbc with iv as 0-filled block size 220 | * 221 | * Used for Zigbee NWK layer encryption/decryption 222 | */ 223 | export function computeAuthTag(authData: Buffer, M: number, key: Buffer, nonce: Buffer, data: Buffer): Buffer { 224 | const startPaddedSize = Math.ceil( 225 | (1 + nonce.byteLength + ZigbeeConsts.SEC_L + ZigbeeConsts.SEC_L + authData.byteLength) / ZigbeeConsts.SEC_BLOCKSIZE, 226 | ); 227 | const endPaddedSize = Math.ceil(data.byteLength / ZigbeeConsts.SEC_BLOCKSIZE); 228 | const prependAuthData = Buffer.alloc(startPaddedSize * ZigbeeConsts.SEC_BLOCKSIZE + endPaddedSize * ZigbeeConsts.SEC_BLOCKSIZE); 229 | let offset = 0; 230 | prependAuthData[offset] = ((((M - 2) / 2) & 0x7) << 3) | (authData.byteLength > 0 ? 0x40 : 0x00) | ZigbeeConsts.SEC_CCM_FLAG_L; 231 | offset += 1; 232 | 233 | prependAuthData.set(nonce, offset); 234 | offset += nonce.byteLength; 235 | 236 | // big endian of size ZigbeeConsts.SEC_L 237 | prependAuthData[offset] = (data.byteLength >> 8) & 0xff; 238 | prependAuthData[offset + 1] = data.byteLength & 0xff; 239 | offset += 2; 240 | 241 | const prepend = authData.byteLength; 242 | // big endian of size ZigbeeConsts.SEC_L 243 | prependAuthData[offset] = (prepend >> 8) & 0xff; 244 | prependAuthData[offset + 1] = prepend & 0xff; 245 | offset += 2; 246 | 247 | prependAuthData.set(authData, offset); 248 | offset += authData.byteLength; 249 | 250 | const dataOffset = Math.ceil(offset / ZigbeeConsts.SEC_BLOCKSIZE) * ZigbeeConsts.SEC_BLOCKSIZE; 251 | prependAuthData.set(data, dataOffset); 252 | 253 | const cipher = createCipheriv("aes-128-cbc", key, Buffer.alloc(ZigbeeConsts.SEC_BLOCKSIZE, 0)); 254 | const cipherU = cipher.update(prependAuthData); 255 | 256 | cipher.final(); 257 | 258 | const authTag = cipherU.subarray(-ZigbeeConsts.SEC_BLOCKSIZE, -ZigbeeConsts.SEC_BLOCKSIZE + M); 259 | 260 | return authTag; 261 | } 262 | 263 | export function combineSecurityControl(control: ZigbeeSecurityControl, levelOverride?: number): number { 264 | return ( 265 | ((levelOverride !== undefined ? levelOverride : control.level) & ZigbeeConsts.SEC_CONTROL_LEVEL) | 266 | ((control.keyId << 3) & ZigbeeConsts.SEC_CONTROL_KEY) | 267 | (((control.nonce ? 1 : 0) << 5) & ZigbeeConsts.SEC_CONTROL_NONCE) 268 | ); 269 | } 270 | 271 | export function makeNonce(header: ZigbeeSecurityHeader, source64: bigint, levelOverride?: number): Buffer { 272 | const nonce = Buffer.alloc(ZigbeeConsts.SEC_NONCE_LEN); 273 | 274 | // TODO: write source64 as all 0/F if undefined? 275 | nonce.writeBigUInt64LE(source64, 0); 276 | nonce.writeUInt32LE(header.frameCounter, 8); 277 | nonce.writeUInt8(combineSecurityControl(header.control, levelOverride), 12); 278 | 279 | return nonce; 280 | } 281 | 282 | /** 283 | * In order: 284 | * ZigbeeKeyType.LINK, ZigbeeKeyType.NWK, ZigbeeKeyType.TRANSPORT, ZigbeeKeyType.LOAD 285 | */ 286 | const defaultHashedKeys: [Buffer, Buffer, Buffer, Buffer] = [Buffer.alloc(0), Buffer.alloc(0), Buffer.alloc(0), Buffer.alloc(0)]; 287 | 288 | /** 289 | * Pre-hashing default keys makes decryptions ~5x faster 290 | */ 291 | export function registerDefaultHashedKeys(link: Buffer, nwk: Buffer, transport: Buffer, load: Buffer): void { 292 | defaultHashedKeys[0] = link; 293 | defaultHashedKeys[1] = nwk; 294 | defaultHashedKeys[2] = transport; 295 | defaultHashedKeys[3] = load; 296 | } 297 | 298 | /** 299 | * See B.1.4 Keyed Hash Function for Message Authentication 300 | * 301 | * @param key ZigBee Security Key (must be ZigbeeConsts.SEC_KEYSIZE) in length. 302 | * @param inputByte Input byte 303 | */ 304 | export function makeKeyedHash(key: Buffer, inputByte: number): Buffer { 305 | const hashOut = Buffer.alloc(ZigbeeConsts.SEC_BLOCKSIZE + 1); 306 | const hashIn = Buffer.alloc(2 * ZigbeeConsts.SEC_BLOCKSIZE); 307 | 308 | for (let i = 0; i < ZigbeeConsts.SEC_KEYSIZE; i++) { 309 | // copy the key into hashIn and XOR with opad to form: (Key XOR opad) 310 | hashIn[i] = key[i] ^ ZigbeeConsts.SEC_OPAD; 311 | // copy the Key into hashOut and XOR with ipad to form: (Key XOR ipad) 312 | hashOut[i] = key[i] ^ ZigbeeConsts.SEC_IPAD; 313 | } 314 | 315 | // append the input byte to form: (Key XOR ipad) || text. 316 | hashOut[ZigbeeConsts.SEC_BLOCKSIZE] = inputByte; 317 | // hash the contents of hashOut and append the contents to hashIn to form: (Key XOR opad) || H((Key XOR ipad) || text) 318 | hashIn.set(aes128MmoHash(hashOut), ZigbeeConsts.SEC_BLOCKSIZE); 319 | // hash the contents of hashIn to get the final result 320 | hashOut.set(aes128MmoHash(hashIn), 0); 321 | 322 | return hashOut.subarray(0, ZigbeeConsts.SEC_BLOCKSIZE); 323 | } 324 | 325 | /** Hash key if needed, else return `key` as is */ 326 | export function makeKeyedHashByType(keyId: ZigbeeKeyType, key: Buffer): Buffer { 327 | switch (keyId) { 328 | case ZigbeeKeyType.NWK: 329 | case ZigbeeKeyType.LINK: { 330 | // NWK: decrypt with the PAN's current network key 331 | // LINK: decrypt with the unhashed link key assigned by the trust center to this source/destination pair 332 | return key; 333 | } 334 | case ZigbeeKeyType.TRANSPORT: { 335 | // decrypt with a Transport key, a hashed link key that protects network keys sent from the trust center 336 | return makeKeyedHash(key, 0x00); 337 | } 338 | case ZigbeeKeyType.LOAD: { 339 | // decrypt with a Load key, a hashed link key that protects link keys sent from the trust center 340 | return makeKeyedHash(key, 0x02); 341 | } 342 | default: { 343 | throw new Error(`Unsupported key ID ${keyId}`); 344 | } 345 | } 346 | } 347 | 348 | export function decodeZigbeeSecurityHeader(data: Buffer, offset: number, source64?: bigint): [ZigbeeSecurityHeader, offset: number] { 349 | const control = data.readUInt8(offset); 350 | offset += 1; 351 | const level = ZigbeeSecurityLevel.ENC_MIC32; // overrides control & ZigbeeConsts.SEC_CONTROL_LEVEL; 352 | const keyId = (control & ZigbeeConsts.SEC_CONTROL_KEY) >> 3; 353 | const nonce = Boolean((control & ZigbeeConsts.SEC_CONTROL_NONCE) >> 5); 354 | 355 | const frameCounter = data.readUInt32LE(offset); 356 | offset += 4; 357 | 358 | if (nonce) { 359 | source64 = data.readBigUInt64LE(offset); 360 | offset += 8; 361 | } 362 | 363 | let keySeqNum: number | undefined; 364 | 365 | if (keyId === ZigbeeKeyType.NWK) { 366 | keySeqNum = data.readUInt8(offset); 367 | offset += 1; 368 | } 369 | 370 | const micLen = 4; 371 | // NOTE: Security level for Zigbee 3.0 === 5 372 | // let micLen: number; 373 | 374 | // switch (level) { 375 | // case ZigbeeSecurityLevel.ENC: 376 | // case ZigbeeSecurityLevel.NONE: 377 | // default: 378 | // micLen = 0; 379 | // break; 380 | 381 | // case ZigbeeSecurityLevel.ENC_MIC32: 382 | // case ZigbeeSecurityLevel.MIC32: 383 | // micLen = 4; 384 | // break; 385 | 386 | // case ZigbeeSecurityLevel.ENC_MIC64: 387 | // case ZigbeeSecurityLevel.MIC64: 388 | // micLen = 8; 389 | // break; 390 | 391 | // case ZigbeeSecurityLevel.ENC_MIC128: 392 | // case ZigbeeSecurityLevel.MIC128: 393 | // micLen = 16; 394 | // break; 395 | // } 396 | 397 | return [ 398 | { 399 | control: { 400 | level, 401 | keyId, 402 | nonce, 403 | }, 404 | frameCounter, 405 | source64, 406 | keySeqNum, 407 | micLen, 408 | }, 409 | offset, 410 | ]; 411 | } 412 | 413 | export function encodeZigbeeSecurityHeader(data: Buffer, offset: number, header: ZigbeeSecurityHeader): number { 414 | data.writeUInt8(combineSecurityControl(header.control), offset); 415 | offset += 1; 416 | 417 | data.writeUInt32LE(header.frameCounter, offset); 418 | offset += 4; 419 | 420 | if (header.control.nonce) { 421 | data.writeBigUInt64LE(header.source64!, offset); 422 | offset += 8; 423 | } 424 | 425 | if (header.control.keyId === ZigbeeKeyType.NWK) { 426 | data.writeUInt8(header.keySeqNum!, offset); 427 | offset += 1; 428 | } 429 | 430 | return offset; 431 | } 432 | 433 | export function decryptZigbeePayload( 434 | data: Buffer, 435 | offset: number, 436 | key?: Buffer, 437 | source64?: bigint, 438 | ): [Buffer, header: ZigbeeSecurityHeader, offset: number] { 439 | const controlOffset = offset; 440 | const [header, hOutOffset] = decodeZigbeeSecurityHeader(data, offset, source64); 441 | 442 | let authTag: Buffer | undefined; 443 | let decryptedPayload: Buffer | undefined; 444 | 445 | if (header.source64 !== undefined) { 446 | const hashedKey = key ? makeKeyedHashByType(header.control.keyId, key) : defaultHashedKeys[header.control.keyId]; 447 | const nonce = makeNonce(header, header.source64); 448 | const encryptedData = data.subarray(hOutOffset); // payload + auth tag 449 | 450 | [authTag, decryptedPayload] = aes128CcmStar(header.micLen!, hashedKey, nonce, encryptedData); 451 | 452 | // take until end of securityHeader for auth tag computation 453 | const adjustedAuthData = data.subarray(0, hOutOffset); 454 | // patch the security level to ZigBee 3.0 455 | const origControl = adjustedAuthData[controlOffset]; 456 | adjustedAuthData[controlOffset] &= ~ZigbeeConsts.SEC_CONTROL_LEVEL; 457 | adjustedAuthData[controlOffset] |= ZigbeeConsts.SEC_CONTROL_LEVEL & ZigbeeSecurityLevel.ENC_MIC32; 458 | 459 | const computedAuthTag = computeAuthTag(adjustedAuthData, header.micLen!, hashedKey, nonce, decryptedPayload); 460 | // restore security level 461 | adjustedAuthData[controlOffset] = origControl; 462 | 463 | if (!computedAuthTag.equals(authTag)) { 464 | throw new Error("Auth tag mismatch while decrypting Zigbee payload"); 465 | } 466 | } 467 | 468 | if (!decryptedPayload) { 469 | throw new Error("Unable to decrypt Zigbee payload"); 470 | } 471 | 472 | return [decryptedPayload, header, hOutOffset]; 473 | } 474 | 475 | export function encryptZigbeePayload( 476 | data: Buffer, 477 | offset: number, 478 | payload: Buffer, 479 | header: ZigbeeSecurityHeader, 480 | key?: Buffer, 481 | ): [Buffer, authTag: Buffer, offset: number] { 482 | const controlOffset = offset; 483 | offset = encodeZigbeeSecurityHeader(data, offset, header); 484 | 485 | let authTag: Buffer | undefined; 486 | let encryptedPayload: Buffer | undefined; 487 | 488 | if (header.source64 !== undefined) { 489 | const hashedKey = key ? makeKeyedHashByType(header.control.keyId, key) : defaultHashedKeys[header.control.keyId]; 490 | const nonce = makeNonce(header, header.source64, ZigbeeSecurityLevel.ENC_MIC32); 491 | const adjustedAuthData = data.subarray(0, offset); 492 | // patch the security level to ZigBee 3.0 493 | const origControl = adjustedAuthData[controlOffset]; 494 | adjustedAuthData[controlOffset] &= ~ZigbeeConsts.SEC_CONTROL_LEVEL; 495 | adjustedAuthData[controlOffset] |= ZigbeeConsts.SEC_CONTROL_LEVEL & ZigbeeSecurityLevel.ENC_MIC32; 496 | 497 | const decryptedData = Buffer.alloc(payload.byteLength + header.micLen!); // payload + auth tag 498 | decryptedData.set(payload, 0); 499 | // take nwkHeader + securityHeader for auth tag computation 500 | const computedAuthTag = computeAuthTag(adjustedAuthData, header.micLen!, hashedKey, nonce, payload); 501 | decryptedData.set(computedAuthTag, payload.byteLength); 502 | 503 | // restore security level 504 | adjustedAuthData[controlOffset] = origControl; 505 | [authTag, encryptedPayload] = aes128CcmStar(header.micLen!, hashedKey, nonce, decryptedData); 506 | } 507 | 508 | if (!encryptedPayload || !authTag) { 509 | throw new Error("Unable to encrypt Zigbee payload"); 510 | } 511 | 512 | return [encryptedPayload, authTag, offset]; 513 | } 514 | 515 | /** 516 | * Converts a channels array to a uint32 channel mask. 517 | * @param channels 518 | * @returns 519 | */ 520 | export const convertChannelsToMask = (channels: number[]): number => { 521 | return channels.reduce((a, c) => a + (1 << c), 0); 522 | }; 523 | 524 | /** 525 | * Converts a uint32 channel mask to a channels array. 526 | * @param mask 527 | * @returns 528 | */ 529 | export const convertMaskToChannels = (mask: number): number[] => { 530 | const channels: number[] = []; 531 | 532 | for (const channel of [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]) { 533 | if ((2 ** channel) & mask) { 534 | channels.push(channel); 535 | } 536 | } 537 | 538 | return channels; 539 | }; 540 | -------------------------------------------------------------------------------- /test/spinel.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { SpinelCommandId } from "../src/spinel/commands.js"; 3 | import * as Hdlc from "../src/spinel/hdlc.js"; 4 | import { SpinelPropertyId } from "../src/spinel/properties.js"; 5 | import { 6 | type SpinelFrame, 7 | decodeSpinelFrame, 8 | encodeSpinelFrame, 9 | getPackedUInt, 10 | readPropertyE, 11 | readPropertyS, 12 | readStreamRaw, 13 | setPackedUInt, 14 | writePropertyAC, 15 | writePropertyE, 16 | writePropertyS, 17 | } from "../src/spinel/spinel.js"; 18 | import { SpinelStatus } from "../src/spinel/statuses.js"; 19 | 20 | describe("Spinel & HDLC", () => { 21 | const encodeHdlcFrameSpy = vi.spyOn(Hdlc, "encodeHdlcFrame"); 22 | 23 | /** see https://datatracker.ietf.org/doc/html/draft-rquattle-spinel-unified#appendix-B.1 */ 24 | const packedUint21TestVectors: [number, string][] = [ 25 | [0, "00"], 26 | [1, "01"], 27 | [127, "7f"], 28 | [128, "8001"], 29 | [129, "8101"], 30 | [1337, "b90a"], 31 | [16383, "ff7f"], 32 | [16384, "808001"], 33 | [16385, "818001"], 34 | [2097151, "ffff7f"], 35 | ]; 36 | 37 | for (const [dec, hex] of packedUint21TestVectors) { 38 | it(`writes Packed Unsigned Integer: ${dec} => ${hex}`, () => { 39 | const buffer = Buffer.from("0ba124f50000000000", "hex"); 40 | let offset = 4; 41 | offset = setPackedUInt(buffer, offset, dec); 42 | 43 | expect(offset).toStrictEqual(4 + hex.length / 2); 44 | expect(buffer.toString("hex").startsWith(`0ba124f5${hex}`)).toStrictEqual(true); 45 | }); 46 | 47 | it(`reads Packed Unsigned Integer: ${hex} => ${dec}`, () => { 48 | const buffer = Buffer.from(`0ba124f5${hex}4f2f6b7caa`, "hex"); 49 | let offset = 4; 50 | 51 | const [val, newOffset] = getPackedUInt(buffer, offset); 52 | offset = newOffset; 53 | 54 | expect(offset).toStrictEqual(4 + hex.length / 2); 55 | expect(val).toStrictEqual(dec); 56 | }); 57 | } 58 | 59 | it("writePropertyE & readPropertyE", () => { 60 | const buf = writePropertyE(SpinelPropertyId.MAC_15_4_LADDR, 123n); 61 | 62 | expect(buf).toStrictEqual(Buffer.from([52, 0, 0, 0, 0, 0, 0, 0, 123])); 63 | 64 | const val = readPropertyE(SpinelPropertyId.MAC_15_4_LADDR, buf); 65 | 66 | expect(val).toStrictEqual(123n); 67 | }); 68 | 69 | it("writePropertyS & readPropertyS", () => { 70 | const buf = writePropertyS(SpinelPropertyId.MAC_15_4_PANID, 43993); 71 | 72 | expect(buf).toStrictEqual(Buffer.from([54, 217, 171])); 73 | 74 | const val = readPropertyS(SpinelPropertyId.MAC_15_4_PANID, buf); 75 | 76 | expect(val).toStrictEqual(43993); 77 | }); 78 | 79 | it("writePropertyAC", () => { 80 | const buf = writePropertyAC(SpinelPropertyId.MAC_SCAN_MASK, [11, 15, 20, 25]); 81 | 82 | expect(buf).toStrictEqual(Buffer.from([49, 11, 15, 20, 25])); 83 | }); 84 | 85 | /** see https://datatracker.ietf.org/doc/html/draft-rquattle-spinel-unified#appendix-B.2 */ 86 | const resetCommandTestVectorFrame: SpinelFrame = { 87 | header: { tid: 0, nli: 0, flg: 2 }, 88 | commandId: SpinelCommandId.RESET, 89 | payload: Buffer.alloc(0), 90 | }; 91 | const resetCommandTestVectorHex = "8001"; 92 | /** verified with code from https://github.com/openthread/openthread/tree/main/src/lib/hdlc */ 93 | const resetCommandTestVectorHdlcHex = "7e800102927e"; 94 | 95 | it("writes Reset Command to HDLC", () => { 96 | const encFrame = encodeSpinelFrame(resetCommandTestVectorFrame); 97 | 98 | expect(encFrame).toBeDefined(); 99 | expect(encodeHdlcFrameSpy).toHaveBeenCalledTimes(1); 100 | expect(encodeHdlcFrameSpy.mock.calls[0][0].toString("hex")).toStrictEqual(resetCommandTestVectorHex); 101 | expect(encFrame.length).toStrictEqual(6); 102 | expect(encFrame.data.subarray(0, encFrame.length)).toStrictEqual(Buffer.from(resetCommandTestVectorHdlcHex, "hex")); 103 | expect(encFrame.fcs).toStrictEqual(Hdlc.HDLC_GOOD_FCS); 104 | }); 105 | 106 | /** see https://datatracker.ietf.org/doc/html/draft-rquattle-spinel-unified#appendix-B.3 */ 107 | const resetNotificationTestVectorFrame: SpinelFrame = { 108 | header: { tid: 0, nli: 0, flg: 2 }, 109 | commandId: SpinelCommandId.PROP_VALUE_IS, 110 | // these are technically packed uint21 but we know values are uint8, we can cheat 111 | payload: Buffer.from([SpinelPropertyId.LAST_STATUS, SpinelStatus.RESET_SOFTWARE]), 112 | }; 113 | const resetNotificationTestVectorHex = "80060072"; 114 | /** verified with code from https://github.com/openthread/openthread/tree/main/src/lib/hdlc */ 115 | const resetNotificationTestVectorHdlcHex = "7e80060072fc577e"; 116 | 117 | it("reads Reset Notification from HDLC", () => { 118 | const decHdlcFrame = Hdlc.decodeHdlcFrame(Buffer.from(resetNotificationTestVectorHdlcHex, "hex")); 119 | 120 | expect(decHdlcFrame.length).toStrictEqual(resetNotificationTestVectorHex.length / 2); 121 | expect(decHdlcFrame.data.subarray(0, decHdlcFrame.length)).toStrictEqual(Buffer.from(resetNotificationTestVectorHex, "hex")); 122 | expect(decHdlcFrame.fcs).toStrictEqual(Hdlc.HDLC_GOOD_FCS); 123 | 124 | const decFrame = decodeSpinelFrame(decHdlcFrame); 125 | 126 | expect(decFrame).toStrictEqual(resetNotificationTestVectorFrame); 127 | }); 128 | 129 | it("reads Spinel STREAM_RAW metadata", () => { 130 | const payload = Buffer.from([ 131 | 0x7e, 0x80, 0x06, 0x71, 0x0a, 0x00, 0x03, 0x08, 0xd0, 0xff, 0xff, 0xff, 0xff, 0x07, 0xff, 0xcc, 0xd7, 0x80, 0x00, 0x00, 0x0a, 0x00, 0x19, 132 | 0xff, 0xc3, 0x0c, 0xc7, 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x87, 0xe9, 0x7e, 133 | ]); 134 | const decHdlcFrame = Hdlc.decodeHdlcFrame(payload); 135 | const decFrame = decodeSpinelFrame(decHdlcFrame); 136 | const [macData, metadata] = readStreamRaw(decFrame.payload, 1); 137 | 138 | expect(macData).toStrictEqual(Buffer.from([0x03, 0x08, 0xd0, 0xff, 0xff, 0xff, 0xff, 0x07, 0xff, 0xcc])); 139 | expect(metadata).toStrictEqual({ rssi: -41, noiseFloor: -128, flags: 0 }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "include": ["./**/*", "vitest.config.mts"], 4 | "compilerOptions": { 5 | "rootDir": "..", 6 | "noEmit": true 7 | }, 8 | "references": [{ "path": ".." }] 9 | } 10 | -------------------------------------------------------------------------------- /test/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | onConsoleLog() { 6 | return false; 7 | }, 8 | coverage: { 9 | enabled: false, 10 | provider: "v8", 11 | include: ["src/**"], 12 | exclude: ["src/dev/**"], 13 | extension: [".ts"], 14 | // exclude: [], 15 | clean: true, 16 | cleanOnRerun: true, 17 | reportsDirectory: "coverage", 18 | reporter: ["text", "html"], 19 | reportOnFailure: false, 20 | thresholds: { 21 | /** current dev status, should maintain above this */ 22 | statements: 70, 23 | functions: 75, 24 | branches: 75, 25 | lines: 70, 26 | }, 27 | }, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /test/wireshark.test.ts: -------------------------------------------------------------------------------- 1 | import { createSocket } from "node:dgram"; 2 | import { describe, it } from "vitest"; 3 | import { DEFAULT_WIRESHARK_IP, DEFAULT_ZEP_UDP_PORT, createWiresharkZEPFrame } from "../src/dev/wireshark"; 4 | 5 | /** 6 | * Util for quick triggering of "send frame to wireshark", not an actual test. 7 | */ 8 | describe.skip("Send to Wireshark", () => { 9 | let wiresharkSeqNum = 0; 10 | 11 | const nextWiresharkSeqNum = (): number => { 12 | wiresharkSeqNum = (wiresharkSeqNum + 1) & 0xffffffff; 13 | 14 | return wiresharkSeqNum + 1; 15 | }; 16 | 17 | it("send", () => { 18 | const wiresharkSocket = createSocket("udp4"); 19 | wiresharkSocket.bind(DEFAULT_ZEP_UDP_PORT); 20 | 21 | const buf = Buffer.from([]); 22 | const wsZEPFrame = createWiresharkZEPFrame(15, 1, 0, 0, nextWiresharkSeqNum(), buf); 23 | 24 | console.log(wsZEPFrame.toString("hex")); 25 | wiresharkSocket.send(wsZEPFrame, DEFAULT_ZEP_UDP_PORT, DEFAULT_WIRESHARK_IP, () => { 26 | wiresharkSocket.close(); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/zigbee.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench, describe } from "vitest"; 2 | import { 3 | decodeMACFrameControl, 4 | decodeMACHeader, 5 | decodeMACPayload, 6 | decodeMACZigbeeBeacon, 7 | encodeMACFrame, 8 | encodeMACFrameZigbee, 9 | } from "../src/zigbee/mac"; 10 | import { ZigbeeKeyType, makeKeyedHashByType, registerDefaultHashedKeys } from "../src/zigbee/zigbee"; 11 | import { decodeZigbeeAPSFrameControl, decodeZigbeeAPSHeader, decodeZigbeeAPSPayload, encodeZigbeeAPSFrame } from "../src/zigbee/zigbee-aps"; 12 | import { decodeZigbeeNWKFrameControl, decodeZigbeeNWKHeader, decodeZigbeeNWKPayload, encodeZigbeeNWKFrame } from "../src/zigbee/zigbee-nwk"; 13 | import { decodeZigbeeNWKGPFrameControl, decodeZigbeeNWKGPHeader, decodeZigbeeNWKGPPayload, encodeZigbeeNWKGPFrame } from "../src/zigbee/zigbee-nwkgp"; 14 | import { 15 | NET2_ASSOC_REQ_FROM_DEVICE, 16 | NET2_ASSOC_RESP_FROM_COORD, 17 | NET2_BEACON_REQ_FROM_DEVICE, 18 | NET2_BEACON_RESP_FROM_COORD, 19 | NET2_COORD_EUI64_BIGINT, 20 | NET2_DEVICE_LEAVE_BROADCAST, 21 | NET2_REQUEST_KEY_TC_FROM_DEVICE, 22 | NET2_TRANSPORT_KEY_NWK_FROM_COORD, 23 | NETDEF_ACK_FRAME_FROM_COORD, 24 | NETDEF_ACK_FRAME_TO_COORD, 25 | NETDEF_LINK_STATUS_FROM_DEV, 26 | NETDEF_MTORR_FRAME_FROM_COORD, 27 | NETDEF_NETWORK_KEY, 28 | NETDEF_ROUTE_RECORD_TO_COORD, 29 | NETDEF_TC_KEY, 30 | NETDEF_ZCL_FRAME_CMD_TO_COORD, 31 | NETDEF_ZCL_FRAME_DEF_RSP_TO_COORD, 32 | NETDEF_ZGP_COMMISSIONING, 33 | NETDEF_ZGP_FRAME_BCAST_RECALL_SCENE_0, 34 | } from "./data"; 35 | 36 | describe("Zigbee", () => { 37 | registerDefaultHashedKeys( 38 | makeKeyedHashByType(ZigbeeKeyType.LINK, NETDEF_TC_KEY), 39 | makeKeyedHashByType(ZigbeeKeyType.NWK, NETDEF_NETWORK_KEY), 40 | makeKeyedHashByType(ZigbeeKeyType.TRANSPORT, NETDEF_TC_KEY), 41 | makeKeyedHashByType(ZigbeeKeyType.LOAD, NETDEF_TC_KEY), 42 | ); 43 | 44 | bench( 45 | "NETDEF_ACK_FRAME_TO_COORD", 46 | () => { 47 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NETDEF_ACK_FRAME_TO_COORD, 0); 48 | const [macHeader, macHOutOffset] = decodeMACHeader(NETDEF_ACK_FRAME_TO_COORD, macFCFOutOffset, macFCF); 49 | const macPayload = decodeMACPayload(NETDEF_ACK_FRAME_TO_COORD, macHOutOffset, macFCF, macHeader); 50 | 51 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 52 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 53 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 54 | 55 | const [apsFCF, apsFCFOutOffset] = decodeZigbeeAPSFrameControl(nwkPayload, 0); 56 | const [apsHeader, apsHOutOffset] = decodeZigbeeAPSHeader(nwkPayload, apsFCFOutOffset, apsFCF); 57 | const apsPayload = decodeZigbeeAPSPayload(nwkPayload, apsHOutOffset, undefined, undefined, apsFCF, apsHeader); 58 | 59 | const encMACHeader = structuredClone(macHeader); 60 | encMACHeader.sourcePANId = undefined; 61 | 62 | encodeMACFrameZigbee(encMACHeader, macPayload); 63 | 64 | const encNWKHeader = structuredClone(nwkHeader); 65 | 66 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 67 | 68 | const encAPSHeader = structuredClone(apsHeader); 69 | 70 | encodeZigbeeAPSFrame(encAPSHeader, apsPayload, encAPSHeader.securityHeader!, undefined); 71 | }, 72 | { warmupTime: 1000 }, 73 | ); 74 | 75 | bench( 76 | "NETDEF_ACK_FRAME_FROM_COORD", 77 | () => { 78 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NETDEF_ACK_FRAME_FROM_COORD, 0); 79 | const [macHeader, macHOutOffset] = decodeMACHeader(NETDEF_ACK_FRAME_FROM_COORD, macFCFOutOffset, macFCF); 80 | const macPayload = decodeMACPayload(NETDEF_ACK_FRAME_FROM_COORD, macHOutOffset, macFCF, macHeader); 81 | 82 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 83 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 84 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 85 | 86 | const [apsFCF, apsFCFOutOffset] = decodeZigbeeAPSFrameControl(nwkPayload, 0); 87 | const [apsHeader, apsHOutOffset] = decodeZigbeeAPSHeader(nwkPayload, apsFCFOutOffset, apsFCF); 88 | const apsPayload = decodeZigbeeAPSPayload(nwkPayload, apsHOutOffset, undefined, undefined, apsFCF, apsHeader); 89 | 90 | const encHeader = structuredClone(macHeader); 91 | encHeader.sourcePANId = undefined; 92 | 93 | encodeMACFrameZigbee(encHeader, macPayload); 94 | 95 | const encNWKHeader = structuredClone(nwkHeader); 96 | 97 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 98 | 99 | const encAPSHeader = structuredClone(apsHeader); 100 | 101 | encodeZigbeeAPSFrame(encAPSHeader, apsPayload, encAPSHeader.securityHeader!, undefined); 102 | }, 103 | { warmupTime: 1000 }, 104 | ); 105 | 106 | bench( 107 | "NETDEF_LINK_STATUS_FROM_DEV", 108 | () => { 109 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NETDEF_LINK_STATUS_FROM_DEV, 0); 110 | const [macHeader, macHOutOffset] = decodeMACHeader(NETDEF_LINK_STATUS_FROM_DEV, macFCFOutOffset, macFCF); 111 | const macPayload = decodeMACPayload(NETDEF_LINK_STATUS_FROM_DEV, macHOutOffset, macFCF, macHeader); 112 | 113 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 114 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 115 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 116 | 117 | const encHeader = structuredClone(macHeader); 118 | encHeader.sourcePANId = undefined; 119 | 120 | encodeMACFrameZigbee(encHeader, macPayload); 121 | 122 | const encNWKHeader = structuredClone(nwkHeader); 123 | 124 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 125 | }, 126 | { warmupTime: 1000 }, 127 | ); 128 | 129 | bench( 130 | "NETDEF_ZCL_FRAME_CMD_TO_COORD", 131 | () => { 132 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NETDEF_ZCL_FRAME_CMD_TO_COORD, 0); 133 | const [macHeader, macHOutOffset] = decodeMACHeader(NETDEF_ZCL_FRAME_CMD_TO_COORD, macFCFOutOffset, macFCF); 134 | const macPayload = decodeMACPayload(NETDEF_ZCL_FRAME_CMD_TO_COORD, macHOutOffset, macFCF, macHeader); 135 | 136 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 137 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 138 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 139 | 140 | const [apsFCF, apsFCFOutOffset] = decodeZigbeeAPSFrameControl(nwkPayload, 0); 141 | const [apsHeader, apsHOutOffset] = decodeZigbeeAPSHeader(nwkPayload, apsFCFOutOffset, apsFCF); 142 | const apsPayload = decodeZigbeeAPSPayload(nwkPayload, apsHOutOffset, undefined, undefined, apsFCF, apsHeader); 143 | 144 | const encHeader = structuredClone(macHeader); 145 | encHeader.sourcePANId = undefined; 146 | 147 | encodeMACFrameZigbee(encHeader, macPayload); 148 | 149 | const encNWKHeader = structuredClone(nwkHeader); 150 | 151 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 152 | 153 | const encAPSHeader = structuredClone(apsHeader); 154 | 155 | encodeZigbeeAPSFrame(encAPSHeader, apsPayload, encAPSHeader.securityHeader!, undefined); 156 | }, 157 | { warmupTime: 1000 }, 158 | ); 159 | 160 | bench( 161 | "NETDEF_ZCL_FRAME_DEF_RSP_TO_COORD", 162 | () => { 163 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NETDEF_ZCL_FRAME_DEF_RSP_TO_COORD, 0); 164 | const [macHeader, macHOutOffset] = decodeMACHeader(NETDEF_ZCL_FRAME_DEF_RSP_TO_COORD, macFCFOutOffset, macFCF); 165 | const macPayload = decodeMACPayload(NETDEF_ZCL_FRAME_DEF_RSP_TO_COORD, macHOutOffset, macFCF, macHeader); 166 | 167 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 168 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 169 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 170 | 171 | const [apsFCF, apsFCFOutOffset] = decodeZigbeeAPSFrameControl(nwkPayload, 0); 172 | const [apsHeader, apsHOutOffset] = decodeZigbeeAPSHeader(nwkPayload, apsFCFOutOffset, apsFCF); 173 | const apsPayload = decodeZigbeeAPSPayload(nwkPayload, apsHOutOffset, undefined, undefined, apsFCF, apsHeader); 174 | 175 | const encHeader = structuredClone(macHeader); 176 | encHeader.sourcePANId = undefined; 177 | 178 | encodeMACFrameZigbee(encHeader, macPayload); 179 | 180 | const encNWKHeader = structuredClone(nwkHeader); 181 | 182 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 183 | 184 | const encAPSHeader = structuredClone(apsHeader); 185 | 186 | encodeZigbeeAPSFrame(encAPSHeader, apsPayload, encAPSHeader.securityHeader!, undefined); 187 | }, 188 | { warmupTime: 1000 }, 189 | ); 190 | 191 | bench( 192 | "NETDEF_ROUTE_RECORD_TO_COORD", 193 | () => { 194 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NETDEF_ROUTE_RECORD_TO_COORD, 0); 195 | const [macHeader, macHOutOffset] = decodeMACHeader(NETDEF_ROUTE_RECORD_TO_COORD, macFCFOutOffset, macFCF); 196 | const macPayload = decodeMACPayload(NETDEF_ROUTE_RECORD_TO_COORD, macHOutOffset, macFCF, macHeader); 197 | 198 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 199 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 200 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 201 | 202 | // TODO Zigbee NWK cmd 203 | 204 | const encHeader = structuredClone(macHeader); 205 | encHeader.sourcePANId = undefined; 206 | 207 | encodeMACFrameZigbee(encHeader, macPayload); 208 | 209 | const encNWKHeader = structuredClone(nwkHeader); 210 | 211 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 212 | }, 213 | { warmupTime: 1000 }, 214 | ); 215 | 216 | bench( 217 | "NETDEF_MTORR_FRAME_FROM_COORD", 218 | () => { 219 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NETDEF_MTORR_FRAME_FROM_COORD, 0); 220 | const [macHeader, macHOutOffset] = decodeMACHeader(NETDEF_MTORR_FRAME_FROM_COORD, macFCFOutOffset, macFCF); 221 | const macPayload = decodeMACPayload(NETDEF_MTORR_FRAME_FROM_COORD, macHOutOffset, macFCF, macHeader); 222 | 223 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 224 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 225 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 226 | 227 | // TODO Zigbee NWK cmd 228 | 229 | const encHeader = structuredClone(macHeader); 230 | encHeader.sourcePANId = undefined; 231 | 232 | encodeMACFrameZigbee(encHeader, macPayload); 233 | 234 | const encNWKHeader = structuredClone(nwkHeader); 235 | 236 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 237 | }, 238 | { warmupTime: 1000 }, 239 | ); 240 | 241 | bench( 242 | "NETDEF_ZGP_FRAME_BCAST_RECALL_SCENE_0", 243 | () => { 244 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NETDEF_ZGP_FRAME_BCAST_RECALL_SCENE_0, 0); 245 | const [macHeader, macHOutOffset] = decodeMACHeader(NETDEF_ZGP_FRAME_BCAST_RECALL_SCENE_0, macFCFOutOffset, macFCF); 246 | const macPayload = decodeMACPayload(NETDEF_ZGP_FRAME_BCAST_RECALL_SCENE_0, macHOutOffset, macFCF, macHeader); 247 | 248 | const [nwkGPFCF, nwkGPFCFOutOffset] = decodeZigbeeNWKGPFrameControl(macPayload, 0); 249 | const [nwkGPHeader, nwkGPHOutOffset] = decodeZigbeeNWKGPHeader(macPayload, nwkGPFCFOutOffset, nwkGPFCF); 250 | const nwkGPPayload = decodeZigbeeNWKGPPayload(macPayload, nwkGPHOutOffset, NETDEF_NETWORK_KEY, macHeader.source64, nwkGPFCF, nwkGPHeader); 251 | 252 | const encHeader = structuredClone(macHeader); 253 | encHeader.sourcePANId = undefined; 254 | 255 | encodeMACFrameZigbee(encHeader, macPayload); 256 | 257 | const encNWKGPHeader = structuredClone(nwkGPHeader); 258 | 259 | encodeZigbeeNWKGPFrame(encNWKGPHeader, nwkGPPayload, NETDEF_NETWORK_KEY, macHeader.source64); 260 | }, 261 | { warmupTime: 1000 }, 262 | ); 263 | 264 | bench( 265 | "NETDEF_ZGP_COMMISSIONING", 266 | () => { 267 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NETDEF_ZGP_COMMISSIONING, 0); 268 | const [macHeader, macHOutOffset] = decodeMACHeader(NETDEF_ZGP_COMMISSIONING, macFCFOutOffset, macFCF); 269 | const macPayload = decodeMACPayload(NETDEF_ZGP_COMMISSIONING, macHOutOffset, macFCF, macHeader); 270 | 271 | const [nwkGPFCF, nwkGPFCFOutOffset] = decodeZigbeeNWKGPFrameControl(macPayload, 0); 272 | const [nwkGPHeader, nwkGPHOutOffset] = decodeZigbeeNWKGPHeader(macPayload, nwkGPFCFOutOffset, nwkGPFCF); 273 | const nwkGPPayload = decodeZigbeeNWKGPPayload(macPayload, nwkGPHOutOffset, NETDEF_NETWORK_KEY, macHeader.source64, nwkGPFCF, nwkGPHeader); 274 | 275 | const encHeader = structuredClone(macHeader); 276 | encHeader.sourcePANId = undefined; 277 | 278 | encodeMACFrameZigbee(encHeader, macPayload); 279 | 280 | const encNWKGPHeader = structuredClone(nwkGPHeader); 281 | 282 | encodeZigbeeNWKGPFrame(encNWKGPHeader, nwkGPPayload, NETDEF_NETWORK_KEY, macHeader.source64); 283 | }, 284 | { warmupTime: 1000 }, 285 | ); 286 | 287 | bench( 288 | "NET2_DEVICE_LEAVE_BROADCAST", 289 | () => { 290 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NET2_DEVICE_LEAVE_BROADCAST, 0); 291 | const [macHeader, macHOutOffset] = decodeMACHeader(NET2_DEVICE_LEAVE_BROADCAST, macFCFOutOffset, macFCF); 292 | const macPayload = decodeMACPayload(NET2_DEVICE_LEAVE_BROADCAST, macHOutOffset, macFCF, macHeader); 293 | 294 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 295 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 296 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 297 | 298 | const encHeader = structuredClone(macHeader); 299 | encHeader.sourcePANId = undefined; 300 | 301 | encodeMACFrameZigbee(encHeader, macPayload); 302 | 303 | const encNWKHeader = structuredClone(nwkHeader); 304 | 305 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 306 | }, 307 | { warmupTime: 1000 }, 308 | ); 309 | 310 | bench( 311 | "NET2_BEACON_REQ_FROM_DEVICE", 312 | () => { 313 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NET2_BEACON_REQ_FROM_DEVICE, 0); 314 | const [macHeader, macHOutOffset] = decodeMACHeader(NET2_BEACON_REQ_FROM_DEVICE, macFCFOutOffset, macFCF); 315 | decodeMACPayload(NET2_BEACON_REQ_FROM_DEVICE, macHOutOffset, macFCF, macHeader); 316 | }, 317 | { warmupTime: 1000 }, 318 | ); 319 | 320 | bench( 321 | "NET2_BEACON_RESP_FROM_COORD", 322 | () => { 323 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NET2_BEACON_RESP_FROM_COORD, 0); 324 | const [macHeader, macHOutOffset] = decodeMACHeader(NET2_BEACON_RESP_FROM_COORD, macFCFOutOffset, macFCF); 325 | const macPayload = decodeMACPayload(NET2_BEACON_RESP_FROM_COORD, macHOutOffset, macFCF, macHeader); 326 | decodeMACZigbeeBeacon(macPayload, 0); 327 | }, 328 | { warmupTime: 1000 }, 329 | ); 330 | 331 | bench( 332 | "NET2_ASSOC_REQ_FROM_DEVICE", 333 | () => { 334 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NET2_ASSOC_REQ_FROM_DEVICE, 0); 335 | const [macHeader, macHOutOffset] = decodeMACHeader(NET2_ASSOC_REQ_FROM_DEVICE, macFCFOutOffset, macFCF); 336 | const macPayload = decodeMACPayload(NET2_ASSOC_REQ_FROM_DEVICE, macHOutOffset, macFCF, macHeader); 337 | 338 | const encHeader = structuredClone(macHeader); 339 | 340 | encodeMACFrame(encHeader, macPayload); 341 | }, 342 | { warmupTime: 1000 }, 343 | ); 344 | 345 | bench( 346 | "NET2_ASSOC_RESP_FROM_COORD", 347 | () => { 348 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NET2_ASSOC_RESP_FROM_COORD, 0); 349 | const [macHeader, macHOutOffset] = decodeMACHeader(NET2_ASSOC_RESP_FROM_COORD, macFCFOutOffset, macFCF); 350 | const macPayload = decodeMACPayload(NET2_ASSOC_RESP_FROM_COORD, macHOutOffset, macFCF, macHeader); 351 | 352 | const encHeader = structuredClone(macHeader); 353 | 354 | encodeMACFrame(encHeader, macPayload); 355 | }, 356 | { warmupTime: 1000 }, 357 | ); 358 | 359 | bench( 360 | "NET2_TRANSPORT_KEY_NWK_FROM_COORD", 361 | () => { 362 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NET2_TRANSPORT_KEY_NWK_FROM_COORD, 0); 363 | const [macHeader, macHOutOffset] = decodeMACHeader(NET2_TRANSPORT_KEY_NWK_FROM_COORD, macFCFOutOffset, macFCF); 364 | const macPayload = decodeMACPayload(NET2_TRANSPORT_KEY_NWK_FROM_COORD, macHOutOffset, macFCF, macHeader); 365 | 366 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 367 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 368 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 369 | 370 | const [apsFCF, apsFCFOutOffset] = decodeZigbeeAPSFrameControl(nwkPayload, 0); 371 | const [apsHeader, apsHOutOffset] = decodeZigbeeAPSHeader(nwkPayload, apsFCFOutOffset, apsFCF); 372 | const apsPayload = decodeZigbeeAPSPayload(nwkPayload, apsHOutOffset, undefined, NET2_COORD_EUI64_BIGINT, apsFCF, apsHeader); 373 | 374 | const encMACHeader = structuredClone(macHeader); 375 | encMACHeader.sourcePANId = undefined; 376 | 377 | encodeMACFrameZigbee(encMACHeader, macPayload); 378 | 379 | const encNWKHeader = structuredClone(nwkHeader); 380 | 381 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 382 | 383 | const encAPSHeader = structuredClone(apsHeader); 384 | 385 | encodeZigbeeAPSFrame(encAPSHeader, apsPayload, encAPSHeader.securityHeader!, undefined); 386 | }, 387 | { warmupTime: 1000 }, 388 | ); 389 | 390 | bench( 391 | "NET2_REQUEST_KEY_TC_FROM_DEVICE", 392 | () => { 393 | const [macFCF, macFCFOutOffset] = decodeMACFrameControl(NET2_REQUEST_KEY_TC_FROM_DEVICE, 0); 394 | const [macHeader, macHOutOffset] = decodeMACHeader(NET2_REQUEST_KEY_TC_FROM_DEVICE, macFCFOutOffset, macFCF); 395 | const macPayload = decodeMACPayload(NET2_REQUEST_KEY_TC_FROM_DEVICE, macHOutOffset, macFCF, macHeader); 396 | 397 | const [nwkFCF, nwkFCFOutOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); 398 | const [nwkHeader, nwkHOutOffset] = decodeZigbeeNWKHeader(macPayload, nwkFCFOutOffset, nwkFCF); 399 | const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkHOutOffset, undefined, macHeader.source64, nwkFCF, nwkHeader); 400 | 401 | const [apsFCF, apsFCFOutOffset] = decodeZigbeeAPSFrameControl(nwkPayload, 0); 402 | const [apsHeader, apsHOutOffset] = decodeZigbeeAPSHeader(nwkPayload, apsFCFOutOffset, apsFCF); 403 | const apsPayload = decodeZigbeeAPSPayload(nwkPayload, apsHOutOffset, undefined, NET2_COORD_EUI64_BIGINT, apsFCF, apsHeader); 404 | 405 | const encMACHeader = structuredClone(macHeader); 406 | encMACHeader.sourcePANId = undefined; 407 | encodeMACFrameZigbee(encMACHeader, macPayload); 408 | 409 | const encNWKHeader = structuredClone(nwkHeader); 410 | encodeZigbeeNWKFrame(encNWKHeader, nwkPayload, encNWKHeader.securityHeader!, undefined); 411 | 412 | const encAPSHeader = structuredClone(apsHeader); 413 | encodeZigbeeAPSFrame(encAPSHeader, apsPayload, encAPSHeader.securityHeader!, undefined); 414 | }, 415 | { warmupTime: 1000 }, 416 | ); 417 | }); 418 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | "composite": true /* Enable constraints that allow a TypeScript project to be used with project references. */, 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "NodeNext" /* Specify what module code is generated. */, 29 | "rootDir": "src" /* Specify the root folder within your source files. */, 30 | "moduleResolution": "nodenext" /* Specify how TypeScript looks up a file from a given module specifier. */, 31 | "baseUrl": "." /* Specify the base directory to resolve non-relative module names. */, 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | "resolveJsonModule": true /* Enable importing .json files. */, 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 63 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 64 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 65 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 66 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 67 | "newLine": "lf" /* Set the newline character for emitting files. */, 68 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 69 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 70 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 71 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 72 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 73 | 74 | /* Interop Constraints */ 75 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 76 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 77 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 78 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 79 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 80 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 81 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 82 | 83 | /* Type Checking */ 84 | "strict": true /* Enable all strict type-checking options. */, 85 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 86 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 87 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 88 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 89 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 90 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 91 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 92 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 93 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 94 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 95 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 96 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 97 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 98 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 99 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 100 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 101 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 102 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 103 | 104 | /* Completeness */ 105 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 106 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 107 | }, 108 | "include": ["./src/**/*.ts", "./src/**/*.json"], 109 | "exclude": ["./dist", "./node_modules"] 110 | } 111 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "exclude": ["./src/dev"] 4 | } 5 | --------------------------------------------------------------------------------