Merge branch 'meshtastic:master' into traceroute

This commit is contained in:
Hunter Thornsberry
2024-07-04 17:06:28 -04:00
committed by GitHub
49 changed files with 4674 additions and 3390 deletions

View File

@@ -1,4 +1,7 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
}
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit"
},
"editor.formatOnSave": true
}

View File

@@ -1,10 +1,11 @@
{
"$schema": "https://biomejs.dev/schemas/1.6.3/schema.json",
"$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"ignoreUnknown": true
"ignoreUnknown": true,
"ignore": ["vercel.json"]
},
"vcs": {
"enabled": true,
@@ -20,7 +21,7 @@
"linter": {
"enabled": true,
"rules": {
"all": true
"recommended": true
}
}
}

View File

@@ -6,8 +6,9 @@
"license": "GPL-3.0-only",
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"build": "tsc && pnpm check && vite build ",
"check": "biome check .",
"check:fix": "pnpm check --write",
"preview": "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/ $(ls ./dist/output/)"
},
@@ -20,64 +21,64 @@
},
"homepage": "https://meshtastic.org",
"dependencies": {
"@bufbuild/protobuf": "^1.8.0",
"@bufbuild/protobuf": "^1.10.0",
"@emeraldpay/hashicon-react": "^0.5.2",
"@meshtastic/js": "2.3.4-0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-menubar": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@meshtastic/js": "2.3.7-0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.1",
"@turf/turf": "^6.5.0",
"base64-js": "^1.5.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"immer": "^10.0.4",
"immer": "^10.1.1",
"lucide-react": "^0.363.0",
"mapbox-gl": "npm:empty-npm-package@^1.0.0",
"maplibre-gl": "4.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-map-gl": "7.1.7",
"react-qrcode-logo": "^2.9.0",
"react-qrcode-logo": "^2.10.0",
"rfc4648": "^1.5.3",
"tailwind-merge": "^2.2.2",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"timeago-react": "^3.0.6",
"zustand": "4.5.2"
},
"devDependencies": {
"@biomejs/biome": "^1.6.3",
"@buf/meshtastic_protobufs.bufbuild_es": "1.8.0-20240325205556-b11811405eea.2",
"@biomejs/biome": "^1.8.2",
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240613143006-244927bc441a.1",
"@types/chrome": "^0.0.263",
"@types/node": "^20.11.30",
"@types/react": "^18.2.73",
"@types/react-dom": "^18.2.23",
"@types/node": "^20.14.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/w3c-web-serial": "^1.0.6",
"@types/web-bluetooth": "^0.0.20",
"@vitejs/plugin-react": "^4.2.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"gzipper": "^7.2.0",
"postcss": "^8.4.38",
"rollup-plugin-visualizer": "^5.12.0",
"tailwindcss": "^3.4.3",
"tailwindcss": "^3.4.4",
"tar": "^6.2.1",
"tslib": "^2.6.2",
"typescript": "^5.4.3",
"vite": "^5.2.6",
"tslib": "^2.6.3",
"typescript": "^5.5.2",
"vite": "^5.3.1",
"vite-plugin-environment": "^1.1.3"
}
}

7404
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
autoprefixer: {},
},
};

View File

