Browse Source

Merge remote-tracking branch 'meshtastic-remote/master' into fix/static-ip-display

pull/269/head
Hunter Thornsberry 2 years ago
parent
commit
d05ea5a2cc
  1. 6
      .github/workflows/release.yml
  2. 8
      package.json
  3. 808
      pnpm-lock.yaml
  4. 11
      src/components/CommandPalette.tsx
  5. 39
      src/components/Dialog/PkiRegenerateDialog.tsx
  6. 10
      src/components/Form/DynamicForm.tsx
  7. 10
      src/components/Form/FormInput.tsx
  8. 6
      src/components/Form/FormPasswordGenerator.tsx
  9. 19
      src/components/PageComponents/Config/Device.tsx
  10. 7
      src/components/PageComponents/Config/LoRa.tsx
  11. 237
      src/components/PageComponents/Config/Security.tsx
  12. 3
      src/components/PageLayout.tsx
  13. 2
      src/components/Sidebar.tsx
  14. 54
      src/components/UI/Generator.tsx
  15. 3
      src/components/UI/Input.tsx
  16. 3
      src/core/stores/deviceStore.ts
  17. 15
      src/core/utils/x25519.ts
  18. 5
      src/pages/Config/DeviceConfig.tsx
  19. 2
      src/pages/Dashboard/index.tsx
  20. 190
      src/pages/Map.tsx
  21. 19
      src/pages/Messages.tsx
  22. 10
      src/pages/Nodes.tsx
  23. 6
      src/validation/config/bluetooth.ts
  24. 6
      src/validation/config/lora.ts
  25. 3
      src/validation/config/power.ts
  26. 35
      src/validation/config/security.ts
  27. 5
      src/validation/moduleConfig/storeForward.ts
  28. 3
      vite.config.ts

6
.github/workflows/release.yml

@ -27,6 +27,12 @@ jobs:
- name: Package Output
run: pnpm package
- name: Archive compressed build
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

8
package.json

@ -23,7 +23,8 @@
"dependencies": {
"@bufbuild/protobuf": "^1.10.0",
"@emeraldpay/hashicon-react": "^0.5.2",
"@meshtastic/js": "2.3.7-0",
"@meshtastic/js": "2.3.7-5",
"@noble/curves": "^1.5.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
@ -48,7 +49,7 @@
"crypto-random-string": "^5.0.0",
"immer": "^10.1.1",
"lucide-react": "^0.363.0",
"mapbox-gl": "npm:empty-npm-package@^1.0.0",
"mapbox-gl": "^3.6.0",
"maplibre-gl": "4.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@ -59,11 +60,12 @@
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"timeago-react": "^3.0.6",
"vite-plugin-node-polyfills": "^0.22.0",
"zustand": "4.5.2"
},
"devDependencies": {
"@biomejs/biome": "^1.8.2",
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240613143006-244927bc441a.1",
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240906232734-3da561588c55.1",
"@types/chrome": "^0.0.263",
"@types/node": "^20.14.9",
"@types/react": "^18.3.3",

808
pnpm-lock.yaml

File diff suppressed because it is too large

11
src/components/CommandPalette.tsx

@ -200,10 +200,17 @@ export const CommandPalette = (): JSX.Element => {
},
},
{
label: "Factory Reset",
label: "Factory Reset Device",
icon: FactoryIcon,
action() {
connection?.factoryReset();
connection?.factoryResetDevice();
},
},
{
label: "Factory Reset Config",
icon: FactoryIcon,
action() {
connection?.factoryResetConfig();
},
},
],

39
src/components/Dialog/PkiRegenerateDialog.tsx

@ -0,0 +1,39 @@
import { Button } from "@components/UI/Button.js";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
export interface PkiRegenerateDialogProps {
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
open,
onOpenChange,
onSubmit,
}: PkiRegenerateDialogProps): JSX.Element => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogDescription>
Are you sure you want to regenerate key pair?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Regenerate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

10
src/components/Form/DynamicForm.tsx

