Browse Source

Merge branch 'meshtastic:main' into persistent-nodedb

pull/780/head
Jeremy Gallant 11 months ago
committed by GitHub
parent
commit
4d14b08ded
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 15
      .github/workflows/nightly.yml
  2. 2
      .github/workflows/pr.yml
  3. 17
      .github/workflows/release-packages.yml
  4. 19
      .github/workflows/release-web.yml
  5. 2
      package.json
  6. 3
      packages/transport-deno/scripts/build_npm.ts
  7. 9
      packages/transport-node-serial/src/transport.ts
  8. 1
      packages/transport-web-bluetooth/src/transport.ts
  9. 2
      packages/transport-web-serial/package.json
  10. 59
      packages/transport-web-serial/src/transport.ts
  11. 3
      packages/web/package.json
  12. 3
      packages/web/src/App.tsx
  13. 2
      packages/web/src/components/CommandPalette/index.tsx
  14. 17
      packages/web/src/components/UI/Footer.tsx
  15. 22
      packages/web/src/core/hooks/useFeatureFlags.ts
  16. 10
      packages/web/src/core/services/dev-overrides.ts
  17. 89
      packages/web/src/core/services/featureFlags.ts
  18. 749
      pnpm-lock.yaml

15
.github/workflows/nightly.yml

@ -17,10 +17,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
# Node + pnpm (with built-in cache)
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@ -28,12 +32,7 @@ jobs:
cache: pnpm cache: pnpm
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup pnpm - name: Install dependencies (root)
uses: pnpm/action-setup@v4
with:
version: latest
- name: Install deps (root)
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Run tests - name: Run tests

2
.github/workflows/pr.yml

@ -36,7 +36,7 @@ jobs:
cache: pnpm cache: pnpm
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies (root) - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Run linter - name: Run linter

17
.github/workflows/release-packages.yml

@ -18,18 +18,20 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
# Node + pnpm (with cache) - name: Setup pnpm
- name: Setup Node uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup pnpm - name: Install dependencies
uses: pnpm/action-setup@v4 run: pnpm install --frozen-lockfile
with:
version: latest
- name: Configure npm auth - name: Configure npm auth
env: env:
@ -38,9 +40,6 @@ jobs:
pnpm config set //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} pnpm config set //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
pnpm config set registry https://registry.npmjs.org/ pnpm config set registry https://registry.npmjs.org/
- name: Install deps (root)
run: pnpm install
- name: Resolve package list - name: Resolve package list
id: pkgs id: pkgs
shell: bash shell: bash

19
.github/workflows/release-web.yml

@ -62,7 +62,11 @@ jobs:
echo "push_latest=$PUSH_LATEST" >> "$GITHUB_OUTPUT" echo "push_latest=$PUSH_LATEST" >> "$GITHUB_OUTPUT"
echo "Resolved tag: $TAG (push_latest=$PUSH_LATEST)" echo "Resolved tag: $TAG (push_latest=$PUSH_LATEST)"
# --- Setup Node.js and pnpm (with cache) --- - name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@ -70,14 +74,17 @@ jobs:
cache: pnpm cache: pnpm
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Build web package
working-directory: packages/web
run: pnpm run build
- name: Build web package
working-directory: packages/web
run: pnpm run build
- name: Create Web App Release Archive - name: Create Web App Release Archive
working-directory: packages/web working-directory: packages/web
run: pnpm run package run: pnpm run package

2
package.json

@ -36,7 +36,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.16.4", "@types/node": "^22.16.4",
"biome": "^0.3.3", "@biomejs/biome": "2.0.6",
"tsdown": "^0.13.4", "tsdown": "^0.13.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vitest": "^3.2.4" "vitest": "^3.2.4"

3
packages/transport-deno/scripts/build_npm.ts