@@ -19,7 +19,7 @@ import {
LayersIcon,
LayoutIcon,
LinkIcon,
LucideIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
MoonIcon,
@@ -350,7 +350,7 @@ export const CommandPalette = (): JSX.Element => {
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, []);
}, [setCommandPaletteOpen]);
return (
<CommandDialog

View File

@@ -9,8 +9,8 @@ import {
LanguagesIcon,
MoonIcon,
PlusIcon,
SunIcon,
SearchIcon,
SunIcon,
} from "lucide-react";
export const DeviceSelector = (): JSX.Element => {

View File

@@ -1,9 +1,9 @@
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js";
import { ImportDialog } from "@components/Dialog/ImportDialog.js";
import { QRDialog } from "@components/Dialog/QRDialog.js";
import { RebootDialog } from "@components/Dialog/RebootDialog.js";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js";
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js"
import { useDevice } from "@core/stores/deviceStore.js";
export const DialogManager = (): JSX.Element => {

View File

@@ -9,10 +9,10 @@ import {
} from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js";
import { Protobuf, Types } from "@meshtastic/js";
import { Protobuf, type Types } from "@meshtastic/js";
import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo";
export interface QRDialogProps {
@@ -32,7 +32,7 @@ export const QRDialog = ({
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
const allChannels = Array.from(channels.values());
const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
useEffect(() => {
const channelsToEncode = allChannels
@@ -50,8 +50,10 @@ export const QRDialog = ({
.replace(/\+/g, "-")
.replace(/\//g, "_");
setQrCodeUrl(`https://meshtastic.org/e/#${base64}${qrCodeAdd ? "?add=true" : ""}`);
}, [channels, selectedChannels, qrCodeAdd, loraConfig]);
setQrCodeUrl(
`https://meshtastic.org/e/#${base64}${qrCodeAdd ? "?add=true" : ""}`,
);
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -97,18 +99,26 @@ export const QRDialog = ({
</div>
<div className="flex justify-center">
<button
type="button"
className={ "border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") }
type="button"
className={`border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(true)}
>
Add Channels
>
Add Channels
</button>
<button
type="button"
className={ "border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (!qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") }
type="button"
className={`border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(false)}
>
Replace Channels
>
Replace Channels
</button>
</div>
</div>

View File

@@ -37,7 +37,7 @@ export const RebootDialog = ({
<Input
type="number"
value={time}
onChange={(e) => setTime(parseInt(e.target.value))}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
action={{
icon: ClockIcon,
onClick() {

View File

@@ -44,7 +44,9 @@ export const RemoveNodeDialog = ({
</form>
</div>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>Remove</Button>
<Button variant="destructive" onClick={() => onSubmit()}>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -38,7 +38,7 @@ export const ShutdownDialog = ({
<Input
type="number"
value={time}
onChange={(e) => setTime(parseInt(e.target.value))}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
suffix="Minutes"
/>
<Button

View File

@@ -1,17 +1,17 @@
import {
DynamicFormField,
FieldProps,
type FieldProps,
} from "@components/Form/DynamicFormField.js";
import { FieldWrapper } from "@components/Form/FormWrapper.js";
import { Button } from "@components/UI/Button.js";
import { H4 } from "@components/UI/Typography/H4.js";
import { Subtle } from "@components/UI/Typography/Subtle.js";
import {
Control,
DefaultValues,
FieldValues,
Path,
SubmitHandler,
type Control,
type DefaultValues,
type FieldValues,
type Path,
type SubmitHandler,
useForm,
} from "react-hook-form";
@@ -26,7 +26,7 @@ export interface BaseFormBuilderProps<T> {
disabledBy?: DisabledBy<T>[];
label: string;
description?: string;
properties?: {};
properties?: Record<string, unknown>;
}
export interface GenericFormElementProps<T extends FieldValues, Y> {
@@ -94,9 +94,12 @@ export function DynamicForm<T extends FieldValues>({
</div>
{fieldGroup.fields.map((field) => (
<FieldWrapper label={field.label} description={field.description}>
<FieldWrapper
key={field.label}
label={field.label}
description={field.description}
>
<DynamicFormField
key={field.label}
field={field}
control={control}
disabled={isDisabled(field.disabledBy)}

View File

@@ -1,6 +1,15 @@
import { GenericInput, InputFieldProps } from "@components/Form/FormInput.js";
import { SelectFieldProps, SelectInput } from "@components/Form/FormSelect.js";
import { ToggleFieldProps, ToggleInput } from "@components/Form/FormToggle.js";
import {
GenericInput,
type InputFieldProps,
} from "@components/Form/FormInput.js";
import {
type SelectFieldProps,
SelectInput,
} from "@components/Form/FormSelect.js";
import {
type ToggleFieldProps,
ToggleInput,
} from "@components/Form/FormToggle.js";
import type { Control, FieldValues } from "react-hook-form";
export type FieldProps<T> =

View File

@@ -4,7 +4,7 @@ import type {
} from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js";
import type { LucideIcon } from "lucide-react";
import { Controller, FieldValues } from "react-hook-form";
import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";

View File

@@ -9,7 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from "@components/UI/Select.js";
import { Controller, FieldValues } from "react-hook-form";
import { Controller, type FieldValues } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select" | "multiSelect";
@@ -40,7 +40,7 @@ export function SelectInput<T extends FieldValues>({
: [];
return (
<Select
onValueChange={(e) => onChange(parseInt(e))}
onValueChange={(e) => onChange(Number.parseInt(e))}
disabled={disabled}
value={value?.toString()}
{...remainingProperties}

View File

@@ -3,8 +3,8 @@ import type {
GenericFormElementProps,
} from "@components/Form/DynamicForm.js";
import { Switch } from "@components/UI/Switch.js";
import { ChangeEvent } from "react";
import { Controller, FieldValues } from "react-hook-form";
import type { ChangeEvent } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
type: "toggle";

View File

@@ -1,4 +1,4 @@
import type{ ChannelValidation } from "@app/validation/channel.js";
import type { ChannelValidation } from "@app/validation/channel.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useToast } from "@core/hooks/useToast.js";
import { useDevice } from "@core/stores/deviceStore.js";
@@ -20,8 +20,12 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
...data.settings,
psk: toByteArray(data.settings.psk ?? ""),
moduleSettings: {
positionPrecision: data.settings.positionEnabled ? data.settings.preciseLocation ? 32 : data.settings.positionPrecision : 0,
}
positionPrecision: data.settings.positionEnabled
? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
},
},
});
connection?.setChannel(channel).then(() => {
@@ -43,9 +47,16 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
settings: {
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
positionEnabled: channel?.settings?.moduleSettings?.positionPrecision != undefined && channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation: channel?.settings?.moduleSettings?.positionPrecision == 32,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision == undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision
positionEnabled:
channel?.settings?.moduleSettings?.positionPrecision !==
undefined &&
channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation:
channel?.settings?.moduleSettings?.positionPrecision === 32,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision === undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
},
}}
@@ -111,9 +122,32 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
description:
"If not sharing precise location, position shared on channel will be accurate within this distance",
properties: {
enumValue: config.display?.units == 0 ?
{ "Within 23 km":10, "Within 12 km":11, "Within 5.8 km":12, "Within 2.9 km":13, "Within 1.5 km":14, "Within 700 m":15, "Within 350 m":16, "Within 200 m":17, "Within 90 m":18, "Within 50 m":19 } :
{ "Within 15 miles":10, "Within 7.3 miles":11, "Within 3.6 miles":12, "Within 1.8 miles":13, "Within 0.9 miles":14, "Within 0.5 miles":15, "Within 0.2 miles":16, "Within 600 feet":17, "Within 300 feet":18, "Within 150 feet":19 }
enumValue:
config.display?.units === 0
? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
},
},
],

View File

@@ -41,7 +41,7 @@ export const LoRa = (): JSX.Element => {
label: "Hop Limit",
description: "Maximum number of hops",
properties: {
enumValue: {1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7}
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
},
},
{

View File

@@ -1,4 +1,4 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js";

View File

@@ -1,4 +1,4 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js";
import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js";
@@ -8,6 +8,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js";
import { HttpConnection } from "@meshtastic/js";
import { useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
@@ -19,7 +20,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
}>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
window.location.hostname
window.location.hostname,
)
? "meshtastic.local"
: window.location.hostname,
@@ -33,10 +34,13 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
defaultValue: location.protocol === "https:",
});
const [connectionInProgress, setConnectionInProgress] = useState(false);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
const id = randId();
const device = addDevice(id);
setSelectedDevice(id);
const connection = new HttpConnection(id);
// TODO: Promise never resolves
await connection.connect({
@@ -44,9 +48,10 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
fetchInterval: 2000,
tls: data.tls,
});
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection);
closeDialog();
});
@@ -58,6 +63,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
// label="IP Address/Hostname"
prefix={tlsEnabled ? "https://" : "http://"}
placeholder="000.000.000.000 / meshtastic.local"
disabled={connectionInProgress}
{...register("ip")}
/>
<Controller
@@ -69,7 +75,9 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
<Switch
// label="Use TLS"
// description="Description"
disabled={location.protocol === "https:"}
disabled={
location.protocol === "https:" || connectionInProgress
}
checked={value}
{...rest}
/>
@@ -77,8 +85,8 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
)}
/>
</div>
<Button type="submit">
<span>Connect</span>
<Button type="submit" disabled={connectionInProgress}>
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button>
</form>
);

View File

@@ -1,4 +1,4 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js";
@@ -48,19 +48,22 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
return (
<div className="flex w-full flex-col gap-2 p-4">
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => (
<Button
key={index}
disabled={port.readable !== null}
onClick={async () => {
await onConnect(port);
}}
>
{`# ${index} - ${port.getInfo().usbVendorId ?? "UNK"} - ${
port.getInfo().usbProductId ?? "UNK"
}`}
</Button>
))}
{serialPorts.map((port, index) => {
const { usbProductId, usbVendorId } = port.getInfo();
return (
<Button
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
disabled={port.readable !== null}
onClick={async () => {
await onConnect(port);
}}
>
{`# ${index} - ${usbVendorId ?? "UNK"} - ${
usbProductId ?? "UNK"
}`}
</Button>
);
})}
{serialPorts.length === 0 && (
<Mono className="m-auto select-none">No devices paired yet.</Mono>
)}

View File

@@ -1,5 +1,8 @@
import { Subtle } from "@app/components/UI/Typography/Subtle.js";
import { MessageWithState, useDevice } from "@app/core/stores/deviceStore.js";
import {
type MessageWithState,
useDevice,
} from "@app/core/stores/deviceStore.js";
import { Message } from "@components/PageComponents/Messages/Message.js";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.js";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.js";

View File

@@ -38,9 +38,9 @@ export const DetectionSensor = (): JSX.Element => {
label: "Minimum Broadcast Seconds",
description:
"The interval in seconds of how often we can send a message to the mesh when a state change is detected",
properties: {
suffix: "Seconds",
},
properties: {
suffix: "Seconds",
},
disabledBy: [
{
fieldName: "enabled",

View File

@@ -4,14 +4,20 @@ import { DynamicForm } from "@components/Form/DynamicForm.js";
import { Protobuf } from "@meshtastic/js";
export const MQTT = (): JSX.Element => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
const onSubmit = (data: MqttValidation) => {
setWorkingModuleConfig(
new Protobuf.ModuleConfig.ModuleConfig({
payloadVariant: {
case: "mqtt",
value: data,
value: {
...data,
mapReportSettings:
new Protobuf.ModuleConfig.ModuleConfig_MapReportSettings(
data.mapReportSettings,
),
},
},
}),
);
@@ -70,7 +76,8 @@ export const MQTT = (): JSX.Element => {
type: "toggle",
name: "encryptionEnabled",
label: "Encryption Enabled",
description: "Enable or disable MQTT encryption",
description:
"Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data.",
disabledBy: [
{
fieldName: "enabled",
@@ -151,10 +158,39 @@ export const MQTT = (): JSX.Element => {
],
},
{
type: "number",
type: "select",
name: "mapReportSettings.positionPrecision",
label: "Position Precision",
description: "Precision of the position",
label: "Approximate Location",
description:
"Position shared will be accurate within this distance",
properties: {
enumValue:
config.display?.units === 0
? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
},
disabledBy: [
{
fieldName: "enabled",

View File

@@ -36,7 +36,7 @@ export const NeighborInfo = (): JSX.Element => {
type: "number",
name: "updateInterval",
label: "Update Interval",
description:
description:
"Interval in seconds of how often we should try to send our Neighbor Info to the mesh",
properties: {
suffix: "Seconds",

View File

@@ -36,7 +36,8 @@ export const Paxcounter = (): JSX.Element => {
type: "number",
name: "paxcounterUpdateInterval",
label: "Update Interval (seconds)",
description: "How long to wait between sending paxcounter packets",
description:
"How long to wait between sending paxcounter packets",
properties: {
suffix: "Seconds",
},

View File

@@ -1,5 +1,5 @@
import { cn } from "@app/core/utils/cn.js";
import { AlignLeftIcon, LucideIcon } from "lucide-react";
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
export interface PageLayoutProps {
label: string;

View File

@@ -4,15 +4,16 @@ import { Subtle } from "@components/UI/Typography/Subtle.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { Page } from "@core/stores/deviceStore.js";
import {
BatteryMediumIcon,
CpuIcon,
EditIcon,
LayersIcon,
LucideIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
SettingsIcon,
UsersIcon,
ZapIcon,
BatteryMediumIcon
} from "lucide-react";
export interface SidebarProps {
@@ -20,8 +21,9 @@ export interface SidebarProps {
}
export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
const { hardware, nodes } = useDevice();
const { hardware, nodes, metadata } = useDevice();
const myNode = nodes.get(hardware.myNodeNum);
const myMetadata = metadata.get(0);
const { activePage, setActivePage, setDialogOpen } = useDevice();
interface NavLink {
@@ -77,12 +79,18 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
</div>
<div className="px-8 pb-6">
<div className="flex items-center">
<BatteryMediumIcon size={24} viewBox={'0 0 28 24'}/>
<Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle>
<BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
<Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle>
</div>
<div className="flex items-center">
<ZapIcon size={24} viewBox={'0 0 36 24'}/>
<Subtle>{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts</Subtle>
<ZapIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>
{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">
<CpuIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div>
</div>

View File

@@ -17,8 +17,14 @@ export function Toaster() {
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle className="dark:text-white">{title}</ToastTitle>}
{description && <ToastDescription className="dark:text-white-400">{description}</ToastDescription>}
{title && (
<ToastTitle className="dark:text-white">{title}</ToastTitle>
)}
{description && (
<ToastDescription className="dark:text-white-400">
{description}
</ToastDescription>
)}
</div>
{action}
<ToastClose />

View File

@@ -1,4 +1,4 @@
import { VariantProps, cva } from "class-variance-authority";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@core/utils/cn.js";

View File

@@ -1,5 +1,5 @@
import * as ToastPrimitives from "@radix-ui/react-toast";
import { VariantProps, cva } from "class-variance-authority";
import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";

View File

@@ -1,4 +1,5 @@
import { ChevronUpIcon } from "lucide-react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import React, { useState } from "react";
export interface TableProps {
headings: Heading[];
@@ -12,6 +13,49 @@ export interface Heading {
}
export const Table = ({ headings, rows }: TableProps): JSX.Element => {
const [sortColumn, setSortColumn] = useState<string | null>("Last Heard");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const headingSort = (title: string) => {
if (sortColumn === title) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortColumn(title);
setSortOrder("asc");
}
};
const sortedRows = rows.slice().sort((a, b) => {
if (!sortColumn) return 0;
const columnIndex = headings.findIndex((h) => h.title === sortColumn);
const aValue = a[columnIndex].props.children;
const bValue = b[columnIndex].props.children;
// Custom comparison for 'Last Heard' column
if (sortColumn === "Last Heard") {
const aTimestamp = aValue.props.timestamp ?? 0;
const bTimestamp = bValue.props.timestamp ?? 0;
if (aTimestamp < bTimestamp) {
return sortOrder === "asc" ? -1 : 1;
}
if (aTimestamp > bTimestamp) {
return sortOrder === "asc" ? 1 : -1;
}
return 0;
}
// Default comparison for other columns
if (aValue < bValue) {
return sortOrder === "asc" ? -1 : 1;
}
if (aValue > bValue) {
return sortOrder === "asc" ? 1 : -1;
}
return 0;
});
return (
<table className="min-w-full">
<thead className="bg-backgroundPrimary text-sm font-semibold text-textPrimary">
@@ -25,11 +69,19 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
? "cursor-pointer hover:brightness-hover active:brightness-press"
: ""
}`}
onClick={() => heading.sortable && headingSort(heading.title)}
onKeyUp={() => heading.sortable && headingSort(heading.title)}
>
<div className="flex gap-2">
{heading.title}
{heading.sortable && (
<ChevronUpIcon size={16} className="my-auto" />
{sortColumn === heading.title && (
<>
{sortOrder === "asc" ? (
<ChevronUpIcon size={16} />
) : (
<ChevronDownIcon size={16} />
)}
</>
)}
</div>
</th>
@@ -37,10 +89,12 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
{sortedRows.map((row, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
<tr key={index}>
{row.map((item, index) => (
<td
// biome-ignore lint/suspicious/noArrayIndexKey: OK because column order never changes.
key={index}
className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2"
>

View File

@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useState } from "react";
import { type ReactNode, useSyncExternalStore } from "react";
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.js";
@@ -92,9 +92,9 @@ export const reducer = (state: State, action: Action): State => {
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
for (const toast of state.toasts) {
addToRemoveQueue(toast.id);
});
}
}
return {
@@ -130,9 +130,9 @@ let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
for (const listener of listeners) {
listener(memoryState);
});
}
}
type Toast = Omit<ToasterToast, "id">;
@@ -166,18 +166,22 @@ function toast({ ...props }: Toast) {
};
}
function useToast() {
const [state, setState] = useState<State>(memoryState);
const subscribe = (listener: () => void) => {
listeners.push(listener);
return function unsubscribe() {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
};
useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
const getState = () => {
return memoryState;
};
function useToast() {
const state = useSyncExternalStore(subscribe, getState);
return {
...state,

View File

@@ -50,7 +50,10 @@ export const useAppStore = create<AppState>()((set) => ({
currentPage: "messages",
rasterSources: [],
commandPaletteOpen: false,
darkMode: window.matchMedia("(prefers-color-scheme: dark)").matches,
darkMode:
localStorage.getItem("theme-dark") !== null
? localStorage.getItem("theme-dark") === "true"
: window.matchMedia("(prefers-color-scheme: dark)").matches,
accent: "orange",
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
@@ -96,6 +99,7 @@ export const useAppStore = create<AppState>()((set) => ({
);
},
setDarkMode: (enabled: boolean) => {
localStorage.setItem("theme-dark", enabled.toString());
set(
produce<AppState>((draft) => {
draft.darkMode = enabled;
@@ -104,7 +108,7 @@ export const useAppStore = create<AppState>()((set) => ({
},
setNodeNumToBeRemoved: (nodeNum) =>
set((state) => ({
nodeNumToBeRemoved: nodeNum
nodeNumToBeRemoved: nodeNum,
})),
setAccent(color) {
set(

View File

@@ -530,8 +530,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
return;
}
device.nodes.delete(nodeNum);
})
)
}),
);
},
setMessageState: (
type: "direct" | "broadcast",

View File

@@ -1,5 +1,5 @@
import type { Device } from "@core/stores/deviceStore.js";
import { Protobuf, Types } from "@meshtastic/js";
import { Protobuf, type Types } from "@meshtastic/js";
export const subscribeAll = (
device: Device,

View File

@@ -1,4 +1,4 @@
import { ClassValue, clsx } from "clsx";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {

View File

@@ -53,10 +53,7 @@ export const DeviceConfig = (): JSX.Element => {
<Tabs defaultValue="Device">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
>
<TabsTrigger key={tab.label} value={tab.label}>
{tab.label}
</TabsTrigger>
))}

View File

@@ -5,11 +5,11 @@ import { Audio } from "@components/PageComponents/ModuleConfig/Audio.js";
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.js";
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js";
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.js";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.js";
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.js";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.js";
import {
Tabs,
TabsContent,

View File

@@ -14,20 +14,20 @@ import {
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Marker, useMap } from "react-map-gl";
import MapGl from "react-map-gl/maplibre";
export const MapPage = (): JSX.Element => {
const { nodes, waypoints } = useDevice();
const { rasterSources } = useAppStore();
const { rasterSources, darkMode } = useAppStore();
const { default: map } = useMap();
const [zoom, setZoom] = useState(0);
const allNodes = Array.from(nodes.values());
const getBBox = () => {
const getBBox = useCallback(() => {
if (!map) {
return;
}
@@ -64,7 +64,7 @@ export const MapPage = (): JSX.Element => {
if (center) {
map.easeTo(center);
}
};
}, [allNodes, map]);
useEffect(() => {
map?.on("zoom", () => {
@@ -128,6 +128,11 @@ export const MapPage = (): JSX.Element => {
attributionControl={false}
renderWorldCopies={false}
maxPitch={0}
style={{
filter: darkMode
? "brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)"
: "",
}}
dragRotate={false}
touchZoomRotate={false}
initialViewState={{
@@ -160,20 +165,19 @@ export const MapPage = (): JSX.Element => {
key={node.num}
longitude={node.position.longitudeI / 1e7}
latitude={node.position.latitudeI / 1e7}
style={{ filter: darkMode ? "invert(1)" : "" }}
anchor="bottom"
onClick={() => {
map?.easeTo({
zoom: 12,
center: [
(node.position?.longitudeI ?? 0) / 1e7,
(node.position?.latitudeI ?? 0) / 1e7,
],
});
}}
>
<div
className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5"
onClick={() => {
map?.easeTo({
zoom: 12,
center: [
(node.position?.longitudeI ?? 0) / 1e7,
(node.position?.latitudeI ?? 0) / 1e7,
],
});
}}
>
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
<Hashicon value={node.num.toString()} size={22} />
<Subtle className={cn(zoom < 12 && "hidden")}>
{node.user?.longName}

View File

@@ -1,15 +1,15 @@
import { useAppStore } from "@app/core/stores/appStore";
import { Sidebar } from "@components/Sidebar.js";
import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js";
import { Table } from "@components/generic/Table/index.js";
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf } from "@meshtastic/js";
import { base16 } from "rfc4648";
import { Button } from "@components/UI/Button.js";
import { TrashIcon } from "lucide-react";
import { useAppStore } from "@app/core/stores/appStore";
import { Fragment } from "react";
import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps {
open: boolean;
@@ -40,8 +40,8 @@ export const NodesPage = (): JSX.Element => {
{ title: "Remove", type: "normal", sortable: false },
]}
rows={filteredNodes.map((node) => [
<Hashicon size={24} value={node.num.toString()} />,
<h1>
<Hashicon key="icon" size={24} value={node.num.toString()} />,
<h1 key="header">
{node.user?.longName ??
(node.user?.macaddr
? `Meshtastic ${base16
@@ -50,34 +50,46 @@ export const NodesPage = (): JSX.Element => {
: `UNK: ${node.num}`)}
</h1>,
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>,
<Mono>
<Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>,
<Mono key="addr">
{base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? "UNK"}
</Mono>,
node.lastHeard === 0 ? (
<p>Never</p>
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
),
<Mono>
<Fragment key="lastHeard">
{node.lastHeard === 0 ? (
<p>Never</p>
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</Fragment>,
<Mono key="snr">
{node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw
</Mono>,
<Mono>
{node.lastHeard != 0 ?
(node.viaMqtt === false && node.hopsAway === 0
? "Direct": node.hopsAway.toString() + " hops away")
<Mono key="hops">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway.toString()} hops away`
: "-"}
{node.viaMqtt === true? ", via MQTT": ""}
</Mono>,
<Button variant="destructive" onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true)
}}><TrashIcon />Remove</Button>
{node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>,
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>,
])}
/>
</div>

View File

@@ -1,6 +1,6 @@
import type { Message } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsEnum, IsInt } from "class-validator";
import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator";
export class DeviceValidation
implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message>
@@ -34,4 +34,10 @@ export class DeviceValidation
@IsBoolean()
disableTripleClick: boolean;
@IsBoolean()
ledHeartbeatDisabled: boolean;
@IsString()
tzdef: string;
}

View File

@@ -34,4 +34,7 @@ export class DisplayValidation
@IsBoolean()
wakeOnTapOrMotion: boolean;
@IsEnum(Protobuf.Config.Config_DisplayConfig_CompassOrientation)
compassOrientation: Protobuf.Config.Config_DisplayConfig_CompassOrientation;
}

View File

@@ -2,10 +2,14 @@ import type { Message } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/js";
import { IsArray, IsBoolean, IsEnum, IsInt } from "class-validator";
const DeprecatedPositionValidationFields = ['gpsEnabled', 'gpsAttemptTime'];
const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"];
export class PositionValidation
implements Omit<Protobuf.Config.Config_PositionConfig, keyof Message | typeof DeprecatedPositionValidationFields[number]>
implements
Omit<
Protobuf.Config.Config_PositionConfig,
keyof Message | (typeof DeprecatedPositionValidationFields)[number]
>
{
@IsInt()
positionBroadcastSecs: number;

View File

@@ -1,6 +1,12 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsString, Length, IsNumber } from "class-validator";
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Length,
} from "class-validator";
export class MqttValidation
implements
@@ -47,8 +53,10 @@ export class MqttValidationMapReportSettings
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message>
{
@IsNumber()
@IsOptional()
publishIntervalSecs: number;
@IsNumber()
@IsOptional()
positionPrecision: number;
}

View File

@@ -11,4 +11,10 @@ export class PaxcounterValidation
@IsInt()
paxcounterUpdateInterval: number;
@IsInt()
bleThreshold: number;
@IsInt()
wifiThreshold: number;
}

View File

@@ -1,5 +1 @@
{
"github": {
"silent": true
}
}
{ "github": { "silent": true } }

View File

@@ -1,5 +1,5 @@
import { execSync } from "child_process";
import { resolve } from "path";
import { execSync } from "node:child_process";
import { resolve } from "node:path";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite";
import EnvironmentPlugin from "vite-plugin-environment";