├── .github └── workflows │ └── github_pages.yml ├── .gitignore ├── LICENSE ├── README.md ├── donate.md ├── meshtxt.service ├── package-lock.json ├── package.json ├── postcss.config.js ├── screenshots ├── 1_connect.png ├── 2_channels.png ├── 3_nodes.png ├── 4_direct_messages.png ├── 5_trace_routes.png └── screenshot.png ├── server.js ├── src ├── components │ ├── App.vue │ ├── AppBar.vue │ ├── DropDownMenu.vue │ ├── DropDownMenuItem.vue │ ├── FetchingDataInfo.vue │ ├── Header.vue │ ├── IconButton.vue │ ├── RefreshButton.vue │ ├── SaveButton.vue │ ├── TextButton.vue │ ├── TraceRouteSnrLabel.vue │ ├── channels │ │ ├── ChannelListItem.vue │ │ ├── ChannelPskBadge.vue │ │ └── ChannelsList.vue │ ├── connect │ │ └── ConnectButtons.vue │ ├── messages │ │ └── MessageViewer.vue │ ├── nodes │ │ ├── NodeDropDownMenu.vue │ │ ├── NodeIcon.vue │ │ ├── NodeListItem.vue │ │ └── NodesList.vue │ └── pages │ │ ├── ChannelMessagesPage.vue │ │ ├── ConnectPage.vue │ │ ├── ConnectViaHttpPage.vue │ │ ├── MainPage.vue │ │ ├── NodeFilesPage.vue │ │ ├── NodeMessagesPage.vue │ │ ├── NodePage.vue │ │ ├── NodeRunTraceRoutePage.vue │ │ ├── NodeTraceRoutesPage.vue │ │ ├── Page.vue │ │ ├── TraceRoutePage.vue │ │ └── settings │ │ ├── NodeChannelSettingsPage.vue │ │ ├── NodeChannelsSettingsPage.vue │ │ ├── NodeSettingsList.vue │ │ ├── NodeSettingsPage.vue │ │ └── NodeUserSettingsPage.vue ├── index.html ├── js │ ├── ChannelUtils.js │ ├── Connection.js │ ├── Database.js │ ├── DeviceUtils.js │ ├── DialogUtils.js │ ├── FileTransferAPI.js │ ├── FileTransferrer.js │ ├── GlobalState.js │ ├── MessageUtils.js │ ├── NodeAPI.js │ ├── NodeUtils.js │ ├── PacketUtils.js │ ├── SecurityUtils.js │ ├── TimeUtils.js │ └── exceptions │ │ └── RoutingError.js ├── main.js ├── public │ ├── icon.png │ ├── manifest.json │ ├── protos │ │ └── file_transfer.proto │ └── service-worker.js └── style.css ├── tailwind.config.js └── vite.config.js /.github/workflows/github_pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ['master'] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow one concurrent deployment 18 | concurrency: 19 | group: 'pages' 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | deploy: 24 | environment: 25 | name: github-pages 26 | url: ${{ steps.deployment.outputs.page_url }} 27 | runs-on: ubuntu-latest 28 | steps: 29 | 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Set up Node 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | cache: 'npm' 38 | 39 | - name: Install Dependencies 40 | run: npm ci 41 | 42 | - name: Build 43 | run: npm run build 44 | 45 | - name: Setup Pages 46 | uses: actions/configure-pages@v4 47 | 48 | - name: Upload Artifact 49 | uses: actions/upload-pages-artifact@v3 50 | with: 51 | # Upload dist folder 52 | path: './dist' 53 | 54 | - name: Deploy to GitHub Pages 55 | id: deployment 56 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Liam Cottle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

MeshTXT

2 | 3 |

4 | discord 5 | twitter 6 |
7 | donate on ko-fi 8 | donate bitcoin 9 |