@ -23,6 +23,7 @@ interface DisabledBy<T> {
export interface BaseFormBuilderProps<T> {
name: Path<T>;
disabled?: boolean;
disabledBy?: DisabledBy<T>[];
label: string;
description?: string;
@ -62,11 +63,16 @@ export function DynamicForm<T extends FieldValues>({
defaultValues: defaultValues,
});
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => {
const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
): boolean => {
if (disabled) return true;
if (!disabledBy) return false;
return disabledBy.some((field) => {
const value = getValues(field.fieldName);
if (value === "always") return true;
if (typeof value === "boolean") return field.invert ? value : !value;
if (typeof value === "number")
return field.invert
@ -109,7 +115,7 @@ export function DynamicForm<T extends FieldValues>({
<DynamicFormField
field={field}
control={control}
disabled={isDisabled(field.disabledBy)}
disabled={isDisabled(field.disabledBy, field.disabled)}
/>
</FieldWrapper>
))}

10
src/components/Form/FormInput.tsx

@ -4,11 +4,14 @@ import type {
} from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js";
import type { LucideIcon } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";
inputChange?: ChangeEventHandler;
properties?: {
value?: string;
prefix?: string;
suffix?: string;
step?: number;
@ -33,13 +36,14 @@ export function GenericInput<T extends FieldValues>({
type={field.type}
step={field.properties?.step}
value={field.type === "number" ? Number.parseFloat(value) : value}
onChange={(e) =>
onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange(
field.type === "number"
? Number.parseFloat(e.target.value)
: e.target.value,
)
}
);
}}
{...field.properties}
{...rest}
disabled={disabled}

6
src/components/Form/FormPasswordGenerator.tsx

@ -8,6 +8,8 @@ import { Controller, type FieldValues } from "react-hook-form";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange: ChangeEventHandler;
selectChange: (event: string) => void;
@ -17,6 +19,7 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
export function PasswordGenerator<T extends FieldValues>({
control,
field,
disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
return (
<Controller
@ -24,7 +27,9 @@ export function PasswordGenerator<T extends FieldValues>({
control={control}
render={({ field: { value, ...rest } }) => (
<Generator
hide={field.hide}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
selectChange={field.selectChange}
buttonClick={field.buttonClick}
@ -33,6 +38,7 @@ export function PasswordGenerator<T extends FieldValues>({
buttonText="Generate"
{...field.properties}
{...rest}
disabled={disabled}
/>
)}
/>

19
src/components/PageComponents/Config/Device.tsx

@ -36,19 +36,6 @@ export const Device = (): JSX.Element => {
formatEnumName: true,
},
},
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Enable the device's serial console",
},
{
type: "toggle",
name: "debugLogEnabled",
label: "Enabled Debug Log",
description:
"Output debugging information to the device's serial port (auto disables when serial client is connected)",
},
{
type: "number",
name: "buttonGpio",
@ -86,12 +73,6 @@ export const Device = (): JSX.Element => {
label: "Double Tap as Button Press",
description: "Treat double tap as button press",
},
{
type: "toggle",
name: "isManaged",
label: "Managed",
description: "Is this device managed by a mesh administator",
},
{
type: "toggle",
name: "disableTripleClick",

7
src/components/PageComponents/Config/LoRa.tsx

@ -56,6 +56,13 @@ export const LoRa = (): JSX.Element => {
label: "Ignore MQTT",
description: "Don't forward MQTT messages over the mesh",
},
{
type: "toggle",
name: "configOkToMqtt",
label: "OK to MQTT",
description:
"When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
},
],
},
{

237
src/components/PageComponents/Config/Security.tsx

@ -0,0 +1,237 @@
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
import { DynamicForm } from "@app/components/Form/DynamicForm.js";
import {
getX25519PrivateKey,
getX25519PublicKey,
} from "@app/core/utils/x25519";
import type { SecurityValidation } from "@app/validation/config/security.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js";
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
export const Security = (): JSX.Element => {
const { config, nodes, hardware, setWorkingConfig } = useDevice();
const [privateKey, setPrivateKey] = useState<string>(
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
);
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
config.security?.privateKey.length ?? 32,
);
const [privateKeyValidationText, setPrivateKeyValidationText] =
useState<string>();
const [publicKey, setPublicKey] = useState<string>(
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
);
const [adminKey, setAdminKey] = useState<string>(
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)),
);
const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return;
setWorkingConfig(
new Protobuf.Config.Config({
payloadVariant: {
case: "security",
value: {
...data,
adminKey: [toByteArray(adminKey)],
privateKey: toByteArray(privateKey),
publicKey: toByteArray(publicKey),
},
},
}),
);
};
const validateKey = (
input: string,
count: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => void,
) => {
try {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
}
} catch (e) {
console.error(e);
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
}
};
const privateKeyClickEvent = () => {
setDialogOpen(true);
};
const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey();
const publicKey = getX25519PublicKey(privateKey);
setPrivateKey(fromByteArray(privateKey));
setPublicKey(fromByteArray(publicKey));
validateKey(
fromByteArray(privateKey),
privateKeyBitCount,
setPrivateKeyValidationText,
);
setDialogOpen(false);
};
const privateKeyInputChangeEvent = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const privateKeyB64String = e.target.value;
setPrivateKey(privateKeyB64String);
validateKey(
privateKeyB64String,
privateKeyBitCount,
setPrivateKeyValidationText,
);
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
setPublicKey(fromByteArray(publicKey));
};
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setAdminKey(psk);
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
};
const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setPrivateKeyBitCount(count);
validateKey(privateKey, count, setPrivateKeyValidationText);
};
return (
<>
<DynamicForm<SecurityValidation>
onSubmit={onSubmit}
submitType="onChange"
defaultValues={{
...config.security,
...{
adminKey: adminKey,
privateKey: privateKey,
publicKey: publicKey,
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
isManaged: config.security?.isManaged ?? false,
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
serialEnabled: config.security?.serialEnabled ?? false,
},
}}
fieldGroups={[
{
label: "Security Settings",
description: "Settings for the Security configuration",
fields: [
{
type: "passwordGenerator",
name: "privateKey",
label: "Private Key",
description: "Used to create a shared key with a remote device",
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
validationText: privateKeyValidationText,
devicePSKBitCount: privateKeyBitCount,
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible,
buttonClick: privateKeyClickEvent,
properties: {
value: privateKey,
action: {
icon: privateKeyVisible ? EyeOff : Eye,
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
},
},
},
{
type: "text",
name: "publicKey",
label: "Public Key",
disabled: true,
description:
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: {
value: publicKey,
},
},
],
},
{
label: "Admin Settings",
description: "Settings for Admin",
fields: [
{
type: "toggle",
name: "adminChannelEnabled",
label: "Allow Legacy Admin",
description:
"Allow incoming device control over the insecure legacy admin channel",
},
{
type: "toggle",
name: "isManaged",
label: "Managed",
description:
'If true, device is considered to be "managed" by a mesh administrator via admin messages',
},
{
type: "text",
name: "adminKey",
label: "Admin Key",
description:
"The public key authorized to send admin messages to this node",
validationText: adminKeyValidationText,
inputChange: adminKeyInputChangeEvent,
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
value: adminKey,
},
},
],
},
{
label: "Logging Settings",
description: "Settings for Logging",
fields: [
{
type: "toggle",
name: "debugLogApiEnabled",
label: "Enable Debug Log API",
description:
"Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
},
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Serial Console over the Stream API",
},
],
},
]}
/>
<PkiRegenerateDialog
open={dialogOpen}
onOpenChange={() => setDialogOpen(false)}
onSubmit={() => pkiRegenerate()}
/>
</>
);
};