@ -13,7 +13,8 @@ await build({
// package.json properties // package.json properties
name: "@meshtastic/transport-deno", name: "@meshtastic/transport-deno",
version: Deno.args[0], version: Deno.args[0],
description: "A Deno transport layer for your project, enabling seamless integration with npm.", description:
"A Deno transport layer for your project, enabling seamless integration with npm.",
license: "GPL-3.0-only", license: "GPL-3.0-only",
repository: { repository: {
type: "git", type: "git",

9
packages/transport-node-serial/src/transport.ts

@ -14,7 +14,10 @@ export class TransportNodeSerial implements Types.Transport {
* @param baudRate - The port number for the TCP connection (defaults to 4403). * @param baudRate - The port number for the TCP connection (defaults to 4403).
* @returns A promise that resolves with a connected TransportNode instance. * @returns A promise that resolves with a connected TransportNode instance.
*/ */
public static create(path: string, baudRate = 115200): Promise<TransportNodeSerial> { public static create(
path: string,
baudRate = 115200,
): Promise<TransportNodeSerial> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const port = new SerialPort({ const port = new SerialPort({
path, path,
@ -45,9 +48,7 @@ export class TransportNodeSerial implements Types.Transport {
console.error("Serial port connection error:", err); console.error("Serial port connection error:", err);
}); });
const fromDeviceSource = Readable.toWeb( const fromDeviceSource = Readable.toWeb(port) as ReadableStream<Uint8Array>;
port,
) as ReadableStream<Uint8Array>;
this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream()); this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
// Stream for data going FROM the application TO the Meshtastic device. // Stream for data going FROM the application TO the Meshtastic device.

1
packages/transport-web-bluetooth/src/transport.ts

@ -95,6 +95,7 @@ export class TransportWebBluetooth implements Types.Transport {
this._isFirstWrite = false; this._isFirstWrite = false;
setTimeout(() => { setTimeout(() => {
// biome-ignore lint/style/noNonNullAssertion: we know this will be set
this.readFromRadio(this._fromDeviceController!); this.readFromRadio(this._fromDeviceController!);
}, 50); }, 50);
} }

2
packages/transport-web-serial/package.json

@ -1,6 +1,6 @@
{ {
"name": "@meshtastic/transport-web-serial", "name": "@meshtastic/transport-web-serial",
"version": "0.2.3-1", "version": "0.2.4-0",
"description": "A transport layer for Meshtastic applications using Web Serial API.", "description": "A transport layer for Meshtastic applications using Web Serial API.",
"exports": { "exports": {
".": "./mod.ts" ".": "./mod.ts"

59
packages/transport-web-serial/src/transport.ts

@ -5,6 +5,8 @@ export class TransportWebSerial implements Types.Transport {
private _toDevice: WritableStream<Uint8Array>; private _toDevice: WritableStream<Uint8Array>;
private _fromDevice: ReadableStream<Types.DeviceOutput>; private _fromDevice: ReadableStream<Types.DeviceOutput>;
private connection: SerialPort; private connection: SerialPort;
private pipePromise: Promise<void> | null = null;
private abortController: AbortController;
public static async create(baudRate?: number): Promise<TransportWebSerial> { public static async create(baudRate?: number): Promise<TransportWebSerial> {
const port = await navigator.serial.requestPort(); const port = await navigator.serial.requestPort();
@ -26,8 +28,13 @@ export class TransportWebSerial implements Types.Transport {
} }
this.connection = connection; this.connection = connection;
this.abortController = new AbortController();
Utils.toDeviceStream.readable.pipeTo(connection.writable); // Set up the pipe with abort signal for clean cancellation
this.pipePromise = Utils.toDeviceStream.readable.pipeTo(
connection.writable,
{ signal: this.abortController.signal },
);
this._toDevice = Utils.toDeviceStream.writable; this._toDevice = Utils.toDeviceStream.writable;
this._fromDevice = connection.readable.pipeThrough( this._fromDevice = connection.readable.pipeThrough(
@ -43,7 +50,53 @@ export class TransportWebSerial implements Types.Transport {
return this._fromDevice; return this._fromDevice;
} }
disconnect() { /**
return this.connection.close(); * Safely disconnects the serial port, following best practices from
* https://github.com/WICG/serial/. Cancels any active pipe
* operations and only closes the port after streams are unlocked.
*/
async disconnect() {
try {
this.abortController.abort();
if (this.pipePromise) {
try {
await this.pipePromise;
} catch (error) {
if (error instanceof Error && error.name !== "AbortError") {
throw error;
}
}
}
// Cancel any remaining streams
if (this._fromDevice?.locked) {
try {
await this._fromDevice.cancel();
} catch {
// Stream cancellation might fail if already cancelled
}
}
await this.connection.close();
} catch (error) {
// If we can't close cleanly, let the browser handle cleanup
console.warn("Could not cleanly disconnect serial port:", error);
}
}
/**
* Reconnects the transport by creating a new AbortController and re-establishing
* the pipe connection. Only call this after disconnect() or if the connection failed.
*/
async reconnect() {
// Create a new AbortController for the new connection
this.abortController = new AbortController();
// Re-establish the pipe connection
this.pipePromise = Utils.toDeviceStream.readable.pipeTo(
this.connection.writable,
{ signal: this.abortController.signal },
);
} }
} }

3
packages/web/package.json

@ -1,6 +1,6 @@
{ {
"name": "meshtastic-web", "name": "meshtastic-web",
"version": "2.7.0-0", "version": "2.6.6-0",
"type": "module", "type": "module",
"description": "Meshtastic web client", "description": "Meshtastic web client",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
@ -86,7 +86,6 @@
"zustand": "5.0.6" "zustand": "5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.6",
"@tanstack/router-plugin": "^1.127.9", "@tanstack/router-plugin": "^1.127.9",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",

3
packages/web/src/App.tsx

@ -12,6 +12,8 @@ import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { MapProvider } from "react-map-gl/maplibre"; import { MapProvider } from "react-map-gl/maplibre";
// Import feature flags and dev overrides
import "@core/services/dev-overrides.ts";
export function App() { export function App() {
const { getDevice } = useDeviceStore(); const { getDevice } = useDeviceStore();
@ -31,7 +33,6 @@ export function App() {
setConnectDialogOpen(open); setConnectDialogOpen(open);
}} }}
/> />
{/* <Toaster /> */}
<TanStackRouterDevtools position="bottom-right" /> <TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper deviceId={selectedDeviceId}> <DeviceWrapper deviceId={selectedDeviceId}>
<div <div

2
packages/web/src/components/CommandPalette/index.tsx

@ -199,7 +199,7 @@ export const CommandPalette = () => {
label: t("contextual.command.dfuMode"), label: t("contextual.command.dfuMode"),
icon: HardDriveUpload, icon: HardDriveUpload,
action() { action() {
connection?.enterDfuMode() connection?.enterDfuMode();
}, },
}, },
{ {

17
packages/web/src/components/UI/Footer.tsx

@ -1,4 +1,5 @@
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import React from "react";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
type FooterProps = { type FooterProps = {
@ -6,6 +7,18 @@ type FooterProps = {
}; };
const Footer = ({ className, ...props }: FooterProps) => { const Footer = ({ className, ...props }: FooterProps) => {
const version = React.useMemo(
() => String(import.meta.env.VITE_VERSION)?.toUpperCase() || "",
[],
);
const commitHash = React.useMemo(
() =>
String(import.meta.env.VITE_COMMIT_HASH)
?.toUpperCase()
.slice(0, 7) || "unk",
[],
);
return ( return (
<footer <footer
className={cn( className={cn(
@ -16,13 +29,13 @@ const Footer = ({ className, ...props }: FooterProps) => {
> >
<div className="px-2"> <div className="px-2">
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40"> <span className="font-semibold text-gray-500/40 dark:text-gray-400/40">
{String(import.meta.env.VITE_VERSION)?.toUpperCase()} {version}
</span> </span>
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40 mx-2"> <span className="font-semibold text-gray-500/40 dark:text-gray-400/40 mx-2">
- -
</span> </span>
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40"> <span className="font-semibold text-gray-500/40 dark:text-gray-400/40">
{`#${String(import.meta.env.VITE_COMMIT_HASH)?.toUpperCase()}`} {`#${commitHash}`}
</span> </span>
</div> </div>
<p className="ml-auto mr-auto text-gray-500 dark:text-gray-400"> <p className="ml-auto mr-auto text-gray-500 dark:text-gray-400">

22
packages/web/src/core/hooks/useFeatureFlags.ts

@ -0,0 +1,22 @@
import {
type FlagKey,
type Flags,
featureFlags,
} from "@core/services/featureFlags.ts";
import * as React from "react";
export function useFeatureFlags(): Flags {
return React.useSyncExternalStore(
(cb) => featureFlags.subscribe(cb),
() => featureFlags.all(),
() => featureFlags.all(),
);
}
export function useFeatureFlag(key: FlagKey): boolean {
return React.useSyncExternalStore(
(cb) => featureFlags.subscribe(cb),
() => featureFlags.get(key),
() => featureFlags.get(key),
);
}

10
packages/web/src/core/services/dev-overrides.ts

@ -0,0 +1,10 @@
import { featureFlags } from "@core/services/featureFlags";
const isDev = typeof import.meta !== "undefined" && import.meta.env?.DEV;
console.log(`Dev mode: ${isDev}`);
if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
});
}

89
packages/web/src/core/services/featureFlags.ts

@ -0,0 +1,89 @@
import { z } from "zod";
/** Map feature keys -> env var names (Vite exposes only VITE_*). */
export const FLAG_ENV = {
persistNodeDB: "VITE_PERSIST_NODE_DB",
persistMessages: "VITE_PERSIST_MESSAGES",
} as const;
export type FlagKey = keyof typeof FLAG_ENV;
export type Flags = Record<FlagKey, boolean>;
type Listener = () => void;
const present = z
.string()
.optional()
.transform((v) => v !== undefined);
const mutableShape: Record<string, z.ZodTypeAny> = {};
for (const envName of Object.values(FLAG_ENV)) {
mutableShape[envName] = present;
}
const EnvSchema = z.object(mutableShape);
class FeatureFlags {
private base: Flags;
private overrides: Partial<Flags> = {};
private listeners = new Set<Listener>();
constructor(base: Flags) {
this.base = base;
}
get(key: FlagKey): boolean {
return this.overrides[key] ?? this.base[key];
}
/** Get all flags */
all(): Flags {
return { ...this.base, ...this.overrides };
}
/** Optional dev/test override. Pass null to clear. */
setOverride(key: FlagKey, val: boolean | null) {
if (val === null) {
delete this.overrides[key];
} else {
this.overrides[key] = val;
}
this.emit();
}
/** Set many at once */
setOverrides(partial: Partial<Flags>) {
for (const [k, v] of Object.entries(partial)) {
this.setOverride(k as FlagKey, v as boolean);
if (v === null) {
delete this.overrides[k as FlagKey];
} else {
this.overrides[k as FlagKey] = v as boolean;
}
}
this.emit();
}
subscribe(fn: Listener) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private emit() {
for (const listener of this.listeners) {
listener();
}
}
}
export function createFeatureFlags(env: Record<string, unknown>): FeatureFlags {
const parsed = EnvSchema.parse(env);
const base = Object.fromEntries(
(Object.keys(FLAG_ENV) as FlagKey[]).map((k) => [
k,
parsed[FLAG_ENV[k]] as boolean,
]),
) as Flags;
return new FeatureFlags(base);
}
export const featureFlags = createFeatureFlags(import.meta.env);

749
pnpm-lock.yaml

File diff suppressed because it is too large
Loading…
Cancel
Save