10 | 11 | A simple, mobile friendly, web based [Meshtastic](https://meshtastic.org/) client developed by [Liam Cottle](https://liamcottle.com) 12 | 13 | 14 | 15 | ## What can it do? 16 | 17 | - Connect to a Meshtastic device over Bluetooth, Serial and HTTP 18 | - Send and receive text messages on existing channels 19 | - Send and receive direct messages with known nodes 20 | - Send and receive node info on demand with a specific node 21 | - Display information about a specific node 22 | - Perform and visualise trace routes to other nodes 23 | - Saves messages and trace routes to database so they survive page reloads 24 | - Uses a unique database for each node you connect so message history is separated 25 | - Mark and unmark a node as a favourite 26 | - Filter nodes to only show favourites 27 | - Allows you to run a server directly from a meshtasticd (Linux Native) device 28 | - Allows file transfers between MeshTXT clients 29 | - Allows pinging a node to see how long it took to respond and how many hops away it is 30 | - Has a basic implementation of remote management, such as configuring user info and channels 31 | 32 | > Note: database state is saved in your browser, and is not shared across other browsers or devices. Maybe the server script could support a local SQLite database in the future. 33 | 34 | ## Is there a hosted version? 35 | 36 | Yes! I have a hosted web client available at https://meshtxt.liamcottle.net 37 | 38 | I would however suggest that you self host this so you can still use it when the internet is down. 39 | 40 | Sometime in the future I may add support for caching of the PWA assets, but for now this is not implemented. 41 | 42 | Do note that connecting to your Meshtastic device over HTTP has some limitations due to CORS. Consider using the [server.js](./server.js) script to resolve these issues. 43 | 44 | ## Running Locally 45 | 46 | ``` 47 | git clone https://github.com/liamcottle/meshtxt 48 | cd meshtxt 49 | npm install 50 | npm run dev 51 | ``` 52 | 53 | ## Running with Meshtastic Linux Native 54 | 55 | If you have a Linux Native `meshtasticd` setup, you can install and run MeshTXT directly on the same hardware. 56 | 57 | There are a couple of options for doing this: 58 | 59 | - Changing `RootPath` in `/etc/meshtasticd/config.yaml` to point to MeshTXT instead of the bundled Web Client. 60 | - Running [server.js](./server.js) as a separate process. 61 | 62 | **Changing RootPath** 63 | 64 | This is the easiest approach, but it means you lose access to the bundled Meshtastic web client that comes with `meshtasticd`. 65 | 66 | If you'd like to do this, edit `/etc/meshtasticd/config.yaml` and update it to the following: 67 | 68 | ``` 69 | Webserver: 70 | Port: 443 71 | #RootPath: /usr/share/doc/meshtasticd/web 72 | RootPath: /home/liamcottle/meshtxt/dist 73 | ``` 74 | 75 | > Note: make sure to update `/home/liamcottle/meshtxt/dist` to the `dist` folder where you cloned the repo. 76 | 77 | You'll also need to build the MeshTXT web app. 78 | 79 | ``` 80 | npm run build 81 | ``` 82 | 83 | Then make sure to restart the `meshtasticd` service. 84 | 85 | ``` 86 | service meshtasticd restart 87 | ``` 88 | 89 | Now when you navigate to your `meshtasticd` web server, it will serve MeshTXT. 90 | 91 | **Running server.js** 92 | 93 | This approach allows you to run the original web client as-is, while running MeshTXT as a separate process on a different port. 94 | 95 | When running the server, it will automatically proxy `fromradio` and `toradio` requests to the internal `meshtasticd` web server as well as serve the MeshTXT web UI. 96 | 97 | The server needs to run an HTTP proxy internally to allow the MeshTXT web UI to access the `fromradio` and `toradio` APIs from the same origin. This is important to bypass CORS restrictions in web browsers. 98 | 99 | If you want to proxy to a `meshtasticd` instance on another device, you can use the `--meshtastic-api-url` flag as shown in the example further down. 100 | 101 | It is up to you to roll your own HTTPS support if you want it. I generally put all of my internal HTTP servers behind Caddy, which provides automatic HTTPS on my external domains. 102 | 103 | ``` 104 | git clone https://github.com/liamcottle/meshtxt 105 | cd meshtxt 106 | npm install 107 | npm run build 108 | node server.js --port 8080 --meshtastic-api-url https://127.0.0.1 109 | ``` 110 | 111 | **Systemd Service** 112 | 113 | A systemd service file is available and can be installed with the following commands: 114 | 115 | ``` 116 | sudo cp meshtxt.service /etc/systemd/system/meshtxt.service 117 | sudo systemctl enable meshtxt.service 118 | sudo systemctl start meshtxt.service 119 | sudo systemctl status meshtxt.service 120 | ``` 121 | 122 | > Note: Make sure to update the usernames in the service file if needed. 123 | 124 | ## TODO 125 | 126 | - Add new nodes to node list when a new node is discovered 127 | - Implement Tauri or Electron app shell for building portable .exe and .dmg 128 | - Tauri doesn't support BLE and Serial by default 129 | - Electron is very large when bundled, also requires rolling own implementation of BLE/Serial device selection 130 | - Pagination message history instead of loading all at once 131 | - Add side drawer navigation 132 | - Implement standalone "messages/inbox" page that shows conversations ordered by most recent message 133 | - Save nodes to database and allow user to set a custom name/label for "anonymous" nodes 134 | - Add lora region, frequency and modem preset settings page 135 | - Use an SQLite database when running from server.js to allow cross device message history sharing 136 | - Generate a unique memory cache key for file transfers between two nodes, so another node can't interfere by using the same file transfer ID. 137 | 138 | ## CORS Proxy for HTTP Connections 139 | 140 | > Note: this info was written before I wrote the [server.js](./server.js) script. You should probably use that instead. 141 | 142 | - The `/api/v1/fromradio` endpoint in `meshtasticd` works as expected. 143 | - The `/api/v1/toradio` endpoint in `meshtasticd` does not return an `OPTIONS` response. 144 | 145 | What does this mean? It means that it is possible to fetch packets from a `meshtasticd` device over HTTP, however you cannot send packets to `meshtasticd` over HTTP as the browser will reject the request due to the CORS preflight request having failed. 146 | 147 | This could be fixed by adding the correct CORS response in `meshtasticd` code, or you can alternatively use an HTTP reverse proxy that injects the required CORS headers in all responses. 148 | 149 | Here is an example config I use in my Caddy reverse proxy. Do note that I have omitted my TLS configuration and IP allow list. 150 | 151 | ``` 152 | # Meshtastic - Liam's Pi Gateway 153 | meshtasticd.example.com { 154 | 155 | # always respond with these cors headers 156 | header Access-Control-Allow-Origin "*" 157 | header Access-Control-Allow-Methods "*" 158 | header Access-Control-Allow-Headers "*" 159 | 160 | # respond with http 200 for all options requests and bypass sending to meshtasticd 161 | @options method OPTIONS 162 | respond @options "" 200 163 | 164 | # reverse proxy to meshtasticd 165 | reverse_proxy https://10.1.0.123 { 166 | 167 | # strip existing cors headers from meshtasticd responses 168 | header_down -Access-Control-Allow-Origin 169 | header_down -Access-Control-Allow-Methods 170 | header_down -Access-Control-Allow-Headers 171 | 172 | # allow self signed cert 173 | transport http { 174 | tls 175 | tls_insecure_skip_verify 176 | } 177 | 178 | } 179 | 180 | } 181 | ``` 182 | 183 | ## Contributing 184 | 185 | If you have a feature request, or find a bug, please [open an issue](https://github.com/liamcottle/meshtxt/issues) here on GitHub. 186 | 187 | ## License 188 | 189 | MIT 190 | 191 | ## Legal 192 | 193 | This project is not affiliated with or endorsed by the Meshtastic project. 194 | 195 | The Meshtastic logo is the trademark of Meshtastic LLC. 196 | -------------------------------------------------------------------------------- /donate.md: -------------------------------------------------------------------------------- 1 | # Donate 2 | 3 | Thank you for considering donating, this helps support my work on this project 😁 4 | 5 | ## How can I donate? 6 | 7 | - Bitcoin: bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q 8 | - Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D 9 | - Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle) 10 | - Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle) 11 | -------------------------------------------------------------------------------- /meshtxt.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=meshtxt 3 | After=network.target 4 | StartLimitIntervalSec=0 5 | 6 | [Service] 7 | Type=simple 8 | Restart=always 9 | RestartSec=1 10 | User=root 11 | Group=root 12 | WorkingDirectory=/home/liamcottle/meshtxt 13 | ExecStart=/usr/bin/env node /home/liamcottle/meshtxt/server.js --port 80 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meshtxt", 3 | "version": "1.0.0", 4 | "description": "A simple, mobile friendly, web based Meshtastic client developed by Liam Cottle", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "vite build", 9 | "dev": "vite" 10 | }, 11 | "author": "Liam Cottle ", 12 | "license": "MIT", 13 | "dependencies": { 14 | "@meshtastic/js": "^2.3.7-5", 15 | "@tailwindcss/forms": "^0.5.9", 16 | "@vitejs/plugin-vue": "^5.2.0", 17 | "axios": "^1.7.7", 18 | "click-outside-vue3": "^4.0.1", 19 | "command-line-args": "^6.0.1", 20 | "command-line-usage": "^7.0.3", 21 | "express": "^4.21.1", 22 | "moment": "^2.30.1", 23 | "protobufjs": "^7.4.0", 24 | "rxdb": "^15.38.2", 25 | "rxjs": "^7.8.1", 26 | "uuid": "^11.0.3", 27 | "vite": "^5.4.11", 28 | "vue": "^3.5.12", 29 | "vue-router": "^4.4.5" 30 | }, 31 | "devDependencies": { 32 | "autoprefixer": "^10.4.20", 33 | "postcss": "^8.4.49", 34 | "tailwindcss": "^3.4.14" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: { 4 | 5 | }, 6 | autoprefixer: { 7 | 8 | }, 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /screenshots/1_connect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcottle/meshtxt/b48101bf58f066b9e2c66a333c8ba92aaf3b4713/screenshots/1_connect.png -------------------------------------------------------------------------------- /screenshots/2_channels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcottle/meshtxt/b48101bf58f066b9e2c66a333c8ba92aaf3b4713/screenshots/2_channels.png -------------------------------------------------------------------------------- /screenshots/3_nodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcottle/meshtxt/b48101bf58f066b9e2c66a333c8ba92aaf3b4713/screenshots/3_nodes.png -------------------------------------------------------------------------------- /screenshots/4_direct_messages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcottle/meshtxt/b48101bf58f066b9e2c66a333c8ba92aaf3b4713/screenshots/4_direct_messages.png -------------------------------------------------------------------------------- /screenshots/5_trace_routes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcottle/meshtxt/b48101bf58f066b9e2c66a333c8ba92aaf3b4713/screenshots/5_trace_routes.png -------------------------------------------------------------------------------- /screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcottle/meshtxt/b48101bf58f066b9e2c66a333c8ba92aaf3b4713/screenshots/screenshot.png -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // node server.js --port 8080 --meshtastic-api-url https://10.1.0.249 2 | 3 | import axios from "axios"; 4 | import express from "express"; 5 | import commandLineArgs from "command-line-args"; 6 | import commandLineUsage from "command-line-usage"; 7 | 8 | // fixme: setup http agent to allow invalid cert for axios instead? 9 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; 10 | 11 | const optionsList = [ 12 | { 13 | name: "help", 14 | alias: "h", 15 | type: Boolean, 16 | description: "Display this usage guide." 17 | }, 18 | { 19 | name: "port", 20 | type: Number, 21 | description: "Port to serve Web UI and API from. e.g: 8080", 22 | }, 23 | { 24 | name: "meshtastic-api-url", 25 | type: String, 26 | description: "The URL to a Meshtastic devices HTTP API. e.g: https://10.1.0.123", 27 | }, 28 | ]; 29 | 30 | // parse command line args 31 | const options = commandLineArgs(optionsList); 32 | 33 | function main() { 34 | 35 | // show help 36 | if(options.help){ 37 | const usage = commandLineUsage([ 38 | { 39 | header: "MeshTXT Server", 40 | content: "A server that hosts the MeshTXT Web UI and runs a proxy to a Meshtastic devices HTTP API.", 41 | }, 42 | { 43 | header: "Options", 44 | optionList: optionsList, 45 | }, 46 | ]); 47 | console.log(usage); 48 | return; 49 | } 50 | 51 | // get options and fallback to default values 52 | const port = options["port"] ?? 8080; 53 | const meshtasticApiUrl = options["meshtastic-api-url"] ?? "https://localhost"; 54 | 55 | // if provided, ensure meshtastic api url is http or https 56 | if(meshtasticApiUrl !== "" && !meshtasticApiUrl.startsWith("http://") && !meshtasticApiUrl.startsWith("https://")){ 57 | console.log("ERROR: --meshtastic-api-url must start with http:// or https://"); 58 | return; 59 | } 60 | 61 | // create express app 62 | const app = express(); 63 | 64 | // allow retrieving raw request body as buffer 65 | app.use((req, res, next) => { 66 | const chunks = []; 67 | req.on("data", (chunk) => chunks.push(chunk)); 68 | req.on("end", () => { 69 | req.rawBody = Buffer.concat(chunks); 70 | next(); 71 | }); 72 | }); 73 | 74 | // serve vite app from /dist 75 | app.use("/", express.static("./dist")); 76 | 77 | // setup proxy endpoints to meshtasticd if api url was provided 78 | if(meshtasticApiUrl !== ""){ 79 | 80 | // proxy fromradio to meshtasticd to allow connecting to localhost to bypass cors 81 | app.get("/api/v1/fromradio", async (req, res) => { 82 | try { 83 | 84 | // proxy fromradio request to meshtasticd endpoint 85 | const response = await axios({ 86 | method: "GET", 87 | responseType: "arraybuffer", 88 | url: `${meshtasticApiUrl}/api/v1/fromradio`, 89 | params: req.query, 90 | }); 91 | 92 | // send response back 93 | res.status(response.status).set({ 94 | "Content-Type": "application/x-protobuf", 95 | "Content-Length": response.data.length, 96 | }).send(response.data); 97 | 98 | } catch(e) { 99 | console.error(`Proxy error: ${e.message}`); 100 | res.status(502).send("Proxy Error"); 101 | } 102 | }); 103 | 104 | app.put("/api/v1/toradio", async (req, res) => { 105 | try { 106 | 107 | // proxy toradio request to meshtasticd endpoint 108 | const response = await axios({ 109 | method: "PUT", 110 | responseType: "arraybuffer", 111 | url: `${meshtasticApiUrl}/api/v1/toradio`, 112 | headers: { 113 | "Content-Type": "application/x-protobuf", 114 | "Content-Length": req.rawBody.length, 115 | }, 116 | data: req.rawBody, 117 | params: req.query, 118 | }); 119 | 120 | // send response back 121 | res.status(response.status).set({ 122 | "Content-Type": "application/x-protobuf", 123 | "Content-Length": response.data.length, 124 | }).send(response.data); 125 | 126 | } catch(e) { 127 | console.error(`Proxy error: ${e.message}`); 128 | res.status(502).send("Proxy Error"); 129 | } 130 | }); 131 | 132 | } 133 | 134 | // run server 135 | app.listen(port, () => { 136 | console.log(`Server running at http://localhost:${port}`); 137 | }); 138 | 139 | } 140 | 141 | main(); 142 | -------------------------------------------------------------------------------- /src/components/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 31 | -------------------------------------------------------------------------------- /src/components/AppBar.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 44 | -------------------------------------------------------------------------------- /src/components/DropDownMenu.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 93 | -------------------------------------------------------------------------------- /src/components/DropDownMenuItem.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/FetchingDataInfo.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 88 | -------------------------------------------------------------------------------- /src/components/IconButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/RefreshButton.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/components/SaveButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /src/components/TextButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/TraceRouteSnrLabel.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /src/components/channels/ChannelListItem.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 76 | -------------------------------------------------------------------------------- /src/components/channels/ChannelPskBadge.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 96 | -------------------------------------------------------------------------------- /src/components/channels/ChannelsList.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 44 | -------------------------------------------------------------------------------- /src/components/connect/ConnectButtons.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 102 | -------------------------------------------------------------------------------- /src/components/nodes/NodeDropDownMenu.vue: -------------------------------------------------------------------------------- 1 | 134 | 135 | 208 | -------------------------------------------------------------------------------- /src/components/nodes/NodeIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 24 | -------------------------------------------------------------------------------- /src/components/nodes/NodeListItem.vue: -------------------------------------------------------------------------------- 1 | 135 | 136 | 217 | -------------------------------------------------------------------------------- /src/components/nodes/NodesList.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 183 | -------------------------------------------------------------------------------- /src/components/pages/ChannelMessagesPage.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 84 | -------------------------------------------------------------------------------- /src/components/pages/ConnectPage.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 29 | -------------------------------------------------------------------------------- /src/components/pages/ConnectViaHttpPage.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 173 | -------------------------------------------------------------------------------- /src/components/pages/MainPage.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 93 | -------------------------------------------------------------------------------- /src/components/pages/NodeMessagesPage.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 134 | -------------------------------------------------------------------------------- /src/components/pages/NodePage.vue: -------------------------------------------------------------------------------- 1 | 169 | 170 | 298 | -------------------------------------------------------------------------------- /src/components/pages/NodeRunTraceRoutePage.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 189 | -------------------------------------------------------------------------------- /src/components/pages/NodeTraceRoutesPage.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 116 | -------------------------------------------------------------------------------- /src/components/pages/Page.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/pages/TraceRoutePage.vue: -------------------------------------------------------------------------------- 1 | 149 | 150 | 214 | -------------------------------------------------------------------------------- /src/components/pages/settings/NodeChannelSettingsPage.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 159 | -------------------------------------------------------------------------------- /src/components/pages/settings/NodeChannelsSettingsPage.vue: -------------------------------------------------------------------------------- 1 | 81 | 82 | 243 | -------------------------------------------------------------------------------- /src/components/pages/settings/NodeSettingsList.vue: -------------------------------------------------------------------------------- 1 | 179 | 180 | 274 | -------------------------------------------------------------------------------- /src/components/pages/settings/NodeSettingsPage.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 49 | -------------------------------------------------------------------------------- /src/components/pages/settings/NodeUserSettingsPage.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 172 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MeshTXT 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 31 | 32 | 33 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/js/ChannelUtils.js: -------------------------------------------------------------------------------- 1 | import GlobalState from "./GlobalState.js"; 2 | import {Protobuf} from "@meshtastic/js"; 3 | import NodeUtils from "./NodeUtils.js"; 4 | 5 | class ChannelUtils { 6 | 7 | // channel index used for private key cryptography packets 8 | static PKC_CHANNEL_INDEX = 8; 9 | 10 | // https://github.com/meshtastic/firmware/blob/2b0113ae82f2dc5cde82e5c00921d41d10ac141d/src/mesh/Channels.cpp#L294 11 | static getChannelName(channelId) { 12 | const channel = GlobalState.channelsByIndex[channelId]; 13 | return this.getChannelNameFromChannelAndLoraConfig(channel, GlobalState.loraConfig); 14 | } 15 | 16 | static getChannelNameFromChannelAndLoraConfig(channel, loraConfig) { 17 | 18 | // get channel name from channel settings 19 | var channelName = channel?.settings?.name; 20 | 21 | // if channel name is empty, determine what the name should be based on modem preset 22 | if(channelName === ""){ 23 | if(loraConfig?.usePreset === true){ 24 | channelName = this.getModemPresetDisplayName(loraConfig.modemPreset); 25 | } else { 26 | channelName = "Custom"; 27 | } 28 | } 29 | 30 | return channelName; 31 | 32 | } 33 | 34 | static getModemPresetDisplayName(modemPreset) { 35 | switch(modemPreset){ 36 | case Protobuf.Config.Config_LoRaConfig_ModemPreset.SHORT_TURBO: return "ShortTurbo"; 37 | case Protobuf.Config.Config_LoRaConfig_ModemPreset.SHORT_SLOW: return "ShortSlow"; 38 | case Protobuf.Config.Config_LoRaConfig_ModemPreset.SHORT_FAST: return "ShortFast"; 39 | case Protobuf.Config.Config_LoRaConfig_ModemPreset.MEDIUM_SLOW: return "MediumSlow"; 40 | case Protobuf.Config.Config_LoRaConfig_ModemPreset.MEDIUM_FAST: return "MediumFast"; 41 | case Protobuf.Config.Config_LoRaConfig_ModemPreset.LONG_SLOW: return "LongSlow"; 42 | case Protobuf.Config.Config_LoRaConfig_ModemPreset.LONG_FAST: return "LongFast"; 43 | case Protobuf.Config.Config_LoRaConfig_ModemPreset.LONG_MODERATE: return "LongMod"; 44 | case Protobuf.Config.Config_LoRaConfig_ModemPreset.VERY_LONG_SLOW: return "VLongSlow"; 45 | default: return "Invalid"; 46 | } 47 | } 48 | 49 | // https://github.com/meshtastic/firmware/blob/2b0113ae82f2dc5cde82e5c00921d41d10ac141d/src/mesh/Channels.cpp#L312 50 | static isDefaultChannel(channelId) { 51 | 52 | // find channel by id 53 | const channel = GlobalState.channelsByIndex[channelId]; 54 | if(!channel){ 55 | return false; 56 | } 57 | 58 | // check if channel has default key 59 | const hasDefaultPsk = channel.settings.psk.length === 1 && channel.settings.psk[0] === 1; 60 | 61 | // check if channel has default display name 62 | const channelName = this.getChannelName(channelId); 63 | const modemPresetDisplayName = this.getModemPresetDisplayName(GlobalState.loraConfig?.modemPreset); 64 | const hasDefaultDisplayName = channelName === modemPresetDisplayName; 65 | 66 | // channel is a default channel if it is using the default key and default display name 67 | return hasDefaultPsk && hasDefaultDisplayName; 68 | 69 | } 70 | 71 | static getAdminChannelIndex(nodeId) { 72 | 73 | // ensure node id is numeric 74 | nodeId = parseInt(nodeId); 75 | 76 | // always use channel 0 when sending admin packets to self 77 | if(nodeId === GlobalState.myNodeId){ 78 | return 0; 79 | } 80 | 81 | // use pkc channel if our node and remote node both have pkc keys available 82 | const myNodeHasPkc = NodeUtils.hasPublicKey(GlobalState.myNodeId); 83 | const remoteNodeHasPkc = NodeUtils.hasPublicKey(nodeId); 84 | if(myNodeHasPkc && remoteNodeHasPkc){ 85 | return ChannelUtils.PKC_CHANNEL_INDEX; 86 | } 87 | 88 | // find channel with the name "admin" (case-insensitive) 89 | const adminChannel = Object.values(GlobalState.channelsByIndex).find((channel) => { 90 | const channelName = ChannelUtils.getChannelName(channel.index); 91 | return channelName?.toLowerCase() === "admin"; 92 | }); 93 | 94 | // use admin channel if available 95 | if(adminChannel){ 96 | return adminChannel.index; 97 | } 98 | 99 | // fallback to channel index 0 100 | return 0; 101 | 102 | } 103 | 104 | } 105 | 106 | export default ChannelUtils; 107 | -------------------------------------------------------------------------------- /src/js/DeviceUtils.js: -------------------------------------------------------------------------------- 1 | class DeviceUtils { 2 | 3 | static isMobile() { 4 | return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); 5 | } 6 | 7 | } 8 | 9 | export default DeviceUtils; 10 | -------------------------------------------------------------------------------- /src/js/DialogUtils.js: -------------------------------------------------------------------------------- 1 | import RoutingError from "./exceptions/RoutingError.js"; 2 | 3 | class DialogUtils { 4 | 5 | static showSettingsSavedAlert() { 6 | alert("Settings saved. Node might reboot. If it does you will need to reconnect!"); 7 | } 8 | 9 | static showErrorAlert(error) { 10 | 11 | // check for routing error 12 | if(error instanceof RoutingError){ 13 | alert(error.getRoutingErrorMessage()); 14 | return; 15 | } 16 | 17 | // standard error message 18 | alert(error); 19 | 20 | } 21 | 22 | } 23 | 24 | export default DialogUtils; 25 | -------------------------------------------------------------------------------- /src/js/FileTransferAPI.js: -------------------------------------------------------------------------------- 1 | import protobufjs from "protobufjs"; 2 | import {Protobuf} from "@meshtastic/js"; 3 | import NodeUtils from "./NodeUtils.js"; 4 | import NodeAPI from "./NodeAPI.js"; 5 | 6 | class FileTransferAPI { 7 | 8 | // static memory cache for loaded protobuf 9 | static _fileTransferPacket = null; 10 | 11 | static async getOrLoadFileTransferPacketProto() { 12 | 13 | // return from memory cache if available 14 | if(this._fileTransferPacket != null){ 15 | return this._fileTransferPacket; 16 | } 17 | 18 | // otherwise load protobuf and cache in memory 19 | const root = await protobufjs.load("../protos/file_transfer.proto"); 20 | this._fileTransferPacket = root.lookupType("FileTransferPacket"); 21 | return this._fileTransferPacket; 22 | 23 | } 24 | 25 | static async sendFileTransferPacket(nodeId, data) { 26 | 27 | // create file transfer packet 28 | const FileTransferPacket = await this.getOrLoadFileTransferPacketProto(); 29 | const fileTransferPacket = FileTransferPacket.encode(FileTransferPacket.fromObject(data)).finish(); 30 | 31 | // send file transfer packet to destination 32 | const portNum = Protobuf.Portnums.PortNum.PRIVATE_APP; 33 | const byteData = fileTransferPacket; 34 | const channel = NodeUtils.getNodeChannel(nodeId); 35 | await NodeAPI.sendPacketAndWaitForResponse(nodeId, portNum, byteData, channel, false); 36 | 37 | } 38 | 39 | static async offerFileTransfer(nodeId, fileTransferId, fileName, fileSize) { 40 | await this.sendFileTransferPacket(nodeId, { 41 | offerFileTransfer: { 42 | id: fileTransferId, 43 | fileName: fileName, 44 | fileSize: fileSize, 45 | }, 46 | }); 47 | } 48 | 49 | static async rejectFileTransfer(nodeId, fileTransferId) { 50 | await this.sendFileTransferPacket(nodeId, { 51 | rejectFileTransfer: { 52 | fileTransferId: fileTransferId, 53 | }, 54 | }); 55 | } 56 | 57 | static async cancelFileTransfer(nodeId, fileTransferId) { 58 | await this.sendFileTransferPacket(nodeId, { 59 | cancelFileTransfer: { 60 | fileTransferId: fileTransferId, 61 | }, 62 | }); 63 | } 64 | 65 | static async completeFileTransfer(nodeId, fileTransferId) { 66 | await this.sendFileTransferPacket(nodeId, { 67 | completedFileTransfer: { 68 | fileTransferId: fileTransferId, 69 | }, 70 | }); 71 | } 72 | 73 | static async requestFileChunk(nodeId, fileTransferId, offset, length) { 74 | await this.sendFileTransferPacket(nodeId, { 75 | requestFileChunk: { 76 | fileTransferId: fileTransferId, 77 | offset: offset, 78 | length: length, 79 | }, 80 | }); 81 | } 82 | 83 | static async sendFileChunk(nodeId, fileTransferId, offset, length, data) { 84 | await this.sendFileTransferPacket(nodeId, { 85 | fileChunk: { 86 | fileTransferId: fileTransferId, 87 | offset: offset, 88 | length: length, 89 | data: data, 90 | }, 91 | }); 92 | } 93 | 94 | } 95 | 96 | export default FileTransferAPI; 97 | -------------------------------------------------------------------------------- /src/js/FileTransferrer.js: -------------------------------------------------------------------------------- 1 | import NodeAPI from "./NodeAPI.js"; 2 | import GlobalState from "./GlobalState.js"; 3 | import FileTransferAPI from "./FileTransferAPI.js"; 4 | import Connection from "./Connection.js"; 5 | 6 | class FileTransferrer { 7 | 8 | static DIRECTION_INCOMING = "incoming"; 9 | static DIRECTION_OUTGOING = "outgoing"; 10 | 11 | static STATUS_OFFERING = "offering"; 12 | static STATUS_REJECTED = "rejected"; 13 | static STATUS_CANCELLED = "cancelled"; 14 | static STATUS_COMPLETED = "completed"; 15 | static STATUS_SENDING = "sending"; 16 | static STATUS_RECEIVING = "receiving"; 17 | 18 | static MAX_PACKET_ATTEMPTS = 3; 19 | 20 | static log(message) { 21 | console.log(`[FileTransferrer] ${message}`); 22 | } 23 | 24 | static async offerFileTransfer(to, file) { 25 | 26 | // generate random file transfer id 27 | const fileTransferId = NodeAPI.generatePacketId(); 28 | 29 | // get file details 30 | to = parseInt(to); 31 | const fileName = file.name; 32 | const fileData = new Uint8Array(await file.arrayBuffer()); 33 | const fileSize = fileData.length; 34 | 35 | const fileTransfer = { 36 | id: fileTransferId, 37 | to: to, 38 | from: GlobalState.myNodeId, 39 | direction: this.DIRECTION_OUTGOING, 40 | status: this.STATUS_OFFERING, 41 | filename: fileName, 42 | filesize: fileSize, 43 | progress: 0, 44 | data: fileData, 45 | }; 46 | 47 | // add to file transfers list 48 | GlobalState.fileTransfers.push(fileTransfer); 49 | 50 | // send file transfer offer 51 | for(var attempt = 1; attempt <= this.MAX_PACKET_ATTEMPTS; attempt++){ 52 | try { 53 | this.log(`offerFileTransfer attempt ${attempt}`); 54 | await FileTransferAPI.offerFileTransfer(to, fileTransferId, fileName, fileSize); 55 | this.log(`offerFileTransfer attempt ${attempt} success`); 56 | return; 57 | } catch(e) { 58 | console.log(e); 59 | if(attempt === this.MAX_PACKET_ATTEMPTS){ 60 | this.log("offerFileTransfer failed", e); 61 | throw e; 62 | } 63 | } 64 | } 65 | 66 | } 67 | 68 | static async acceptFileTransfer(fileTransfer) { 69 | 70 | // create buffer for file data 71 | fileTransfer.status = this.STATUS_RECEIVING; 72 | fileTransfer.data = new Uint8Array(0); 73 | 74 | // loop until all bytes received 75 | var offset = 0; 76 | var length = 200; 77 | while(fileTransfer.data.length < fileTransfer.filesize){ 78 | 79 | // stop fetching file chunks if the file transfer has been cancelled 80 | if(fileTransfer.status === FileTransferrer.STATUS_CANCELLED){ 81 | return; 82 | } 83 | 84 | try { 85 | 86 | // fetch next file chunk 87 | offset = fileTransfer.data.length; 88 | const fileChunk = await FileTransferrer.getFileChunk(fileTransfer, offset, length); 89 | 90 | // append received data 91 | fileTransfer.data = new Uint8Array([ 92 | ...fileTransfer.data, 93 | ...fileChunk.data, 94 | ]); 95 | 96 | // check if completed 97 | if(fileTransfer.data.length === fileTransfer.filesize){ 98 | // todo check integrity of received data (implement a crc or hash) 99 | fileTransfer.status = FileTransferrer.STATUS_COMPLETED; 100 | fileTransfer.blob = new Blob([fileTransfer.data], { 101 | type: "application/octet-stream", 102 | }); 103 | await FileTransferrer.completeFileTransfer(fileTransfer); 104 | return; 105 | } 106 | 107 | // update file transfer progress 108 | fileTransfer.progress = Math.min(100, Math.ceil((fileChunk.offset + fileChunk.length) / fileTransfer.filesize * 100)); 109 | 110 | } catch(e) { 111 | this.log("failed to get file chunk", e); 112 | } 113 | 114 | } 115 | 116 | } 117 | 118 | static async rejectFileTransfer(fileTransfer) { 119 | 120 | // remove from ui 121 | GlobalState.fileTransfers = GlobalState.fileTransfers.filter((existingFileTransfer) => { 122 | return existingFileTransfer.id !== fileTransfer.id; 123 | }); 124 | 125 | for(var attempt = 1; attempt <= this.MAX_PACKET_ATTEMPTS; attempt++){ 126 | try { 127 | this.log(`rejectFileTransfer attempt ${attempt}`); 128 | await FileTransferAPI.rejectFileTransfer(fileTransfer.from, fileTransfer.id); 129 | fileTransfer.status = this.STATUS_REJECTED; 130 | return; 131 | } catch(e) { 132 | console.log(e); 133 | if(attempt === this.MAX_PACKET_ATTEMPTS){ 134 | this.log("rejectFileTransfer failed", e); 135 | throw e; 136 | } 137 | } 138 | } 139 | 140 | } 141 | 142 | static async cancelFileTransfer(fileTransfer) { 143 | 144 | fileTransfer.status = this.STATUS_CANCELLED; 145 | 146 | for(var attempt = 1; attempt <= this.MAX_PACKET_ATTEMPTS; attempt++){ 147 | try { 148 | this.log(`cancelFileTransfer attempt ${attempt}`); 149 | await FileTransferAPI.cancelFileTransfer(fileTransfer.to, fileTransfer.id); 150 | return; 151 | } catch(e) { 152 | console.log(e); 153 | if(attempt === this.MAX_PACKET_ATTEMPTS){ 154 | this.log("cancelFileTransfer failed", e); 155 | throw e; 156 | } 157 | } 158 | } 159 | 160 | } 161 | 162 | static removeFileTransfer(fileTransfer) { 163 | GlobalState.fileTransfers = GlobalState.fileTransfers.filter((existingFileTransfer) => { 164 | return existingFileTransfer.id !== fileTransfer.id; 165 | }); 166 | } 167 | 168 | static async requestFileChunk(fileTransfer, offset, length) { 169 | await FileTransferAPI.requestFileChunk(fileTransfer.from, fileTransfer.id, offset, length); 170 | } 171 | 172 | static async sendFileChunk(fileTransfer, offset, length) { 173 | 174 | // update status 175 | fileTransfer.status = FileTransferrer.STATUS_SENDING; 176 | 177 | // get data for this part 178 | const data = fileTransfer.data.slice(offset, offset + length); 179 | 180 | // send file chunk 181 | await FileTransferAPI.sendFileChunk(fileTransfer.to, fileTransfer.id, offset, length, data); 182 | 183 | } 184 | 185 | static async completeFileTransfer(fileTransfer) { 186 | for(var attempt = 1; attempt <= this.MAX_PACKET_ATTEMPTS; attempt++){ 187 | try { 188 | this.log(`completeFileTransfer attempt ${attempt}`); 189 | await FileTransferAPI.completeFileTransfer(fileTransfer.from, fileTransfer.id); 190 | return; 191 | } catch(e) { 192 | console.log(e); 193 | if(attempt === this.MAX_PACKET_ATTEMPTS){ 194 | this.log("completeFileTransfer failed", e); 195 | throw e; 196 | } 197 | } 198 | } 199 | } 200 | 201 | /** 202 | * Fetches a file chunk for the provided file transfer 203 | * @param fileTransfer the file transfer to get a file chunk from 204 | * @param offset the offset to fetch from 205 | * @param length the length of data to fetch 206 | * @param timeoutMillis how long to wait for file chunk before giving up 207 | * @returns {Promise} 208 | */ 209 | static async getFileChunk(fileTransfer, offset, length, timeoutMillis = 15000) { 210 | var timeout = null; 211 | var fileChunkListener = null; 212 | return new Promise(async (resolve, reject) => { 213 | try { 214 | 215 | // handle file chunk 216 | fileChunkListener = (meshPacket, fileChunk) => { 217 | 218 | // ignore packet if not from expected node 219 | if(meshPacket.from !== fileTransfer.from){ 220 | this.log("ignoring file chunk that isn't from the node that offered this file transfer"); 221 | return; 222 | } 223 | 224 | // ignore packet if not for requested file transfer id 225 | if(fileChunk.fileTransferId !== fileTransfer.id){ 226 | this.log("ignoring file chunk that isn't for this file transfer"); 227 | return; 228 | } 229 | 230 | // ignore packet if not for requested offset and length 231 | if(fileChunk.offset !== offset || fileChunk.length !== length){ 232 | this.log("ignoring file chunk that isn't for the offset and length we requested"); 233 | return; 234 | } 235 | 236 | // we have file chunk, so we no longer want to time out 237 | clearTimeout(timeout); 238 | 239 | // stop listening for file chunks 240 | Connection.removeFileChunkListener(fileChunkListener); 241 | 242 | // resolve promise 243 | resolve(fileChunk); 244 | 245 | }; 246 | 247 | // timeout after configured delay 248 | timeout = setTimeout(() => { 249 | Connection.removeFileChunkListener(fileChunkListener); 250 | reject("timeout"); 251 | }, timeoutMillis); 252 | 253 | // listen for file chunks 254 | Connection.addFileChunkListener(fileChunkListener); 255 | 256 | // request file chunk 257 | await this.requestFileChunk(fileTransfer, offset, length); 258 | 259 | } catch(e) { 260 | clearTimeout(timeout); 261 | Connection.removeFileChunkListener(fileChunkListener); 262 | reject(e); 263 | } 264 | }); 265 | } 266 | 267 | } 268 | 269 | export default FileTransferrer; 270 | -------------------------------------------------------------------------------- /src/js/GlobalState.js: -------------------------------------------------------------------------------- 1 | import { reactive } from "vue"; 2 | 3 | // global state 4 | const globalState = reactive({ 5 | 6 | isConnected: false, 7 | isConfigComplete: false, 8 | connection: null, 9 | deviceStatus: null, 10 | 11 | myNodeId: null, 12 | myNodeUser: null, 13 | myNodeDeviceMetrics: null, 14 | myNodeFiles: [], 15 | 16 | loraConfig: null, 17 | channelsByIndex: {}, 18 | nodesById: {}, 19 | 20 | // cache channels fetched from remote nodes 21 | remoteNodeChannels: {}, 22 | remoteNodeLoraConfig: {}, 23 | 24 | // cache file transfers in memory 25 | fileTransfers: [], 26 | 27 | }); 28 | 29 | export default globalState; 30 | -------------------------------------------------------------------------------- /src/js/MessageUtils.js: -------------------------------------------------------------------------------- 1 | import GlobalState from "./GlobalState.js"; 2 | 3 | class MessageUtils { 4 | 5 | static isMessageInbound(message) { 6 | // inbound messages are not from us 7 | return message.from !== GlobalState.myNodeId; 8 | } 9 | 10 | static isMessageOutbound(message) { 11 | // outbound messages are from us 12 | return message.from === GlobalState.myNodeId; 13 | } 14 | 15 | static isMessageAcknowledged(message) { 16 | // implicit acks for broadcast messages are always acked from our own id 17 | // acked_by_node_id is saved on the message in the database when we receive an ack for this messages packet id 18 | return message.acked_by_node_id === GlobalState.myNodeId; 19 | } 20 | 21 | static isMessageDelivered(message) { 22 | // message is delivered if it was a direct message and was acked by the recipient 23 | return message.type === "direct" && message.acked_by_node_id === message.to; 24 | } 25 | 26 | static isMessageFailed(message) { 27 | return message.error != null; 28 | } 29 | 30 | } 31 | 32 | export default MessageUtils; 33 | -------------------------------------------------------------------------------- /src/js/NodeUtils.js: -------------------------------------------------------------------------------- 1 | import GlobalState from "./GlobalState.js"; 2 | import { Protobuf } from "@meshtastic/js"; 3 | import PacketUtils from "./PacketUtils.js"; 4 | 5 | class NodeUtils { 6 | 7 | static getPublicKey(nodeId) { 8 | const publicKey = GlobalState.nodesById[nodeId]?.user?.publicKey; 9 | return publicKey != null && publicKey.length > 0 ? publicKey : null; 10 | } 11 | 12 | static hasPublicKey(nodeId) { 13 | return this.getPublicKey(nodeId) !== null; 14 | } 15 | 16 | static getNodeHexId(nodeId) { 17 | return "!" + parseInt(nodeId).toString(16); 18 | } 19 | 20 | static getNodeColour(nodeId) { 21 | // convert node id to a hex colour 22 | return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0'); 23 | } 24 | 25 | static getNodeTextColour(nodeId) { 26 | 27 | // extract rgb components 28 | const r = (nodeId & 0xFF0000) >> 16; 29 | const g = (nodeId & 0x00FF00) >> 8; 30 | const b = nodeId & 0x0000FF; 31 | 32 | // calculate brightness 33 | const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255; 34 | 35 | // determine text color based on brightness 36 | return brightness > 0.5 ? "#000000" : "#FFFFFF"; 37 | 38 | } 39 | 40 | static getNodeShortName(nodeId) { 41 | return GlobalState.nodesById[nodeId]?.user?.shortName?.substring(0, 4) ?? "?"; 42 | } 43 | 44 | static getNodeLongName(nodeId) { 45 | return GlobalState.nodesById[nodeId]?.user?.longName ?? NodeUtils.getNodeHexId(nodeId); 46 | } 47 | 48 | static getRoleName(roleId) { 49 | return Protobuf.Config.Config_DeviceConfig_Role[roleId]; 50 | } 51 | 52 | static getHardwareName(hardwareId) { 53 | return Protobuf.Mesh.HardwareModel[hardwareId]; 54 | } 55 | 56 | static getNodeChannel(nodeId) { 57 | // get the channel we should use to send packets to this node 58 | // otherwise we will just fallback to the primary channel 59 | return GlobalState.nodesById[nodeId]?.channel ?? 0; 60 | } 61 | 62 | static convertPublicKeyToBase64(publicKey) { 63 | return PacketUtils.uInt8ArrayToBase64(publicKey); 64 | } 65 | 66 | /** 67 | * Converts latitudeI or longitudeI to an actual lat/long value 68 | * e.g: -38123456 -> -38.123456 69 | * @param latLongInteger 70 | * @returns {number} 71 | */ 72 | static latLongIntegerToLatLong(latLongInteger) { 73 | return latLongInteger / 10000000; 74 | } 75 | 76 | } 77 | 78 | export default NodeUtils; 79 | -------------------------------------------------------------------------------- /src/js/PacketUtils.js: -------------------------------------------------------------------------------- 1 | class PacketUtils { 2 | 3 | static getPacketHops(packet) { 4 | const hopStart = packet.hopStart; 5 | const hopLimit = packet.hopLimit; 6 | const hopsAway = (hopStart === 0 || hopLimit > hopStart) ? -1 : hopStart - hopLimit; 7 | return hopsAway; 8 | } 9 | 10 | static uInt8ArrayToBase64(uInt8Array) { 11 | let binary = ""; 12 | for(let i = 0; i < uInt8Array.length; i++){ 13 | binary += String.fromCharCode(uInt8Array[i]); 14 | } 15 | return btoa(binary); 16 | } 17 | 18 | static base64ToUInt8Array(base64) { 19 | const binaryString = atob(base64); 20 | const bytes = new Uint8Array(binaryString.length); 21 | for(var i = 0; i < binaryString.length; i++){ 22 | bytes[i] = binaryString.charCodeAt(i); 23 | } 24 | return bytes; 25 | } 26 | 27 | } 28 | 29 | export default PacketUtils; 30 | -------------------------------------------------------------------------------- /src/js/SecurityUtils.js: -------------------------------------------------------------------------------- 1 | class SecurityUtils { 2 | 3 | static isDefaultPsk(pskAsUInt8Array) { 4 | // default psk is 1 byte (8 bits) and is equal to 0x1 and is also known as "AQ==" 5 | return pskAsUInt8Array != null && pskAsUInt8Array.length === 1 && pskAsUInt8Array[0] === 0x1; 6 | } 7 | 8 | } 9 | 10 | export default SecurityUtils; 11 | -------------------------------------------------------------------------------- /src/js/TimeUtils.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | class TimeUtils { 4 | 5 | static formatDateTime(date) { 6 | return moment(date).format("DD/MMM/YYYY hh:mm A"); 7 | }; 8 | 9 | static getTimeAgo(date) { 10 | return moment(date).fromNow(); 11 | }; 12 | 13 | static getTimeAgoShortHand(date) { 14 | 15 | // get duration between now and provided date 16 | const duration = moment.duration(moment().diff(date)); 17 | 18 | // years 19 | const years = Math.floor(duration.asYears()); 20 | if(years > 0){ 21 | return `${years} ${years === 1 ? 'year' : 'years'} ago`; 22 | } 23 | 24 | // months 25 | const months = Math.floor(duration.asMonths()); 26 | if(months > 0){ 27 | return `${months} ${months === 1 ? 'month' : 'months'} ago`; 28 | } 29 | 30 | // weeks 31 | const weeks = Math.floor(duration.asWeeks()); 32 | if(weeks > 0){ 33 | return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`; 34 | } 35 | 36 | // days 37 | const days = Math.floor(duration.asDays()); 38 | if(days > 0){ 39 | return `${days} ${days === 1 ? 'day' : 'days'} ago`; 40 | } 41 | 42 | // hours 43 | const hours = Math.floor(duration.asHours()); 44 | if(hours > 0){ 45 | return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`; 46 | } 47 | 48 | // minutes 49 | const minutes = Math.floor(duration.asMinutes()); 50 | if(minutes > 0){ 51 | return `${minutes} ${minutes === 1 ? 'min' : 'mins'} ago`; 52 | } 53 | 54 | return "now"; 55 | 56 | }; 57 | 58 | } 59 | 60 | export default TimeUtils; 61 | -------------------------------------------------------------------------------- /src/js/exceptions/RoutingError.js: -------------------------------------------------------------------------------- 1 | import {Protobuf} from "@meshtastic/js"; 2 | 3 | class RoutingError extends Error { 4 | 5 | constructor(routingErrorNumber) { 6 | super("Routing Error"); 7 | this.routingErrorNumber = routingErrorNumber; 8 | } 9 | 10 | getRoutingErrorMessage() { 11 | switch(this.routingErrorNumber){ 12 | case Protobuf.Mesh.Routing_Error.NONE: return "NONE"; 13 | case Protobuf.Mesh.Routing_Error.NO_ROUTE: return "NO_ROUTE"; 14 | case Protobuf.Mesh.Routing_Error.GOT_NAK: return "GOT_NAK"; 15 | case Protobuf.Mesh.Routing_Error.TIMEOUT: return "TIMEOUT"; 16 | case Protobuf.Mesh.Routing_Error.NO_INTERFACE: return "NO_INTERFACE"; 17 | case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT: return "MAX_RETRANSMIT"; 18 | case Protobuf.Mesh.Routing_Error.NO_CHANNEL: return "NO_CHANNEL"; 19 | case Protobuf.Mesh.Routing_Error.TOO_LARGE: return "TOO_LARGE"; 20 | case Protobuf.Mesh.Routing_Error.NO_RESPONSE: return "NO_RESPONSE"; 21 | case Protobuf.Mesh.Routing_Error.DUTY_CYCLE_LIMIT: return "DUTY_CYCLE_LIMIT"; 22 | case Protobuf.Mesh.Routing_Error.BAD_REQUEST: return "BAD_REQUEST"; 23 | case Protobuf.Mesh.Routing_Error.NOT_AUTHORIZED: return "NOT_AUTHORIZED"; 24 | case Protobuf.Mesh.Routing_Error.PKI_FAILED: return "PKI_FAILED"; 25 | case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY: return "PKI_UNKNOWN_PUBKEY"; 26 | case Protobuf.Mesh.Routing_Error.ADMIN_BAD_SESSION_KEY: return "ADMIN_BAD_SESSION_KEY"; 27 | case Protobuf.Mesh.Routing_Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED: return "ADMIN_PUBLIC_KEY_UNAUTHORIZED"; 28 | default: return `${this.routingErrorNumber}`; 29 | } 30 | } 31 | 32 | } 33 | 34 | export default RoutingError; 35 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import { createRouter, createMemoryHistory } from 'vue-router'; 3 | import vClickOutside from "click-outside-vue3"; 4 | import "./style.css"; 5 | 6 | import App from './components/App.vue'; 7 | import GlobalState from "./js/GlobalState.js"; 8 | import {BleConnection, Protobuf, SerialConnection} from "@meshtastic/js"; 9 | 10 | const routes = [ 11 | { 12 | name: "main", 13 | path: '/', 14 | component: () => import("./components/pages/MainPage.vue"), 15 | }, 16 | { 17 | name: "connect", 18 | path: '/connect', 19 | component: () => import("./components/pages/ConnectPage.vue"), 20 | }, 21 | { 22 | name: "connect.http", 23 | path: '/connect/http', 24 | component: () => import("./components/pages/ConnectViaHttpPage.vue"), 25 | }, 26 | { 27 | name: "channel.messages", 28 | path: '/channels/:channelId/messages', 29 | props: true, 30 | component: () => import("./components/pages/ChannelMessagesPage.vue"), 31 | }, 32 | { 33 | name: "node", 34 | path: '/nodes/:nodeId', 35 | props: true, 36 | component: () => import("./components/pages/NodePage.vue"), 37 | }, 38 | { 39 | name: "node.messages", 40 | path: '/nodes/:nodeId/messages', 41 | props: true, 42 | component: () => import("./components/pages/NodeMessagesPage.vue"), 43 | }, 44 | { 45 | name: "node.files", 46 | path: '/nodes/:nodeId/files', 47 | props: true, 48 | component: () => import("./components/pages/NodeFilesPage.vue"), 49 | }, 50 | { 51 | name: "node.settings", 52 | path: '/nodes/:nodeId/settings', 53 | props: true, 54 | component: () => import("./components/pages/settings/NodeSettingsPage.vue"), 55 | }, 56 | { 57 | name: "node.settings.user", 58 | path: '/nodes/:nodeId/settings/user', 59 | props: true, 60 | component: () => import("./components/pages/settings/NodeUserSettingsPage.vue"), 61 | }, 62 | { 63 | name: "node.settings.channels", 64 | path: '/nodes/:nodeId/settings/channels', 65 | props: true, 66 | component: () => import("./components/pages/settings/NodeChannelsSettingsPage.vue"), 67 | }, 68 | { 69 | name: "node.settings.channels.edit", 70 | path: '/nodes/:nodeId/settings/channels/:channelId', 71 | props: true, 72 | component: () => import("./components/pages/settings/NodeChannelSettingsPage.vue"), 73 | }, 74 | { 75 | name: "node.traceroutes", 76 | path: '/nodes/:nodeId/traceroutes', 77 | props: true, 78 | component: () => import("./components/pages/NodeTraceRoutesPage.vue"), 79 | }, 80 | { 81 | name: "node.traceroutes.run", 82 | path: '/nodes/:nodeId/traceroutes/run', 83 | props: true, 84 | component: () => import("./components/pages/NodeRunTraceRoutePage.vue"), 85 | }, 86 | { 87 | name: "traceroute", 88 | path: '/traceroutes/:traceRouteId', 89 | props: true, 90 | component: () => import("./components/pages/TraceRoutePage.vue"), 91 | }, 92 | ]; 93 | 94 | const router = createRouter({ 95 | history: createMemoryHistory(), 96 | routes: routes, 97 | }); 98 | 99 | // preload all route components, so they are available even if the server deploys a new version before the user navigates to a page 100 | for(const route of routes){ 101 | if(typeof route.component === 'function'){ 102 | route.component(); 103 | } 104 | } 105 | 106 | createApp(App) 107 | .use(router) 108 | .use(vClickOutside) 109 | .mount('#app'); 110 | 111 | // disconnect from ble and serial before unloading page (chrome webview on android was crashing without this...) 112 | // don't disconnect from http connection, as this seem to cause an infinite loop issue, and the crash was from ble anyway... 113 | window.addEventListener("beforeunload", () => { 114 | if(GlobalState.connection){ 115 | if(GlobalState.connection instanceof BleConnection || GlobalState.connection instanceof SerialConnection){ 116 | GlobalState.connection.disconnect(); 117 | GlobalState.isConnected = false; 118 | } 119 | } 120 | }); 121 | 122 | // debug access to global state and protobufs 123 | window.GlobalState = GlobalState; 124 | window.Protobuf = Protobuf; 125 | -------------------------------------------------------------------------------- /src/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcottle/meshtxt/b48101bf58f066b9e2c66a333c8ba92aaf3b4713/src/public/icon.png -------------------------------------------------------------------------------- /src/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MeshTXT", 3 | "short_name": "MeshTXT", 4 | "description": "A simple, mobile friendly, web based Meshtastic client.", 5 | "scope": "/", 6 | "start_url": "/", 7 | "icons": [ 8 | { 9 | "src": "icon.png", 10 | "sizes": "512x512", 11 | "type": "image/png" 12 | } 13 | ], 14 | "display": "standalone", 15 | "theme_color": "#FFFFFF", 16 | "background_color": "#FFFFFF" 17 | } 18 | -------------------------------------------------------------------------------- /src/public/protos/file_transfer.proto: -------------------------------------------------------------------------------- 1 | /** 2 | File transfer protos written by Liam Cottle 3 | - sender should send an OfferFileTransfer to recipient 4 | - recipient should send RejectFileTransfer if they don't want this file 5 | - recipient should send RequestFileChunk as many times as needed to receive the file if they want it 6 | - sender should send a FileChunk back when a RequestFileChunk is received 7 | - recipient should send CompletedFileTransfer when they have received the entire file 8 | - sender and recipient can send CancelFileTransfer to tell the other side they are no longer interested in the file 9 | - todo: add fileHash or fileCrc in OfferFileTransfer to allow recipient to confirm assembled parts are not corrupted 10 | */ 11 | syntax = "proto3"; 12 | 13 | message FileTransferPacket { 14 | optional OfferFileTransfer offerFileTransfer = 1; 15 | optional RejectFileTransfer rejectFileTransfer = 3; 16 | optional CancelFileTransfer cancelFileTransfer = 4; 17 | optional CompletedFileTransfer completedFileTransfer = 5; 18 | optional RequestFileChunk requestFileChunk = 8; 19 | optional FileChunk fileChunk = 9; 20 | } 21 | 22 | message OfferFileTransfer { 23 | uint32 id = 1; 24 | string fileName = 2; 25 | uint32 fileSize = 3; 26 | } 27 | 28 | message RejectFileTransfer { 29 | uint32 fileTransferId = 1; 30 | } 31 | 32 | message CancelFileTransfer { 33 | uint32 fileTransferId = 1; 34 | } 35 | 36 | message CompletedFileTransfer { 37 | uint32 fileTransferId = 1; 38 | } 39 | 40 | message RequestFileChunk { 41 | uint32 fileTransferId = 1; 42 | uint32 offset = 2; 43 | uint32 length = 3; 44 | } 45 | 46 | message FileChunk { 47 | uint32 fileTransferId = 1; 48 | uint32 offset = 2; 49 | uint32 length = 3; 50 | bytes data = 4; 51 | } 52 | -------------------------------------------------------------------------------- /src/public/service-worker.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('fetch',function() { 2 | // this is required to meet the requirements for an installable pwa 3 | // it allows the browser to ask the user if they want to install to their homescreen 4 | }); 5 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import formsPlugin from '@tailwindcss/forms'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: [ 6 | "./src/index.html", 7 | "./src/**/*.{vue,js,ts,jsx,tsx}", 8 | ], 9 | theme: { 10 | extend: { 11 | 12 | }, 13 | }, 14 | plugins: [ 15 | formsPlugin, 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import vue from '@vitejs/plugin-vue'; 3 | 4 | export default { 5 | 6 | // vite app is loaded from /src 7 | root: path.join(__dirname, "src"), 8 | 9 | // build to /dist instead of /src/dist 10 | build: { 11 | outDir: '../dist', 12 | emptyOutDir: true, 13 | }, 14 | 15 | // add plugins 16 | plugins: [ 17 | vue(), 18 | ], 19 | 20 | } 21 | --------------------------------------------------------------------------------