3
src/components/PageLayout.tsx

@ -8,6 +8,7 @@ export interface PageLayoutProps {
children: React.ReactNode;
actions?: {
icon: LucideIcon;
iconClasses?: string;
onClick: () => void;
}[];
}
@ -39,7 +40,7 @@ export const PageLayout = ({
className="transition-all hover:text-accent"
onClick={action.onClick}
>
<action.icon />
<action.icon className={action.iconClasses} />
</button>
))}
</div>

2
src/components/Sidebar.tsx

@ -85,7 +85,7 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
<div className="flex items-center">
<ZapIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>
{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts
{myNode?.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">

54
src/components/UI/Generator.tsx

@ -9,63 +9,95 @@ import {
SelectTrigger,
SelectValue,
} from "@components/UI/Select.js";
import type { LucideIcon } from "lucide-react";
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
hide?: boolean;
devicePSKBitCount?: number;
value: string;
variant: "default" | "invalid";
buttonText?: string;
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
action?: {
icon: LucideIcon;
onClick: () => void;
};
disabled?: boolean;
}
const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
(
{
hide = true,
devicePSKBitCount,
variant,
value,
buttonText,
bits = [
{ text: "256 bit", value: "32", key: "bit256" },
{ text: "128 bit", value: "16", key: "bit128" },
{ text: "8 bit", value: "1", key: "bit8" },
],
selectChange,
inputChange,
buttonClick,
action,
disabled,
...props
},
ref,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
// Invokes onChange event on the input element when the value changes from the parent component
React.useEffect(() => {
if (!inputRef.current) return;
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (!setValue) return;
inputRef.current.value = "";
setValue.call(inputRef.current, value);
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
}, [value]);
return (
<>
<Input
type="text"
type={hide ? "password" : "text"}
id="pskInput"
variant={variant}
value={value}
onChange={inputChange}
action={action}
disabled={disabled}
ref={inputRef}
/>
<Select
value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)}
disabled={disabled}
>
<SelectTrigger>
<SelectTrigger className="!max-w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="bit256" value="32">
256 bit
</SelectItem>
<SelectItem key="bit128" value="16">
128 bit
</SelectItem>
<SelectItem key="bit8" value="1">
8 bit
</SelectItem>
{bits.map(({ text, value, key }) => (
<SelectItem key={key} value={value}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="success"
onClick={buttonClick}
disabled={disabled}
{...props}
>
{buttonText}

3
src/components/UI/Input.tsx

@ -31,7 +31,7 @@ export interface InputProps
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, variant, prefix, suffix, action, ...props }, ref) => {
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
return (
<div className="relative w-full">
{prefix && (
@ -45,6 +45,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className,
inputVariants({ variant }),
)}
value={value}
ref={ref}
{...props}
/>

3
src/core/stores/deviceStore.ts

@ -191,6 +191,9 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
device.config.bluetooth = config.payloadVariant.value;
break;
}
case "security": {
device.config.security = config.payloadVariant.value;
}
}
}
}),

