@ -0,0 +1,12 @@ |
|||
#!/bin/sh |
|||
|
|||
if [ "$SKIP_SIMPLE_GIT_HOOKS" = "1" ]; then |
|||
echo "[INFO] SKIP_SIMPLE_GIT_HOOKS is set to 1, skipping hook." |
|||
exit 0 |
|||
fi |
|||
|
|||
if [ -f "$SIMPLE_GIT_HOOKS_RC" ]; then |
|||
. "$SIMPLE_GIT_HOOKS_RC" |
|||
fi |
|||
|
|||
cd packages/web && deno task lint:fix && deno task format |
|||
@ -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 |
|||
@ -1,3 +1,3 @@ |
|||
{ |
|||
"recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"] |
|||
"recommendations": ["bradlc.vscode-tailwindcss", "denoland.vscode-deno"] |
|||
} |
|||
|
|||
@ -1,121 +0,0 @@ |
|||
{ |
|||
"name": "@meshtastic/web", |
|||
"version": "2.6.0-0", |
|||
"type": "module", |
|||
"description": "Meshtastic web client", |
|||
"license": "GPL-3.0-only", |
|||
"scripts": { |
|||
"build": "vite build", |
|||
"build:analyze": "BUNDLE_ANALYZE=true deno task build", |
|||
"lint": "deno lint src/", |
|||
"lint:fix": "deno lint --fix src/", |
|||
"format": "deno fmt src/", |
|||
"dev": "deno task dev:ui", |
|||
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev", |
|||
"test": "deno run -A npm:vitest", |
|||
"preview": "deno run -A npm:vite preview", |
|||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ." |
|||
}, |
|||
"repository": { |
|||
"type": "git", |
|||
"url": "git+https://github.com/meshtastic/web.git" |
|||
}, |
|||
"bugs": { |
|||
"url": "https://github.com/meshtastic/web/issues" |
|||
}, |
|||
"simple-git-hooks": { |
|||
"pre-commit": "deno task lint:fix && deno task format" |
|||
}, |
|||
"lint-staged": { |
|||
"*.{ts,tsx}": [ |
|||
"deno task lint:fix", |
|||
"deno task format" |
|||
] |
|||
}, |
|||
"homepage": "https://meshtastic.org", |
|||
"dependencies": { |
|||
"@bufbuild/protobuf": "^2.2.5", |
|||
"@meshtastic/core": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-http": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-web-serial": "npm:@jsr/[email protected]", |
|||
"@noble/curves": "^1.9.0", |
|||
"@radix-ui/react-accordion": "^1.2.8", |
|||
"@radix-ui/react-checkbox": "^1.2.3", |
|||
"@radix-ui/react-dialog": "^1.1.11", |
|||
"@radix-ui/react-dropdown-menu": "^2.1.12", |
|||
"@radix-ui/react-label": "^2.1.4", |
|||
"@radix-ui/react-menubar": "^1.1.12", |
|||
"@radix-ui/react-popover": "^1.1.11", |
|||
"@radix-ui/react-scroll-area": "^1.2.6", |
|||
"@radix-ui/react-select": "^2.2.2", |
|||
"@radix-ui/react-separator": "^1.1.4", |
|||
"@radix-ui/react-slider": "^1.3.2", |
|||
"@radix-ui/react-switch": "^1.2.2", |
|||
"@radix-ui/react-tabs": "^1.1.9", |
|||
"@radix-ui/react-toast": "^1.2.11", |
|||
"@radix-ui/react-toggle-group": "^1.1.9", |
|||
"@radix-ui/react-tooltip": "^1.2.4", |
|||
"@tanstack/react-router": "^1.120.15", |
|||
"@tanstack/react-router-devtools": "^1.120.16", |
|||
"@tanstack/router-devtools": "^1.120.15", |
|||
"@turf/turf": "^7.2.0", |
|||
"base64-js": "^1.5.1", |
|||
"class-variance-authority": "^0.7.1", |
|||
"clsx": "^2.1.1", |
|||
"cmdk": "^1.1.1", |
|||
"crypto-random-string": "^5.0.0", |
|||
"deno": "^2.3.5", |
|||
"i18next": "^25.2.0", |
|||
"i18next-browser-languagedetector": "^8.1.0", |
|||
"i18next-http-backend": "^3.0.2", |
|||
"idb-keyval": "^6.2.1", |
|||
"immer": "^10.1.1", |
|||
"js-cookie": "^3.0.5", |
|||
"lucide-react": "^0.507.0", |
|||
"maplibre-gl": "5.4.0", |
|||
"react": "^19.1.0", |
|||
"react-dom": "^19.1.0", |
|||
"react-error-boundary": "^6.0.0", |
|||
"react-hook-form": "^7.56.2", |
|||
"react-i18next": "^15.5.1", |
|||
"react-map-gl": "8.0.4", |
|||
"react-qrcode-logo": "^3.0.0", |
|||
"rfc4648": "^1.5.4", |
|||
"vite-plugin-i18n-ally": "^6.0.1", |
|||
"vite-plugin-node-polyfills": "^0.23.0", |
|||
"zod": "^3.25.0", |
|||
"zustand": "5.0.4" |
|||
}, |
|||
"devDependencies": { |
|||
"@tailwindcss/postcss": "^4.1.5", |
|||
"@tanstack/router-plugin": "^1.120.15", |
|||
"@testing-library/jest-dom": "^6.6.3", |
|||
"@testing-library/react": "^16.3.0", |
|||
"@testing-library/user-event": "^14.6.1", |
|||
"@types/chrome": "^0.0.318", |
|||
"@types/js-cookie": "^3.0.6", |
|||
"@types/node": "^22.15.3", |
|||
"@types/react": "^19.1.2", |
|||
"@types/react-dom": "^19.1.3", |
|||
"@types/serviceworker": "^0.0.133", |
|||
"@types/w3c-web-serial": "^1.0.8", |
|||
"@types/web-bluetooth": "^0.0.21", |
|||
"@vitejs/plugin-react": "^4.4.1", |
|||
"autoprefixer": "^10.4.21", |
|||
"gzipper": "^8.2.1", |
|||
"happy-dom": "^17.4.6", |
|||
"postcss": "^8.5.3", |
|||
"simple-git-hooks": "^2.13.0", |
|||
"tailwind-merge": "^3.2.0", |
|||
"tailwindcss": "^4.1.5", |
|||
"tailwindcss-animate": "^1.0.7", |
|||
"tar": "^7.4.3", |
|||
"testing-library": "^0.0.2", |
|||
"typescript": "^5.8.3", |
|||
"vite": "^6.3.4", |
|||
"vite-plugin-pwa": "^1.0.0", |
|||
"vite-plugin-static-copy": "^3.0.0", |
|||
"vitest": "^3.1.2" |
|||
} |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
# @meshtastic/core |
|||
|
|||
[](https://jsr.io/@meshtastic/core) |
|||
[](https://github.com/meshtastic/js/actions/workflows/ci.yml) |
|||
[](https://cla-assistant.io/meshtastic/meshtastic.js) |
|||
[](https://opencollective.com/meshtastic/) |
|||
[](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 |
|||
|
|||
 |
|||
@ -0,0 +1,10 @@ |
|||
{ |
|||
"name": "@meshtastic/core", |
|||
"version": "2.6.4", |
|||
"exports": { |
|||
".": "./mod.ts" |
|||
}, |
|||
"imports": { |
|||
"crc": "npm:crc@^4.3.2" |
|||
} |
|||
} |
|||
@ -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"; |
|||
@ -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, |
|||
}; |
|||
@ -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<Uint8Array>; |
|||
fromDevice: ReadableStream<DeviceOutput>; |
|||
} |
|||
|
|||
export interface QueueItem { |
|||
id: number; |
|||
data: Uint8Array; |
|||
sent: boolean; |
|||
added: Date; |
|||
promise: Promise<number>; |
|||
} |
|||
|
|||
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<T> { |
|||
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; |
|||
} |
|||
@ -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<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Text packet has been |
|||
* received from device |
|||
* |
|||
* @event onMessagePacket |
|||
*/ |
|||
public readonly onMessagePacket: SimpleEventDispatcher< |
|||
PacketMetadata<string> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<string> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Remote Hardware packet has |
|||
* been received from device |
|||
* |
|||
* @event onRemoteHardwarePacket |
|||
*/ |
|||
public readonly onRemoteHardwarePacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.RemoteHardware.HardwareMessage> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.RemoteHardware.HardwareMessage> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Position packet has been |
|||
* received from device |
|||
* |
|||
* @event onPositionPacket |
|||
*/ |
|||
public readonly onPositionPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.Position> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.Position> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a User packet has been |
|||
* received from device |
|||
* |
|||
* @event onUserPacket |
|||
*/ |
|||
public readonly onUserPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.User> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.User> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Routing packet has been |
|||
* received from device |
|||
* |
|||
* @event onRoutingPacket |
|||
*/ |
|||
public readonly onRoutingPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.Routing> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.Routing> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when the device receives a Metadata packet |
|||
* |
|||
* @event onDeviceMetadataPacket |
|||
*/ |
|||
public readonly onDeviceMetadataPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.DeviceMetadata> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.DeviceMetadata> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when the device receives a Canned Message Module message packet |
|||
* |
|||
* @event onCannedMessageModulePacket |
|||
*/ |
|||
public readonly onCannedMessageModulePacket: SimpleEventDispatcher< |
|||
PacketMetadata<string> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<string> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Waypoint packet has been |
|||
* received from device |
|||
* |
|||
* @event onWaypointPacket |
|||
*/ |
|||
public readonly onWaypointPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.Waypoint> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.Waypoint> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing an Audio packet has been |
|||
* received from device |
|||
* |
|||
* @event onAudioPacket |
|||
*/ |
|||
public readonly onAudioPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Detection Sensor packet has been |
|||
* received from device |
|||
* |
|||
* @event onDetectionSensorPacket |
|||
*/ |
|||
public readonly onDetectionSensorPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Ping packet has been |
|||
* received from device |
|||
* |
|||
* @event onPingPacket |
|||
*/ |
|||
public readonly onPingPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a IP Tunnel packet has been |
|||
* received from device |
|||
* |
|||
* @event onIpTunnelPacket |
|||
*/ |
|||
public readonly onIpTunnelPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Paxcounter packet has been |
|||
* received from device |
|||
* |
|||
* @event onPaxcounterPacket |
|||
*/ |
|||
public readonly onPaxcounterPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.PaxCount.Paxcount> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.PaxCount.Paxcount> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Serial packet has been |
|||
* received from device |
|||
* |
|||
* @event onSerialPacket |
|||
*/ |
|||
public readonly onSerialPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Store and Forward packet |
|||
* has been received from device |
|||
* |
|||
* @event onStoreForwardPacket |
|||
*/ |
|||
public readonly onStoreForwardPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Store and Forward packet |
|||
* has been received from device |
|||
* |
|||
* @event onRangeTestPacket |
|||
*/ |
|||
public readonly onRangeTestPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Telemetry packet has been |
|||
* received from device |
|||
* |
|||
* @event onTelemetryPacket |
|||
*/ |
|||
public readonly onTelemetryPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Telemetry.Telemetry> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Telemetry.Telemetry> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a ZPS packet has been |
|||
* received from device |
|||
* |
|||
* @event onZPSPacket |
|||
*/ |
|||
public readonly onZpsPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Simulator packet has been |
|||
* received from device |
|||
* |
|||
* @event onSimulatorPacket |
|||
*/ |
|||
public readonly onSimulatorPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Trace Route packet has been |
|||
* received from device |
|||
* |
|||
* @event onTraceRoutePacket |
|||
*/ |
|||
public readonly onTraceRoutePacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.RouteDiscovery> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.RouteDiscovery> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Neighbor Info packet has been |
|||
* received from device |
|||
* |
|||
* @event onNeighborInfoPacket |
|||
*/ |
|||
public readonly onNeighborInfoPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.NeighborInfo> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Protobuf.Mesh.NeighborInfo> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing an ATAK packet has been |
|||
* received from device |
|||
* |
|||
* @event onAtakPluginPacket |
|||
*/ |
|||
public readonly onAtakPluginPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Map Report packet has been |
|||
* received from device |
|||
* |
|||
* @event onMapReportPacket |
|||
*/ |
|||
public readonly onMapReportPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing a Private packet has been |
|||
* received from device |
|||
* |
|||
* @event onPrivatePacket |
|||
*/ |
|||
public readonly onPrivatePacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* Fires when a new MeshPacket message containing an ATAK Forwarder packet has been |
|||
* received from device |
|||
* |
|||
* @event onAtakForwarderPacket |
|||
*/ |
|||
public readonly onAtakForwarderPacket: SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
> = new SimpleEventDispatcher< |
|||
PacketMetadata<Uint8Array> |
|||
>(); |
|||
|
|||
/** |
|||
* 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<Date> = |
|||
new SimpleEventDispatcher<Date>(); |
|||
|
|||
/** |
|||
* Outputs any debug log data (currently serial connections only) |
|||
* |
|||
* @event onDeviceDebugLog |
|||
*/ |
|||
public readonly onDeviceDebugLog: SimpleEventDispatcher<Uint8Array> = |
|||
new SimpleEventDispatcher<Uint8Array>(); |
|||
|
|||
/** |
|||
* 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 |
|||
>(); |
|||
} |
|||
@ -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"; |
|||
@ -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<number>(); |
|||
private errorNotifier = new SimpleEventDispatcher<PacketError>(); |
|||
private timeout: number; |
|||
|
|||
constructor() { |
|||
this.timeout = 60000; |
|||
} |
|||
|
|||
public getState(): QueueItem[] { |
|||
return this.queue; |
|||
} |
|||
|
|||
public clear(): void { |
|||
this.queue = []; |
|||
} |
|||
|
|||
public push(item: Omit<QueueItem, "promise" | "sent" | "added">): void { |
|||
const queueItem: QueueItem = { |
|||
...item, |
|||
sent: false, |
|||
added: new Date(), |
|||
promise: new Promise<number>((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<number> { |
|||
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<Uint8Array>, |
|||
): Promise<void> { |
|||
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; |
|||
} |
|||
} |
|||
} |
|||
@ -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<DeviceOutput>({ |
|||
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}`, |
|||
); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
}); |
|||
@ -0,0 +1,73 @@ |
|||
import type { DeviceOutput } from "../../types.ts"; |
|||
|
|||
export const fromDeviceStream: () => TransformStream<Uint8Array, DeviceOutput> = |
|||
( |
|||
// onReleaseEvent: SimpleEventDispatcher<boolean>,
|
|||
) => { |
|||
let byteBuffer = new Uint8Array([]); |
|||
const textDecoder = new TextDecoder(); |
|||
return new TransformStream<Uint8Array, DeviceOutput>({ |
|||
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; |
|||
} |
|||
} |
|||
}, |
|||
}); |
|||
}; |
|||
@ -0,0 +1,16 @@ |
|||
/** |
|||
* Pads packets with appropriate framing information before writing to the output stream. |
|||
*/ |
|||
export const toDeviceStream: TransformStream<Uint8Array, Uint8Array> = |
|||
new TransformStream<Uint8Array, Uint8Array>({ |
|||
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])); |
|||
}, |
|||
}); |
|||
@ -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<number>; |
|||
|
|||
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<number> { |
|||
return await this.sendCommand( |
|||
Protobuf.Xmodem.XModem_Control.STX, |
|||
this.textEncoder.encode(filename), |
|||
0, |
|||
); |
|||
} |
|||
|
|||
async uploadFile(filename: string, data: Uint8Array): Promise<number> { |
|||
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<number> { |
|||
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<number> { |
|||
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 = []; |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
# @meshtastic/transport-deno |
|||
|
|||
[](https://jsr.io/@meshtastic/transport-deno) |
|||
[](https://github.com/meshtastic/js/actions/workflows/ci.yml) |
|||
[](https://cla-assistant.io/meshtastic/meshtastic.js) |
|||
[](https://opencollective.com/meshtastic/) |
|||
[](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 |
|||
|
|||
 |
|||
@ -0,0 +1,7 @@ |
|||
{ |
|||
"name": "@meshtastic/transport-deno", |
|||
"version": "0.1.1", |
|||
"exports": { |
|||
".": "./mod.ts" |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export { TransportDeno } from "./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<Uint8Array>; |
|||
private _fromDevice: ReadableStream<Types.DeviceOutput>; |
|||
|
|||
public static async create(hostname: string): Promise<TransportDeno> { |
|||
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<Uint8Array> { |
|||
return this._toDevice; |
|||
} |
|||
|
|||
get fromDevice(): ReadableStream<Types.DeviceOutput> { |
|||
return this._fromDevice; |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
# @meshtastic/transport-http |
|||
|
|||
[](https://jsr.io/@meshtastic/transport-http) |
|||
[](https://github.com/meshtastic/js/actions/workflows/ci.yml) |
|||
[](https://cla-assistant.io/meshtastic/meshtastic.js) |
|||
[](https://opencollective.com/meshtastic/) |
|||
[](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 |
|||
|
|||
 |
|||
@ -0,0 +1,7 @@ |
|||
{ |
|||
"name": "@meshtastic/transport-http", |
|||
"version": "0.2.1", |
|||
"exports": { |
|||
".": "./mod.ts" |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export { TransportHTTP } from "./src/transport.ts"; |
|||
@ -0,0 +1,89 @@ |
|||
import type { Types } from "@meshtastic/core"; |
|||
|
|||
export class TransportHTTP implements Types.Transport { |
|||
private _toDevice: WritableStream<Uint8Array>; |
|||
private _fromDevice: ReadableStream<Types.DeviceOutput>; |
|||
private url: string; |
|||
private receiveBatchRequests: boolean; |
|||
private fetchInterval: number; |
|||
|
|||
public static async create( |
|||
address: string, |
|||
tls?: boolean, |
|||
): Promise<TransportHTTP> { |
|||
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<Uint8Array>({ |
|||
write: async (chunk) => { |
|||
await this.writeToRadio(chunk); |
|||
}, |
|||
}); |
|||
|
|||
let controller: ReadableStreamDefaultController<Types.DeviceOutput>; |
|||
|
|||
this._fromDevice = new ReadableStream<Types.DeviceOutput>({ |
|||
start: (ctrl) => { |
|||
controller = ctrl; |
|||
}, |
|||
}); |
|||
|
|||
setInterval(async () => { |
|||
await this.readFromRadio(controller); |
|||
}, this.fetchInterval); |
|||
} |
|||
|
|||
private async readFromRadio( |
|||
controller: ReadableStreamDefaultController<Types.DeviceOutput>, |
|||
): Promise<void> { |
|||
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<void> { |
|||
await fetch(`${this.url}/api/v1/toradio`, { |
|||
method: "PUT", |
|||
headers: { |
|||
"Content-Type": "application/x-protobuf", |
|||
}, |
|||
body: data, |
|||
}); |
|||
} |
|||
|
|||
get toDevice(): WritableStream<Uint8Array> { |
|||
return this._toDevice; |
|||
} |
|||
|
|||
get fromDevice(): ReadableStream<Types.DeviceOutput> { |
|||
return this._fromDevice; |
|||
} |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
# @meshtastic/transport-web-bluetooth |
|||
|
|||
[](https://jsr.io/@meshtastic/transport-web-bluetooth) |
|||
[](https://github.com/meshtastic/js/actions/workflows/ci.yml) |
|||
[](https://cla-assistant.io/meshtastic/meshtastic.js) |
|||
[](https://opencollective.com/meshtastic/) |
|||
[](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 |
|||
|
|||
 |
|||
|
|||
### Compatibility |
|||
|
|||
The Web Bluetooth API's have limited support in browsers, compatibility is |
|||
represented in the matrix below. |
|||
|
|||
 |
|||
@ -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" |
|||
] |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export { TransportWebBluetooth } from "./src/transport.ts"; |
|||
@ -0,0 +1,134 @@ |
|||
import type { Types } from "@meshtastic/core"; |
|||
|
|||
export class TransportWebBluetooth implements Types.Transport { |
|||
private _toDevice: WritableStream<Uint8Array>; |
|||
private _fromDevice: ReadableStream<Types.DeviceOutput>; |
|||
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<TransportWebBluetooth> { |
|||
const device = await navigator.bluetooth.requestDevice({ |
|||
filters: [{ services: [this.ServiceUuid] }], |
|||
}); |
|||
return await this.prepareConnection(device); |
|||
} |
|||
|
|||
public static async createFromDevice( |
|||
device: BluetoothDevice, |
|||
): Promise<TransportWebBluetooth> { |
|||
return await this.prepareConnection(device); |
|||
} |
|||
|
|||
public static async prepareConnection( |
|||
device: BluetoothDevice, |
|||
): Promise<TransportWebBluetooth> { |
|||
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<Uint8Array> { |
|||
return this._toDevice; |
|||
} |
|||
|
|||
get fromDevice(): ReadableStream<Types.DeviceOutput> { |
|||
return this._fromDevice; |
|||
} |
|||
|
|||
protected async readFromRadio( |
|||
controller: ReadableStreamDefaultController<Types.DeviceOutput>, |
|||
): Promise<void> { |
|||
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), |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
# @meshtastic/transport-web-serial |
|||
|
|||
[](https://jsr.io/@meshtastic/transport-web-serial) |
|||
[](https://github.com/meshtastic/js/actions/workflows/ci.yml) |
|||
[](https://cla-assistant.io/meshtastic/meshtastic.js) |
|||
[](https://opencollective.com/meshtastic/) |
|||
[](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 |
|||
|
|||
 |
|||
|
|||
### Compatibility |
|||
|
|||
The Web Serial API's have limited support in browsers, compatibility is |
|||
represented in the matrix below. |
|||
|
|||
 |
|||
@ -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" |
|||
] |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export { TransportWebSerial } from "./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<Uint8Array>; |
|||
private _fromDevice: ReadableStream<Types.DeviceOutput>; |
|||
|
|||
public static async create(baudRate?: number): Promise<TransportWebSerial> { |
|||
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<TransportWebSerial> { |
|||
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<Uint8Array> { |
|||
return this._toDevice; |
|||
} |
|||
|
|||
get fromDevice(): ReadableStream<Types.DeviceOutput> { |
|||
return this._fromDevice; |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
@jsr:registry=https://npm.jsr.io |
|||
@ -0,0 +1,111 @@ |
|||
# i18n Developer Guide |
|||
|
|||
When developing new components, all user-facing text must be added as an i18n |
|||
key and rendered using our translation functions. This ensures your UI can be |
|||
translated into multiple languages. |
|||
|
|||
## Adding New i18n Keys |
|||
|
|||
### Search Before Creating |
|||
|
|||
Before adding a new key, please perform a quick search to see if one that fits |
|||
your needs already exists. Many common labels like "Save," "Cancel," "Name," |
|||
"Description," "Loading...," or "Error" are likely already present, especially |
|||
in the common.json namespace. Reusing existing keys prevents duplication and |
|||
ensures consistency across the application. Using your code editor's search |
|||
function across the /i18n/locales/en/ directory is an effective way to do this. |
|||
|
|||
### Key Naming and Structure Rules |
|||
|
|||
To maintain consistency and ease of use, please adhere to the following rules |
|||
when creating new keys in the JSON files. |
|||
|
|||
- **Keys are camelCase:** `exampleKey`, `anotherExampleKey`. |
|||
- **Avoid Deep Nesting:** One or two levels of nesting are acceptable for |
|||
grouping related keys (e.g., all labels for a specific menu). However, nesting |
|||
deeper than two levels should be avoided to maintain readability and ease of |
|||
use. |
|||
- **Good (1 level):** |
|||
```json |
|||
"buttons": { |
|||
"save": "Save", |
|||
"cancel": "Cancel" |
|||
} |
|||
``` |
|||
- **Acceptable (2 levels):** |
|||
```json |
|||
"userMenu": { |
|||
"items": { |
|||
"profile": "Profile", |
|||
"settings": "Settings" |
|||
} |
|||
} |
|||
``` |
|||
- **Avoid (3+ levels):** |
|||
```json |
|||
"userMenu": { |
|||
"items": { |
|||
"actions": { |
|||
"viewProfile": "View Profile" |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
- **Organize for Retrieval, Not UI Layout:** Keys should be named logically for |
|||
easy retrieval, not to mirror the layout of your component. |
|||
|
|||
### Namespace Rules |
|||
|
|||
We use namespaces to organize keys. All source keys are added to the English |
|||
(`en`) files located at `/i18n/locales/en/`. Place your new keys in the |
|||
appropriate file based on these rules: |
|||
|
|||
- `common.json`: |
|||
- All button labels (`save`, `cancel`, `submit`, etc.). |
|||
- Any text that is repeated and used throughout the application (e.g., |
|||
"Loading...", "Error"). |
|||
- `ui.json`: |
|||
- Labels and text specific to a distinct UI element or view that isn't a |
|||
dialog or a config page. |
|||
- `dialog.json`: |
|||
- All text specific to modal dialogs (titles, body text, prompts). |
|||
- `messages.json`: |
|||
- Text specifically related to the messaging interface. |
|||
- `deviceConfig.json` & `moduleConfig.json`: |
|||
- Labels and descriptions for the settings on the Device and Module |
|||
configuration pages. |
|||
|
|||
## Using i18n Keys in Components |
|||
|
|||
We use the `useTranslation` hook from `react-i18next` to access the translation |
|||
function, `t`. |
|||
|
|||
### Default Namespaces |
|||
|
|||
Our i18next configuration has fallback namespaces configured which includes |
|||
`common`, `ui`, and `dialog`. This means you **do not** need to explicitly |
|||
specify these namespaces when calling the hook. The system will automatically |
|||
check these files for your key. |
|||
|
|||
For any keys in `common.json`, `ui.json`, or `dialog.json`, you can instantiate |
|||
the hook simply: |
|||
|
|||
```typescript |
|||
import { useTranslation } from "react-i18next"; |
|||
|
|||
// In your component |
|||
const { t } = useTranslation(["messages"]); |
|||
|
|||
// Usage |
|||
return <p>{t("someMessageLabel")}</p>; |
|||
``` |
|||
|
|||
You can also specify the namespace on a per-call basis using the options object. |
|||
This is useful if a component primarily uses a default namespace but needs a |
|||
single key from another. |
|||
|
|||
```typescript |
|||
const { t } = useTranslation(); |
|||
|
|||
return <p>{t("someMessageLabel", { ns: "messages" })}</p>; |
|||
``` |
|||
@ -0,0 +1,31 @@ |
|||
# Contributing Translations |
|||
|
|||
Thank you for your interest in making the Meshtastic Web Client accessible to a |
|||
global audience! Your translation efforts are greatly appreciated. |
|||
|
|||
## Our Translation Platform: Crowdin |
|||
|
|||
We manage all our translations through a platform called |
|||
[Crowdin](https://crowdin.com/). This allows for a collaborative and streamlined |
|||
translation process. All translation work should be done on our Crowdin project, |
|||
not directly in the code repository via Pull Requests. |
|||
|
|||
### How to Get Started |
|||
|
|||
1. **Create a Crowdin Account:** If you don't already have one, sign up for a |
|||
free account on Crowdin. |
|||
2. **Join Our Project:** Please ask for a link to our specific Crowdin project |
|||
on the Meshtastic Discord. |
|||
3. **Request Translator Role:** Once you have an account, join the Meshtastic |
|||
Discord and notify an admin in the `#web` channel. They will grant you the |
|||
necessary permissions to start translating. |
|||
4. **Start Translating:** Once you have your role, you can begin translating the |
|||
source labels into your native language directly on the Crowdin platform. |
|||
|
|||
### Language Activation |
|||
|
|||
A new language will only be added to the web client and appear in the language |
|||
picker once its translation is 100% complete on Crowdin. The repository |
|||
maintainers will handle this process once the milestone is reached. |
|||
|
|||
Thank you for helping us bring Meshtastic to more users around the world! |
|||
@ -0,0 +1,175 @@ |
|||
# Meshtastic Web |
|||
|
|||
<!--Project specific badges here--> |
|||
|
|||
[](https://github.com/meshtastic/web/actions/workflows/ci.yml) |
|||
[](https://cla-assistant.io/meshtastic/web) |
|||
[](https://opencollective.com/meshtastic/) |
|||
[](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) |
|||
|
|||
## Overview |
|||
|
|||
Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted |
|||
or served from a node |
|||
|
|||
**[Hosted version](https://client.meshtastic.org)** |
|||
|
|||
## Stats |
|||
|
|||
 |
|||
|
|||
## Progress Web App Support (PWA) |
|||
|
|||
Meshtastic Web Client now includes Progressive Web App (PWA) functionality, |
|||
allowing users to: |
|||
|
|||
- Install the app on desktop and mobile devices |
|||
- Access the interface offline |
|||
- Receive updates automatically |
|||
- Experience faster load times with caching |
|||
|
|||
To install as a PWA: |
|||
|
|||
- On desktop: Look for the install icon in your browser's address bar |
|||
- On mobile: Use "Add to Home Screen" option in your browser menu |
|||
|
|||
PWA functionality works with both the hosted version and self-hosted instances. |
|||
|
|||
## Self-host |
|||
|
|||
The client can be self hosted using the precompiled container images with an OCI |
|||
compatible runtime such as [Docker](https://www.docker.com/) or |
|||
[Podman](https://podman.io/). The base image used is |
|||
[Nginx 1.27](https://hub.docker.com/_/nginx) |
|||
|
|||
```bash |
|||
# With Docker |
|||
docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web |
|||
|
|||
#With Podman |
|||
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web |
|||
``` |
|||
|
|||
## Release Schedule |
|||
|
|||
Our release process follows these guidelines: |
|||
|
|||
- **Versioning:** We use Semantic Versioning (`Major.Minor.Patch`). |
|||
- **Stable Releases:** Published around the beginning of each month (e.g., |
|||
`v2.3.4`). |
|||
- **Pre-releases:** A pre-release is typically issued mid-month for testing and |
|||
early adoption. |
|||
- **Nightly Builds:** An experimental Docker image containing the latest |
|||
cutting-edge features and fixes is automatically built nightly from the `main` |
|||
branch. |
|||
|
|||
### Nightly Builds |
|||
|
|||
```bash |
|||
# With Docker |
|||
docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web:nightly |
|||
#With Podman |
|||
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web:nightly |
|||
``` |
|||
|
|||
> [!WARNING] |
|||
> |
|||
> - Nightly builds represent the latest development state and may contain |
|||
> breaking changes |
|||
> - These builds undergo automated testing but may be less stable than tagged |
|||
> release versions |
|||
> - Not recommended for production environments unless you are actively testing |
|||
> new features |
|||
> - No guarantee of backward compatibility between nightly builds |
|||
|
|||
#### Version Information |
|||
|
|||
Each nightly build is tagged with: |
|||
|
|||
- The nightly tag for the latest build |
|||
- A specific SHA for build reproducibility |
|||
|
|||
### 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 |
|||
|
|||
## Development & Building |
|||
|
|||
You'll need to download the package manager used with this repo. You can install |
|||
it by visiting [deno.com](https://deno.com/) and following the installation |
|||
instructions listed on the home page. |
|||
|
|||
### Development |
|||
|
|||
Install the dependencies. |
|||
|
|||
```bash |
|||
deno i |
|||
``` |
|||
|
|||
Start the development server: |
|||
|
|||
```bash |
|||
deno task dev |
|||
``` |
|||
|
|||
### Building and Packaging |
|||
|
|||
Build the project: |
|||
|
|||
```bash |
|||
deno task build |
|||
``` |
|||
|
|||
GZip the output: |
|||
|
|||
```bash |
|||
deno task package |
|||
``` |
|||
|
|||
### 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! |
|||
@ -0,0 +1,58 @@ |
|||
{ |
|||
"imports": { |
|||
"@app/": "./src/", |
|||
"@public/": "./public/", |
|||
"@pages/": "./src/pages/", |
|||
"@components/": "./src/components/", |
|||
"@core/": "./src/core/", |
|||
"@layouts/": "./src/layouts/", |
|||
"@std/path": "jsr:@std/path@^1.1.0" |
|||
}, |
|||
"tasks": { |
|||
"build": "vite build", |
|||
"build:analyze": "BUNDLE_ANALYZE=true deno task build", |
|||
"lint": "deno lint src/", |
|||
"lint:fix": "deno lint --fix src/", |
|||
"format": "deno fmt src/", |
|||
"dev": "deno task dev:ui", |
|||
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev", |
|||
"test": "deno run -A npm:vitest", |
|||
"check": "deno check", |
|||
"preview": "deno run -A npm:vite preview", |
|||
"generate:routes": "deno run -A npm:@tanstack/router-cli generate --outDir src/ routes --rootRoutePath /", |
|||
"package": "gzipper c -i html,js,css,png,ico,svg,json,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ." |
|||
}, |
|||
"include": ["src", "./vite-env.d.ts"], |
|||
"compilerOptions": { |
|||
"lib": [ |
|||
"DOM", |
|||
"DOM.Iterable", |
|||
"ESNext", |
|||
"deno.window", |
|||
"deno.ns" |
|||
], |
|||
"jsx": "react-jsx", |
|||
"strict": true, |
|||
"noUnusedLocals": true, |
|||
"noUnusedParameters": true, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"strictNullChecks": true, |
|||
"types": [ |
|||
"vite/client", |
|||
"node", |
|||
"npm:@types/w3c-web-serial", |
|||
"npm:@types/web-bluetooth" |
|||
], |
|||
"strictPropertyInitialization": false |
|||
}, |
|||
|
|||
"exclude": [ |
|||
"routeTree.gen.ts", |
|||
"node_modules/", |
|||
"dist", |
|||
"build", |
|||
"coverage", |
|||
"out", |
|||
".vscode-test" |
|||
] |
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
{ |
|||
"name": "meshtastic-web", |
|||
"version": "2.7.0-0", |
|||
"type": "module", |
|||
"description": "Meshtastic web client", |
|||
"license": "GPL-3.0-only", |
|||
|
|||
"repository": { |
|||
"type": "git", |
|||
"url": "git+https://github.com/meshtastic/web.git" |
|||
}, |
|||
"bugs": { |
|||
"url": "https://github.com/meshtastic/web/issues" |
|||
}, |
|||
"simple-git-hooks": { |
|||
"pre-commit": "deno task lint:fix && deno task format" |
|||
}, |
|||
"homepage": "https://meshtastic.org", |
|||
"dependencies": { |
|||
"@bufbuild/protobuf": "^2.6.0", |
|||
"@hookform/resolvers": "^5.1.1", |
|||
"@meshtastic/core": "npm:@jsr/[email protected]", |
|||
"@meshtastic/js": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
|||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", |
|||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", |
|||
"@noble/curves": "^1.9.2", |
|||
"@radix-ui/react-accordion": "^1.2.11", |
|||
"@radix-ui/react-checkbox": "^1.3.2", |
|||
"@radix-ui/react-dialog": "^1.1.14", |
|||
"@radix-ui/react-dropdown-menu": "^2.1.15", |
|||
"@radix-ui/react-label": "^2.1.7", |
|||
"@radix-ui/react-menubar": "^1.1.15", |
|||
"@radix-ui/react-popover": "^1.1.14", |
|||
"@radix-ui/react-scroll-area": "^1.2.9", |
|||
"@radix-ui/react-select": "^2.2.5", |
|||
"@radix-ui/react-separator": "^1.1.7", |
|||
"@radix-ui/react-slider": "^1.3.5", |
|||
"@radix-ui/react-switch": "^1.2.5", |
|||
"@radix-ui/react-tabs": "^1.1.12", |
|||
"@radix-ui/react-toast": "^1.2.14", |
|||
"@radix-ui/react-toggle-group": "^1.1.10", |
|||
"@radix-ui/react-tooltip": "^1.2.7", |
|||
"@tanstack/react-router": "^1.125.4", |
|||
"@tanstack/react-router-devtools": "^1.125.4", |
|||
"@tanstack/router-cli": "^1.125.4", |
|||
"@tanstack/router-devtools": "^1.125.4", |
|||
"@turf/turf": "^7.2.0", |
|||
"@types/node": "^24.0.10", |
|||
"@types/web-bluetooth": "^0.0.21", |
|||
"base64-js": "^1.5.1", |
|||
"class-variance-authority": "^0.7.1", |
|||
"clsx": "^2.1.1", |
|||
"cmdk": "^1.1.1", |
|||
"crypto-random-string": "^5.0.0", |
|||
"i18next": "^25.3.1", |
|||
"i18next-browser-languagedetector": "^8.2.0", |
|||
"i18next-http-backend": "^3.0.2", |
|||
"idb-keyval": "^6.2.2", |
|||
"immer": "^10.1.1", |
|||
"js-cookie": "^3.0.5", |
|||
"lucide-react": "^0.525.0", |
|||
"maplibre-gl": "5.6.1", |
|||
"react": "^19.1.0", |
|||
"react-dom": "^19.1.0", |
|||
"react-error-boundary": "^6.0.0", |
|||
"react-hook-form": "^7.60.0", |
|||
"react-i18next": "^15.6.0", |
|||
"react-map-gl": "8.0.4", |
|||
"react-qrcode-logo": "^3.0.0", |
|||
"rfc4648": "^1.5.4", |
|||
"zod": "^3.25.75", |
|||
"zustand": "5.0.6" |
|||
}, |
|||
"devDependencies": { |
|||
"@tailwindcss/postcss": "^4.1.11", |
|||
"@tanstack/router-plugin": "^1.125.5", |
|||
"@testing-library/jest-dom": "^6.6.3", |
|||
"@testing-library/react": "^16.3.0", |
|||
"@testing-library/user-event": "^14.6.1", |
|||
"@types/chrome": "^0.0.329", |
|||
"@types/react": "^19.1.8", |
|||
"@types/react-dom": "^19.1.6", |
|||
"@types/serviceworker": "^0.0.140", |
|||
"@types/js-cookie": "^3.0.6", |
|||
"@types/w3c-web-serial": "^1.0.8", |
|||
"@vitejs/plugin-react": "^4.6.0", |
|||
"autoprefixer": "^10.4.21", |
|||
"gzipper": "^8.2.1", |
|||
"happy-dom": "^18.0.1", |
|||
"postcss": "^8.5.6", |
|||
"simple-git-hooks": "^2.13.0", |
|||
"tailwind-merge": "^3.3.1", |
|||
"tailwindcss": "^4.1.11", |
|||
"tailwindcss-animate": "^1.0.7", |
|||
"tar": "^7.4.3", |
|||
"testing-library": "^0.0.2", |
|||
"typescript": "^5.8.3", |
|||
"vite": "^7.0", |
|||
"vitest": "^3.2.4" |
|||
} |
|||
} |
|||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.4 KiB |