diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml new file mode 100644 index 00000000..e03b29e7 --- /dev/null +++ b/.github/workflows/js-ci.yml @@ -0,0 +1,37 @@ +name: Pull Request + +on: + push: + paths: + - "packages/core" + - "packages/transport-deno" + - "packages/transport-http" + - "packages/transport-web-bluetooth" + - "packages/transport-web-serial" + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Check formatting + run: deno fmt --check + + - name: Check types + run: deno lint + + - name: Publish to JSR + run: npx jsr publish diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 1e24054e..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "src/core/connection"] - path = src/core/connection - url = https://github.com/meshtastic/js.git diff --git a/README.md b/README.md new file mode 100644 index 00000000..bdaa7661 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# Meshtastic Web Monorepo + +[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/web/ci.yml?branch=main&label=Web%20CI&logo=github&color=yellow)](https://github.com/meshtastic/web/actions/workflows/ci.yml) +[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/js/ci.yml?branch=master&label=JS%20CI&logo=github&color=yellow)](https://github.com/meshtastic/js/actions/workflows/ci.yml) +[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/web)](https://cla-assistant.io/meshtastic/web) +[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/) +[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) + +## Overview + +This monorepo consolidates the official [Meshtastic](https://meshtastic.org) web +interface and its supporting JavaScript libraries. It aims to provide a unified +development experience for interacting with Meshtastic devices. + +### Projects within this Monorepo (`packages/`) + +All projects are located within the `packages/` directory: + +- **`packages/web` (Meshtastic Web Client):** The official web interface, + designed to be hosted or served directly from a Meshtastic node. + - **[Hosted version](https://client.meshtastic.org)** +- **`packages/core`:** Core functionality for Meshtastic JS. +- **`packages/transport-deno`:** TCP Transport for the Deno runtime. +- **`packages/transport-http`:** HTTP Transport. +- **`packages/transport-web-bluetooth`:** Web Bluetooth Transport. +- **`packages/transport-web-serial`:** Web Serial Transport. + +All `Meshtastic JS` packages (core and transports) are published to +[JSR](https://jsr.io/@meshtastic). + +--- + +## Stats + +| Project | Repobeats | +| :-------------------- | :-------------------------------------------------------------------------------------------------------------------- | +| Meshtastic Web Client | ![Alt](https://repobeats.axiom.co/api/embed/e5b062db986cb005d83e81724c00cb2b9cce8e4c.svg "Repobeats analytics image") | +| Meshtastic JS | ![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image") | + +--- + +## Tech Stack + +This monorepo leverages the following technologies: + +- **Runtime:** Deno +- **Web Client:** React.js +- **Styling:** Tailwind CSS +- **Bundling:** Vite +- **Language:** TypeScript +- **Testing:** Vitest, React Testing Library + +--- + +## Getting Started + +### Prerequisites + +You'll need to have [Deno](https://deno.com/) installed to work with this +monorepo. Follow the installation instructions on their home page. + +### Development Setup + +1. **Clone the repository:** + ```bash + git clone [https://github.com/meshtastic/meshtastic-web.git](https://github.com/meshtastic/meshtastic-web.git) + cd meshtastic-web + ``` +2. **Install dependencies for all packages:** + ```bash + deno i + ``` + This command installs all necessary dependencies for all packages within the + monorepo. + +### Running Projects + +#### Meshtastic Web Client + +To start the development server for the web client: + +```bash +deno task dev --filter packages/web +``` + +This will typically run the web client on http://localhost:3000 and requires a +Chromium browser + +## Meshtastic JS Packages + +While the js packages are primarily libraries, you can run their tests or +specific development scripts if defined within their deno.json files. For +example, to run tests for a specific package: + +```bash +deno test packages/core +``` + +### Building All Projects + +To build all projects in this monorepo: + +```bash +deno task build --filter . +``` + +This will build both the web client and all JS packages. + +### Feedback + +If you encounter any issues with nightly builds, please report them in our +[issues tracker](https://github.com/meshtastic/web/issues). Your feedback helps +improve the stability of future releases + +### Why Deno? + +Meshtastic Web uses Deno as its development platform for several compelling +reasons: + +- **Built-in Security**: Deno's security-first approach requires explicit + permissions for file, network, and environment access, reducing vulnerability + risks. +- **TypeScript Support**: Native TypeScript support without additional + configuration, enhancing code quality and developer experience. +- **Modern JavaScript**: First-class support for ESM imports, top-level await, + and other modern JavaScript features. +- **Simplified Tooling**: Built-in formatter, linter, test runner, and bundler + eliminate the need for multiple third-party tools. +- **Reproducible Builds**: Lockfile ensures consistent builds across all + environments. +- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable + between server and client environments. + +### Contributing + +We welcome contributions! Here’s how the deployment flow works for pull +requests: + +- **Preview Deployments:**\ + Every pull request automatically generates a preview deployment on Vercel. + This allows you and reviewers to easily preview changes before merging. + +- **Staging Environment (`client-test`):**\ + Once your PR is merged, your changes will be available on our staging site: + [client-test.meshtastic.org](https://client-test.meshtastic.org/).\ + This environment supports rapid feature iteration and testing without + impacting the production site. + +- **Production Releases:**\ + At regular intervals, stable and fully tested releases are promoted to our + production site: [client.meshtastic.org](https://client.meshtastic.org/).\ + This is the primary interface used by the public to connect with their + Meshtastic nodes. + +Please review our +[Contribution Guidelines](https://github.com/meshtastic/web/blob/main/CONTRIBUTING.md) +before submitting a pull request. We appreciate your help in making the project +better! diff --git a/deno.json b/deno.json index ef8c88f8..2957035a 100644 --- a/deno.json +++ b/deno.json @@ -1,8 +1,19 @@ { "workspace": [ - "./packages/web" + "./packages/web", + "./packages/core", + "./packages/transport-deno", + "./packages/transport-http", + "./packages/transport-web-bluetooth", + "./packages/transport-web-serial" ], - "imports": {}, + "imports": { + "@bufbuild/protobuf": "npm:@bufbuild/protobuf@^2.2.3", + "@meshtastic/protobufs": "jsr:@meshtastic/protobufs@^2.7.0", + "@types/node": "npm:@types/node@^22.13.10", + "ste-simple-events": "npm:ste-simple-events@^3.0.11", + "tslog": "npm:tslog@^4.9.3" + }, "nodeModulesDir": "auto", "lint": { "exclude": [ diff --git a/deno.lock b/deno.lock index 216f65ec..196e74da 100644 --- a/deno.lock +++ b/deno.lock @@ -1,7 +1,9 @@ { "version": "5", "specifiers": { + "jsr:@meshtastic/protobufs@^2.7.0": "2.7.0", "jsr:@std/path@^1.1.0": "1.1.0", + "npm:@bufbuild/protobuf@^2.2.3": "2.5.2", "npm:@bufbuild/protobuf@^2.2.5": "2.5.2", "npm:@hookform/resolvers@^5.1.1": "5.1.1_react-hook-form@7.58.1__react@19.1.0_react@19.1.0", "npm:@jsr/meshtastic__core@2.6.4": "2.6.4", @@ -31,27 +33,31 @@ "npm:@tanstack/react-router@^1.120.15": "1.121.34_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@tanstack/router-cli@^1.121.37": "1.121.37", "npm:@tanstack/router-devtools@^1.120.15": "1.121.34_@tanstack+react-router@1.121.34__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0", - "npm:@tanstack/router-plugin@^1.120.15": "1.121.37_@tanstack+react-router@1.121.34__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@babel+core@7.27.4_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@24.0.4", + "npm:@tanstack/router-plugin@^1.120.15": "1.121.37_@tanstack+react-router@1.121.34__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@babel+core@7.27.4_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@24.0.4_@types+node@22.15.33", "npm:@testing-library/jest-dom@^6.6.3": "6.6.3", "npm:@testing-library/react@^16.3.0": "16.3.0_@testing-library+dom@10.4.0_@types+react@19.1.8_@types+react-dom@19.1.6__@types+react@19.1.8_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.0", "npm:@turf/turf@^7.2.0": "7.2.0", "npm:@types/chrome@^0.0.318": "0.0.318", "npm:@types/js-cookie@^3.0.6": "3.0.6", + "npm:@types/node@^22.13.10": "22.15.33", "npm:@types/node@^24.0.4": "24.0.4", "npm:@types/react-dom@^19.1.3": "19.1.6_@types+react@19.1.8", "npm:@types/react@^19.1.2": "19.1.8", "npm:@types/serviceworker@^0.0.133": "0.0.133", "npm:@types/w3c-web-serial@*": "1.0.8", + "npm:@types/w3c-web-serial@^1.0.7": "1.0.8", "npm:@types/w3c-web-serial@^1.0.8": "1.0.8", "npm:@types/web-bluetooth@*": "0.0.21", + "npm:@types/web-bluetooth@^0.0.20": "0.0.20", "npm:@types/web-bluetooth@^0.0.21": "0.0.21", - "npm:@vitejs/plugin-react@^4.4.1": "4.6.0_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@babel+core@7.27.4_@types+node@24.0.4", + "npm:@vitejs/plugin-react@^4.4.1": "4.6.0_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@babel+core@7.27.4_@types+node@24.0.4_@types+node@22.15.33", "npm:autoprefixer@^10.4.21": "10.4.21_postcss@8.5.6", "npm:base64-js@^1.5.1": "1.5.1", "npm:class-variance-authority@~0.7.1": "0.7.1", "npm:clsx@^2.1.1": "2.1.1", "npm:cmdk@^1.1.1": "1.1.1_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+react@19.1.8_@types+react-dom@19.1.6__@types+react@19.1.8", + "npm:crc@^4.3.2": "4.3.2", "npm:crypto-random-string@5": "5.0.0", "npm:gzipper@^8.2.1": "8.2.1", "npm:happy-dom@^17.4.6": "17.6.3", @@ -73,19 +79,27 @@ "npm:react@^19.1.0": "19.1.0", "npm:rfc4648@^1.5.4": "1.5.4", "npm:simple-git-hooks@^2.13.0": "2.13.0", + "npm:ste-simple-events@^3.0.11": "3.0.11", "npm:tailwind-merge@^3.2.0": "3.3.1", "npm:tailwindcss-animate@^1.0.7": "1.0.7_tailwindcss@4.1.10", "npm:tailwindcss@^4.1.5": "4.1.10", "npm:tar@^7.4.3": "7.4.3", "npm:testing-library@^0.0.2": "0.0.2_@angular+common@6.1.10__@angular+core@6.1.10___rxjs@6.6.7___zone.js@0.8.29__rxjs@6.6.7_@angular+core@6.1.10__rxjs@6.6.7__zone.js@0.8.29", + "npm:tslog@^4.9.3": "4.9.3", "npm:typescript@^5.8.3": "5.8.3", - "npm:vite@*": "7.0.0_@types+node@24.0.4_picomatch@4.0.2", - "npm:vite@7": "7.0.0_@types+node@24.0.4_picomatch@4.0.2", - "npm:vitest@^3.2.4": "3.2.4_@types+node@24.0.4_happy-dom@17.6.3_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2", + "npm:vite@*": "7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33", + "npm:vite@7": "7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33", + "npm:vitest@^3.2.4": "3.2.4_@types+node@24.0.4_happy-dom@17.6.3_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@types+node@22.15.33", "npm:zod@^3.25.67": "3.25.67", "npm:zustand@5.0.5": "5.0.5_@types+react@19.1.8_immer@10.1.1_react@19.1.0" }, "jsr": { + "@meshtastic/protobufs@2.7.0": { + "integrity": "38357241bd8a7431c87366dbe12ce9e69f204ebb6ec23da12f7682765b6c8376", + "dependencies": [ + "npm:@bufbuild/protobuf@^2.2.3" + ] + }, "@std/path@1.1.0": { "integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886" } @@ -1756,12 +1770,37 @@ "babel-dead-code-elimination", "chokidar", "unplugin", - "vite", + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2", "zod" ], "optionalPeers": [ "@tanstack/react-router", - "vite" + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2" + ] + }, + "@tanstack/router-plugin@1.121.37_@tanstack+react-router@1.121.34__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@babel+core@7.27.4_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@24.0.4_@types+node@22.15.33": { + "integrity": "sha512-zrolQ1J53xDUdxdO6MLfvnpVINnkIfOnEDVeX3kwHKBGQ5zyGdbolVcVVrJIRYQS0SJoWesn8cf8j+z+u8nZtg==", + "dependencies": [ + "@babel/core", + "@babel/plugin-syntax-jsx", + "@babel/plugin-syntax-typescript", + "@babel/template", + "@babel/traverse", + "@babel/types", + "@tanstack/react-router", + "@tanstack/router-core", + "@tanstack/router-generator", + "@tanstack/router-utils", + "@tanstack/virtual-file-routes", + "babel-dead-code-elimination", + "chokidar", + "unplugin", + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33", + "zod" + ], + "optionalPeers": [ + "@tanstack/react-router", + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33" ] }, "@tanstack/router-utils@1.121.21_@babel+core@7.27.4": { @@ -3262,10 +3301,16 @@ "@types/pbf" ] }, + "@types/node@22.15.33": { + "integrity": "sha512-wzoocdnnpSxZ+6CjW4ADCK1jVmd1S/J3ArNWfn8FDDQtRm8dkDg7TA+mvek2wNrfCgwuZxqEOiB9B1XCJ6+dbw==", + "dependencies": [ + "undici-types@6.21.0" + ] + }, "@types/node@24.0.4": { "integrity": "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA==", "dependencies": [ - "undici-types" + "undici-types@7.8.0" ] }, "@types/pbf@3.0.5": { @@ -3295,6 +3340,9 @@ "@types/w3c-web-serial@1.0.8": { "integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" }, + "@types/web-bluetooth@0.0.20": { + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" + }, "@types/web-bluetooth@0.0.21": { "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==" }, @@ -3326,7 +3374,19 @@ "@rolldown/pluginutils", "@types/babel__core", "react-refresh", - "vite" + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2" + ] + }, + "@vitejs/plugin-react@4.6.0_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@babel+core@7.27.4_@types+node@24.0.4_@types+node@22.15.33": { + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "dependencies": [ + "@babel/core", + "@babel/plugin-transform-react-jsx-self", + "@babel/plugin-transform-react-jsx-source", + "@rolldown/pluginutils", + "@types/babel__core", + "react-refresh", + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33" ] }, "@vitest/expect@3.2.4": { @@ -3345,10 +3405,22 @@ "@vitest/spy", "estree-walker", "magic-string", - "vite" + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2" + ], + "optionalPeers": [ + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2" + ] + }, + "@vitest/mocker@3.2.4_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@types+node@24.0.4_@types+node@22.15.33": { + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dependencies": [ + "@vitest/spy", + "estree-walker", + "magic-string", + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33" ], "optionalPeers": [ - "vite" + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33" ] }, "@vitest/pretty-format@3.2.4": { @@ -4823,6 +4895,9 @@ "typewise-core" ] }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, "undici-types@7.8.0": { "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==" }, @@ -4891,14 +4966,25 @@ "debug", "es-module-lexer", "pathe", - "vite" + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2" + ], + "bin": true + }, + "vite-node@3.2.4_@types+node@24.0.4_@types+node@22.15.33": { + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dependencies": [ + "cac", + "debug", + "es-module-lexer", + "pathe", + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33" ], "bin": true }, "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2": { "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dependencies": [ - "@types/node", + "@types/node@24.0.4", "esbuild", "fdir", "picomatch@4.0.2", @@ -4910,7 +4996,26 @@ "fsevents" ], "optionalPeers": [ - "@types/node" + "@types/node@24.0.4" + ], + "bin": true + }, + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33": { + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", + "dependencies": [ + "@types/node@22.15.33", + "esbuild", + "fdir", + "picomatch@4.0.2", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@types/node@22.15.33" ], "bin": true }, @@ -4918,9 +5023,9 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dependencies": [ "@types/chai", - "@types/node", + "@types/node@24.0.4", "@vitest/expect", - "@vitest/mocker", + "@vitest/mocker@3.2.4_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@types+node@24.0.4", "@vitest/pretty-format", "@vitest/runner", "@vitest/snapshot", @@ -4939,12 +5044,47 @@ "tinyglobby", "tinypool", "tinyrainbow", - "vite", - "vite-node", + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2", + "vite-node@3.2.4_@types+node@24.0.4", "why-is-node-running" ], "optionalPeers": [ - "@types/node", + "@types/node@24.0.4", + "happy-dom" + ], + "bin": true + }, + "vitest@3.2.4_@types+node@24.0.4_happy-dom@17.6.3_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@types+node@22.15.33": { + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dependencies": [ + "@types/chai", + "@types/node@22.15.33", + "@vitest/expect", + "@vitest/mocker@3.2.4_vite@7.0.0__@types+node@24.0.4__picomatch@4.0.2_@types+node@24.0.4_@types+node@22.15.33", + "@vitest/pretty-format", + "@vitest/runner", + "@vitest/snapshot", + "@vitest/spy", + "@vitest/utils", + "chai", + "debug", + "expect-type", + "happy-dom", + "magic-string", + "pathe", + "picomatch@4.0.2", + "std-env", + "tinybench", + "tinyexec", + "tinyglobby", + "tinypool", + "tinyrainbow", + "vite@7.0.0_@types+node@24.0.4_picomatch@4.0.2_@types+node@22.15.33", + "vite-node@3.2.4_@types+node@24.0.4_@types+node@22.15.33", + "why-is-node-running" + ], + "optionalPeers": [ + "@types/node@22.15.33", "happy-dom" ], "bin": true @@ -5053,7 +5193,29 @@ } }, "workspace": { + "dependencies": [ + "jsr:@meshtastic/protobufs@^2.7.0", + "npm:@bufbuild/protobuf@^2.2.3", + "npm:@types/node@^22.13.10", + "npm:ste-simple-events@^3.0.11", + "npm:tslog@^4.9.3" + ], "members": { + "packages/core": { + "dependencies": [ + "npm:crc@^4.3.2" + ] + }, + "packages/transport-web-bluetooth": { + "dependencies": [ + "npm:@types/web-bluetooth@^0.0.20" + ] + }, + "packages/transport-web-serial": { + "dependencies": [ + "npm:@types/w3c-web-serial@^1.0.7" + ] + }, "packages/web": { "dependencies": [ "jsr:@std/path@^1.1.0", diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000..18ce7cf7 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,26 @@ +# @meshtastic/core + +[![JSR](https://jsr.io/badges/@meshtastic/core)](https://jsr.io/@meshtastic/core) +[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/js/ci.yml?branch=master&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/js/actions/workflows/ci.yml) +[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/meshtastic.js)](https://cla-assistant.io/meshtastic/meshtastic.js) +[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/) +[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) + +## Overview + +`@meshtastic/core` Provides core functionality for interfacing with Meshtastic +devices. Installation instructions are available at +[JSR](https://jsr.io/@meshtastic/core) + +## Usage + +```ts +import { MeshDevice } from "@meshtastic/core"; + +// Transport if provided by one of the available transport adapters +const device = new MeshDevice(transport); +``` + +## Stats + +![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image") diff --git a/packages/core/deno.json b/packages/core/deno.json new file mode 100644 index 00000000..fb29ac95 --- /dev/null +++ b/packages/core/deno.json @@ -0,0 +1,10 @@ +{ + "name": "@meshtastic/core", + "version": "2.6.4", + "exports": { + ".": "./mod.ts" + }, + "imports": { + "crc": "npm:crc@^4.3.2" + } +} diff --git a/packages/core/mod.ts b/packages/core/mod.ts new file mode 100644 index 00000000..dd991929 --- /dev/null +++ b/packages/core/mod.ts @@ -0,0 +1,5 @@ +export { Constants } from "./src/constants.ts"; +export { MeshDevice } from "./src/meshDevice.ts"; +export * as Protobuf from "@meshtastic/protobufs"; +export * as Types from "./src/types.ts"; +export * as Utils from "./src/utils/mod.ts"; diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts new file mode 100644 index 00000000..01053c3f --- /dev/null +++ b/packages/core/src/constants.ts @@ -0,0 +1,10 @@ +/** Broadcast destination number */ +const broadcastNum = 0xffffffff; + +/** Minimum device firmware version supported by this version of the library. */ +const minFwVer = 2.2; + +export const Constants = { + broadcastNum, + minFwVer, +}; diff --git a/packages/core/src/meshDevice.ts b/packages/core/src/meshDevice.ts new file mode 100755 index 00000000..36a2dcfa --- /dev/null +++ b/packages/core/src/meshDevice.ts @@ -0,0 +1,1177 @@ +import { Logger } from "tslog"; + +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import * as Protobuf from "@meshtastic/protobufs"; + +import { EventSystem, Queue, Xmodem } from "./utils/mod.ts"; +import { decodePacket } from "./utils/transform/decodePacket.ts"; +import { Constants } from "./constants.ts"; +import type { Destination, PacketMetadata, Transport } from "./types.ts"; +import { ChannelNumber, DeviceStatusEnum, Emitter } from "./types.ts"; + +export class MeshDevice { + public transport: Transport; + + /** Logs to the console and the logging event emitter */ + public log: Logger; + + /** Describes the current state of the device */ + protected deviceStatus: DeviceStatusEnum; + + /** Describes the current state of the device */ + protected isConfigured: boolean; + + /** Are there any settings that have yet to be applied? */ + protected pendingSettingsChanges: boolean; + + /** Device's node number */ + private myNodeInfo: Protobuf.Mesh.MyNodeInfo; + + /** Randomly generated number to ensure confiuration lockstep */ + public configId: number; + + /** + * Packert queue, to space out transmissions and routing handle errors and + * acks + */ + public queue: Queue; + + public events: EventSystem; + + public xModem: Xmodem; + + constructor(transport: Transport, configId?: number) { + this.log = new Logger({ + name: "iMeshDevice", + prettyLogTemplate: + "{{hh}}:{{MM}}:{{ss}}:{{ms}}\t{{logLevelName}}\t[{{name}}]\t", + }); + + this.transport = transport; + this.deviceStatus = DeviceStatusEnum.DeviceDisconnected; + this.isConfigured = false; + this.pendingSettingsChanges = false; + this.myNodeInfo = create(Protobuf.Mesh.MyNodeInfoSchema); + this.configId = configId ?? this.generateRandId(); + this.queue = new Queue(); + this.events = new EventSystem(); + this.xModem = new Xmodem(this.sendRaw.bind(this)); //TODO: try wihtout bind + + this.events.onDeviceStatus.subscribe((status) => { + this.deviceStatus = status; + if (status === DeviceStatusEnum.DeviceConfigured) { + this.isConfigured = true; + } else if (status === DeviceStatusEnum.DeviceConfiguring) { + this.isConfigured = false; + } + }); + + this.events.onMyNodeInfo.subscribe((myNodeInfo) => { + this.myNodeInfo = myNodeInfo; + }); + + this.events.onPendingSettingsChange.subscribe((state) => { + this.pendingSettingsChanges = state; + }); + + this.transport.fromDevice.pipeTo(decodePacket(this)); + } + + /** Abstract method that connects to the radio */ + // protected abstract connect( + // parameters: Types.ConnectionParameters, + // ): Promise; + + /** Abstract method that disconnects from the radio */ + // protected abstract disconnect(): void; + + /** Abstract method that pings the radio */ + // protected abstract ping(): Promise; + + /** + * Sends a text over the radio + */ + public async sendText( + text: string, + destination?: Destination, + wantAck?: boolean, + channel?: ChannelNumber, + replyId?: number, + emoji?: number, + ): Promise { + this.log.debug( + Emitter[Emitter.SendText], + `πŸ“€ Sending message to ${destination ?? "broadcast"} on channel ${ + channel?.toString() ?? 0 + }`, + ); + + const enc = new TextEncoder(); + + return await this.sendPacket( + enc.encode(text), + Protobuf.Portnums.PortNum.TEXT_MESSAGE_APP, + destination ?? "broadcast", + channel, + wantAck, + false, + true, + replyId, + emoji, + ); + } + + /** + * Sends a text over the radio + */ + public sendWaypoint( + waypointMessage: Protobuf.Mesh.Waypoint, + destination: Destination, + channel?: ChannelNumber, + ): Promise { + this.log.debug( + Emitter[Emitter.SendWaypoint], + `πŸ“€ Sending waypoint to ${destination} on channel ${ + channel?.toString() ?? 0 + }`, + ); + + waypointMessage.id = this.generateRandId(); + + return this.sendPacket( + toBinary(Protobuf.Mesh.WaypointSchema, waypointMessage), + Protobuf.Portnums.PortNum.WAYPOINT_APP, + destination, + channel, + true, + false, + ); + } + + /** + * Sends packet over the radio + */ + public async sendPacket( + byteData: Uint8Array, + portNum: Protobuf.Portnums.PortNum, + destination: Destination, + channel: ChannelNumber = ChannelNumber.Primary, + wantAck = true, + wantResponse = true, + echoResponse = false, + replyId?: number, + emoji?: number, + ): Promise { + this.log.trace( + Emitter[Emitter.SendPacket], + `πŸ“€ Sending ${Protobuf.Portnums.PortNum[portNum]} to ${destination}`, + ); + + const meshPacket = create(Protobuf.Mesh.MeshPacketSchema, { + payloadVariant: { + case: "decoded", + value: { + payload: byteData, + portnum: portNum, + wantResponse, + emoji, + replyId, + dest: 0, //change this! + requestId: 0, //change this! + source: 0, //change this! + }, + }, + from: this.myNodeInfo.myNodeNum, + to: destination === "broadcast" + ? Constants.broadcastNum + : destination === "self" + ? this.myNodeInfo.myNodeNum + : destination, + id: this.generateRandId(), + wantAck: wantAck, + channel, + }); + + const toRadioMessage = create(Protobuf.Mesh.ToRadioSchema, { + payloadVariant: { + case: "packet", + value: meshPacket, + }, + }); + + if (echoResponse) { + meshPacket.rxTime = Math.trunc(new Date().getTime() / 1000); + this.handleMeshPacket(meshPacket); + } + return await this.sendRaw( + toBinary(Protobuf.Mesh.ToRadioSchema, toRadioMessage), + meshPacket.id, + ); + } + + /** + * Sends raw packet over the radio + */ + public async sendRaw( + toRadio: Uint8Array, + id: number = this.generateRandId(), + ): Promise { + if (toRadio.length > 512) { + throw new Error("Message longer than 512 bytes, it will not be sent!"); + } + this.queue.push({ + id, + data: toRadio, + }); + + await this.queue.processQueue(this.transport.toDevice); + + return this.queue.wait(id); + } + + /** + * Writes config to device + */ + public async setConfig(config: Protobuf.Config.Config): Promise { + this.log.debug( + Emitter[Emitter.SetConfig], + `βš™οΈ Setting config, Variant: ${config.payloadVariant.case ?? "Unknown"}`, + ); + + if (!this.pendingSettingsChanges) { + await this.beginEditSettings(); + } + + const configMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "setConfig", + value: config, + }, + }); + + return this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, configMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Writes module config to device + */ + public async setModuleConfig( + moduleConfig: Protobuf.ModuleConfig.ModuleConfig, + ): Promise { + this.log.debug( + Emitter[Emitter.SetModuleConfig], + "βš™οΈ Setting module config", + ); + + const moduleConfigMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "setModuleConfig", + value: moduleConfig, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, moduleConfigMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + // Write cannedMessages to device + public async setCannedMessages( + cannedMessages: Protobuf.CannedMessages.CannedMessageModuleConfig, + ): Promise { + this.log.debug( + Emitter[Emitter.SetCannedMessages], + "βš™οΈ Setting CannedMessages", + ); + + const cannedMessagesMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "setCannedMessageModuleMessages", + value: cannedMessages.messages, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, cannedMessagesMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Sets devices owner data + */ + public async setOwner(owner: Protobuf.Mesh.User): Promise { + this.log.debug(Emitter[Emitter.SetOwner], "πŸ‘€ Setting owner"); + + const setOwnerMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "setOwner", + value: owner, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, setOwnerMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Sets devices ChannelSettings + */ + public async setChannel(channel: Protobuf.Channel.Channel): Promise { + this.log.debug( + Emitter[Emitter.SetChannel], + `πŸ“» Setting Channel: ${channel.index}`, + ); + + const setChannelMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "setChannel", + value: channel, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, setChannelMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Triggers Device to enter DFU mode + */ + public async enterDfuMode(): Promise { + this.log.debug( + Emitter[Emitter.EnterDfuMode], + "πŸ”Œ Entering DFU mode", + ); + + const enterDfuModeRequest = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "enterDfuModeRequest", + value: true, + }, + }); + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, enterDfuModeRequest), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Sets static position of device + */ + public async setPosition( + positionMessage: Protobuf.Mesh.Position, + ): Promise { + return await this.sendPacket( + toBinary(Protobuf.Mesh.PositionSchema, positionMessage), + Protobuf.Portnums.PortNum.POSITION_APP, + "self", + ); + } + + /** + * Gets specified channel information from the radio + */ + public async getChannel(index: number): Promise { + this.log.debug( + Emitter[Emitter.GetChannel], + `πŸ“» Requesting Channel: ${index}`, + ); + + const getChannelRequestMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "getChannelRequest", + value: index + 1, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, getChannelRequestMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Gets devices config + */ + public async getConfig( + configType: Protobuf.Admin.AdminMessage_ConfigType, + ): Promise { + this.log.debug( + Emitter[Emitter.GetConfig], + "βš™οΈ Requesting config", + ); + + const getRadioRequestMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "getConfigRequest", + value: configType, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, getRadioRequestMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Gets Module config + */ + public async getModuleConfig( + moduleConfigType: Protobuf.Admin.AdminMessage_ModuleConfigType, + ): Promise { + this.log.debug( + Emitter[Emitter.GetModuleConfig], + "βš™οΈ Requesting module config", + ); + + const getRadioRequestMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "getModuleConfigRequest", + value: moduleConfigType, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, getRadioRequestMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** Gets devices Owner */ + public async getOwner(): Promise { + this.log.debug( + Emitter[Emitter.GetOwner], + "πŸ‘€ Requesting owner", + ); + + const getOwnerRequestMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "getOwnerRequest", + value: true, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, getOwnerRequestMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Gets devices metadata + */ + public async getMetadata(nodeNum: number): Promise { + this.log.debug( + Emitter[Emitter.GetMetadata], + `🏷️ Requesting metadata from ${nodeNum}`, + ); + + const getDeviceMetricsRequestMessage = create( + Protobuf.Admin.AdminMessageSchema, + { + payloadVariant: { + case: "getDeviceMetadataRequest", + value: true, + }, + }, + ); + + return await this.sendPacket( + toBinary( + Protobuf.Admin.AdminMessageSchema, + getDeviceMetricsRequestMessage, + ), + Protobuf.Portnums.PortNum.ADMIN_APP, + nodeNum, + ChannelNumber.Admin, + ); + } + + /** + * Clears specific channel with the designated index + */ + public async clearChannel(index: number): Promise { + this.log.debug( + Emitter[Emitter.ClearChannel], + `πŸ“» Clearing Channel ${index}`, + ); + + const channel = create(Protobuf.Channel.ChannelSchema, { + index, + role: Protobuf.Channel.Channel_Role.DISABLED, + }); + const setChannelMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "setChannel", + value: channel, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, setChannelMessage), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + private async beginEditSettings(): Promise { + this.events.onPendingSettingsChange.dispatch(true); + + const beginEditSettings = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "beginEditSettings", + value: true, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, beginEditSettings), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + public async commitEditSettings(): Promise { + this.events.onPendingSettingsChange.dispatch(false); + + const commitEditSettings = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "commitEditSettings", + value: true, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, commitEditSettings), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Resets the internal NodeDB of the radio, usefull for removing old nodes + * that no longer exist. + */ + public async resetNodes(): Promise { + this.log.debug( + Emitter[Emitter.ResetNodes], + "πŸ“» Resetting NodeDB", + ); + + const resetNodes = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "nodedbReset", + value: 1, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, resetNodes), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Removes a node from the internal NodeDB of the radio by node number + */ + public async removeNodeByNum(nodeNum: number): Promise { + this.log.debug( + Emitter[Emitter.RemoveNodeByNum], + `πŸ“» Removing Node ${nodeNum} from NodeDB`, + ); + + const removeNodeByNum = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "removeByNodenum", + value: nodeNum, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, removeNodeByNum), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** Shuts down the current node after the specified amount of time has elapsed. */ + public async shutdown(time: number): Promise { + this.log.debug( + Emitter[Emitter.Shutdown], + `πŸ”Œ Shutting down ${time > 2 ? "now" : `in ${time} seconds`}`, + ); + + const shutdown = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "shutdownSeconds", + value: time, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, shutdown), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** Reboots the current node after the specified amount of time has elapsed. */ + public async reboot(time: number): Promise { + this.log.debug( + Emitter[Emitter.Reboot], + `πŸ”Œ Rebooting node ${time > 0 ? "now" : `in ${time} seconds`}`, + ); + + const reboot = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "rebootSeconds", + value: time, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, reboot), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Reboots the current node into OTA mode after the specified amount of time has elapsed. + */ + public async rebootOta(time: number): Promise { + this.log.debug( + Emitter[Emitter.RebootOta], + `πŸ”Œ Rebooting into OTA mode ${time > 0 ? "now" : `in ${time} seconds`}`, + ); + + const rebootOta = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "rebootOtaSeconds", + value: time, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, rebootOta), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Factory resets the current device + */ + public async factoryResetDevice(): Promise { + this.log.debug( + Emitter[Emitter.FactoryReset], + "♻️ Factory resetting device", + ); + + const factoryReset = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "factoryResetDevice", + value: 1, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, factoryReset), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Factory resets the current config + */ + public async factoryResetConfig(): Promise { + this.log.debug( + Emitter[Emitter.FactoryReset], + "♻️ Factory resetting config", + ); + + const factoryReset = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "factoryResetConfig", + value: 1, + }, + }); + + return await this.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, factoryReset), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + } + + /** + * Triggers the device configure process + */ + public configure(): Promise { + this.log.debug( + Emitter[Emitter.Configure], + "βš™οΈ Requesting device configuration", + ); + this.updateDeviceStatus(DeviceStatusEnum.DeviceConfiguring); + + const toRadio = create(Protobuf.Mesh.ToRadioSchema, { + payloadVariant: { + case: "wantConfigId", + value: this.configId, + }, + }); + + return this.sendRaw(toBinary(Protobuf.Mesh.ToRadioSchema, toRadio)); + } + + /** + * Serial connection requires a heartbeat ping to stay connected, otherwise times out after 15 minutes + */ + public heartbeat(): Promise { + this.log.debug( + Emitter[Emitter.Ping], + "❀️ Send heartbeat ping to radio", + ); + + const toRadio = create(Protobuf.Mesh.ToRadioSchema, { + payloadVariant: { + case: "heartbeat", + value: {}, + }, + }); + + return this.sendRaw(toBinary(Protobuf.Mesh.ToRadioSchema, toRadio)); + } + + /** + * Sends a trace route packet to the designated node + */ + public async traceRoute(destination: number): Promise { + const routeDiscovery = create(Protobuf.Mesh.RouteDiscoverySchema, { + route: [], + }); + + return await this.sendPacket( + toBinary(Protobuf.Mesh.RouteDiscoverySchema, routeDiscovery), + Protobuf.Portnums.PortNum.TRACEROUTE_APP, + destination, + ); + } + + /** + * Requests position from the designated node + */ + public async requestPosition(destination: number): Promise { + return await this.sendPacket( + new Uint8Array(), + Protobuf.Portnums.PortNum.POSITION_APP, + destination, + ); + } + + /** + * Updates the device status eliminating duplicate status events + */ + public updateDeviceStatus(status: DeviceStatusEnum): void { + if (status !== this.deviceStatus) { + this.events.onDeviceStatus.dispatch(status); + } + } + + /** + * Generates random packet identifier + * + * @returns {number} Random packet ID + */ + private generateRandId(): number { + const seed = crypto.getRandomValues(new Uint32Array(1)); + if (!seed[0]) { + throw new Error("Cannot generate CSPRN"); + } + + return Math.floor(seed[0] * 2 ** -32 * 1e9); + } + + /** Completes all Events */ + public complete(): void { + this.queue.clear(); + } + + /** + * Gets called when a MeshPacket is received from device + */ + public handleMeshPacket(meshPacket: Protobuf.Mesh.MeshPacket): void { + this.events.onMeshPacket.dispatch(meshPacket); + if (meshPacket.from !== this.myNodeInfo.myNodeNum) { + /** + * TODO: this shouldn't be called unless the device interracts with the + * mesh, currently it does. + */ + this.events.onMeshHeartbeat.dispatch(new Date()); + } + + switch (meshPacket.payloadVariant.case) { + case "decoded": { + this.handleDecodedPacket(meshPacket.payloadVariant.value, meshPacket); + break; + } + + case "encrypted": { + this.log.debug( + Emitter[Emitter.HandleMeshPacket], + "πŸ” Device received encrypted data packet, ignoring.", + ); + break; + } + + default: + throw new Error(`Unhandled case ${meshPacket.payloadVariant.case}`); + } + } + + private handleDecodedPacket( + dataPacket: Protobuf.Mesh.Data, + meshPacket: Protobuf.Mesh.MeshPacket, + ) { + let adminMessage: Protobuf.Admin.AdminMessage | undefined = undefined; + let routingPacket: Protobuf.Mesh.Routing | undefined = undefined; + + const packetMetadata: Omit, "data"> = { + id: meshPacket.id, + rxTime: new Date(meshPacket.rxTime * 1000), + type: meshPacket.to === Constants.broadcastNum ? "broadcast" : "direct", + from: meshPacket.from, + to: meshPacket.to, + channel: meshPacket.channel, + }; + + this.log.trace( + Emitter[Emitter.HandleMeshPacket], + `πŸ“¦ Received ${Protobuf.Portnums.PortNum[dataPacket.portnum]} packet`, + ); + + switch (dataPacket.portnum) { + case Protobuf.Portnums.PortNum.TEXT_MESSAGE_APP: { + this.events.onMessagePacket.dispatch({ + ...packetMetadata, + data: new TextDecoder().decode(dataPacket.payload), + }); + break; + } + + case Protobuf.Portnums.PortNum.REMOTE_HARDWARE_APP: { + this.events.onRemoteHardwarePacket.dispatch({ + ...packetMetadata, + data: fromBinary( + Protobuf.RemoteHardware.HardwareMessageSchema, + dataPacket.payload, + ), + }); + break; + } + + case Protobuf.Portnums.PortNum.POSITION_APP: { + this.events.onPositionPacket.dispatch({ + ...packetMetadata, + data: fromBinary(Protobuf.Mesh.PositionSchema, dataPacket.payload), + }); + break; + } + + case Protobuf.Portnums.PortNum.NODEINFO_APP: { + this.events.onUserPacket.dispatch({ + ...packetMetadata, + data: fromBinary(Protobuf.Mesh.UserSchema, dataPacket.payload), + }); + break; + } + + case Protobuf.Portnums.PortNum.ROUTING_APP: { + routingPacket = fromBinary( + Protobuf.Mesh.RoutingSchema, + dataPacket.payload, + ); + + this.events.onRoutingPacket.dispatch({ + ...packetMetadata, + data: routingPacket, + }); + switch (routingPacket.variant.case) { + case "errorReason": { + if ( + routingPacket.variant.value === Protobuf.Mesh.Routing_Error.NONE + ) { + this.queue.processAck(dataPacket.requestId); + } else { + this.queue.processError({ + id: dataPacket.requestId, + error: routingPacket.variant.value, + }); + } + + break; + } + case "routeReply": { + break; + } + case "routeRequest": { + break; + } + + default: { + throw new Error(`Unhandled case ${routingPacket.variant.case}`); + } + } + break; + } + + case Protobuf.Portnums.PortNum.ADMIN_APP: { + adminMessage = fromBinary( + Protobuf.Admin.AdminMessageSchema, + dataPacket.payload, + ); + switch (adminMessage.payloadVariant.case) { + case "getChannelResponse": { + this.events.onChannelPacket.dispatch( + adminMessage.payloadVariant.value, + ); + break; + } + case "getOwnerResponse": { + this.events.onUserPacket.dispatch({ + ...packetMetadata, + data: adminMessage.payloadVariant.value, + }); + break; + } + case "getConfigResponse": { + this.events.onConfigPacket.dispatch( + adminMessage.payloadVariant.value, + ); + break; + } + case "getModuleConfigResponse": { + this.events.onModuleConfigPacket.dispatch( + adminMessage.payloadVariant.value, + ); + break; + } + case "getDeviceMetadataResponse": { + this.log.debug( + Emitter[Emitter.GetMetadata], + `🏷️ Received metadata packet from ${dataPacket.source}`, + ); + + this.events.onDeviceMetadataPacket.dispatch({ + ...packetMetadata, + data: adminMessage.payloadVariant.value, + }); + break; + } + case "getCannedMessageModuleMessagesResponse": { + this.log.debug( + Emitter[Emitter.GetMetadata], + `πŸ₯« Received CannedMessage Module Messages response packet`, + ); + + this.events.onCannedMessageModulePacket.dispatch({ + ...packetMetadata, + data: adminMessage.payloadVariant.value, + }); + break; + } + default: { + this.log.error( + Emitter[Emitter.HandleMeshPacket], + `⚠️ Received unhandled AdminMessage, type ${ + adminMessage.payloadVariant.case ?? "undefined" + }`, + dataPacket.payload, + ); + } + } + break; + } + + case Protobuf.Portnums.PortNum.WAYPOINT_APP: { + this.events.onWaypointPacket.dispatch({ + ...packetMetadata, + data: fromBinary(Protobuf.Mesh.WaypointSchema, dataPacket.payload), + }); + break; + } + + case Protobuf.Portnums.PortNum.AUDIO_APP: { + this.events.onAudioPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.DETECTION_SENSOR_APP: { + this.events.onDetectionSensorPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.REPLY_APP: { + this.events.onPingPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, //TODO: decode + }); + break; + } + + case Protobuf.Portnums.PortNum.IP_TUNNEL_APP: { + this.events.onIpTunnelPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.PAXCOUNTER_APP: { + this.events.onPaxcounterPacket.dispatch({ + ...packetMetadata, + data: fromBinary( + Protobuf.PaxCount.PaxcountSchema, + dataPacket.payload, + ), + }); + break; + } + + case Protobuf.Portnums.PortNum.SERIAL_APP: { + this.events.onSerialPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.STORE_FORWARD_APP: { + this.events.onStoreForwardPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.RANGE_TEST_APP: { + this.events.onRangeTestPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.TELEMETRY_APP: { + this.events.onTelemetryPacket.dispatch({ + ...packetMetadata, + data: fromBinary( + Protobuf.Telemetry.TelemetrySchema, + dataPacket.payload, + ), + }); + break; + } + + case Protobuf.Portnums.PortNum.ZPS_APP: { + this.events.onZpsPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.SIMULATOR_APP: { + this.events.onSimulatorPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.TRACEROUTE_APP: { + this.events.onTraceRoutePacket.dispatch({ + ...packetMetadata, + data: fromBinary( + Protobuf.Mesh.RouteDiscoverySchema, + dataPacket.payload, + ), + }); + break; + } + + case Protobuf.Portnums.PortNum.NEIGHBORINFO_APP: { + this.events.onNeighborInfoPacket.dispatch({ + ...packetMetadata, + data: fromBinary( + Protobuf.Mesh.NeighborInfoSchema, + dataPacket.payload, + ), + }); + break; + } + + case Protobuf.Portnums.PortNum.ATAK_PLUGIN: { + this.events.onAtakPluginPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.MAP_REPORT_APP: { + this.events.onMapReportPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.PRIVATE_APP: { + this.events.onPrivatePacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + case Protobuf.Portnums.PortNum.ATAK_FORWARDER: { + this.events.onAtakForwarderPacket.dispatch({ + ...packetMetadata, + data: dataPacket.payload, + }); + break; + } + + default: + throw new Error(`Unhandled case ${dataPacket.portnum}`); + } + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 00000000..31457e96 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,128 @@ +import type * as Protobuf from "@meshtastic/protobufs"; + +interface Packet { + type: "packet"; + data: Uint8Array; +} + +interface DebugLog { + type: "debug"; + data: string; +} + +export type DeviceOutput = Packet | DebugLog; + +export interface Transport { + toDevice: WritableStream; + fromDevice: ReadableStream; +} + +export interface QueueItem { + id: number; + data: Uint8Array; + sent: boolean; + added: Date; + promise: Promise; +} + +export interface HttpRetryConfig { + maxRetries: number; + initialDelayMs: number; + maxDelayMs: number; + backoffFactor: number; +} + +export enum DeviceStatusEnum { + DeviceRestarting = 1, + DeviceDisconnected = 2, + DeviceConnecting = 3, + DeviceReconnecting = 4, + DeviceConnected = 5, + DeviceConfiguring = 6, + DeviceConfigured = 7, +} + +export type LogEventPacket = LogEvent & { date: Date }; + +export type PacketDestination = "broadcast" | "direct"; + +export interface PacketMetadata { + id: number; + rxTime: Date; + type: PacketDestination; + from: number; + to: number; + channel: ChannelNumber; + data: T; +} + +export enum EmitterScope { + MeshDevice = 1, + SerialConnection = 2, + NodeSerialConnection = 3, + BleConnection = 4, + HttpConnection = 5, +} + +export enum Emitter { + Constructor = 0, + SendText = 1, + SendWaypoint = 2, + SendPacket = 3, + SendRaw = 4, + SetConfig = 5, + SetModuleConfig = 6, + ConfirmSetConfig = 7, + SetOwner = 8, + SetChannel = 9, + ConfirmSetChannel = 10, + ClearChannel = 11, + GetChannel = 12, + GetAllChannels = 13, + GetConfig = 14, + GetModuleConfig = 15, + GetOwner = 16, + Configure = 17, + HandleFromRadio = 18, + HandleMeshPacket = 19, + Connect = 20, + Ping = 21, + ReadFromRadio = 22, + WriteToRadio = 23, + SetDebugMode = 24, + GetMetadata = 25, + ResetNodes = 26, + Shutdown = 27, + Reboot = 28, + RebootOta = 29, + FactoryReset = 30, + EnterDfuMode = 31, + RemoveNodeByNum = 32, + SetCannedMessages = 33, +} + +export interface LogEvent { + scope: EmitterScope; + emitter: Emitter; + message: string; + level: Protobuf.Mesh.LogRecord_Level; + packet?: Uint8Array; +} + +export enum ChannelNumber { + Primary = 0, + Channel1 = 1, + Channel2 = 2, + Channel3 = 3, + Channel4 = 4, + Channel5 = 5, + Channel6 = 6, + Admin = 7, +} + +export type Destination = number | "self" | "broadcast"; + +export interface PacketError { + id: number; + error: Protobuf.Mesh.Routing_Error; +} diff --git a/packages/core/src/utils/eventSystem.ts b/packages/core/src/utils/eventSystem.ts new file mode 100644 index 00000000..a2da58c8 --- /dev/null +++ b/packages/core/src/utils/eventSystem.ts @@ -0,0 +1,467 @@ +import { SimpleEventDispatcher } from "ste-simple-events"; +import type * as Protobuf from "@meshtastic/protobufs"; +import type { PacketMetadata } from "../types.ts"; +import type * as Types from "../types.ts"; + +export class EventSystem { + /** + * Fires when a new FromRadio message has been received from the device + * + * @event onLogEvent + */ + public readonly onLogEvent: SimpleEventDispatcher< + Types.LogEventPacket + > = new SimpleEventDispatcher< + Types.LogEventPacket + >(); + + /** + * Fires when a new FromRadio message has been received from the device + * + * @event onFromRadio + */ + public readonly onFromRadio: SimpleEventDispatcher< + Protobuf.Mesh.FromRadio + > = new SimpleEventDispatcher< + Protobuf.Mesh.FromRadio + >(); + + /** + * Fires when a new FromRadio message containing a Data packet has been + * received from the device + * + * @event onMeshPacket + */ + public readonly onMeshPacket: SimpleEventDispatcher< + Protobuf.Mesh.MeshPacket + > = new SimpleEventDispatcher< + Protobuf.Mesh.MeshPacket + >(); + + /** + * Fires when a new MyNodeInfo message has been received from the device + * + * @event onMyNodeInfo + */ + public readonly onMyNodeInfo: SimpleEventDispatcher< + Protobuf.Mesh.MyNodeInfo + > = new SimpleEventDispatcher< + Protobuf.Mesh.MyNodeInfo + >(); + + /** + * Fires when a new MeshPacket message containing a NodeInfo packet has been + * received from device + * + * @event onNodeInfoPacket + */ + public readonly onNodeInfoPacket: SimpleEventDispatcher< + Protobuf.Mesh.NodeInfo + > = new SimpleEventDispatcher< + Protobuf.Mesh.NodeInfo + >(); + + /** + * Fires when a new Channel message is received + * + * @event onChannelPacket + */ + public readonly onChannelPacket: SimpleEventDispatcher< + Protobuf.Channel.Channel + > = new SimpleEventDispatcher< + Protobuf.Channel.Channel + >(); + + /** + * Fires when a new Config message is received + * + * @event onConfigPacket + */ + public readonly onConfigPacket: SimpleEventDispatcher< + Protobuf.Config.Config + > = new SimpleEventDispatcher< + Protobuf.Config.Config + >(); + + /** + * Fires when a new ModuleConfig message is received + * + * @event onModuleConfigPacket + */ + public readonly onModuleConfigPacket: SimpleEventDispatcher< + Protobuf.ModuleConfig.ModuleConfig + > = new SimpleEventDispatcher< + Protobuf.ModuleConfig.ModuleConfig + >(); + + /** + * Fires when a new MeshPacket message containing a ATAK packet has been + * received from device + * + * @event onAtakPacket + */ + public readonly onAtakPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Text packet has been + * received from device + * + * @event onMessagePacket + */ + public readonly onMessagePacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Remote Hardware packet has + * been received from device + * + * @event onRemoteHardwarePacket + */ + public readonly onRemoteHardwarePacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Position packet has been + * received from device + * + * @event onPositionPacket + */ + public readonly onPositionPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a User packet has been + * received from device + * + * @event onUserPacket + */ + public readonly onUserPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Routing packet has been + * received from device + * + * @event onRoutingPacket + */ + public readonly onRoutingPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when the device receives a Metadata packet + * + * @event onDeviceMetadataPacket + */ + public readonly onDeviceMetadataPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when the device receives a Canned Message Module message packet + * + * @event onCannedMessageModulePacket + */ + public readonly onCannedMessageModulePacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Waypoint packet has been + * received from device + * + * @event onWaypointPacket + */ + public readonly onWaypointPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing an Audio packet has been + * received from device + * + * @event onAudioPacket + */ + public readonly onAudioPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Detection Sensor packet has been + * received from device + * + * @event onDetectionSensorPacket + */ + public readonly onDetectionSensorPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Ping packet has been + * received from device + * + * @event onPingPacket + */ + public readonly onPingPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a IP Tunnel packet has been + * received from device + * + * @event onIpTunnelPacket + */ + public readonly onIpTunnelPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Paxcounter packet has been + * received from device + * + * @event onPaxcounterPacket + */ + public readonly onPaxcounterPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Serial packet has been + * received from device + * + * @event onSerialPacket + */ + public readonly onSerialPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Store and Forward packet + * has been received from device + * + * @event onStoreForwardPacket + */ + public readonly onStoreForwardPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Store and Forward packet + * has been received from device + * + * @event onRangeTestPacket + */ + public readonly onRangeTestPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Telemetry packet has been + * received from device + * + * @event onTelemetryPacket + */ + public readonly onTelemetryPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a ZPS packet has been + * received from device + * + * @event onZPSPacket + */ + public readonly onZpsPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Simulator packet has been + * received from device + * + * @event onSimulatorPacket + */ + public readonly onSimulatorPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Trace Route packet has been + * received from device + * + * @event onTraceRoutePacket + */ + public readonly onTraceRoutePacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Neighbor Info packet has been + * received from device + * + * @event onNeighborInfoPacket + */ + public readonly onNeighborInfoPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing an ATAK packet has been + * received from device + * + * @event onAtakPluginPacket + */ + public readonly onAtakPluginPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Map Report packet has been + * received from device + * + * @event onMapReportPacket + */ + public readonly onMapReportPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing a Private packet has been + * received from device + * + * @event onPrivatePacket + */ + public readonly onPrivatePacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when a new MeshPacket message containing an ATAK Forwarder packet has been + * received from device + * + * @event onAtakForwarderPacket + */ + public readonly onAtakForwarderPacket: SimpleEventDispatcher< + PacketMetadata + > = new SimpleEventDispatcher< + PacketMetadata + >(); + + /** + * Fires when the devices connection or configuration status changes + * + * @event onDeviceStatus + */ + public readonly onDeviceStatus: SimpleEventDispatcher< + Types.DeviceStatusEnum + > = new SimpleEventDispatcher< + Types.DeviceStatusEnum + >(); + + /** + * Fires when a new FromRadio message containing a LogRecord packet has been + * received from device + * + * @event onLogRecord + */ + public readonly onLogRecord: SimpleEventDispatcher< + Protobuf.Mesh.LogRecord + > = new SimpleEventDispatcher< + Protobuf.Mesh.LogRecord + >(); + + /** + * Fires when the device receives a meshPacket, returns a timestamp + * + * @event onMeshHeartbeat + */ + public readonly onMeshHeartbeat: SimpleEventDispatcher = + new SimpleEventDispatcher(); + + /** + * Outputs any debug log data (currently serial connections only) + * + * @event onDeviceDebugLog + */ + public readonly onDeviceDebugLog: SimpleEventDispatcher = + new SimpleEventDispatcher(); + + /** + * Outputs status of pending settings changes + * + * @event onpendingSettingsChange + */ + public readonly onPendingSettingsChange: SimpleEventDispatcher< + boolean + > = new SimpleEventDispatcher< + boolean + >(); + + /** + * Fires when a QueueStatus message is generated + * + * @event onQueueStatus + */ + public readonly onQueueStatus: SimpleEventDispatcher< + Protobuf.Mesh.QueueStatus + > = new SimpleEventDispatcher< + Protobuf.Mesh.QueueStatus + >(); +} diff --git a/packages/core/src/utils/mod.ts b/packages/core/src/utils/mod.ts new file mode 100644 index 00000000..6c7d61d8 --- /dev/null +++ b/packages/core/src/utils/mod.ts @@ -0,0 +1,5 @@ +export { EventSystem } from "./eventSystem.ts"; +export { Queue } from "./queue.ts"; +export { Xmodem } from "./xmodem.ts"; +export { toDeviceStream } from "./transform/toDevice.ts"; +export { fromDeviceStream } from "./transform/fromDevice.ts"; diff --git a/packages/core/src/utils/queue.ts b/packages/core/src/utils/queue.ts new file mode 100644 index 00000000..4e2f26af --- /dev/null +++ b/packages/core/src/utils/queue.ts @@ -0,0 +1,119 @@ +import { SimpleEventDispatcher } from "ste-simple-events"; +import { fromBinary } from "@bufbuild/protobuf"; +import * as Protobuf from "@meshtastic/protobufs"; +import type { PacketError, QueueItem } from "../types.ts"; + +export class Queue { + private queue: QueueItem[] = []; + private lock = false; + private ackNotifier = new SimpleEventDispatcher(); + private errorNotifier = new SimpleEventDispatcher(); + private timeout: number; + + constructor() { + this.timeout = 60000; + } + + public getState(): QueueItem[] { + return this.queue; + } + + public clear(): void { + this.queue = []; + } + + public push(item: Omit): void { + const queueItem: QueueItem = { + ...item, + sent: false, + added: new Date(), + promise: new Promise((resolve, reject) => { + this.ackNotifier.subscribe((id) => { + if (item.id === id) { + this.remove(item.id); + resolve(id); + } + }); + this.errorNotifier.subscribe((e) => { + if (item.id === e.id) { + this.remove(item.id); + reject(e); + } + }); + setTimeout(() => { + if (this.queue.findIndex((qi) => qi.id === item.id) !== -1) { + this.remove(item.id); + const decoded = fromBinary(Protobuf.Mesh.ToRadioSchema, item.data); + console.warn( + `Packet ${item.id} of type ${decoded.payloadVariant.case} timed out`, + ); + + reject({ + id: item.id, + error: Protobuf.Mesh.Routing_Error.TIMEOUT, + }); + } + }, this.timeout); + }), + }; + this.queue.push(queueItem); + } + + public remove(id: number): void { + if (this.lock) { + setTimeout(() => this.remove(id), 100); + return; + } + this.queue = this.queue.filter((item) => item.id !== id); + } + + public processAck(id: number): void { + this.ackNotifier.dispatch(id); + } + + public processError(e: PacketError): void { + console.error( + `Error received for packet ${e.id}: ${ + Protobuf.Mesh.Routing_Error[e.error] + }`, + ); + this.errorNotifier.dispatch(e); + } + + public wait(id: number): Promise { + const queueItem = this.queue.find((qi) => qi.id === id); + if (!queueItem) { + throw new Error("Packet does not exist"); + } + return queueItem.promise; + } + + public async processQueue( + outputStream: WritableStream, + ): Promise { + if (this.lock) { + return; + } + + this.lock = true; + const writer = outputStream.getWriter(); + + try { + while (this.queue.filter((p) => !p.sent).length > 0) { + const item = this.queue.filter((p) => !p.sent)[0]; + if (item) { + await new Promise((resolve) => setTimeout(resolve, 200)); + try { + await writer.write(item.data); + item.sent = true; + } catch (error) { + console.error(`Error sending packet ${item.id}`, error); + } + } + } + } finally { + writer.releaseLock(); + this.lock = false; + } + } +} diff --git a/packages/core/src/utils/transform/decodePacket.ts b/packages/core/src/utils/transform/decodePacket.ts new file mode 100644 index 00000000..60cecc22 --- /dev/null +++ b/packages/core/src/utils/transform/decodePacket.ts @@ -0,0 +1,222 @@ +import { fromBinary } from "@bufbuild/protobuf"; +import type { DeviceOutput } from "../../types.ts"; +import { Constants, Protobuf, Types } from "../../../mod.ts"; +import type { MeshDevice } from "../../../mod.ts"; + +export const decodePacket = (device: MeshDevice) => + new WritableStream({ + write(chunk) { + switch (chunk.type) { + case "debug": { + break; + } + case "packet": { + const decodedMessage = fromBinary( + Protobuf.Mesh.FromRadioSchema, + chunk.data, + ); + device.events.onFromRadio.dispatch(decodedMessage); + + /** @todo Add map here when `all=true` gets fixed. */ + switch (decodedMessage.payloadVariant.case) { + case "packet": { + device.handleMeshPacket(decodedMessage.payloadVariant.value); + break; + } + + case "myInfo": { + device.events.onMyNodeInfo.dispatch( + decodedMessage.payloadVariant.value, + ); + device.log.info( + Types.Emitter[Types.Emitter.HandleFromRadio], + "πŸ“± Received Node info for this device", + ); + break; + } + + case "nodeInfo": { + device.log.info( + Types.Emitter[Types.Emitter.HandleFromRadio], + `πŸ“± Received Node Info packet for node: ${decodedMessage.payloadVariant.value.num}`, + ); + + device.events.onNodeInfoPacket.dispatch( + decodedMessage.payloadVariant.value, + ); + + //TODO: HERE + if (decodedMessage.payloadVariant.value.position) { + device.events.onPositionPacket.dispatch({ + id: decodedMessage.id, + rxTime: new Date(), + from: decodedMessage.payloadVariant.value.num, + to: decodedMessage.payloadVariant.value.num, + type: "direct", + channel: Types.ChannelNumber.Primary, + data: decodedMessage.payloadVariant.value.position, + }); + } + + //TODO: HERE + if (decodedMessage.payloadVariant.value.user) { + device.events.onUserPacket.dispatch({ + id: decodedMessage.id, + rxTime: new Date(), + from: decodedMessage.payloadVariant.value.num, + to: decodedMessage.payloadVariant.value.num, + type: "direct", + channel: Types.ChannelNumber.Primary, + data: decodedMessage.payloadVariant.value.user, + }); + } + break; + } + + case "config": { + if (decodedMessage.payloadVariant.value.payloadVariant.case) { + device.log.trace( + Types.Emitter[Types.Emitter.HandleFromRadio], + `πŸ’Ύ Received Config packet of variant: ${decodedMessage.payloadVariant.value.payloadVariant.case}`, + ); + } else { + device.log.warn( + Types.Emitter[Types.Emitter.HandleFromRadio], + `⚠️ Received Config packet of variant: ${"UNK"}`, + ); + } + + device.events.onConfigPacket.dispatch( + decodedMessage.payloadVariant.value, + ); + break; + } + + case "logRecord": { + device.log.trace( + Types.Emitter[Types.Emitter.HandleFromRadio], + "Received onLogRecord", + ); + device.events.onLogRecord.dispatch( + decodedMessage.payloadVariant.value, + ); + break; + } + + case "configCompleteId": { + if (decodedMessage.payloadVariant.value !== device.configId) { + device.log.error( + Types.Emitter[Types.Emitter.HandleFromRadio], + `❌ Invalid config id received from device, expected ${device.configId} but received ${decodedMessage.payloadVariant.value}`, + ); + } + + device.log.info( + Types.Emitter[Types.Emitter.HandleFromRadio], + `βš™οΈ Valid config id received from device: ${device.configId}`, + ); + + device.updateDeviceStatus( + Types.DeviceStatusEnum.DeviceConfigured, + ); + break; + } + + case "rebooted": { + device.configure().catch(() => { + // TODO: FIX, workaround for `wantConfigId` not getting acks. + }); + break; + } + + case "moduleConfig": { + if (decodedMessage.payloadVariant.value.payloadVariant.case) { + device.log.trace( + Types.Emitter[Types.Emitter.HandleFromRadio], + `πŸ’Ύ Received Module Config packet of variant: ${decodedMessage.payloadVariant.value.payloadVariant.case}`, + ); + } else { + device.log.warn( + Types.Emitter[Types.Emitter.HandleFromRadio], + "⚠️ Received Module Config packet of variant: UNK", + ); + } + + device.events.onModuleConfigPacket.dispatch( + decodedMessage.payloadVariant.value, + ); + break; + } + + case "channel": { + device.log.trace( + Types.Emitter[Types.Emitter.HandleFromRadio], + `πŸ” Received Channel: ${decodedMessage.payloadVariant.value.index}`, + ); + + device.events.onChannelPacket.dispatch( + decodedMessage.payloadVariant.value, + ); + break; + } + + case "queueStatus": { + device.log.trace( + Types.Emitter[Types.Emitter.HandleFromRadio], + `🚧 Received Queue Status: ${decodedMessage.payloadVariant.value}`, + ); + + device.events.onQueueStatus.dispatch( + decodedMessage.payloadVariant.value, + ); + break; + } + + case "xmodemPacket": { + device.xModem.handlePacket(decodedMessage.payloadVariant.value); + break; + } + + case "metadata": { + if ( + Number.parseFloat( + decodedMessage.payloadVariant.value.firmwareVersion, + ) < Constants.minFwVer + ) { + device.log.fatal( + Types.Emitter[Types.Emitter.HandleFromRadio], + `Device firmware outdated. Min supported: ${Constants.minFwVer} got : ${decodedMessage.payloadVariant.value.firmwareVersion}`, + ); + } + device.log.debug( + Types.Emitter[Types.Emitter.GetMetadata], + "🏷️ Received metadata packet", + ); + + device.events.onDeviceMetadataPacket.dispatch({ + id: decodedMessage.id, + rxTime: new Date(), + from: 0, + to: 0, + type: "direct", + channel: Types.ChannelNumber.Primary, + data: decodedMessage.payloadVariant.value, + }); + break; + } + + case "mqttClientProxyMessage": { + break; + } + + default: { + device.log.warn( + Types.Emitter[Types.Emitter.HandleFromRadio], + `⚠️ Unhandled payload variant: ${decodedMessage.payloadVariant.case}`, + ); + } + } + } + } + }, + }); diff --git a/packages/core/src/utils/transform/fromDevice.ts b/packages/core/src/utils/transform/fromDevice.ts new file mode 100644 index 00000000..98e66ac3 --- /dev/null +++ b/packages/core/src/utils/transform/fromDevice.ts @@ -0,0 +1,73 @@ +import type { DeviceOutput } from "../../types.ts"; + +export const fromDeviceStream: () => TransformStream = + ( + // onReleaseEvent: SimpleEventDispatcher, + ) => { + let byteBuffer = new Uint8Array([]); + const textDecoder = new TextDecoder(); + return new TransformStream({ + transform(chunk: Uint8Array, controller): void { + // onReleaseEvent.subscribe(() => { + // controller.terminate(); + // }); + byteBuffer = new Uint8Array([...byteBuffer, ...chunk]); + let processingExhausted = false; + while (byteBuffer.length !== 0 && !processingExhausted) { + const framingIndex = byteBuffer.findIndex((byte) => byte === 0x94); + const framingByte2 = byteBuffer[framingIndex + 1]; + if (framingByte2 === 0xc3) { + if (byteBuffer.subarray(0, framingIndex).length) { + controller.enqueue({ + type: "debug", + data: textDecoder.decode(byteBuffer.subarray(0, framingIndex)), + }); + byteBuffer = byteBuffer.subarray(framingIndex); + } + + const msb = byteBuffer[2]; + const lsb = byteBuffer[3]; + + if ( + msb !== undefined && + lsb !== undefined && + byteBuffer.length >= 4 + (msb << 8) + lsb + ) { + const packet = byteBuffer.subarray(4, 4 + (msb << 8) + lsb); + + const malformedDetectorIndex = packet.findIndex( + (byte) => byte === 0x94, + ); + if ( + malformedDetectorIndex !== -1 && + packet[malformedDetectorIndex + 1] === 0xc3 + ) { + console.warn( + `⚠️ Malformed packet found, discarding: ${ + byteBuffer + .subarray(0, malformedDetectorIndex - 1) + .toString() + }`, + ); + + byteBuffer = byteBuffer.subarray(malformedDetectorIndex); + } else { + byteBuffer = byteBuffer.subarray(3 + (msb << 8) + lsb + 1); + + controller.enqueue({ + type: "packet", + data: packet, + }); + } + } else { + /** Only partioal message in buffer, wait for the rest */ + processingExhausted = true; + } + } else { + /** Message not complete, only 1 byte in buffer */ + processingExhausted = true; + } + } + }, + }); + }; diff --git a/packages/core/src/utils/transform/toDevice.ts b/packages/core/src/utils/transform/toDevice.ts new file mode 100644 index 00000000..ef8e2203 --- /dev/null +++ b/packages/core/src/utils/transform/toDevice.ts @@ -0,0 +1,16 @@ +/** + * Pads packets with appropriate framing information before writing to the output stream. + */ +export const toDeviceStream: TransformStream = + new TransformStream({ + transform(chunk: Uint8Array, controller): void { + const bufLen = chunk.length; + const header = new Uint8Array([ + 0x94, + 0xC3, + (bufLen >> 8) & 0xFF, + bufLen & 0xFF, + ]); + controller.enqueue(new Uint8Array([...header, ...chunk])); + }, + }); diff --git a/packages/core/src/utils/xmodem.ts b/packages/core/src/utils/xmodem.ts new file mode 100644 index 00000000..47f2c3b1 --- /dev/null +++ b/packages/core/src/utils/xmodem.ts @@ -0,0 +1,135 @@ +import crc16ccitt from "crc/calculators/crc16ccitt"; +import { create, toBinary } from "@bufbuild/protobuf"; +import * as Protobuf from "@meshtastic/protobufs"; + +//if counter > 35 then reset counter/clear/error/reject promise +type XmodemProps = (toRadio: Uint8Array, id?: number) => Promise; + +export class Xmodem { + private sendRaw: XmodemProps; + private rxBuffer: Uint8Array[]; + private txBuffer: Uint8Array[]; + private textEncoder: TextEncoder; + private counter: number; + + constructor(sendRaw: XmodemProps) { + this.sendRaw = sendRaw; + this.rxBuffer = []; + this.txBuffer = []; + this.textEncoder = new TextEncoder(); + this.counter = 0; + } + + async downloadFile(filename: string): Promise { + return await this.sendCommand( + Protobuf.Xmodem.XModem_Control.STX, + this.textEncoder.encode(filename), + 0, + ); + } + + async uploadFile(filename: string, data: Uint8Array): Promise { + for (let i = 0; i < data.length; i += 128) { + this.txBuffer.push(data.slice(i, i + 128)); + } + + return await this.sendCommand( + Protobuf.Xmodem.XModem_Control.SOH, + this.textEncoder.encode(filename), + 0, + ); + } + + async sendCommand( + command: Protobuf.Xmodem.XModem_Control, + buffer?: Uint8Array, + sequence?: number, + crc16?: number, + ): Promise { + const toRadio = create(Protobuf.Mesh.ToRadioSchema, { + payloadVariant: { + case: "xmodemPacket", + value: { + buffer, + control: command, + seq: sequence, + crc16: crc16, + }, + }, + }); + return await this.sendRaw(toBinary(Protobuf.Mesh.ToRadioSchema, toRadio)); + } + + async handlePacket(packet: Protobuf.Xmodem.XModem): Promise { + await new Promise((resolve) => setTimeout(resolve, 100)); + + switch (packet.control) { + case Protobuf.Xmodem.XModem_Control.NUL: { + // nothing + break; + } + case Protobuf.Xmodem.XModem_Control.SOH: { + this.counter = packet.seq; + if (this.validateCrc16(packet)) { + this.rxBuffer[this.counter] = packet.buffer; + return this.sendCommand(Protobuf.Xmodem.XModem_Control.ACK); + } + return await this.sendCommand( + Protobuf.Xmodem.XModem_Control.NAK, + undefined, + packet.seq, + ); + } + case Protobuf.Xmodem.XModem_Control.STX: { + break; + } + case Protobuf.Xmodem.XModem_Control.EOT: { + // end of transmission + break; + } + case Protobuf.Xmodem.XModem_Control.ACK: { + this.counter++; + if (this.txBuffer[this.counter - 1]) { + return this.sendCommand( + Protobuf.Xmodem.XModem_Control.SOH, + this.txBuffer[this.counter - 1], + this.counter, + crc16ccitt(this.txBuffer[this.counter - 1] ?? new Uint8Array()), + ); + } + if (this.counter === this.txBuffer.length + 1) { + return this.sendCommand(Protobuf.Xmodem.XModem_Control.EOT); + } + this.clear(); + break; + } + case Protobuf.Xmodem.XModem_Control.NAK: { + return this.sendCommand( + Protobuf.Xmodem.XModem_Control.SOH, + this.txBuffer[this.counter], + this.counter, + crc16ccitt(this.txBuffer[this.counter - 1] ?? new Uint8Array()), + ); + } + case Protobuf.Xmodem.XModem_Control.CAN: { + this.clear(); + break; + } + case Protobuf.Xmodem.XModem_Control.CTRLZ: { + break; + } + } + + return Promise.resolve(0); + } + + validateCrc16(packet: Protobuf.Xmodem.XModem): boolean { + return crc16ccitt(packet.buffer) === packet.crc16; + } + + clear() { + this.counter = 0; + this.rxBuffer = []; + this.txBuffer = []; + } +} diff --git a/packages/transport-deno/README.md b/packages/transport-deno/README.md new file mode 100644 index 00000000..1c5213f8 --- /dev/null +++ b/packages/transport-deno/README.md @@ -0,0 +1,27 @@ +# @meshtastic/transport-deno + +[![JSR](https://jsr.io/badges/@meshtastic/transport-deno)](https://jsr.io/@meshtastic/transport-deno) +[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/js/ci.yml?branch=master&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/js/actions/workflows/ci.yml) +[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/meshtastic.js)](https://cla-assistant.io/meshtastic/meshtastic.js) +[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/) +[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) + +## Overview + +`@meshtastic/transport-deno` Provides TCP transport (Deno) for Meshtastic +devices. Installation instructions are avaliable at +[JSR](https://jsr.io/@meshtastic/transport-deno) + +## Usage + +```ts +import { MeshDevice } from "@meshtastic/core"; +import { TransportDeno } from "@meshtastic/transport-deno"; + +const transport = await TransportDeno.create("10.10.0.57"); +const device = new MeshDevice(transport); +``` + +## Stats + +![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image") diff --git a/packages/transport-deno/deno.json b/packages/transport-deno/deno.json new file mode 100644 index 00000000..50968ed6 --- /dev/null +++ b/packages/transport-deno/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@meshtastic/transport-deno", + "version": "0.1.1", + "exports": { + ".": "./mod.ts" + } +} diff --git a/packages/transport-deno/mod.ts b/packages/transport-deno/mod.ts new file mode 100644 index 00000000..55a63346 --- /dev/null +++ b/packages/transport-deno/mod.ts @@ -0,0 +1 @@ +export { TransportDeno } from "./src/transport.ts"; diff --git a/packages/transport-deno/src/transport.ts b/packages/transport-deno/src/transport.ts new file mode 100644 index 00000000..c5ef2bb7 --- /dev/null +++ b/packages/transport-deno/src/transport.ts @@ -0,0 +1,32 @@ +import { Utils } from "@meshtastic/core"; +import type { Types } from "@meshtastic/core"; + +export class TransportDeno implements Types.Transport { + private _toDevice: WritableStream; + private _fromDevice: ReadableStream; + + public static async create(hostname: string): Promise { + const connection = await Deno.connect({ + hostname, + port: 4403, + }); + return new TransportDeno(connection); + } + + constructor(connection: Deno.Conn) { + Utils.toDeviceStream.readable.pipeTo(connection.writable); + + this._toDevice = Utils.toDeviceStream.writable; + this._fromDevice = connection.readable.pipeThrough( + Utils.fromDeviceStream(), + ); + } + + get toDevice(): WritableStream { + return this._toDevice; + } + + get fromDevice(): ReadableStream { + return this._fromDevice; + } +} diff --git a/packages/transport-http/README.md b/packages/transport-http/README.md new file mode 100644 index 00000000..560b44b4 --- /dev/null +++ b/packages/transport-http/README.md @@ -0,0 +1,27 @@ +# @meshtastic/transport-http + +[![JSR](https://jsr.io/badges/@meshtastic/transport-http)](https://jsr.io/@meshtastic/transport-http) +[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/js/ci.yml?branch=master&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/js/actions/workflows/ci.yml) +[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/meshtastic.js)](https://cla-assistant.io/meshtastic/meshtastic.js) +[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/) +[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) + +## Overview + +`@meshtastic/transport-http` Provides HTTP(S) transport for Meshtastic devices. +Installation instructions are available at +[JSR](https://jsr.io/@meshtastic/transport-http) + +## Usage + +```ts +import { MeshDevice } from "@meshtastic/core"; +import { TransportHTTP } from "@meshtastic/transport-http"; + +const transport = await TransportHTTP.create("10.10.0.57"); +const device = new MeshDevice(transport); +``` + +## Stats + +![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image") diff --git a/packages/transport-http/deno.json b/packages/transport-http/deno.json new file mode 100644 index 00000000..1bcb8348 --- /dev/null +++ b/packages/transport-http/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@meshtastic/transport-http", + "version": "0.2.1", + "exports": { + ".": "./mod.ts" + } +} diff --git a/packages/transport-http/mod.ts b/packages/transport-http/mod.ts new file mode 100644 index 00000000..20af396c --- /dev/null +++ b/packages/transport-http/mod.ts @@ -0,0 +1 @@ +export { TransportHTTP } from "./src/transport.ts"; diff --git a/packages/transport-http/src/transport.ts b/packages/transport-http/src/transport.ts new file mode 100644 index 00000000..03f578aa --- /dev/null +++ b/packages/transport-http/src/transport.ts @@ -0,0 +1,89 @@ +import type { Types } from "@meshtastic/core"; + +export class TransportHTTP implements Types.Transport { + private _toDevice: WritableStream; + private _fromDevice: ReadableStream; + private url: string; + private receiveBatchRequests: boolean; + private fetchInterval: number; + + public static async create( + address: string, + tls?: boolean, + ): Promise { + const connectionUrl = `${tls ? "https" : "http"}://${address}`; + await fetch(`${connectionUrl}/json/report`); + await Promise.resolve(); + return new TransportHTTP(connectionUrl); + } + + constructor(url: string) { + this.url = url; + this.receiveBatchRequests = false; + this.fetchInterval = 3000; + + this._toDevice = new WritableStream({ + write: async (chunk) => { + await this.writeToRadio(chunk); + }, + }); + + let controller: ReadableStreamDefaultController; + + this._fromDevice = new ReadableStream({ + start: (ctrl) => { + controller = ctrl; + }, + }); + + setInterval(async () => { + await this.readFromRadio(controller); + }, this.fetchInterval); + } + + private async readFromRadio( + controller: ReadableStreamDefaultController, + ): Promise { + let readBuffer = new ArrayBuffer(1); + while (readBuffer.byteLength > 0) { + const response = await fetch( + `${this.url}/api/v1/fromradio?all=${ + this.receiveBatchRequests ? "true" : "false" + }`, + { + method: "GET", + headers: { + Accept: "application/x-protobuf", + }, + }, + ); + + readBuffer = await response.arrayBuffer(); + + if (readBuffer.byteLength > 0) { + controller.enqueue({ + type: "packet", + data: new Uint8Array(readBuffer), + }); + } + } + } + + private async writeToRadio(data: Uint8Array): Promise { + await fetch(`${this.url}/api/v1/toradio`, { + method: "PUT", + headers: { + "Content-Type": "application/x-protobuf", + }, + body: data, + }); + } + + get toDevice(): WritableStream { + return this._toDevice; + } + + get fromDevice(): ReadableStream { + return this._fromDevice; + } +} diff --git a/packages/transport-web-bluetooth/README.md b/packages/transport-web-bluetooth/README.md new file mode 100644 index 00000000..ab1af911 --- /dev/null +++ b/packages/transport-web-bluetooth/README.md @@ -0,0 +1,34 @@ +# @meshtastic/transport-web-bluetooth + +[![JSR](https://jsr.io/badges/@meshtastic/transport-web-bluetooth)](https://jsr.io/@meshtastic/transport-web-bluetooth) +[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/js/ci.yml?branch=master&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/js/actions/workflows/ci.yml) +[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/meshtastic.js)](https://cla-assistant.io/meshtastic/meshtastic.js) +[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/) +[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) + +## Overview + +`@meshtastic/transport-web-bluetooth` Provides Web Bluetooth transport for +Meshtastic devices. Installation instructions are available at +[JSR](https://jsr.io/@meshtastic/transport-web-bluetooth) + +## Usage + +```ts +import { MeshDevice } from "@meshtastic/core"; +import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; + +const transport = await TransportWebBluetooth.create(); +const device = new MeshDevice(transport); +``` + +## Stats + +![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image") + +### Compatibility + +The Web Bluetooth API's have limited support in browsers, compatibility is +represented in the matrix below. + +![Web Bluetooth compatability matrix](https://caniuse.bitsofco.de/image/web-bluetooth.png) diff --git a/packages/transport-web-bluetooth/deno.json b/packages/transport-web-bluetooth/deno.json new file mode 100644 index 00000000..6aab4b7b --- /dev/null +++ b/packages/transport-web-bluetooth/deno.json @@ -0,0 +1,15 @@ +{ + "name": "@meshtastic/transport-web-bluetooth", + "version": "0.1.2", + "exports": { + ".": "./mod.ts" + }, + "imports": { + "@types/web-bluetooth": "npm:@types/web-bluetooth@^0.0.20" + }, + "compilerOptions": { + "types": [ + "@types/web-bluetooth" + ] + } +} diff --git a/packages/transport-web-bluetooth/mod.ts b/packages/transport-web-bluetooth/mod.ts new file mode 100644 index 00000000..5aebcf2f --- /dev/null +++ b/packages/transport-web-bluetooth/mod.ts @@ -0,0 +1 @@ +export { TransportWebBluetooth } from "./src/transport.ts"; diff --git a/packages/transport-web-bluetooth/src/transport.ts b/packages/transport-web-bluetooth/src/transport.ts new file mode 100644 index 00000000..d2b9dd61 --- /dev/null +++ b/packages/transport-web-bluetooth/src/transport.ts @@ -0,0 +1,134 @@ +import type { Types } from "@meshtastic/core"; + +export class TransportWebBluetooth implements Types.Transport { + private _toDevice: WritableStream; + private _fromDevice: ReadableStream; + private _fromDeviceController?: ReadableStreamDefaultController< + Types.DeviceOutput + >; + private _isFirstWrite = true; + + private toRadioCharacteristic: BluetoothRemoteGATTCharacteristic; + private fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic; + private fromNumCharacteristic: BluetoothRemoteGATTCharacteristic; + + static ToRadioUuid = "f75c76d2-129e-4dad-a1dd-7866124401e7"; + static FromRadioUuid = "2c55e69e-4993-11ed-b878-0242ac120002"; + static FromNumUuid = "ed9da18c-a800-4f66-a670-aa7547e34453"; + static ServiceUuid = "6ba1b218-15a8-461f-9fa8-5dcae273eafd"; + + public static async create(): Promise { + const device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [this.ServiceUuid] }], + }); + return await this.prepareConnection(device); + } + + public static async createFromDevice( + device: BluetoothDevice, + ): Promise { + return await this.prepareConnection(device); + } + + public static async prepareConnection( + device: BluetoothDevice, + ): Promise { + const gattServer = await device.gatt?.connect(); + + if (!gattServer) { + throw new Error("Failed to connect to GATT server"); + } + + const service = await gattServer.getPrimaryService(this.ServiceUuid); + + const toRadioCharacteristic = await service.getCharacteristic( + this.ToRadioUuid, + ); + const fromRadioCharacteristic = await service.getCharacteristic( + this.FromRadioUuid, + ); + const fromNumCharacteristic = await service.getCharacteristic( + this.FromNumUuid, + ); + + if ( + !toRadioCharacteristic || !fromRadioCharacteristic || + !fromNumCharacteristic + ) { + throw new Error("Failed to find required characteristics"); + } + + console.log("Connected to device", device.name); + + return new TransportWebBluetooth( + toRadioCharacteristic, + fromRadioCharacteristic, + fromNumCharacteristic, + ); + } + + constructor( + toRadioCharacteristic: BluetoothRemoteGATTCharacteristic, + fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic, + fromNumCharacteristic: BluetoothRemoteGATTCharacteristic, + ) { + this.toRadioCharacteristic = toRadioCharacteristic; + this.fromRadioCharacteristic = fromRadioCharacteristic; + this.fromNumCharacteristic = fromNumCharacteristic; + + this._fromDevice = new ReadableStream({ + start: (ctrl) => { + this._fromDeviceController = ctrl; + }, + }); + + this._toDevice = new WritableStream({ + write: async (chunk) => { + await this.toRadioCharacteristic.writeValue(chunk); + + if (this._isFirstWrite && this._fromDeviceController) { + this._isFirstWrite = false; + setTimeout(() => { + this.readFromRadio(this._fromDeviceController!); + }, 50); + } + }, + }); + + this.fromNumCharacteristic.addEventListener( + "characteristicvaluechanged", + () => { + if (this._fromDeviceController) { + this.readFromRadio(this._fromDeviceController); + } + }, + ); + + this.fromNumCharacteristic.startNotifications(); + } + + get toDevice(): WritableStream { + return this._toDevice; + } + + get fromDevice(): ReadableStream { + return this._fromDevice; + } + + protected async readFromRadio( + controller: ReadableStreamDefaultController, + ): Promise { + let hasMoreData = true; + while (hasMoreData && this.fromRadioCharacteristic) { + const value = await this.fromRadioCharacteristic.readValue(); + if (value.byteLength === 0) { + hasMoreData = false; + continue; + } + controller.enqueue({ + type: "packet", + data: new Uint8Array(value.buffer), + }); + } + } +} diff --git a/packages/transport-web-serial/README.md b/packages/transport-web-serial/README.md new file mode 100644 index 00000000..81c08b99 --- /dev/null +++ b/packages/transport-web-serial/README.md @@ -0,0 +1,34 @@ +# @meshtastic/transport-web-serial + +[![JSR](https://jsr.io/badges/@meshtastic/transport-web-serial)](https://jsr.io/@meshtastic/transport-web-serial) +[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/js/ci.yml?branch=master&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/js/actions/workflows/ci.yml) +[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/meshtastic.js)](https://cla-assistant.io/meshtastic/meshtastic.js) +[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/) +[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) + +## Overview + +`@meshtastic/transport-web-serial` Provides Web Serial transport for Meshtastic +devices. Installation instructions are avaliable at +[JSR](https://jsr.io/@meshtastic/transport-web-serial) + +## Usage + +```ts +import { MeshDevice } from "@meshtastic/core"; +import { TransportWebSerial } from "@meshtastic/transport-web-serial"; + +const transport = await TransportWebSerial.create(); +const device = new MeshDevice(transport); +``` + +## Stats + +![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image") + +### Compatibility + +The Web Serial API's have limited support in browsers, compatibility is +represented in the matrix below. + +![Web Serial compatability matrix](https://caniuse.bitsofco.de/image/web-serial.png) diff --git a/packages/transport-web-serial/deno.json b/packages/transport-web-serial/deno.json new file mode 100644 index 00000000..45a90622 --- /dev/null +++ b/packages/transport-web-serial/deno.json @@ -0,0 +1,15 @@ +{ + "name": "@meshtastic/transport-web-serial", + "version": "0.2.1", + "exports": { + ".": "./mod.ts" + }, + "imports": { + "@types/w3c-web-serial": "npm:@types/w3c-web-serial@^1.0.7" + }, + "compilerOptions": { + "types": [ + "@types/w3c-web-serial" + ] + } +} diff --git a/packages/transport-web-serial/mod.ts b/packages/transport-web-serial/mod.ts new file mode 100644 index 00000000..a914a57c --- /dev/null +++ b/packages/transport-web-serial/mod.ts @@ -0,0 +1 @@ +export { TransportWebSerial } from "./src/transport.ts"; diff --git a/packages/transport-web-serial/src/transport.ts b/packages/transport-web-serial/src/transport.ts new file mode 100644 index 00000000..0f445cbe --- /dev/null +++ b/packages/transport-web-serial/src/transport.ts @@ -0,0 +1,42 @@ +import { Utils } from "@meshtastic/core"; +import type { Types } from "@meshtastic/core"; + +export class TransportWebSerial implements Types.Transport { + private _toDevice: WritableStream; + private _fromDevice: ReadableStream; + + public static async create(baudRate?: number): Promise { + const port = await navigator.serial.requestPort(); + await port.open({ baudRate: baudRate || 115200 }); + return new TransportWebSerial(port); + } + + public static async createFromPort( + port: SerialPort, + baudRate?: number, + ): Promise { + await port.open({ baudRate: baudRate || 115200 }); + return new TransportWebSerial(port); + } + + constructor(connection: SerialPort) { + if (!connection.readable || !connection.writable) { + throw new Error("Stream not accessible"); + } + + Utils.toDeviceStream.readable.pipeTo(connection.writable); + + this._toDevice = Utils.toDeviceStream.writable; + this._fromDevice = connection.readable.pipeThrough( + Utils.fromDeviceStream(), + ); + } + + get toDevice(): WritableStream { + return this._toDevice; + } + + get fromDevice(): ReadableStream { + return this._fromDevice; + } +}