15
src/core/utils/x25519.ts

@ -0,0 +1,15 @@
import { x25519 } from "@noble/curves/ed25519";
export function getX25519PrivateKey(): Uint8Array {
const key = x25519.utils.randomPrivateKey();
key[0] &= 248;
key[31] &= 127;
key[31] |= 64;
return key;
}
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
return x25519.getPublicKey(privateKey);
}

5
src/pages/Config/DeviceConfig.tsx

@ -5,6 +5,7 @@ import { LoRa } from "@components/PageComponents/Config/LoRa.js";
import { Network } from "@components/PageComponents/Config/Network.js";
import { Position } from "@components/PageComponents/Config/Position.js";
import { Power } from "@components/PageComponents/Config/Power.js";
import { Security } from "@components/PageComponents/Config/Security.js";
import {
Tabs,
TabsContent,
@ -47,6 +48,10 @@ export const DeviceConfig = (): JSX.Element => {
label: "Bluetooth",
element: Bluetooth,
},
{
label: "Security",
element: Security,
},
];
return (

2
src/pages/Dashboard/index.tsx

@ -84,7 +84,7 @@ export const Dashboard = () => {
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
<H3>No Devices</H3>
<Subtle>Connect atleast one device to get started</Subtle>
<Subtle>Connect at least one device to get started</Subtle>
<Button
className="gap-2"
onClick={() => setConnectDialogOpen(true)}

190
src/pages/Map.tsx

@ -87,110 +87,108 @@ export const MapPage = (): JSX.Element => {
))}
</SidebarSection>
</Sidebar>
<div className="flex flex-col flex-grow">
<PageLayout
label="Map"
noPadding={true}
actions={[
{
icon: ZoomInIcon,
onClick() {
map?.zoomIn();
},
<PageLayout
label="Map"
noPadding={true}
actions={[
{
icon: ZoomInIcon,
onClick() {
map?.zoomIn();
},
{
icon: ZoomOutIcon,
onClick() {
map?.zoomOut();
},
},
{
icon: ZoomOutIcon,
onClick() {
map?.zoomOut();
},
{
icon: BoxSelectIcon,
onClick() {
getBBox();
},
},
{
icon: BoxSelectIcon,
onClick() {
getBBox();
},
]}
>
<MapGl
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
// onClick={(e) => {
// const waypoint = new Protobuf.Waypoint({
// name: "test",
// description: "test description",
// latitudeI: Math.trunc(e.lngLat.lat * 1e7),
// longitudeI: Math.trunc(e.lngLat.lng * 1e7)
// });
// addWaypoint(waypoint);
// connection?.sendWaypoint(waypoint, "broadcast");
// }}
},
]}
>
<MapGl
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
// onClick={(e) => {
// const waypoint = new Protobuf.Waypoint({
// name: "test",
// description: "test description",
// latitudeI: Math.trunc(e.lngLat.lat * 1e7),
// longitudeI: Math.trunc(e.lngLat.lng * 1e7)
// });
// addWaypoint(waypoint);
// connection?.sendWaypoint(waypoint, "broadcast");
// }}
// @ts-ignore
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={{
zoom: 1.6,
latitude: 35,
longitude: 0,
}}
>
{waypoints.map((wp) => (
<Marker
key={wp.id}
longitude={wp.longitudeI / 1e7}
latitude={wp.latitudeI / 1e7}
anchor="bottom"
>
<div>
<MapPinIcon size={16} />
</div>
</Marker>
))}
{/* {rasterSources.map((source, index) => (
// @ts-ignore
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={{
zoom: 1.6,
latitude: 35,
longitude: 0,
}}
>
{waypoints.map((wp) => (
<Marker
key={wp.id}
longitude={(wp.longitudeI ?? 0) / 1e7}
latitude={(wp.latitudeI ?? 0) / 1e7}
anchor="bottom"
>
<div>
<MapPinIcon size={16} />
</div>
</Marker>
))}
{/* {rasterSources.map((source, index) => (
<Source key={index} type="raster" {...source}>
<Layer type="raster" />
</Source>
))} */}
{allNodes.map((node) => {
if (node.position?.latitudeI) {
return (
<Marker
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">
<Hashicon value={node.num.toString()} size={22} />
<Subtle className={cn(zoom < 12 && "hidden")}>
{node.user?.longName}
</Subtle>
</div>
</Marker>
);
}
})}
</MapGl>
</PageLayout>
</div>
{allNodes.map((node) => {
if (node.position?.latitudeI) {
return (
<Marker
key={node.num}
longitude={(node.position.longitudeI ?? 0) / 1e7}
latitude={(node.position.latitudeI ?? 0) / 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">
<Hashicon value={node.num.toString()} size={22} />
<Subtle className={cn(zoom < 12 && "hidden")}>
{node.user?.longName}
</Subtle>
</div>
</Marker>
);
}
})}
</MapGl>
</PageLayout>
</>
);
};

19
src/pages/Messages.tsx

@ -8,7 +8,7 @@ import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf, Types } from "@meshtastic/js";
import { getChannelName } from "@pages/Channels.js";
import { HashIcon, WaypointsIcon } from "lucide-react";
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
import { useState } from "react";
export const MessagesPage = (): JSX.Element => {
@ -79,6 +79,23 @@ export const MessagesPage = (): JSX.Element => {
actions={
chatType === "direct"
? [
{
icon: nodes.get(activeChat)?.user?.publicKey.length
? LockIcon
: LockOpenIcon,
iconClasses: nodes.get(activeChat)?.user?.publicKey.length
? "text-green-600"
: "text-yellow-300",
async onClick() {
const targetNode = nodes.get(activeChat)?.num;
if (targetNode === undefined) return;
toast({
title: nodes.get(activeChat)?.user?.publicKey.length
? "Chat is using PKI encryption."
: "Chat is using PSK encryption.",
});
},
},
{
icon: WaypointsIcon,
async onClick() {

10
src/pages/Nodes.tsx

@ -8,7 +8,7 @@ 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 { TrashIcon } from "lucide-react";
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
import { Fragment } from "react";
import { base16 } from "rfc4648";
@ -38,6 +38,7 @@ export const NodesPage = (): JSX.Element => {
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false },
{ title: "Connection", type: "normal", sortable: true },
{ title: "Remove", type: "normal", sortable: false },
]}
@ -73,6 +74,13 @@ export const NodesPage = (): JSX.Element => {
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw
</Mono>,
<Mono key="pki">
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon className="text-green-600" />
) : (
<LockOpenIcon className="text-yellow-300" />
)}
</Mono>,
<Mono key="hops">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0

6
src/validation/config/bluetooth.ts

@ -3,7 +3,11 @@ import { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsEnum, IsInt } from "class-validator";
export class BluetoothValidation
implements Omit<Protobuf.Config.Config_BluetoothConfig, keyof Message>
implements
Omit<
Protobuf.Config.Config_BluetoothConfig,
keyof Message | "deviceLoggingEnabled"
>
{
@IsBoolean()
enabled: boolean;

6
src/validation/config/lora.ts

@ -3,7 +3,8 @@ import { Protobuf } from "@meshtastic/js";
import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator";
export class LoRaValidation
implements Omit<Protobuf.Config.Config_LoRaConfig, keyof Message>
implements
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled">
{
@IsBoolean()
usePreset: boolean;
@ -59,4 +60,7 @@ export class LoRaValidation
@IsBoolean()
ignoreMqtt: boolean;
@IsBoolean()
configOkToMqtt: boolean;
}

3
src/validation/config/power.ts

@ -3,7 +3,8 @@ import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
export class PowerValidation
implements Omit<Protobuf.Config.Config_PowerConfig, keyof Message>
implements
Omit<Protobuf.Config.Config_PowerConfig, keyof Message | "powermonEnables">
{
@IsBoolean()
isPowerSaving: boolean;

35
src/validation/config/security.ts

@ -0,0 +1,35 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsString } from "class-validator";
export class SecurityValidation
implements
Omit<
Protobuf.Config.Config_SecurityConfig,
keyof Message | "adminKey" | "privateKey" | "publicKey"
>
{
@IsBoolean()
adminChannelEnabled: boolean;
@IsString()
adminKey: string;
@IsBoolean()
bluetoothLoggingEnabled: boolean;
@IsBoolean()
debugLogApiEnabled: boolean;
@IsBoolean()
isManaged: boolean;
@IsString()
privateKey: string;
@IsString()
publicKey: string;
@IsBoolean()
serialEnabled: boolean;
}

5
src/validation/moduleConfig/storeForward.ts

@ -4,7 +4,10 @@ import { IsBoolean, IsInt } from "class-validator";
export class StoreForwardValidation
implements
Omit<Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig, keyof Message>
Omit<
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig,
keyof Message | "isServer"
>
{
@IsBoolean()
enabled: boolean;

3
vite.config.ts

@ -1,11 +1,10 @@
import { execSync } from "node:child_process";
import { resolve } from "node:path";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite";
import EnvironmentPlugin from "vite-plugin-environment";
import react from "@vitejs/plugin-react";
let hash = "";
try {

Loading…
Cancel
Save