2.0 Overhaul start

This commit is contained in:
Sacha Weatherstone
2022-09-22 16:35:40 +10:00
parent 4e45990ef5
commit a73eab1ea6
87 changed files with 2880 additions and 3573 deletions

View File

@@ -1 +1,4 @@
{}
{
"tabWidth": 2,
"useTabs": false
}

View File

@@ -20,22 +20,23 @@
"homepage": "https://meshtastic.org",
"dependencies": {
"@emeraldpay/hashicon-react": "^0.5.2",
"@hookform/resolvers": "^2.9.7",
"@headlessui/react": "^1.7.2",
"@heroicons/react": "^2.0.11",
"@hookform/resolvers": "^2.9.8",
"@meshtastic/eslint-config": "^1.0.8",
"@meshtastic/meshtasticjs": "^0.6.98",
"@meshtastic/meshtasticjs": "^0.6.99",
"@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.7",
"base64-js": "^1.5.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"evergreen-ui": "^6.10.3",
"geodesy": "^2.4.0",
"immer": "^9.0.15",
"mapbox-gl": "npm:empty-npm-package@^1.0.0",
"maplibre-gl": "^2.4.0",
"modern-css-reset": "^1.4.0",
"prettier": "^2.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.34.2",
"react-hook-form": "^7.35.0",
"react-icons": "^4.4.0",
"react-json-pretty": "^2.2.0",
"react-map-gl": "^7.0.19",
@@ -44,20 +45,25 @@
"zustand": "4.1.1"
},
"devDependencies": {
"@types/chrome": "^0.0.196",
"@types/chrome": "^0.0.197",
"@types/geodesy": "^2.2.3",
"@types/node": "^18.7.16",
"@types/react": "^18.0.18",
"@types/node": "^18.7.18",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@types/w3c-web-serial": "^1.0.2",
"@types/web-bluetooth": "^0.0.15",
"@vitejs/plugin-react": "^2.1.0",
"autoprefixer": "^10.4.11",
"gzipper": "^7.1.0",
"postcss": "^8.4.16",
"prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.13",
"rollup-plugin-visualizer": "^5.8.1",
"tailwindcss": "^3.1.8",
"tar": "^6.1.11",
"tslib": "^2.4.0",
"typescript": "^4.8.3",
"vite": "^3.1.0",
"vite": "^3.1.3",
"vite-plugin-environment": "^1.1.2"
}
}

1283
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

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

View File

@@ -1,20 +1,37 @@
import type React from "react";
import { Pane } from "evergreen-ui";
import { MapProvider } from "react-map-gl";
import { AppLayout } from "@components/layout/AppLayout.js";
import { useAppStore } from "@app/core/stores/appStore.js";
import { DeviceWrapper } from "@app/DeviceWrapper.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { DeviceSelector } from "./components/DeviceSelector.js";
import { NewDevice } from "./components/NewDevice.js";
import { PageNav } from "./components/PageNav.js";
import { Sidebar } from "./components/Sidebar.js";
import { PageRouter } from "./PageRouter.js";
export const App = (): JSX.Element => {
const { getDevice } = useDeviceStore();
const { selectedDevice } = useAppStore();
const device = getDevice(selectedDevice);
return (
<Pane display="flex">
<AppLayout>
<MapProvider>
<PageRouter />
</MapProvider>
</AppLayout>
</Pane>
<div className="h-full flex w-full">
<DeviceSelector />
{device && (
<DeviceWrapper device={device}>
<Sidebar />
<PageNav />
<MapProvider>
<PageRouter />
</MapProvider>
</DeviceWrapper>
)}
{selectedDevice === 0 && <NewDevice />}
</div>
);
};

View File

@@ -1,7 +1,7 @@
import type React from "react";
import type React from 'react';
import { DeviceContext } from "@core/providers/useDevice.js";
import type { Device } from "@core/stores/deviceStore.js";
import { DeviceContext } from '@core/providers/useDevice.js';
import type { Device } from '@core/stores/deviceStore.js';
export interface DeviceProps {
children: React.ReactNode;

View File

@@ -1,12 +1,12 @@
import type React from "react";
import { useDevice } from "@core/providers/useDevice.js";
import { ChannelsPage } from "@pages/Channels/index.js";
import { ChannelsPage } from "@pages/Channels.js";
import { ConfigPage } from "@pages/Config/index.js";
import { ExtensionsPage } from "@pages/Extensions/Index.js";
import { InfoPage } from "@pages/Info/index.js";
import { MapPage } from "@pages/Map/index.js";
import { MessagesPage } from "@pages/Messages/index.js";
import { InfoPage } from "@pages/Info.js";
import { MapPage } from "@pages/Map.js";
import { MessagesPage } from "@pages/Messages.js";
export const PageRouter = (): JSX.Element => {
const { activePage } = useDevice();

43
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,43 @@
import type React from "react";
import type { ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
variant?: "primary" | "secondary";
iconBefore?: JSX.Element;
iconAfter?: JSX.Element;
}
export const Button = ({
size = "md",
variant = "primary",
iconBefore,
iconAfter,
children,
disabled,
...rest
}: ButtonProps): JSX.Element => {
return (
<button
className={`px-3 w-full rounded-md flex border border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 ${
variant === "primary"
? "bg-orange-600 text-white shadow-sm hover:bg-orange-700"
: "bg-orange-100 text-orange-700 hover:bg-orange-200"
} ${
size === "sm"
? "h-8 text-sm"
: size === "md"
? "h-10 text-sm"
: "h-10 text-base"
} ${disabled ? "cursor-not-allowed bg-red-400 focus:ring-red-500" : ""}`}
disabled={disabled}
{...rest}
>
<div className="flex items-center m-auto gap-2 font-medium">
{iconBefore}
{children}
{iconAfter}
</div>
</button>
);
};

View File

@@ -0,0 +1,48 @@
import type React from "react";
import { useAppStore } from "@app/core/stores/appStore.js";
import { useDeviceStore } from "@app/core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { PlusIcon } from "@heroicons/react/24/outline";
export const DeviceSelector = (): JSX.Element => {
const { getDevices } = useDeviceStore();
const { selectedDevice, setSelectedDevice } = useAppStore();
return (
<div className="flex bg-slate-50 w-16 items-center whitespace-nowrap py-12 text-sm [writing-mode:vertical-rl] h-full">
<span className="font-mono text-slate-500">Connected Devices</span>
<span className="mt-6 flex gap-4 font-bold text-slate-900">
{getDevices().map((device) => (
<div
key={device.id}
onClick={() => {
setSelectedDevice(device.id);
}}
className="group flex w-8 h-8 p-0.5 cursor-pointer drop-shadow-md"
>
<Hashicon size={32} value={device.hardware.myNodeNum.toString()} />
<div
className={`absolute -left-1.5 w-0.5 h-7 rounded-full group-hover:bg-orange-300 ${
device.id === selectedDevice
? "bg-orange-400"
: "bg-transparent"
}`}
/>
</div>
))}
<div
onClick={() => {
setSelectedDevice(0);
}}
className={`w-8 h-8 p-2 border-dashed border-2 rounded-md hover:border-orange-300 cursor-pointer ${
selectedDevice === 0 ? "border-orange-400" : "border-slate-200"
}`}
>
<PlusIcon />
</div>
</span>
<img src="Logo_Black.svg" className="px-3 mt-auto" />
</div>
);
};

View File

@@ -1,43 +0,0 @@
import type React from "react";
import { CogIcon, CrossIcon, IconButton, Text } from "evergreen-ui";
import { TabbedContent, TabType } from "../layout/page/TabbedContent.js";
import { Dialog } from "./index.js";
export interface HelpDialogProps {
isOpen: boolean;
close: () => void;
}
export const HelpDialog = ({ isOpen, close }: HelpDialogProps): JSX.Element => {
const tabs: TabType[] = [
{
name: "Device Config",
icon: CogIcon,
element: () => (
<div>
<Text>Title</Text>
</div>
),
},
{
name: "Device Config",
icon: CogIcon,
element: () => (
<div>
<Text>Title 2</Text>
</div>
),
},
];
return (
<Dialog isOpen={isOpen} close={close} title="Help">
<TabbedContent
tabs={tabs}
actions={[() => <IconButton icon={CrossIcon} onClick={close} />]}
/>
</Dialog>
);
};

View File

@@ -1,126 +0,0 @@
import type React from "react";
import {
HelperManagementIcon,
IconButton,
majorScale,
MoreIcon,
Table,
TagIcon,
Tooltip,
} from "evergreen-ui";
import { toMGRS } from "@app/core/utils/toMGRS.js";
import { useDevice } from "@core/providers/useDevice.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf } from "@meshtastic/meshtasticjs";
import { Dialog } from "./index.js";
export interface PeersDialogProps {
isOpen: boolean;
close: () => void;
}
export const PeersDialog = ({
isOpen,
close,
}: PeersDialogProps): JSX.Element => {
const { hardware, nodes, connection, setPeerInfoOpen, setActivePeer } =
useDevice();
return (
<Dialog isOpen={isOpen} close={close} width={majorScale(120)}>
<Table>
<Table.Head>
<Table.HeaderCell flexBasis={48} flexShrink={0} flexGrow={0} />
<Table.TextHeaderCell flexBasis={96} flexShrink={0} flexGrow={0}>
Number
</Table.TextHeaderCell>
<Table.TextHeaderCell flexBasis={116} flexShrink={0} flexGrow={0}>
Name
</Table.TextHeaderCell>
<Table.TextHeaderCell flexBasis={48} flexShrink={0} flexGrow={0}>
SNR
</Table.TextHeaderCell>
<Table.TextHeaderCell>Location</Table.TextHeaderCell>
<Table.TextHeaderCell>Telemetry</Table.TextHeaderCell>
<Table.TextHeaderCell>Last Heard</Table.TextHeaderCell>
<Table.TextHeaderCell>Actions</Table.TextHeaderCell>
</Table.Head>
<Table.Body height={240}>
{nodes
.filter((n) => n.data.num !== hardware.myNodeNum)
.map((node) => (
<Table.Row
key={node.data.num}
isSelectable
onSelect={() => {
setActivePeer(node.data.num);
setPeerInfoOpen(true);
}}
>
<Table.Cell flexBasis={48} flexShrink={0} flexGrow={0}>
<Hashicon
value={node.data.num.toString()}
size={majorScale(3)}
/>
</Table.Cell>
<Table.TextCell flexBasis={96} flexShrink={0} flexGrow={0}>
{node.data.num}
</Table.TextCell>
<Table.TextCell flexBasis={116} flexShrink={0} flexGrow={0}>
{node.data.user?.longName}
</Table.TextCell>
<Table.TextCell flexBasis={48} flexShrink={0} flexGrow={0}>
{node.data.snr}
</Table.TextCell>
<Table.TextCell>
{toMGRS(
node.data.position?.latitudeI,
node.data.position?.longitudeI
)}
</Table.TextCell>
<Table.TextCell>Tmp</Table.TextCell>
<Table.TextCell>
{new Date(node.data.lastHeard * 1000).toLocaleString()}
</Table.TextCell>
<Table.Cell gap={majorScale(1)}>
<Tooltip content="Manage">
<IconButton icon={HelperManagementIcon} />
</Tooltip>
<IconButton
icon={TagIcon}
onClick={() => {
void connection?.sendPacket(
Protobuf.AdminMessage.toBinary({
payloadVariant: {
oneofKind: "getConfigRequest",
getConfigRequest:
Protobuf.AdminMessage_ConfigType.LORA_CONFIG,
},
}),
Protobuf.PortNum.ADMIN_APP,
node.data.num,
true,
7,
true,
false,
async (test) => {
console.log(test);
console.log("got response");
return Promise.resolve();
}
);
}}
/>
<IconButton icon={MoreIcon} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Dialog>
);
};

View File

@@ -2,21 +2,14 @@ import type React from "react";
import { useEffect, useState } from "react";
import { fromByteArray } from "base64-js";
import {
Checkbox,
ClipboardIcon,
FormField,
IconButton,
majorScale,
Pane,
TextInputField,
Tooltip,
} from "evergreen-ui";
import { QRCode } from "react-qrcode-logo";
import { Dialog } from "@headlessui/react";
import { ClipboardIcon } from "@heroicons/react/24/outline";
import { Protobuf } from "@meshtastic/meshtasticjs";
import { Dialog } from "./index.js";
import { Checkbox } from "../form/Checkbox.js";
import { Input } from "../form/Input.js";
export interface QRDialogProps {
isOpen: boolean;
@@ -54,64 +47,65 @@ export const QRDialog = ({
}, [channels, selectedChannels, loraConfig]);
return (
// <Dialog
// isShown={isOpen}
//
// onCloseComplete={close}
// hasFooter={false}
// >
<Dialog isOpen={isOpen} close={close} title="Generate QR Code" background>
<Pane display="flex">
<FormField
width="12rem"
label="Channels to include"
description="The current LoRa configuration will also be shared."
>
{channels.map((channel) => (
<Checkbox
key={channel.index}
disabled={channel.role === Protobuf.Channel_Role.DISABLED}
label={
channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.index}`
}
checked={selectedChannels.includes(channel.index)}
onChange={() => {
if (selectedChannels.includes(channel.index)) {
setSelectedChannels(
selectedChannels.filter((c) => c !== channel.index)
);
} else {
setSelectedChannels([...selectedChannels, channel.index]);
}
}}
/>
))}
</FormField>
<Pane
display="flex"
flexDirection="column"
flexGrow={1}
margin={majorScale(1)}
>
<Pane display="flex" margin="auto">
<QRCode value={QRCodeURL} size={250} qrStyle="dots" />
</Pane>
<Pane display="flex" gap={majorScale(1)}>
<TextInputField
label="Sharable URL"
value={QRCodeURL}
width="100%"
/>
<Tooltip content="Copy to Clipboard">
<IconButton icon={ClipboardIcon} marginTop="1.6rem" />
</Tooltip>
</Pane>
</Pane>
</Pane>
<Dialog open={isOpen} onClose={close}>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-sm rounded bg-white p-3">
<Dialog.Title>Generate QR Code</Dialog.Title>
<Dialog.Description>
This will permanently deactivate your account
</Dialog.Description>
<div className="flex">
<div className="flex flex-col">
<span className="font-medium text-lg">Channels to include</span>
<span className="text-sm text-slate-600">
The current LoRa configuration will also be shared.
</span>
{channels.map((channel) => (
<Checkbox
key={channel.index}
disabled={channel.role === Protobuf.Channel_Role.DISABLED}
label={
channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.index}`
}
checked={selectedChannels.includes(channel.index)}
onChange={() => {
if (selectedChannels.includes(channel.index)) {
setSelectedChannels(
selectedChannels.filter((c) => c !== channel.index)
);
} else {
setSelectedChannels([...selectedChannels, channel.index]);
}
}}
/>
))}
</div>
<div className="flex flex-col flex-grow m-2">
<div className="flex m-auto">
<QRCode value={QRCodeURL} size={250} qrStyle="dots" />
</div>
<div className="flex gap-2">
<Input
label="Sharable URL"
value={QRCodeURL}
action={{
icon: <ClipboardIcon className="h-4" />,
action: () => {
console.log("");
},
}}
/>
</div>
</div>
</div>
</Dialog.Panel>
</div>
</Dialog>
);
};

View File

@@ -1,69 +0,0 @@
import type React from "react";
import { useEffect, useState } from "react";
import { SelectField } from "evergreen-ui";
import { useForm } from "react-hook-form";
import { LoRaValidation } from "@app/validation/config/lora.js";
import { useDevice } from "@core/providers/useDevice.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
import { Form } from "../form/Form.js";
import { Dialog } from "./index.js";
export interface RegionDialogProps {
isOpen: boolean;
}
export const RegionDialog = ({ isOpen }: RegionDialogProps): JSX.Element => {
const { config, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
} = useForm<LoRaValidation>({
defaultValues: config.lora,
resolver: classValidatorResolver(LoRaValidation),
});
useEffect(() => {
reset(config.lora);
}, [reset, config.lora]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setConfig(
{
payloadVariant: {
oneofKind: "lora",
lora: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Dialog isOpen={isOpen} close={close} title="Set Device Region" background>
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<SelectField
label="Region"
description="This is a description."
isInvalid={!!errors.region?.message}
validationMessage={errors.region?.message}
{...register("region", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
</SelectField>
</Form>
</Dialog>
);
};

View File

@@ -1,63 +0,0 @@
import type React from "react";
import {
CrossIcon,
Heading,
IconButton,
majorScale,
Overlay,
Pane,
} from "evergreen-ui";
export interface DialogProps {
isOpen: boolean;
close: () => void;
title?: string;
background?: boolean;
width?: number;
children: React.ReactNode;
}
export const Dialog = ({
isOpen,
close,
title,
background,
width,
children,
}: DialogProps): JSX.Element => {
return (
<Overlay
isShown={isOpen}
onExit={close}
containerProps={{
display: "flex",
}}
>
<Pane
role="dialog"
width={width ?? majorScale(80)}
margin="auto"
display="flex"
flexDirection="column"
zIndex={1}
borderRadius={majorScale(1)}
padding={majorScale(3)}
background={background ? "white" : undefined}
>
{background && (
<Pane
display="flex"
justifyContent="space-between"
marginBottom={majorScale(2)}
>
<Heading size={600}>{title}</Heading>
<IconButton icon={CrossIcon} onClick={close} />
</Pane>
)}
{children}
</Pane>
</Overlay>
);
};

View File

@@ -0,0 +1,33 @@
import type React from "react";
import type { ButtonHTMLAttributes } from "react";
export interface IconButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
variant?: "primary" | "secondary";
icon?: JSX.Element;
}
export const IconButton = ({
size = "md",
variant = "primary",
icon,
disabled,
...rest
}: IconButtonProps): JSX.Element => {
return (
<button
className={`flex border border-transparent rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 ${
variant === "primary"
? "bg-orange-600 text-white shadow-sm hover:bg-orange-700"
: "bg-orange-100 text-orange-700 hover:bg-orange-200"
} ${
size === "sm" ? "h-8 w-8" : size === "md" ? "h-10 w-10" : "h-12 w-12"
} ${disabled ? "cursor-not-allowed bg-red-400 focus:ring-red-500" : ""}`}
disabled={disabled}
{...rest}
>
<div className="m-auto">{icon}</div>
</button>
);
};

View File

@@ -0,0 +1,34 @@
import type React from "react";
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi";
import { TabbedContent, TabType } from "./layout/page/TabbedContent.js";
import { BLE } from "./PageComponents/Connect/BLE.js";
import { HTTP } from "./PageComponents/Connect/HTTP.js";
import { Serial } from "./PageComponents/Connect/Serial.js";
export const NewDevice = () => {
const tabs: TabType[] = [
{
name: "BLE",
icon: <FiBluetooth className="h-4" />,
element: BLE,
},
{
name: "HTTP",
icon: <FiWifi className="h-4" />,
element: HTTP,
},
{
name: "Serial",
icon: <FiTerminal className="h-4" />,
element: Serial,
},
];
return (
<div className="w-96 h-96 m-auto">
<TabbedContent tabs={tabs} />
</div>
);
};

View File

@@ -0,0 +1,198 @@
import type React from "react";
import { useEffect, useState } from "react";
import { fromByteArray, toByteArray } from "base64-js";
import { Controller, useForm } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
import {
ArrowPathIcon,
EyeIcon,
EyeSlashIcon,
} from "@heroicons/react/24/outline";
import { Protobuf } from "@meshtastic/meshtasticjs";
import { Select } from "../form/Select.js";
import { Toggle } from "../form/Toggle.js";
export interface SettingsPanelProps {
channel: Protobuf.Channel;
}
export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
const { connection } = useDevice();
const [loading, setLoading] = useState(false);
const [keySize, setKeySize] = useState<128 | 256>(256);
const [pskHidden, setPskHidden] = useState(true);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
setValue,
} = useForm<
Omit<Protobuf.ChannelSettings, "psk"> & { psk: string; enabled: boolean }
>({
defaultValues: {
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
},
});
useEffect(() => {
reset({
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
});
}, [channel, reset]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
const channelData = Protobuf.Channel.create({
role:
channel?.role === Protobuf.Channel_Role.PRIMARY
? Protobuf.Channel_Role.PRIMARY
: data.enabled
? Protobuf.Channel_Role.SECONDARY
: Protobuf.Channel_Role.DISABLED,
index: channel?.index,
settings: {
...data,
psk: toByteArray(data.psk ?? ""),
},
});
await connection?.setChannel(channelData, (): Promise<void> => {
reset({ ...data });
setLoading(false);
return Promise.resolve();
});
});
return (
<Form
title="Channel Editor"
breadcrumbs={[
"Channels",
channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.index}`,
]}
reset={() =>
reset({
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
})
}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
{channel?.index !== 0 && (
<>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
label="Name"
description="Max transmit power in dBm"
{...register("name")}
/>
</>
)}
<Select
label="Key Size"
description="Desired size of generated key."
value={keySize}
onChange={(e): void => {
setKeySize(parseInt(e.target.value) as 128 | 256);
}}
action={{
icon: <ArrowPathIcon className="h-4" />,
action: () => {
const key = new Uint8Array(keySize / 8);
crypto.getRandomValues(key);
setValue("psk", fromByteArray(key));
},
}}
>
<option value={128}>128 Bit</option>
<option value={256}>256 Bit</option>
</Select>
<Input
width="100%"
label="Pre-Shared Key"
description="Max transmit power in dBm"
type={pskHidden ? "password" : "text"}
action={{
icon: pskHidden ? (
<EyeIcon className="w-4" />
) : (
<EyeSlashIcon className="w-4" />
),
action: () => {
setPskHidden(!pskHidden);
},
}}
{...register("psk")}
/>
<Controller
name="uplinkEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Uplink Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="downlinkEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Downlink Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form>
);
};

View File

@@ -1,9 +1,11 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { BluetoothValidation } from "@app/validation/config/bluetooth.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -54,43 +56,41 @@ export const Bluetooth = (): JSX.Element => {
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Bluetooth Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<SelectField
<Form
title="Bluetooth Config"
breadcrumbs={["Config", "Bluetooth"]}
reset={() => reset(config.bluetooth)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Select
label="Pairing mode"
description="This is a description."
isInvalid={!!errors.mode?.message}
validationMessage={errors.mode?.message}
{...register("mode", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_BluetoothConfig_PairingMode)}
</SelectField>
</Select>
<TextInputField
display={
<Input
disabled={
pairingMode !== Protobuf.Config_BluetoothConfig_PairingMode.FIXED_PIN
? "none"
: "block"
}
label="Pin"
description="This is a description."
type="number"
isInvalid={!!errors.fixedPin?.message}
validationMessage={errors.fixedPin?.message}
{...register("fixedPin", {
valueAsNumber: true,
})}

View File

@@ -1,9 +1,10 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, toaster } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { DeviceValidation } from "@app/validation/config/device.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -39,7 +40,7 @@ export const Device = (): JSX.Element => {
},
},
async () => {
toaster.success("Successfully updated device config");
// toaster.success("Successfully updated device config");
reset({ ...data });
setLoading(false);
await Promise.resolve();
@@ -47,58 +48,45 @@ export const Device = (): JSX.Element => {
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<SelectField
<Form
title="Device Config"
breadcrumbs={["Config", "Device"]}
reset={() => reset(config.device)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Select
label="Role"
description="This is a description."
isInvalid={!!errors.role?.message}
validationMessage={errors.role?.message}
{...register("role", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_DeviceConfig_Role)}
</SelectField>
<FormField
label="Serial Console Disabled"
description="Description"
isInvalid={!!errors.serialDisabled?.message}
validationMessage={errors.serialDisabled?.message}
>
<Controller
name="serialDisabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Factory Reset Device"
description="Description"
isInvalid={!!errors.factoryReset?.message}
validationMessage={errors.factoryReset?.message}
>
<Controller
name="factoryReset"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Enabled Debug Log"
description="Description"
isInvalid={!!errors.debugLogEnabled?.message}
validationMessage={errors.debugLogEnabled?.message}
>
<Controller
name="debugLogEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Select>
<Controller
name="serialEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Serial Output Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="debugLogEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Enabled Debug Log"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form>
);
};

View File

@@ -1,9 +1,11 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { DisplayValidation } from "@app/validation/config/display.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -46,42 +48,47 @@ export const Display = (): JSX.Element => {
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<TextInputField
<Form
title="Display Config"
breadcrumbs={["Config", "Display"]}
reset={() => reset(config.display)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Input
label="Screen Timeout"
description="This is a description."
hint="Seconds"
suffix="Seconds"
type="number"
{...register("screenOnSecs", { valueAsNumber: true })}
/>
<TextInputField
<Input
label="Carousel Delay"
description="This is a description."
hint="Seconds"
suffix="Seconds"
type="number"
{...register("autoScreenCarouselSecs", { valueAsNumber: true })}
/>
<SelectField
<Select
label="GPS Display Units"
description="This is a description."
{...register("gpsFormat", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_DisplayConfig_GpsCoordinateFormat)}
</SelectField>
<FormField
label="Compass North Top"
description="Description"
isInvalid={!!errors.compassNorthTop?.message}
validationMessage={errors.compassNorthTop?.message}
>
<Controller
name="compassNorthTop"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Select>
<Controller
name="compassNorthTop"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Compass North Top"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form>
);
};

View File

@@ -1,9 +1,11 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { LoRaValidation } from "@app/validation/config/lora.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -14,7 +16,6 @@ import { Protobuf } from "@meshtastic/meshtasticjs";
export const LoRa = (): JSX.Element => {
const { config, connection } = useDevice();
const [loading, setLoading] = useState(false);
const [usePreset, setUsePreset] = useState(true);
const {
register,
@@ -27,6 +28,12 @@ export const LoRa = (): JSX.Element => {
resolver: classValidatorResolver(LoRaValidation),
});
const usePreset = useWatch({
control,
name: "usePreset",
defaultValue: true,
});
useEffect(() => {
reset(config.lora);
}, [reset, config.lora]);
@@ -49,115 +56,109 @@ export const LoRa = (): JSX.Element => {
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Use Preset"
description="Description"
isInvalid={!!errors.txDisabled?.message}
validationMessage={errors.txDisabled?.message}
>
<Switch
height={24}
marginLeft="auto"
checked={usePreset}
onChange={(e) => setUsePreset(e.target.checked)}
/>
</FormField>
<SelectField
display={usePreset ? "block" : "none"}
<Form
title="LoRa Config"
breadcrumbs={["Config", "LoRa"]}
reset={() => reset(config.lora)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="usePreset"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Use Preset"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Select
label="Preset"
description="This is a description."
isInvalid={!!errors.modemPreset?.message}
validationMessage={errors.modemPreset?.message}
disabled={!usePreset}
{...register("modemPreset", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
</SelectField>
</Select>
<TextInputField
display={usePreset ? "none" : "block"}
<Input
label="Bandwidth"
description="Max transmit power in dBm"
type="number"
hint="MHz"
isInvalid={!!errors.bandwidth?.message}
validationMessage={errors.bandwidth?.message}
suffix="MHz"
error={errors.bandwidth?.message}
{...register("bandwidth", {
valueAsNumber: true,
})}
disabled={usePreset}
/>
<TextInputField
display={usePreset ? "none" : "block"}
<Input
label="Spread Factor"
description="Max transmit power in dBm"
type="number"
hint="CPS"
isInvalid={!!errors.spreadFactor?.message}
validationMessage={errors.spreadFactor?.message}
suffix="CPS"
error={errors.spreadFactor?.message}
{...register("spreadFactor", {
valueAsNumber: true,
})}
disabled={usePreset}
/>
<TextInputField
display={usePreset ? "none" : "block"}
<Input
label="Coding Rate"
description="Max transmit power in dBm"
type="number"
isInvalid={!!errors.codingRate?.message}
validationMessage={errors.codingRate?.message}
error={errors.codingRate?.message}
{...register("codingRate", {
valueAsNumber: true,
})}
disabled={usePreset}
/>
<TextInputField
label="Transmit Power"
description="Max transmit power in dBm"
type="number"
isInvalid={!!errors.txPower?.message}
validationMessage={errors.txPower?.message}
{...register("txPower", { valueAsNumber: true })}
/>
<TextInputField
label="Hop Limit"
description="This is a description."
hint="Hops"
type="number"
isInvalid={!!errors.hopLimit?.message}
validationMessage={errors.hopLimit?.message}
{...register("hopLimit", { valueAsNumber: true })}
/>
<FormField
label="Transmit Disabled"
description="Description"
isInvalid={!!errors.txDisabled?.message}
validationMessage={errors.txDisabled?.message}
>
<Controller
name="txDisabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
<Input
label="Frequency Offset"
description="This is a description."
hint="Hz"
suffix="Hz"
type="number"
isInvalid={!!errors.frequencyOffset?.message}
validationMessage={errors.frequencyOffset?.message}
error={errors.frequencyOffset?.message}
{...register("frequencyOffset", { valueAsNumber: true })}
/>
<SelectField
<Select
label="Region"
description="This is a description."
isInvalid={!!errors.region?.message}
validationMessage={errors.region?.message}
{...register("region", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
</SelectField>
</Select>
<Input
label="Hop Limit"
description="This is a description."
suffix="Hops"
type="number"
error={errors.hopLimit?.message}
{...register("hopLimit", { valueAsNumber: true })}
/>
<Controller
name="txEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Transmit Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
label="Transmit Power"
description="Max transmit power in dBm"
type="number"
error={errors.txPower?.message}
{...register("txPower", { valueAsNumber: true })}
/>
</Form>
);
};

View File

@@ -1,15 +1,11 @@
import type React from "react";
import { useEffect, useState } from "react";
import {
FormField,
SelectField,
Switch,
TextInputField,
toaster,
} from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { renderOptions } from "@app/core/utils/selectEnumOptions.js";
import { NetworkValidation } from "@app/validation/config/network.js";
import { Form } from "@components/form/Form";
@@ -51,7 +47,7 @@ export const Network = (): JSX.Element => {
},
},
async () => {
toaster.success("Successfully updated Network config");
// toaster.success("Successfully updated Network config");
reset({ ...data });
setLoading(false);
await Promise.resolve();
@@ -59,48 +55,53 @@ export const Network = (): JSX.Element => {
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="WiFi Enabled"
description="Description"
isInvalid={!!errors.wifiEnabled?.message}
validationMessage={errors.wifiEnabled?.message}
>
<Controller
name="wifiEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<SelectField
<Form
title="Network Config"
breadcrumbs={["Config", "Network"]}
reset={() => reset(config.network)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="wifiEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="WiFi Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Select
label="WiFi Mode"
description="This is a description."
disabled={!wifiEnabled}
{...register("wifiMode", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_NetworkConfig_WiFiMode)}
</SelectField>
<TextInputField
</Select>
<Input
label="SSID"
description="This is a description."
isInvalid={!!errors.wifiSsid?.message}
validationMessage={errors.wifiSsid?.message}
error={errors.wifiSsid?.message}
disabled={!wifiEnabled}
{...register("wifiSsid")}
/>
<TextInputField
<Input
label="PSK"
type="password"
description="This is a description."
isInvalid={!!errors.wifiPsk?.message}
validationMessage={errors.wifiPsk?.message}
error={errors.wifiPsk?.message}
disabled={!wifiEnabled}
{...register("wifiPsk")}
/>
<TextInputField
<Input
label="NTP Server"
description="This is a description."
isInvalid={!!errors.ntpServer?.message}
validationMessage={errors.ntpServer?.message}
error={errors.ntpServer?.message}
{...register("ntpServer")}
/>
</Form>

View File

@@ -1,21 +1,14 @@
import type React from "react";
import { useEffect, useState } from "react";
import {
Button,
FormField,
SelectMenu,
Switch,
TextInputField,
} from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { PositionValidation } from "@app/validation/config/position.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
import { bitwiseDecode } from "@core/utils/bitwise";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const Position = (): JSX.Element => {
const { config, connection } = useDevice();
@@ -29,15 +22,6 @@ export const Position = (): JSX.Element => {
} = useForm<PositionValidation>({
defaultValues: config.position,
resolver: classValidatorResolver(PositionValidation),
// defaultValues: {
// ...preferences,
// positionBroadcastSecs:
// preferences.positionBroadcastSecs === 0
// ? preferences.role === Protobuf.Role.Router
// ? 43200
// : 900
// : preferences.positionBroadcastSecs,
// },
});
useEffect(() => {
@@ -62,76 +46,74 @@ export const Position = (): JSX.Element => {
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<TextInputField
hint="Seconds"
<Form
title="Position Config"
breadcrumbs={["Config", "Position"]}
reset={() => reset(config.position)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Input
suffix="Seconds"
label="Broadcast Interval"
description="This is a description."
type="number"
isInvalid={!!errors.positionBroadcastSecs?.message}
validationMessage={errors.positionBroadcastSecs?.message}
error={errors.positionBroadcastSecs?.message}
{...register("positionBroadcastSecs", { valueAsNumber: true })}
/>
<FormField
label="Disable Smart Position"
description="Description"
isInvalid={!!errors.positionBroadcastSmartDisabled?.message}
validationMessage={errors.positionBroadcastSmartDisabled?.message}
>
<Controller
name="positionBroadcastSmartDisabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Use Fixed Position"
description="Description"
isInvalid={!!errors.fixedPosition?.message}
validationMessage={errors.fixedPosition?.message}
>
<Controller
name="fixedPosition"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Disable GPS"
description="Description"
isInvalid={!!errors.gpsDisabled?.message}
validationMessage={errors.gpsDisabled?.message}
>
<Controller
name="gpsDisabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
hint="Seconds"
<Controller
name="positionBroadcastSmartEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Enable Smart Position"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="fixedPosition"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Use Fixed Position"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="gpsEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="GPS Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
suffix="Seconds"
label="GPS Update Interval"
description="This is a description."
type="number"
isInvalid={!!errors.gpsUpdateInterval?.message}
validationMessage={errors.gpsUpdateInterval?.message}
error={errors.gpsUpdateInterval?.message}
{...register("gpsUpdateInterval", { valueAsNumber: true })}
/>
<TextInputField
<Input
label="Last GPS Attempt"
description="This is a description."
type="number"
isInvalid={!!errors.gpsAttemptTime?.message}
validationMessage={errors.gpsAttemptTime?.message}
error={errors.gpsAttemptTime?.message}
{...register("gpsAttemptTime", { valueAsNumber: true })}
/>
<Controller
{/* <Controller
name="positionFlags"
control={control}
render={({ field, fieldState }): JSX.Element => {
@@ -218,7 +200,7 @@ export const Position = (): JSX.Element => {
</FormField>
);
}}
/>
/> */}
</Form>
);
};

View File

@@ -1,9 +1,10 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { PowerValidation } from "@app/validation/config/power.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -44,81 +45,79 @@ export const Power = (): JSX.Element => {
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<TextInputField
<Form
title="Power Config"
breadcrumbs={["Config", "Power"]}
reset={() => reset(config.power)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Input
label="Shutdown on battery delay"
description="This is a description."
hint="Seconds"
suffix="Seconds"
type="number"
isInvalid={!!errors.onBatteryShutdownAfterSecs?.message}
validationMessage={errors.onBatteryShutdownAfterSecs?.message}
error={errors.onBatteryShutdownAfterSecs?.message}
{...register("onBatteryShutdownAfterSecs", { valueAsNumber: true })}
/>
<FormField
label="Power Saving"
description="Description"
isInvalid={!!errors.isPowerSaving?.message}
validationMessage={errors.isPowerSaving?.message}
>
<Controller
name="isPowerSaving"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
<Controller
name="isPowerSaving"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Power Saving"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
label="ADC Multiplier Override ratio"
description="This is a description."
type="number"
isInvalid={!!errors.adcMultiplierOverride?.message}
validationMessage={errors.adcMultiplierOverride?.message}
error={errors.adcMultiplierOverride?.message}
{...register("adcMultiplierOverride", { valueAsNumber: true })}
/>
<TextInputField
<Input
label="Minimum Wake Time"
description="This is a description."
hint="Seconds"
suffix="Seconds"
type="number"
isInvalid={!!errors.minWakeSecs?.message}
validationMessage={errors.minWakeSecs?.message}
error={errors.minWakeSecs?.message}
{...register("minWakeSecs", { valueAsNumber: true })}
/>
<TextInputField
<Input
label="Mesh SDS Timeout"
description="This is a description."
hint="Seconds"
suffix="Seconds"
type="number"
isInvalid={!!errors.meshSdsTimeoutSecs?.message}
validationMessage={errors.meshSdsTimeoutSecs?.message}
error={errors.meshSdsTimeoutSecs?.message}
{...register("meshSdsTimeoutSecs", { valueAsNumber: true })}
/>
<TextInputField
<Input
label="SDS"
description="This is a description."
hint="Seconds"
suffix="Seconds"
type="number"
isInvalid={!!errors.sdsSecs?.message}
validationMessage={errors.sdsSecs?.message}
error={errors.sdsSecs?.message}
{...register("sdsSecs", { valueAsNumber: true })}
/>
<TextInputField
<Input
label="LS"
description="This is a description."
hint="Seconds"
suffix="Seconds"
type="number"
isInvalid={!!errors.lsSecs?.message}
validationMessage={errors.lsSecs?.message}
error={errors.lsSecs?.message}
{...register("lsSecs", { valueAsNumber: true })}
/>
<TextInputField
<Input
label="Wait Bluetooth"
description="This is a description."
hint="Seconds"
suffix="Seconds"
type="number"
isInvalid={!!errors.waitBluetoothSecs?.message}
validationMessage={errors.waitBluetoothSecs?.message}
error={errors.waitBluetoothSecs?.message}
{...register("waitBluetoothSecs", { valueAsNumber: true })}
/>
</Form>

View File

@@ -1,10 +1,12 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { base16 } from "rfc4648";
import { Input } from "@app/components/form/Input.js";
import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { UserValidation } from "@app/validation/config/user.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -50,27 +52,39 @@ export const User = (): JSX.Element => {
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<TextInputField
<Form
title="User Config"
breadcrumbs={["Config", "User"]}
reset={() => {
reset({
longName: myNode?.data.user?.longName,
shortName: myNode?.data.user?.shortName,
isLicensed: myNode?.data.user?.isLicensed,
});
}}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Input
label="Device ID"
description="Preset unique identifier for this device."
isInvalid={!!errors.id?.message}
validationMessage={errors.id?.message}
error={errors.id?.message}
{...register("id")}
readOnly
/>
<TextInputField
<Input
label="Device Name"
description="Personalised name for this device."
{...register("longName")}
/>
<TextInputField
<Input
label="Short Name"
description="This is a description."
maxLength={3}
{...register("shortName")}
/>
<TextInputField
<Input
label="Mac Address"
description="This is a description."
disabled
@@ -81,28 +95,26 @@ export const User = (): JSX.Element => {
?.join(":") ?? ""
}
/>
<SelectField
<Select
label="Hardware"
description="This is a description."
disabled
value={myNode?.data.user?.hwModel}
>
{renderOptions(Protobuf.HardwareModel)}
</SelectField>
<FormField
label="Licenced Operator?"
description="Description"
isInvalid={!!errors.isLicensed?.message}
validationMessage={errors.isLicensed?.message}
>
<Controller
name="isLicensed"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Select>
<Controller
name="isLicensed"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Licenced Operator?"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form>
);
};

View File

@@ -1,17 +1,15 @@
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { Button, majorScale, Pane } from "evergreen-ui";
import { FiPlusCircle } from "react-icons/fi";
import type { CloseProps } from "@components/SlideSheets/NewDevice.js";
import { Button } from "@components/Button.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { Constants, IBLEConnection } from "@meshtastic/meshtasticjs";
export const BLE = ({ close }: CloseProps): JSX.Element => {
export const BLE = (): JSX.Element => {
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore();
const { setSelectedDevice } = useAppStore();
@@ -34,30 +32,25 @@ export const BLE = ({ close }: CloseProps): JSX.Element => {
});
device.addConnection(connection);
subscribeAll(device, connection);
close();
};
return (
<Pane
display="flex"
flexDirection="column"
padding={majorScale(2)}
gap={majorScale(2)}
>
{bleDevices.map((device, index) => (
<Button
key={index}
onClick={() => {
void onConnect(device);
}}
>
{device.name}
</Button>
))}
<div className="flex flex-col p-4 gap-2 w-full">
<div className="flex gap-2 flex-col h-48 overflow-y-auto">
{bleDevices.map((device, index) => (
<Button
key={index}
variant="secondary"
onClick={() => {
void onConnect(device);
}}
>
{device.name}
</Button>
))}
</div>
<Button
appearance="primary"
gap={majorScale(1)}
iconBefore={<PlusCircleIcon className="w-4" />}
onClick={() => {
void navigator.bluetooth
.requestDevice({
@@ -72,8 +65,7 @@ export const BLE = ({ close }: CloseProps): JSX.Element => {
}}
>
New device
<FiPlusCircle />
</Button>
</Pane>
</div>
);
};

View File

@@ -1,27 +1,18 @@
import type React from "react";
import {
Button,
FormField,
majorScale,
Pane,
Switch,
TextInputField,
} from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { FiPlusCircle } from "react-icons/fi";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { Button } from "@components/Button.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { IHTTPConnection } from "@meshtastic/meshtasticjs";
export interface HTTPProps {
close: () => void;
}
export const HTTP = ({ close }: HTTPProps): JSX.Element => {
export const HTTP = (): JSX.Element => {
const { addDevice } = useDeviceStore();
const { setSelectedDevice } = useAppStore();
const { register, handleSubmit, control } = useForm<{
@@ -34,6 +25,12 @@ export const HTTP = ({ close }: HTTPProps): JSX.Element => {
},
});
const TLSEnabled = useWatch({
control,
name: "tls",
defaultValue: false,
});
const onSubmit = handleSubmit((data) => {
const id = randId();
const device = addDevice(id);
@@ -47,42 +44,34 @@ export const HTTP = ({ close }: HTTPProps): JSX.Element => {
});
device.addConnection(connection);
subscribeAll(device, connection);
close();
});
return (
// eslint-disable-next-line @typescript-eslint/no-misused-promises
<form onSubmit={onSubmit}>
<Pane
display="flex"
flexDirection="column"
padding={majorScale(2)}
gap={majorScale(2)}
>
<TextInputField
<form className="w-full p-4 gap-2 flex flex-col" onSubmit={onSubmit}>
<div className="h-48 flex flex-col gap-2">
<Input
label="IP Address/Hostname"
prefix={TLSEnabled ? "https://" : "http://"}
placeholder="000.000.000.000 / meshtastic.local"
{...register("ip")}
/>
<FormField label="Use TLS">
<Controller
name="tls"
control={control}
render={({ field: { value, ...field } }) => (
<Switch
height={24}
marginLeft="auto"
checked={value}
{...field}
/>
)}
/>
</FormField>
<Button appearance="primary" gap={majorScale(1)} type="submit">
Connect
<FiPlusCircle />
</Button>
</Pane>
<Controller
name="tls"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Use TLS"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</div>
<Button iconBefore={<PlusCircleIcon className="w-4" />} type="submit">
Connect
</Button>
</form>
);
};

View File

@@ -1,14 +1,12 @@
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { Button, majorScale, Pane } from "evergreen-ui";
import { FiPlusCircle } from "react-icons/fi";
import type { CloseProps } from "@components/SlideSheets/NewDevice.js";
import { Button } from "@components/Button.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { ISerialConnection } from "@meshtastic/meshtasticjs";
interface USBID {
@@ -16,7 +14,7 @@ interface USBID {
name: string;
}
export const Serial = ({ close }: CloseProps): JSX.Element => {
export const Serial = (): JSX.Element => {
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore();
const { setSelectedDevice } = useAppStore();
@@ -45,50 +43,25 @@ export const Serial = ({ close }: CloseProps): JSX.Element => {
});
device.addConnection(connection);
subscribeAll(device, connection);
close();
};
const VID: USBID[] = [
{
id: 9114,
name: "TBA",
},
];
const PID: USBID[] = [
{
id: 32809,
name: "TBA",
},
];
return (
<Pane
display="flex"
flexDirection="column"
padding={majorScale(2)}
gap={majorScale(2)}
>
{serialPorts.map((port, index) => (
<Button
key={index}
gap={5}
onClick={() => {
void onConnect(port);
}}
>
{VID.find((id) => id.id === port.getInfo().usbVendorId ?? 0)?.name ??
"Unknown"}{" "}
-{" "}
{PID.find((id) => id.id === port.getInfo().usbProductId ?? 0)?.name ??
"Unknown"}
<FiPlusCircle />
</Button>
))}
<div className="flex flex-col p-4 gap-2 w-full">
<div className="flex gap-2 flex-col h-48 overflow-y-auto">
{serialPorts.map((port, index) => (
<Button
key={index}
variant="secondary"
onClick={() => {
void onConnect(port);
}}
>
{port.getInfo().usbVendorId} - {port.getInfo().usbProductId}
</Button>
))}
</div>
<Button
appearance="primary"
gap={majorScale(1)}
iconBefore={<PlusCircleIcon className="w-4" />}
onClick={() => {
void navigator.serial.requestPort().then((port) => {
setSerialPorts(serialPorts.concat(port));
@@ -96,8 +69,7 @@ export const Serial = ({ close }: CloseProps): JSX.Element => {
}}
>
New device
<FiPlusCircle />
</Button>
</Pane>
</div>
);
};

View File

@@ -0,0 +1,85 @@
import type React from "react";
import { ChangeEvent, useState } from "react";
import { Input } from "@app/components/form/Input.js";
import { IconButton } from "@app/components/IconButton.js";
import { Message } from "@components/PageComponents/Messages/Message.js";
import { useDevice } from "@core/providers/useDevice.js";
import type { Channel } from "@core/stores/deviceStore.js";
import { MapPinIcon, PaperAirplaneIcon } from "@heroicons/react/24/outline";
export interface ChannelChatProps {
channel: Channel;
}
export const ChannelChat = ({ channel }: ChannelChatProps): JSX.Element => {
const { nodes, connection, ackMessage } = useDevice();
const [currentMessage, setCurrentMessage] = useState("");
const sendMessage = (): void => {
void connection?.sendText(
currentMessage,
undefined,
true,
channel.config.index,
(id) => {
ackMessage(channel.config.index, id);
return Promise.resolve();
}
);
setCurrentMessage("");
};
return (
<div className="flex flex-col flex-grow">
<div className="flex flex-col flex-grow">
{channel.messages.map((message, index) => (
<Message
key={index}
message={message}
lastMsgSameUser={
index === 0
? false
: channel.messages[index - 1].packet.from ===
message.packet.from
}
sender={
nodes.find((node) => node.data.num === message.packet.from)?.data
}
/>
))}
</div>
<div className="flex gap-2">
<form
className="w-full"
onSubmit={(e): void => {
e.preventDefault();
sendMessage();
}}
>
<div className="flex flex-grow gap-2">
<span className="w-full">
<Input
minLength={2}
label=""
placeholder="Enter Message"
value={currentMessage}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setCurrentMessage(e.target.value);
}}
/>
</span>
<IconButton
variant="secondary"
icon={<PaperAirplaneIcon className="h-4 text-slate-500" />}
/>
</div>
</form>
<IconButton
variant="secondary"
icon={<MapPinIcon className="h-4 text-slate-500" />}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,89 @@
import type React from "react";
import { WaypointMessage } from "@components/PageComponents/Messages/WaypointMessage.js";
import { useDevice } from "@core/providers/useDevice.js";
import type { AllMessageTypes } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import {
CheckCircleIcon,
EllipsisHorizontalCircleIcon,
} from "@heroicons/react/24/outline";
import type { Protobuf } from "@meshtastic/meshtasticjs";
export interface MessageProps {
lastMsgSameUser: boolean;
message: AllMessageTypes;
sender?: Protobuf.NodeInfo;
}
export const Message = ({
lastMsgSameUser,
message,
sender,
}: MessageProps): JSX.Element => {
const { setPeerInfoOpen, setActivePeer } = useDevice();
const openPeer = (): void => {
setActivePeer(message.packet.from);
setPeerInfoOpen(true);
};
return lastMsgSameUser ? (
<div className="flex ml-4">
{message.ack ? (
<CheckCircleIcon className="my-auto text-slate-200 h-4" />
) : (
<EllipsisHorizontalCircleIcon className="my-auto text-slate-200 h-4" />
)}
{"waypointID" in message ? (
<WaypointMessage waypointID={message.waypointID} />
) : (
<span
className={`ml-4 pl-2 border-l-2 border-l-slate-200 ${
message.ack ? "text-black" : "text-slate-500"
}`}
>
{message.text}
</span>
)}
</div>
) : (
<div className="mx-4 gap-2 mt-2">
<div className="flex gap-2">
<div className="cursor-pointer w-6" onClick={openPeer}>
<Hashicon value={(sender?.num ?? 0).toString()} size={32} />
</div>
<span
className="cursor-pointer font-medium text-slate-700"
onClick={openPeer}
>
{sender?.user?.longName ?? "UNK"}
</span>
<span className="text-sm">
{new Date(message.packet.rxTime).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<div className="flex">
{message.ack ? (
<CheckCircleIcon className="my-auto text-slate-200 h-4" />
) : (
<EllipsisHorizontalCircleIcon className="my-auto text-slate-200 h-4" />
)}
{"waypointID" in message ? (
<WaypointMessage waypointID={message.waypointID} />
) : (
<span
className={`ml-4 pl-2 border-l-2 border-l-slate-200 ${
message.ack ? "text-black" : "text-slate-500"
}`}
>
{message.text}
</span>
)}
</div>
</div>
);
};

View File

@@ -1,13 +1,8 @@
import type React from "react";
import {
Button,
majorScale,
Pane,
SelectField,
TextInputField,
} from "evergreen-ui";
import { Input } from "@app/components/form/Input.js";
import { Select } from "@app/components/form/Select.js";
import { Button } from "@components/Button.js";
import { useDevice } from "@core/providers/useDevice.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { Protobuf } from "@meshtastic/meshtasticjs";
@@ -22,20 +17,19 @@ export const NewLocationMessage = (): JSX.Element => {
const { connection } = useDevice();
return (
<Pane width={240} margin={majorScale(2)}>
<div className="w-96 m-4">
<form
onSubmit={(e): void => {
e.preventDefault();
}}
>
<TextInputField label="Name" />
<TextInputField label="Description" />
<SelectField label="Type" value={LocationType.MGRS}>
<Input label="Name" />
<Input label="Description" />
<Select label="Type" value={LocationType.MGRS}>
{renderOptions(LocationType)}
</SelectField>
<TextInputField label="Coordinates" />
</Select>
<Input label="Coordinates" />
<Button
width="100%"
onClick={() => {
void connection?.sendWaypoint(
Protobuf.Waypoint.create({
@@ -50,6 +44,6 @@ export const NewLocationMessage = (): JSX.Element => {
Send
</Button>
</form>
</Pane>
</div>
);
};

View File

@@ -0,0 +1,33 @@
import type React from "react";
import { useDevice } from "@app/core/providers/useDevice.js";
import { toMGRS } from "@core/utils/toMGRS.js";
import { MapPinIcon } from "@heroicons/react/24/outline";
export interface WaypointMessageProps {
waypointID: number;
}
export const WaypointMessage = ({
waypointID,
}: WaypointMessageProps): JSX.Element => {
const { waypoints } = useDevice();
const waypoint = waypoints.find((wp) => wp.id === waypointID);
return (
<div className="ml-4 pl-2 border-l-slate-200 border-l-2">
<div className="gap-2 flex rounded-md p-2 shadow-md shadow-orange-300">
<MapPinIcon className="m-auto w-6 text-slate-600" />
<div>
<div className="flex gap-2">
<div className="font-bold">{waypoint?.name}</div>
<span className="text-sm font-mono text-slate-500">
{toMGRS(waypoint?.latitudeI, waypoint?.longitudeI)}
</span>
</div>
<span className="text-sm">{waypoint?.description}</span>
</div>
</div>
</div>
);
};

View File

@@ -1,9 +1,11 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -52,57 +54,60 @@ export const CannedMessage = (): JSX.Element => {
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="This is a description."
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Rotary Encoder #1 Enabled"
description="This is a description."
isInvalid={!!errors.rotary1Enabled?.message}
validationMessage={errors.rotary1Enabled?.message}
>
<Controller
name="rotary1Enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
<Form
title="Canned Message Config"
breadcrumbs={["Module Config", "Canned Message"]}
reset={() => reset(moduleConfig.cannedMessage)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Module Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="rotary1Enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Rotary Encoder #1 Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
label="Encoder Pin A"
description="Max transmit power in dBm"
type="number"
disabled={moduleEnabled}
{...register("inputbrokerPinA", { valueAsNumber: true })}
/>
<TextInputField
<Input
label="Encoder Pin B"
description="Max transmit power in dBm"
type="number"
disabled={moduleEnabled}
{...register("inputbrokerPinB", { valueAsNumber: true })}
/>
<TextInputField
<Input
label="Endoer Pin Press"
description="Max transmit power in dBm"
type="number"
disabled={moduleEnabled}
{...register("inputbrokerPinPress", { valueAsNumber: true })}
/>
<SelectField
<Select
label="Clockwise event"
description="This is a description."
disabled={moduleEnabled}
@@ -111,8 +116,8 @@ export const CannedMessage = (): JSX.Element => {
{renderOptions(
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
)}
</SelectField>
<SelectField
</Select>
<Select
label="Counter Clockwise event"
description="This is a description."
disabled={moduleEnabled}
@@ -121,8 +126,8 @@ export const CannedMessage = (): JSX.Element => {
{renderOptions(
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
)}
</SelectField>
<SelectField
</Select>
<Select
label="Press event"
description="This is a description."
disabled={moduleEnabled}
@@ -131,41 +136,37 @@ export const CannedMessage = (): JSX.Element => {
{renderOptions(
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
)}
</SelectField>
<FormField
label="Up Down enabled"
description="This is a description."
isInvalid={!!errors.updown1Enabled?.message}
validationMessage={errors.updown1Enabled?.message}
>
<Controller
name="updown1Enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
</Select>
<Controller
name="updown1Enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Up Down enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
label="Allow Input Source"
description="Max transmit power in dBm"
disabled={moduleEnabled}
{...register("allowInputSource")}
/>
<FormField
label="Send Bell"
description="This is a description."
isInvalid={!!errors.sendBell?.message}
validationMessage={errors.sendBell?.message}
>
<Controller
name="sendBell"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<Controller
name="sendBell"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Send Bell"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form>
);
};

View File

@@ -1,9 +1,10 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -50,32 +51,37 @@ export const ExternalNotification = (): JSX.Element => {
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
<Form
title="External Notification Config"
breadcrumbs={["Module Config", "External Notification"]}
reset={() => reset(moduleConfig.externalNotification)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Module Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
type="number"
label="Output MS"
description="Max transmit power in dBm"
hint="ms"
suffix="ms"
disabled={!moduleEnabled}
{...register("outputMs", {
valueAsNumber: true,
})}
/>
<TextInputField
<Input
type="number"
label="Output"
description="Max transmit power in dBm"
@@ -84,51 +90,42 @@ export const ExternalNotification = (): JSX.Element => {
valueAsNumber: true,
})}
/>
<FormField
label="Active"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.active?.message}
validationMessage={errors.active?.message}
>
<Controller
name="active"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Message"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.alertMessage?.message}
validationMessage={errors.alertMessage?.message}
>
<Controller
name="alertMessage"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Bell"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.alertBell?.message}
validationMessage={errors.alertBell?.message}
>
<Controller
name="alertBell"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<Controller
name="active"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Active"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="alertMessage"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Message"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="alertBell"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Bell"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form>
);
};

View File

@@ -1,9 +1,10 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { MQTTValidation } from "@app/validation/moduleConfig/mqtt.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -50,34 +51,39 @@ export const MQTT = (): JSX.Element => {
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
<Form
title="MQTT Config"
breadcrumbs={["Module Config", "MQTT"]}
reset={() => reset(moduleConfig.mqtt)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Module Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
label="MQTT Server Address"
description="Description"
disabled={!moduleEnabled}
{...register("address")}
/>
<TextInputField
<Input
label="MQTT Username"
description="Description"
disabled={!moduleEnabled}
{...register("username")}
/>
<TextInputField
<Input
label="MQTT Password"
description="Description"
type="password"
@@ -85,36 +91,30 @@ export const MQTT = (): JSX.Element => {
disabled={!moduleEnabled}
{...register("password")}
/>
<FormField
label="Encryption Enabled"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.encryptionEnabled?.message}
validationMessage={errors.encryptionEnabled?.message}
>
<Controller
name="encryptionEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="JSON Output Enabled"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.jsonEnabled?.message}
validationMessage={errors.jsonEnabled?.message}
>
<Controller
name="jsonEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<Controller
name="encryptionEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Encryption Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="jsonEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="JSON Output Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form>
);
};

View File

@@ -1,9 +1,10 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -51,46 +52,48 @@ export const RangeTest = (): JSX.Element => {
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
<Form
title="Range Test Config"
breadcrumbs={["Module Config", "Range Test"]}
reset={() => reset(moduleConfig.rangeTest)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Module Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
type="number"
label="Message Interval"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
hint="Seconds"
suffix="Seconds"
{...register("sender", {
valueAsNumber: true,
})}
/>
<FormField
label="Save CSV to storage"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.save?.message}
validationMessage={errors.save?.message}
>
<Controller
name="save"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<Controller
name="save"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Save CSV to storage"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form>
);
};

View File

@@ -1,9 +1,10 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { SerialValidation } from "@app/validation/moduleConfig/serial.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -51,37 +52,39 @@ export const Serial = (): JSX.Element => {
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Echo"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.echo?.message}
validationMessage={errors.echo?.message}
>
<Controller
name="echo"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
<Form
title="Serial Config"
breadcrumbs={["Module Config", "Serial"]}
reset={() => reset(moduleConfig.serial)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Module Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="echo"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Echo"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
type="number"
label="RX"
description="Max transmit power in dBm"
@@ -90,7 +93,7 @@ export const Serial = (): JSX.Element => {
valueAsNumber: true,
})}
/>
<TextInputField
<Input
type="number"
label="TX Pin"
description="Max transmit power in dBm"
@@ -99,7 +102,7 @@ export const Serial = (): JSX.Element => {
valueAsNumber: true,
})}
/>
<TextInputField
<Input
type="number"
label="Baud Rate"
description="Max transmit power in dBm"
@@ -108,7 +111,7 @@ export const Serial = (): JSX.Element => {
valueAsNumber: true,
})}
/>
<TextInputField
<Input
type="number"
label="Timeout"
description="Max transmit power in dBm"
@@ -117,7 +120,7 @@ export const Serial = (): JSX.Element => {
valueAsNumber: true,
})}
/>
<TextInputField
<Input
type="number"
label="Mode"
description="Max transmit power in dBm"

View File

@@ -1,9 +1,10 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -51,47 +52,49 @@ export const StoreForward = (): JSX.Element => {
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Heartbeat Enabled"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.heartbeat?.message}
validationMessage={errors.heartbeat?.message}
>
<Controller
name="heartbeat"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
<Form
title="Store & Forward Config"
breadcrumbs={["Module Config", "Store & Forward"]}
reset={() => reset(moduleConfig.storeForward)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Module Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="heartbeat"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Heartbeat Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
type="number"
label="Number of records"
description="Max transmit power in dBm"
hint="Records"
suffix="Records"
disabled={!moduleEnabled}
{...register("records", {
valueAsNumber: true,
})}
/>
<TextInputField
<Input
type="number"
label="History return max"
description="Max transmit power in dBm"
@@ -100,7 +103,7 @@ export const StoreForward = (): JSX.Element => {
valueAsNumber: true,
})}
/>
<TextInputField
<Input
type="number"
label="History return window"
description="Max transmit power in dBm"

View File

@@ -1,9 +1,10 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
@@ -44,58 +45,59 @@ export const Telemetry = (): JSX.Element => {
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Measurement Enabled"
description="Description"
isInvalid={!!errors.environmentMeasurementEnabled?.message}
validationMessage={errors.environmentMeasurementEnabled?.message}
>
<Controller
name="environmentMeasurementEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Displayed on Screen"
description="Description"
isInvalid={!!errors.environmentScreenEnabled?.message}
validationMessage={errors.environmentScreenEnabled?.message}
>
<Controller
name="environmentScreenEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
<Form
title="Telemetry Config"
breadcrumbs={["Module Config", "Telemetry"]}
reset={() => reset(moduleConfig.telemetry)}
loading={loading}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller
name="environmentMeasurementEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Module Enabled"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Controller
name="environmentScreenEnabled"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Displayed on Screen"
description="Description"
checked={value}
{...rest}
/>
)}
/>
<Input
label="Update Interval"
description="Max transmit power in dBm"
hint="Seconds"
suffix="Seconds"
type="number"
{...register("environmentUpdateInterval", {
valueAsNumber: true,
})}
/>
<FormField
label="Display Farenheit"
description="Description"
isInvalid={!!errors.environmentDisplayFahrenheit?.message}
validationMessage={errors.environmentDisplayFahrenheit?.message}
>
<Controller
name="environmentDisplayFahrenheit"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<Controller
name="environmentDisplayFahrenheit"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Display Farenheit"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form>
);
};

View File

@@ -0,0 +1,77 @@
import type React from "react";
import { useDevice } from "@app/core/providers/useDevice.js";
import type { Page } from "@app/core/stores/deviceStore.js";
import {
BeakerIcon,
Cog8ToothIcon,
IdentificationIcon,
InboxIcon,
MapIcon,
Square3Stack3DIcon,
} from "@heroicons/react/24/outline";
export const PageNav = (): JSX.Element => {
const { activePage, setActivePage } = useDevice();
interface NavLink {
name: string;
icon: JSX.Element;
page: Page;
}
const pages: NavLink[] = [
{
name: "Messages",
icon: <InboxIcon />,
page: "messages",
},
{
name: "Map",
icon: <MapIcon />,
page: "map",
},
{
name: "Extensions",
icon: <BeakerIcon />,
page: "extensions",
},
{
name: "Config",
icon: <Cog8ToothIcon />,
page: "config",
},
{
name: "Channels",
icon: <Square3Stack3DIcon />,
page: "channels",
},
{
name: "Info",
icon: <IdentificationIcon />,
page: "info",
},
];
return (
<div className="flex bg-slate-50 w-12 items-center whitespace-nowrap py-4 text-sm [writing-mode:vertical-rl] h-full border-r border-slate-200 flex-shrink-0">
<span className="mt-6 flex gap-4 font-bold text-slate-500">
{pages.map((Link) => (
<div
key={Link.name}
onClick={() => {
setActivePage(Link.page);
}}
className={`w-8 h-8 p-1 border-2 rounded-md hover:border-orange-300 cursor-pointer ${
Link.page === activePage
? "border-orange-400"
: "border-slate-200"
}`}
>
{Link.icon}
</div>
))}
</span>
</div>
);
};

View File

@@ -1,109 +0,0 @@
import React, { useEffect } from "react";
import {
Button,
majorScale,
Pane,
ResetIcon,
Spinner,
StatusIndicator,
} from "evergreen-ui";
import { useDevice } from "@core/providers/useDevice.js";
export const Progress = (): JSX.Element => {
const {
hardware,
channels,
config,
moduleConfig,
setReady,
nodes,
connection,
} = useDevice();
useEffect(() => {
if (
hardware.myNodeNum !== 0 &&
Object.keys(config).length === 7 &&
Object.keys(moduleConfig).length === 7 &&
channels.length === hardware.maxChannels
) {
setReady(true);
}
}, [
config,
moduleConfig,
channels,
hardware.maxChannels,
hardware.myNodeNum,
setReady,
]);
return (
<Pane
display="flex"
flexGrow={1}
margin={majorScale(3)}
borderRadius={majorScale(1)}
elevation={1}
background="white"
>
<Pane display="flex" margin="auto" gap={majorScale(6)}>
<Pane
marginY="auto"
display="flex"
height="72px"
width="72px"
minWidth="72px"
backgroundColor="#F8E3DA"
borderRadius="50%"
>
<Spinner height="32px" width="32px" margin="auto" />
</Pane>
<Pane>
<Pane display="flex" flexDirection="column">
<StatusIndicator
color={hardware.myNodeNum !== 0 ? "success" : "disabled"}
>
Device Info
</StatusIndicator>
<StatusIndicator color={nodes.length ? "success" : "disabled"}>
Peers ({nodes.length})
</StatusIndicator>
<StatusIndicator
color={Object.keys(config).length === 7 ? "success" : "disabled"}
>
Device Config {`(${Object.keys(config).length - 1} / 6)`}
</StatusIndicator>
<StatusIndicator
color={
Object.keys(moduleConfig).length === 7 ? "success" : "disabled"
}
>
Module Config {`(${Object.keys(moduleConfig).length - 1} / 6)`}
</StatusIndicator>
<StatusIndicator
color={
channels.length > 0 && channels.length === hardware.maxChannels
? "success"
: "disabled"
}
>
Channels{" "}
{hardware.myNodeNum !== 0 &&
`(${channels.length} / ${hardware.maxChannels})`}
</StatusIndicator>
<Button
onClick={() => {
void connection?.configure();
}}
iconBefore={ResetIcon}
>
Retry
</Button>
</Pane>
</Pane>
</Pane>
</Pane>
);
};

View File

@@ -0,0 +1,80 @@
import type React from "react";
import { useDevice } from "@app/core/providers/useDevice.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { Types } from "@meshtastic/meshtasticjs";
import { ConfiguringWidget } from "./Widgets/ConfiguringWidget.js";
import { DeviceWidget } from "./Widgets/DeviceWidget.js";
import { NodeInfoWidget } from "./Widgets/NodeInfoWidget.js";
import { PeersWidget } from "./Widgets/PeersWidget.js";
import { PositionWidget } from "./Widgets/PositionWidget.js";
export const Sidebar = (): JSX.Element => {
const { removeDevice } = useDeviceStore();
const { connection, hardware, nodes, status } = useDevice();
const { selectedDevice, setSelectedDevice } = useAppStore();
return (
<div className="flex flex-col relative bg-slate-50 w-80 p-2 border-x border-slate-200 gap-2 flex-shrink-0">
<DeviceWidget
name={
nodes.find((n) => n.data.num === hardware.myNodeNum)?.data.user
?.longName ?? "UNK"
}
nodeNum={hardware.myNodeNum.toString()}
disconnected={status === Types.DeviceStatusEnum.DEVICE_DISCONNECTED}
disconnect={() => {
void connection?.disconnect();
setSelectedDevice(0);
removeDevice(selectedDevice ?? 0);
}}
reconnect={() => {
console.log("");
}}
/>
{/* <div className="text-left">
<p className="text-xl font-bold text-slate-900">
<a href="/">Their Side</a>
</p>
<p className="mt-3 text-font-medium leading-8 text-slate-700">
Conversations with the most tragically misunderstood people of our
time.
</p>
</div> */}
{/* */}
{/* */}
{/* */}
{/* */}
<div className="space-y-6">
<div>
<h3 className="font-medium text-gray-900">Information</h3>
<dl className="mt-2 divide-y divide-gray-200 border-t border-b border-gray-200">
<div className="flex justify-between py-3 text-sm font-medium">
<dt className="text-gray-500">Firmware version</dt>
<dd className="whitespace-nowrap text-gray-900 hover:underline hover:text-orange-400 cursor-pointer">
{hardware.firmwareVersion}
</dd>
</div>
</dl>
<div className="flex justify-between py-3 text-sm font-medium">
<dt className="text-gray-500">Bitrate</dt>
<dd className="whitespace-nowrap text-gray-900">
{hardware.bitrate.toFixed(2)}
<span className="font-mono text-slate-500 text-sm ">bps</span>
</dd>
</div>
</div>
<NodeInfoWidget />
{/* <BatteryWidget /> */}
<PeersWidget />
<PositionWidget />
<ConfiguringWidget />
</div>
</div>
);
};

View File

@@ -1,114 +0,0 @@
import type React from "react";
import { useState } from "react";
import {
Heading,
majorScale,
Pane,
Paragraph,
SideSheet,
Tab,
Tablist,
} from "evergreen-ui";
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi";
import type { TabType } from "@components/layout/page/TabbedContent.js";
import { BLE } from "@components/SlideSheets/tabs/connect/BLE.js";
import { HTTP } from "@components/SlideSheets/tabs/connect/HTTP.js";
import { Serial } from "@components/SlideSheets/tabs/connect/Serial.js";
export interface NewDeviceProps {
open: boolean;
onClose: () => void;
}
export interface CloseProps {
close: () => void;
}
export type connType = "http" | "ble" | "serial";
export interface ConnTab extends Omit<TabType, "element"> {
connType: connType;
element: ({ close }: CloseProps) => JSX.Element;
}
export const NewDevice = ({ open, onClose }: NewDeviceProps) => {
const [selectedConnType, setSelectedConnType] = useState<connType>("ble");
const tabs: ConnTab[] = [
{
connType: "ble",
icon: FiBluetooth,
name: "BLE",
element: BLE,
disabled: !navigator.bluetooth,
},
{
connType: "http",
icon: FiWifi,
name: "HTTP",
element: HTTP,
},
{
connType: "serial",
icon: FiTerminal,
name: "Serial",
element: Serial,
disabled: !navigator.serial,
},
];
return (
<SideSheet
isShown={open}
onCloseComplete={onClose}
containerProps={{
display: "flex",
flex: "1",
flexDirection: "column",
}}
>
<Pane zIndex={1} flexShrink={0} elevation={1} backgroundColor="white">
<Pane padding={16} borderBottom="muted">
<Heading size={600}>Connect new device</Heading>
<Paragraph size={400} color="muted">
Optional description or sub title
</Paragraph>
</Pane>
<Pane display="flex" padding={8}>
<Tablist>
{tabs.map((TabData, index) => (
<Tab
key={index}
gap={5}
isSelected={selectedConnType === TabData.connType}
onSelect={() => setSelectedConnType(TabData.connType)}
disabled={TabData.disabled}
>
<>
<TabData.icon />
{TabData.name}
</>
</Tab>
))}
</Tablist>
</Pane>
</Pane>
<Pane display="flex" overflowY="scroll" background="tint1" padding={16}>
{tabs.map((TabData, index) => (
<Pane
key={index}
borderRadius={majorScale(1)}
backgroundColor="white"
elevation={1}
flexGrow={1}
display={selectedConnType === TabData.connType ? "block" : "none"}
>
{!TabData.disabled && <TabData.element close={onClose} />}
</Pane>
))}
</Pane>
</SideSheet>
);
};

View File

@@ -1,60 +0,0 @@
import type React from "react";
import { useEffect, useState } from "react";
import { GeolocationIcon, Pane, PropertyIcon, SideSheet } from "evergreen-ui";
import { SlideSheetTabbedContent } from "@components/layout/page/SlideSheetTabbedContent.js";
import type { TabType } from "@components/layout/page/TabbedContent.js";
import { Location } from "@components/SlideSheets/tabs/nodes/Location.js";
import { Overview } from "@components/SlideSheets/tabs/nodes/Overview.js";
import { useDevice } from "@core/providers/useDevice.js";
import type { Node } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const PeerInfo = () => {
const { peerInfoOpen, activePeer, setPeerInfoOpen, nodes } = useDevice();
const [node, setNode] = useState<Node | undefined>();
useEffect(() => {
setNode(nodes.find((n) => n.data.num === activePeer));
}, [nodes, activePeer]);
const tabs: TabType[] = [
{
name: "Info",
icon: PropertyIcon,
element: () => <Overview node={node} />,
},
{
name: "Location",
icon: GeolocationIcon,
element: () => <Location node={node} />,
},
];
return (
<SideSheet
isShown={peerInfoOpen}
onCloseComplete={() => {
setPeerInfoOpen(false);
}}
containerProps={{
display: "flex",
flex: "1",
flexDirection: "column",
}}
>
<SlideSheetTabbedContent
heading={node?.data.user?.longName ?? "UNK"}
description={Protobuf.HardwareModel[node?.data.user?.hwModel ?? 0]}
tabs={tabs}
tabIcon={
<Pane marginY="auto">
<Hashicon size={32} value={(node?.data.num ?? 0).toString()} />
</Pane>
}
/>
</SideSheet>
);
};

View File

@@ -1,18 +0,0 @@
import type React from "react";
import { Pane } from "evergreen-ui";
import JSONPretty from "react-json-pretty";
import type { Node } from "@core/stores/deviceStore.js";
export interface LocationProps {
node?: Node;
}
export const Location = ({ node }: LocationProps): JSX.Element => {
return (
<Pane>
<JSONPretty data={node?.data.position} />
</Pane>
);
};

View File

@@ -1,17 +0,0 @@
import type React from "react";
import { Pane } from "evergreen-ui";
import JSONPretty from "react-json-pretty";
import type { Node } from "@core/stores/deviceStore.js";
export interface OverviewProps {
node?: Node;
}
export const Overview = ({ node }: OverviewProps): JSX.Element => {
return (
<Pane>
<JSONPretty data={node?.data.user} />
</Pane>
);
};

View File

@@ -0,0 +1,117 @@
import React, { useEffect } from "react";
import { useDevice } from "@core/providers/useDevice.js";
export const ConfiguringWidget = (): JSX.Element => {
const {
hardware,
channels,
config,
moduleConfig,
setReady,
nodes,
connection,
} = useDevice();
useEffect(() => {
if (
hardware.myNodeNum !== 0 &&
Object.keys(config).length === 7 &&
Object.keys(moduleConfig).length === 7 &&
channels.length === hardware.maxChannels
) {
setReady(true);
}
}, [
config,
moduleConfig,
channels,
hardware.maxChannels,
hardware.myNodeNum,
setReady,
]);
return (
<div className="p-6 flex flex-col rounded-2xl mb-4 text-sm space-y-3 bg-[#f9e3aa] text-black">
<p className="text-xl font-bold">Connecting to device</p>
<ol className="flex flex-col overflow-hidden gap-3">
<StatusIndicator
title="Device Info"
current={hardware.myNodeNum ? 1 : 0}
total={0}
/>
<StatusIndicator title="Peers" current={nodes.length} total={0} />
<StatusIndicator
title="Device Config"
current={Object.keys(config).length - 1}
total={6}
/>
<StatusIndicator
title="Module Config"
current={Object.keys(moduleConfig).length - 1}
total={6}
/>
<StatusIndicator
title="Channels"
current={channels.length}
total={hardware.maxChannels ?? 0}
/>
</ol>
<div
className="mt-2 rounded-md bg-[#dabb6b] p-1 ring-[#f9e3aa] cursor-pointer text-center"
onClick={() => {
void connection?.configure();
}}
>
Retry
</div>
</div>
);
};
export interface StatusIndicatorProps {
title: string;
current: number;
total: number;
}
const StatusIndicator = ({
title,
current,
total,
}: StatusIndicatorProps): JSX.Element => {
return (
<li className="relative">
<div
className={`absolute top-4 left-2.5 -ml-px h-full w-0.5 ${
current >= total ? "bg-green-500" : "bg-[#f9e3aa]"
}`}
/>
<div className="flex">
<div
className={`flex relative z-10 h-5 w-5 rounded-full border-2 ${
current === 0
? "border-[#dabb6b] bg-[#f9e3aa]"
: current >= total
? "bg-green-500 border-green-500"
: "bg-[#f9e3aa] border-green-500"
}`}
>
<span
className={`m-auto h-1.5 w-1.5 rounded-full ${
current > 0 ? "bg-green-500" : "bg-[#f9e3aa]"
}`}
/>
</div>
<span className="flex text-sm ml-4 gap-1">
<span className="font-medium">{title}</span>
<span className="font-mono text-slate-500">
({current}
{total !== 0 && `/${total}`})
</span>
</span>
</div>
</li>
);
};

View File

@@ -0,0 +1,50 @@
import type React from "react";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { XCircleIcon } from "@heroicons/react/24/outline";
import { Button } from "../Button.js";
export interface DeviceWidgetProps {
name: string;
nodeNum: string;
disconnected: boolean;
disconnect: () => void;
reconnect: () => void;
}
export const DeviceWidget = ({
name,
nodeNum,
disconnected,
disconnect,
reconnect,
}: DeviceWidgetProps): JSX.Element => {
return (
<div className="relative rounded-xl bg-emerald-400 overflow-hidden">
<div className="absolute w-full h-full bottom-20">
<Hashicon size={350} value={nodeNum} />
</div>
<div className="flex backdrop-blur-md backdrop-brightness-50 backdrop-hue-rotate-30 p-3">
<div className="drop-shadow-md">
<Hashicon size={96} value={nodeNum} />
</div>
<div className="w-full flex flex-col">
<span className="font-bold text-slate-200 ml-auto text-xl whitespace-nowrap">
{name}
</span>
<div className="ml-auto my-auto">
<Button
onClick={disconnected ? reconnect : disconnect}
variant={disconnected ? "secondary" : "primary"}
size="sm"
iconAfter={<XCircleIcon className="h-4" />}
>
{disconnected ? "Reconnect" : "Disconnect"}
</Button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,11 @@
import type React from "react";
export interface NodeInfoWidgetProps {}
export const NodeInfoWidget = ({}: NodeInfoWidgetProps): JSX.Element => {
return (
<div className="p-6 flex flex-col rounded-2xl mb-4 text-sm space-y-3 bg-[#f9e3aa] text-black">
node info
</div>
);
};

View File

@@ -0,0 +1,11 @@
import type React from "react";
export interface PeersWidgetProps {}
export const PeersWidget = ({}: PeersWidgetProps): JSX.Element => {
return (
<div className="p-6 flex flex-col rounded-2xl mb-4 text-sm space-y-3 bg-[#f9e3aa] text-black">
Peers
</div>
);
};

View File

@@ -0,0 +1,11 @@
import type React from "react";
export interface PositionWidgetProps {}
export const PositionWidget = ({}: PositionWidgetProps): JSX.Element => {
return (
<div className="p-6 flex flex-col rounded-2xl mb-4 text-sm space-y-3 bg-[#f9e3aa] text-black">
position
</div>
);
};

View File

@@ -0,0 +1,53 @@
import type React from "react";
import { forwardRef, InputHTMLAttributes } from "react";
export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
description?: string;
options?: string[];
prefix?: string;
suffix?: string;
action?: {
icon: JSX.Element;
action: () => void;
};
error?: string;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Input(
{
label,
description,
options,
prefix,
suffix,
action,
error,
children,
...rest
}: CheckboxProps,
ref
) {
return (
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
ref={ref}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
{...rest}
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="comments" className="font-medium text-gray-700">
{label}
</label>
<p id="comments-description" className="text-gray-500">
{description}
</p>
</div>
</div>
);
}
);

View File

@@ -1,15 +1,29 @@
import type React from "react";
import type { HTMLProps } from "react";
import { Button, majorScale, Pane, SavedIcon, Spinner } from "evergreen-ui";
import { FiSave } from "react-icons/fi";
import { Button } from "@components/Button.js";
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
ChevronRightIcon,
HomeIcon,
} from "@heroicons/react/24/outline";
export interface FormProps extends HTMLProps<HTMLFormElement> {
title: string;
breadcrumbs: string[];
reset: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => Promise<void>;
loading: boolean;
dirty: boolean;
}
export const Form = ({
title,
breadcrumbs,
reset,
loading,
dirty,
children,
@@ -18,31 +32,48 @@ export const Form = ({
}: FormProps): JSX.Element => {
return (
// eslint-disable-next-line @typescript-eslint/no-misused-promises
<form onSubmit={onSubmit} style={{ position: "relative" }} {...props}>
<form className="w-full" onSubmit={onSubmit} {...props}>
{loading && (
<Pane
position="absolute"
display="flex"
width="100%"
height="100%"
backgroundColor="rgba(67, 90, 111, 0.2)"
zIndex={10}
borderRadius={majorScale(1)}
>
<Spinner margin="auto" />
</Pane>
<div className="absolute flex w-full h-full bg-slate-600 rounded-md z-10">
<ArrowPathIcon className="h-8 animate-spin m-auto" />
</div>
)}
{children}
<Pane display="flex" marginTop={majorScale(2)}>
<Button
type="submit"
marginLeft="auto"
disabled={!dirty}
iconBefore={<SavedIcon />}
>
Save
</Button>
</Pane>
<div className="select-none rounded-md p-4 bg-gray-700">
<ol className="flex gap-4">
<li className="text-gray-400 hover:text-gray-200 cursor-pointer">
<HomeIcon className="h-5 w-5 flex-shrink-0 text-gray-400" />
</li>
{breadcrumbs.map((breadcrumb, index) => (
<li key={index} className="flex gap-4">
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 text-gray-500" />
<span className="text-sm font-medium text-gray-400 hover:text-gray-200 cursor-pointer">
{breadcrumb}
</span>
</li>
))}
</ol>
<div className="mt-2 flex items-center">
<h2 className="font-bold text-white truncate text-3xl tracking-tight">
{title}
</h2>
<div className="flex gap-2 ml-auto">
<Button
type="button"
onClick={() => {
reset();
}}
variant="secondary"
iconBefore={<ArrowUturnLeftIcon className="w-4" />}
>
Reset
</Button>
<Button disabled={!dirty} iconBefore={<FiSave className="w-4" />}>
Save
</Button>
</div>
</div>
</div>
<div className="flex flex-col p-2 gap-3">{children}</div>
</form>
);
};

View File

@@ -0,0 +1,75 @@
import type React from "react";
import { forwardRef, InputHTMLAttributes } from "react";
import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
description?: string;
prefix?: string;
suffix?: string;
action?: {
icon: JSX.Element;
action: () => void;
};
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, description, prefix, suffix, action, error, ...rest }: InputProps,
ref
) {
return (
<div>
{/* Label */}
<label className="block text-sm font-medium text-gray-700">{label}</label>
{/* */}
<div className="relative flex rounded-md shadow-sm">
{prefix && (
<span className="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-gray-500 sm:text-sm">
{prefix}
</span>
)}
<input
ref={ref}
className={`block w-full min-w-0 flex-1 rounded-md border border-gray-300 px-3 h-10 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
prefix ? "rounded-l-none" : ""
} ${action ? "rounded-r-none" : ""}`}
{...rest}
/>
{suffix && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<span className="text-gray-500 sm:text-sm" id="price-currency">
{suffix}
</span>
</div>
)}
{action && (
<button
type="button"
onClick={action.action}
className="relative -ml-px inline-flex items-center space-x-2 rounded-r-md border border-gray-300 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
{action.icon}
{/* <span>Sort</span> */}
</button>
)}
{error && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<ExclamationCircleIcon className="h-5 w-5 text-red-500" />
</div>
)}
</div>
{description && (
<p className="mt-2 text-sm text-gray-500" id="email-description">
{description}
</p>
)}
{error && (
<p className="mt-2 text-sm text-red-600" id="email-error">
{error}
</p>
)}
</div>
);
});

View File

@@ -0,0 +1,65 @@
import type React from "react";
import { forwardRef, SelectHTMLAttributes } from "react";
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label: string;
description?: string;
options?: string[];
prefix?: string;
suffix?: string;
action?: {
icon: JSX.Element;
action: () => void;
};
error?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Input(
{
label,
description,
options,
prefix,
suffix,
action,
error,
children,
...rest
}: SelectProps,
ref
) {
return (
<div>
<label
htmlFor="location"
className="block text-sm font-medium text-gray-700"
>
{label}
</label>
<div className="flex">
<select
ref={ref}
className={`block w-full min-w-0 flex-1 rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
prefix ? "rounded-l-none" : ""
} ${action ? "rounded-r-none" : ""}`}
{...rest}
>
{options &&
options.map((option, index) => (
<option key={index}>{option}</option>
))}
{children}
</select>
{action && (
<button
type="button"
onClick={action.action}
className="relative -ml-px inline-flex items-center space-x-2 rounded-r-md border border-gray-300 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
{action.icon}
</button>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,48 @@
import type React from "react";
import { Switch } from "@headlessui/react";
export interface ToggleProps {
label: string;
description: string;
checked: boolean;
onChange?: (checked: boolean) => void;
}
export const Toggle = ({
label,
description,
checked,
onChange,
}: ToggleProps): JSX.Element => {
return (
<Switch.Group as="div" className="flex items-center justify-between">
<span className="flex flex-grow flex-col">
<Switch.Label
as="span"
className="text-sm font-medium text-gray-900"
passive
>
{label}
</Switch.Label>
<Switch.Description as="span" className="text-sm text-gray-500">
{description}
</Switch.Description>
</span>
<Switch
checked={checked}
onChange={onChange}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
checked ? "bg-indigo-600" : "bg-gray-200"
}`}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
checked ? "translate-x-5" : "translate-x-0"
}`}
/>
</Switch>
</Switch.Group>
);
};

View File

@@ -1,76 +0,0 @@
import type React from "react";
import { majorScale, Pane } from "evergreen-ui";
import { useAppStore } from "@app/core/stores/appStore.js";
import { DeviceWrapper } from "@app/DeviceWrapper.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { NoDevice } from "../misc/NoDevice.js";
import { Progress } from "../Progress.js";
import { PeerInfo } from "../SlideSheets/PeerInfo.js";
import { Header } from "./Header.js";
import { Sidebar } from "./Sidebar/index.js";
export interface AppLayoutProps {
children: React.ReactNode;
}
export const AppLayout = ({ children }: AppLayoutProps): JSX.Element => {
const { getDevices } = useDeviceStore();
const { selectedDevice } = useAppStore();
const devices = getDevices();
return (
<Pane
width="100vw"
display="flex"
background="tint1"
flexDirection="column"
minHeight="100vh"
>
<Header />
<Pane display="flex" height="100%" width="100%">
{devices.length ? (
devices.map((device) => (
<Pane
key={device.id}
width="100%"
height="100%"
display={device.id === selectedDevice ? "grid" : "none"}
gap={majorScale(3)}
gridTemplateColumns="16rem 1fr"
>
<DeviceWrapper device={device}>
{device && device.ready ? (
<>
<Sidebar />
<PeerInfo />
<Pane height="100%" display="flex">
{children}
</Pane>
</>
) : (
<>
<Pane
width="100%"
flexGrow={1}
margin={majorScale(3)}
borderRadius={majorScale(1)}
background="white"
elevation={1}
/>
<Progress />
</>
)}
</DeviceWrapper>
</Pane>
))
) : (
<NoDevice />
)}
</Pane>
</Pane>
);
};

View File

@@ -1,167 +0,0 @@
import type React from "react";
import { useState } from "react";
import {
Button,
CrossIcon,
GlobeIcon,
HelpIcon,
IconButton,
Link,
majorScale,
Pane,
PlusIcon,
StatusIndicator,
Tab,
Tablist,
Tooltip,
} from "evergreen-ui";
import { FiGithub } from "react-icons/fi";
import { HelpDialog } from "@components/Dialog/HelpDialog.js";
import { NewDevice } from "@components/SlideSheets/NewDevice.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Types } from "@meshtastic/meshtasticjs";
export const Header = (): JSX.Element => {
const { getDevices, removeDevice } = useDeviceStore();
const [newConnectionOpen, setNewConnectionOpen] = useState(false);
const [helpDialogOpen, setHelpDialogOpen] = useState(false);
const { selectedDevice, setSelectedDevice } = useAppStore();
return (
<Pane
is="nav"
width="100%"
position="sticky"
top={0}
backgroundColor="white"
zIndex={10}
height={majorScale(8)}
flexShrink={0}
display="flex"
alignItems="center"
borderBottom="muted"
>
<NewDevice
open={newConnectionOpen}
onClose={() => {
setNewConnectionOpen(false);
}}
/>
<Pane
display="flex"
alignItems="center"
width={majorScale(12)}
marginRight={majorScale(22)}
>
<Link href=".">
<Pane
is="img"
width={100}
height={28}
src="Logo_Black.svg"
cursor="pointer"
/>
</Link>
</Pane>
<Tablist display="flex" marginX={majorScale(4)}>
{getDevices().map((device) => (
<Tab
key={device.id}
gap={majorScale(1)}
isSelected={device.id === selectedDevice}
onSelect={() => {
setSelectedDevice(device.id);
}}
>
<Hashicon value={device.hardware.myNodeNum.toString()} size={20} />
{device.nodes.find((n) => n.data.num === device.hardware.myNodeNum)
?.data.user?.shortName ?? "UNK"}
<StatusIndicator
color={
[
Types.DeviceStatusEnum.DEVICE_CONNECTED,
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
].includes(device.status)
? "success"
: [
Types.DeviceStatusEnum.DEVICE_CONNECTING,
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
Types.DeviceStatusEnum.DEVICE_CONNECTED,
].includes(device.status)
? "warning"
: "danger"
}
/>
</Tab>
))}
</Tablist>
<Pane
display="flex"
marginLeft="auto"
gap={majorScale(1)}
marginRight={majorScale(2)}
>
<Tooltip content="Connect new device">
<Button
display="inline-flex"
marginY="auto"
appearance="primary"
iconBefore={<PlusIcon />}
onClick={() => {
setNewConnectionOpen(true);
}}
>
New
</Button>
</Tooltip>
{getDevices().length !== 0 && (
<Tooltip content="Disconnect active device">
<Button
iconBefore={CrossIcon}
onClick={() => {
void getDevices()
.find((d) => d.id === selectedDevice)
?.connection?.disconnect();
removeDevice(selectedDevice ?? 0);
}}
>
Disconnect
</Button>
</Tooltip>
)}
<Tooltip content="Visit GitHub">
<Link
target="_blank"
href="https://github.com/meshtastic/meshtastic-web"
>
<Button iconBefore={FiGithub}>
{process.env.COMMIT_HASH ?? "DEVELOPMENT"}
</Button>
</Link>
</Tooltip>
<IconButton
icon={HelpIcon}
onClick={() => {
setHelpDialogOpen(true);
}}
/>
<HelpDialog
isOpen={helpDialogOpen}
close={() => {
setHelpDialogOpen(false);
}}
/>
<Tooltip content="Visit Meshtastic.org">
<Link target="_blank" href="https://meshtastic.org/">
<IconButton icon={GlobeIcon} />
</Link>
</Tooltip>
</Pane>
</Pane>
);
};

View File

@@ -1,103 +0,0 @@
import type React from "react";
import {
Badge,
Heading,
Link,
majorScale,
MapMarkerIcon,
Pane,
} from "evergreen-ui";
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi";
import { useDevice } from "@core/providers/useDevice.js";
import { toMGRS } from "@core/utils/toMGRS.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Types } from "@meshtastic/meshtasticjs";
export const DeviceCard = (): JSX.Element => {
const { hardware, nodes, status, connection } = useDevice();
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
return (
<Pane
display="flex"
flexGrow={1}
flexDirection="column"
marginTop="auto"
gap={majorScale(1)}
>
<Pane display="flex" gap={majorScale(2)}>
<Hashicon value={hardware.myNodeNum.toString()} size={42} />
<Pane flexGrow={1}>
<Heading>{myNode?.data.user?.longName}</Heading>
<Link
target="_blank"
href="https://github.com/meshtastic/meshtastic-device/releases/"
>
<Badge
color="green"
width="100%"
marginRight={8}
display="flex"
marginTop={4}
>
{hardware.firmwareVersion}
</Badge>
</Link>
</Pane>
</Pane>
<Pane display="flex" gap={majorScale(1)}>
<MapMarkerIcon />
<Badge
color={myNode?.data.position?.latitudeI ? "green" : "red"}
display="flex"
width="100%"
>
{toMGRS(
myNode?.data.position?.latitudeI,
myNode?.data.position?.longitudeI
)}
</Badge>
</Pane>
<Pane display="flex" gap={majorScale(1)}>
{connection?.connType === "ble" && <FiBluetooth />}
{connection?.connType === "http" && <FiWifi />}
{connection?.connType === "serial" && <FiTerminal />}
<Badge
color={
[
Types.DeviceStatusEnum.DEVICE_CONNECTED,
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
].includes(status)
? "green"
: [
Types.DeviceStatusEnum.DEVICE_CONNECTING,
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
Types.DeviceStatusEnum.DEVICE_CONNECTED,
].includes(status)
? "orange"
: "red"
}
display="flex"
width="100%"
>
{[
Types.DeviceStatusEnum.DEVICE_CONNECTED,
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
].includes(status)
? "Connected"
: [
Types.DeviceStatusEnum.DEVICE_CONNECTING,
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
Types.DeviceStatusEnum.DEVICE_CONNECTED,
].includes(status)
? "Connecting"
: "Disconnected"}
</Badge>
</Pane>
</Pane>
);
};

View File

@@ -1,122 +0,0 @@
import type React from "react";
import { useState } from "react";
import {
ArrayIcon,
GlobeIcon,
IconComponent,
InboxIcon,
InfoSignIcon,
LabTestIcon,
LayersIcon,
majorScale,
Pane,
SettingsIcon,
Tab,
Tablist,
} from "evergreen-ui";
import { PeersDialog } from "@components/Dialog/PeersDialog.js";
import { useDevice } from "@core/providers/useDevice.js";
import type { Page } from "@core/stores/deviceStore.js";
import { DeviceCard } from "./DeviceCard.js";
interface NavLink {
name: string;
icon: IconComponent;
page: Page;
disabled?: boolean;
}
export const Sidebar = (): JSX.Element => {
const { activePage, setActivePage } = useDevice();
const [PeersDialogOpen, setPeersDialogOpen] = useState(false);
const navLinks: NavLink[] = [
{
name: "Messages",
icon: InboxIcon,
page: "messages",
},
{
name: "Map",
icon: GlobeIcon,
page: "map",
},
{
name: "Extensions",
icon: LabTestIcon,
page: "extensions",
},
{
name: "Config",
icon: SettingsIcon,
page: "config",
},
{
name: "Channels",
icon: LayersIcon,
page: "channels",
},
{
name: "Info",
icon: InfoSignIcon,
page: "info",
},
];
return (
<Pane
display="flex"
flexDirection="column"
width="100%"
flexGrow={1}
margin={majorScale(3)}
padding={majorScale(2)}
borderRadius={majorScale(1)}
background="white"
elevation={1}
>
<Tablist>
{navLinks.map((Link) => (
<Tab
key={Link.name}
userSelect="none"
gap={majorScale(2)}
disabled={Link.disabled}
direction="vertical"
isSelected={Link.page === activePage}
onSelect={() => {
setActivePage(Link.page);
}}
>
<Link.icon />
{Link.name}
</Tab>
))}
<Tab
userSelect="none"
gap={5}
direction="vertical"
isSelected={PeersDialogOpen}
onSelect={() => {
setPeersDialogOpen(true);
}}
>
<ArrayIcon />
Peers
</Tab>
</Tablist>
<PeersDialog
isOpen={PeersDialogOpen}
close={() => {
setPeersDialogOpen(false);
}}
/>
<Pane display="flex" flexGrow={1}>
<DeviceCard />
</Pane>
</Pane>
);
};

View File

@@ -1,88 +0,0 @@
import type React from "react";
import { useState } from "react";
import {
Heading,
IconComponent,
majorScale,
Pane,
Paragraph,
Tab,
Tablist,
} from "evergreen-ui";
import type { IconType } from "react-icons";
export interface TabType {
name: string;
icon: IconComponent | IconType;
element: () => JSX.Element;
disabled?: boolean;
}
export interface SlideSheetTabbedContentProps {
heading: string;
description: string;
tabs: TabType[];
tabIcon?: React.ReactNode;
}
export const SlideSheetTabbedContent = ({
heading,
description,
tabs,
tabIcon,
}: SlideSheetTabbedContentProps): JSX.Element => {
const [selectedTab, setSelectedTab] = useState(0);
return (
<>
<Pane zIndex={1} flexShrink={0} elevation={1} backgroundColor="white">
<Pane
display="flex"
padding={16}
borderBottom="muted"
gap={majorScale(1)}
>
{tabIcon}
<Pane>
<Heading size={600}>{heading}</Heading>
<Paragraph size={400} color="muted">
{description}
</Paragraph>
</Pane>
</Pane>
<Pane display="flex" padding={8}>
<Tablist>
{tabs.map((Entry, index) => (
<Tab
key={index}
userSelect="none"
disabled={Entry.disabled}
gap={5}
onSelect={() => setSelectedTab(index)}
isSelected={selectedTab === index}
>
<Entry.icon />
{Entry.name}
</Tab>
))}
</Tablist>
</Pane>
</Pane>
<Pane display="flex" overflowY="scroll" background="tint1" padding={16}>
{tabs.map((Entry, index) => (
<Pane
key={index}
borderRadius={majorScale(1)}
backgroundColor="white"
elevation={1}
flexGrow={1}
display={selectedTab === index ? "block" : "none"}
>
{!Entry.disabled && <Entry.element />}
</Pane>
))}
</Pane>
</>
);
};

View File

@@ -1,12 +1,11 @@
import type React from "react";
import { useState } from "react";
import { Fragment } from "react";
import { IconComponent, majorScale, Pane, Tab, Tablist } from "evergreen-ui";
import type { IconType } from "react-icons";
import { Tab } from "@headlessui/react";
export interface TabType {
name: string;
icon: IconComponent | IconType;
icon?: JSX.Element;
element: () => JSX.Element;
disabled?: boolean;
}
@@ -20,55 +19,41 @@ export const TabbedContent = ({
tabs,
actions,
}: TabbedContentProps): JSX.Element => {
const [selectedTab, setSelectedTab] = useState(0);
return (
<Pane
margin={majorScale(3)}
borderRadius={majorScale(1)}
background="white"
elevation={1}
display="flex"
flexGrow={1}
flexDirection="column"
padding={majorScale(2)}
gap={majorScale(2)}
>
<Pane borderBottom="muted" paddingBottom={majorScale(2)}>
<Pane display="flex">
<Tablist>
{tabs.map((Entry, index) => (
<Tab
key={index}
userSelect="none"
disabled={Entry.disabled}
gap={5}
onSelect={() => setSelectedTab(index)}
isSelected={selectedTab === index}
<Tab.Group as="div" className="flex flex-col gap-2 p-4 flex-grow">
<Tab.List className="flex gap-4 border-b pb-3">
{tabs.map((entry, index) => (
<Tab key={index}>
{({ selected }) => (
<div
className={`flex gap-3 h-10 font-medium text-sm rounded-md cursor-pointer px-3 ${
selected
? "bg-gray-100 text-gray-700"
: "text-gray-500 hover:text-gray-700"
}
`}
>
<Entry.icon />
{Entry.name}
</Tab>
))}
</Tablist>
<Pane marginLeft="auto">
{actions?.map((Action, index) => (
<Action key={index} />
))}
</Pane>
</Pane>
</Pane>
{tabs.map((Entry, index) => (
<Pane
key={index}
display={selectedTab === index ? "flex" : "none"}
flexDirection="column"
flexGrow={1}
>
{!Entry.disabled && <Entry.element />}
</Pane>
))}
</Pane>
{entry.icon && (
<div className="m-auto text-slate-500">{entry.icon}</div>
)}
<span className="m-auto">{entry.name}</span>
</div>
)}
</Tab>
))}
<div className="ml-auto">
{actions?.map((Action, index) => (
<Action key={index} />
))}
</div>
</Tab.List>
<Tab.Panels as={Fragment}>
{tabs.map((entry, index) => (
<Tab.Panel key={index} className="flex flex-grow">
<entry.element />
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
};

View File

@@ -1,18 +0,0 @@
import type React from "react";
import { DisableIcon, EmptyState, Pane } from "evergreen-ui";
export const NoDevice = (): JSX.Element => {
return (
<Pane elevation={1} margin="auto">
<EmptyState
title="No Device Connected"
orientation="horizontal"
background="light"
icon={<DisableIcon color="#EBAC91" />}
iconBgColor="#F8E3DA"
description="You must connect a Meshtastic device to continue."
/>
</Pane>
);
};

View File

@@ -1,13 +1,13 @@
import { createContext, useContext } from "react";
import { createContext, useContext } from 'react';
import type { Device } from "@core/stores/deviceStore.js";
import type { Device } from '@core/stores/deviceStore.js';
export const DeviceContext = createContext<Device | undefined>(undefined);
export const useDevice = (): Device => {
const context = useContext(DeviceContext);
if (context === undefined) {
throw new Error("useDevice must be used within a ConnectionProvider");
throw new Error("useDevice must be used within a DeviceProvider");
}
return context;
};

View File

@@ -1,7 +1,7 @@
import create from "zustand";
import create from 'zustand';
interface AppState {
selectedDevice?: number;
selectedDevice: number;
devices: {
id: number;
num: number;

View File

@@ -79,6 +79,7 @@ export interface DeviceState {
addDevice: (id: number) => Device;
removeDevice: (id: number) => void;
getDevices: () => Device[];
getDevice: (id: number) => Device | undefined;
}
export const useDeviceStore = create<DeviceState>((set, get) => ({
@@ -470,6 +471,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
},
getDevices: () => Array.from(get().devices.values()),
getDevice: (id) => get().devices.get(id),
}));
export const DeviceContext = createContext<Device | undefined>(undefined);

3
src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,4 +1,4 @@
import "modern-css-reset/dist/reset.min.css";
import "./index.css";
import "maplibre-gl/dist/maplibre-gl.css";
import type React from "react";

View File

@@ -1,14 +1,13 @@
import type React from "react";
import { useState } from "react";
import { Button, LayerIcon, LayerOutlineIcon, Tooltip } from "evergreen-ui";
import { IoQrCodeOutline } from "react-icons/io5";
import { Channel } from "@app/components/PageComponents/Channel.js";
import { Button } from "@components/Button.js";
import { QRDialog } from "@components/Dialog/QRDialog.js";
import { TabbedContent, TabType } from "@components/layout/page/TabbedContent";
import { useDevice } from "@core/providers/useDevice.js";
import { QrCodeIcon } from "@heroicons/react/24/outline";
import { Protobuf } from "@meshtastic/meshtasticjs";
import { Channel } from "@pages/Channels/Channel.js";
export const ChannelsPage = (): JSX.Element => {
const { channels, config } = useDevice();
@@ -21,10 +20,6 @@ export const ChannelsPage = (): JSX.Element => {
: channel.config.role === Protobuf.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.config.index}`,
icon:
channel.config.role !== Protobuf.Channel_Role.DISABLED
? LayerIcon
: LayerOutlineIcon,
element: () => <Channel channel={channel.config} />,
};
});
@@ -43,16 +38,15 @@ export const ChannelsPage = (): JSX.Element => {
tabs={tabs}
actions={[
() => (
<Tooltip content="Open QR code generator">
<Button
onClick={() => {
setQRDialogOpen(true);
}}
iconBefore={IoQrCodeOutline}
>
QR Code
</Button>
</Tooltip>
<Button
variant="secondary"
iconBefore={<QrCodeIcon className="w-4" />}
onClick={() => {
setQRDialogOpen(true);
}}
>
QR Code
</Button>
),
]}
/>

View File

@@ -1,205 +0,0 @@
import type React from "react";
import { useEffect, useState } from "react";
import { fromByteArray, toByteArray } from "base64-js";
import {
Button,
EyeOffIcon,
EyeOpenIcon,
FormField,
IconButton,
majorScale,
Pane,
RefreshIcon,
SelectField,
Switch,
TextInputField,
Tooltip,
} from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
import { Protobuf } from "@meshtastic/meshtasticjs";
export interface SettingsPanelProps {
channel: Protobuf.Channel;
}
export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
const { connection } = useDevice();
const [loading, setLoading] = useState(false);
const [keySize, setKeySize] = useState<128 | 256>(256);
const [pskHidden, setPskHidden] = useState(true);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
setValue,
} = useForm<
Omit<Protobuf.ChannelSettings, "psk"> & { psk: string; enabled: boolean }
>({
defaultValues: {
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
},
});
useEffect(() => {
reset({
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
});
}, [channel, reset]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
const channelData = Protobuf.Channel.create({
role:
channel?.role === Protobuf.Channel_Role.PRIMARY
? Protobuf.Channel_Role.PRIMARY
: data.enabled
? Protobuf.Channel_Role.SECONDARY
: Protobuf.Channel_Role.DISABLED,
index: channel?.index,
settings: {
...data,
psk: toByteArray(data.psk ?? ""),
},
});
await connection?.setChannel(channelData, (): Promise<void> => {
reset({ ...data });
setLoading(false);
return Promise.resolve();
});
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
{channel?.index !== 0 && (
<>
<FormField
label="Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch
height={24}
marginLeft="auto"
checked={value}
{...field}
/>
)}
/>
</FormField>
<TextInputField
label="Name"
description="Max transmit power in dBm"
{...register("name")}
/>
</>
)}
<Pane display="flex" gap={majorScale(1)}>
<SelectField
width="100%"
label="Key Size"
description="Desired size of generated key."
value={keySize}
onChange={(e): void => {
setKeySize(parseInt(e.target.value) as 128 | 256);
}}
>
<option value={128}>128 Bit</option>
<option value={256}>256 Bit</option>
</SelectField>
<Tooltip content="Generate new key">
<IconButton
marginTop={majorScale(6)}
onClick={(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
): void => {
e.preventDefault();
const key = new Uint8Array(keySize / 8);
crypto.getRandomValues(key);
setValue("psk", fromByteArray(key));
}}
icon={<RefreshIcon />}
/>
</Tooltip>
</Pane>
<Pane display="flex" gap={majorScale(1)}>
<TextInputField
width="100%"
label="Pre-Shared Key"
description="Max transmit power in dBm"
type={pskHidden ? "password" : "text"}
{...register("psk")}
/>
<Tooltip content={pskHidden ? "Show key" : "Hide key"}>
<Button
marginTop={majorScale(6)}
width={majorScale(12)}
onClick={(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
): void => {
e.preventDefault();
setPskHidden(!pskHidden);
}}
iconBefore={pskHidden ? <EyeOpenIcon /> : <EyeOffIcon />}
>
{pskHidden ? "Show" : "Hide"}
</Button>
</Tooltip>
</Pane>
<FormField
label="Uplink Enabled"
description="Description"
isInvalid={!!errors.uplinkEnabled?.message}
validationMessage={errors.uplinkEnabled?.message}
>
<Controller
name="uplinkEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Downlink Enabled"
description="Description"
isInvalid={!!errors.downlinkEnabled?.message}
validationMessage={errors.downlinkEnabled?.message}
>
<Controller
name="downlinkEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Form>
);
};

View File

@@ -1,15 +1,12 @@
import type React from "react";
import { useState } from "react";
import { Pane, Tab, Tablist } from "evergreen-ui";
import { Fragment } from "react";
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js";
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.js";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
import { Tab } from "@headlessui/react";
export const AppConfig = (): JSX.Element => {
const [selectedIndex, setSelectedIndex] = useState(0);
const configSections = [
{
label: "Interface",
@@ -26,31 +23,31 @@ export const AppConfig = (): JSX.Element => {
];
return (
<Pane display="flex">
<Pane flexBasis={150} marginRight={24}>
<Tablist>
{configSections.map((Config, index) => (
<Tab
key={index}
direction="vertical"
isSelected={index === selectedIndex}
onSelect={() => setSelectedIndex(index)}
>
{Config.label}
</Tab>
))}
</Tablist>
</Pane>
<Pane flex="1">
<Tab.Group as="div" className="flex gap-3 w-full">
<Tab.List className="flex flex-col w-44 gap-1">
{configSections.map((Config, index) => (
<Pane
key={index}
display={index === selectedIndex ? "block" : "none"}
>
<Config.element />
</Pane>
<Tab key={index} as={Fragment}>
{({ selected }) => (
<div
className={`flex items-center px-3 py-2 text-sm font-medium rounded-md cursor-pointer ${
selected
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
}`}
>
{Config.label}
</div>
)}
</Tab>
))}
</Pane>
</Pane>
</Tab.List>
<Tab.Panels as={Fragment}>
{configSections.map((Config, index) => (
<Tab.Panel key={index} as={Fragment}>
<Config.element />
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
};

View File

@@ -1,7 +1,5 @@
import type React from "react";
import { useState } from "react";
import { Pane, Tab, Tablist } from "evergreen-ui";
import { Fragment } from "react";
import { Network } from "@app/components/PageComponents/Config/Network.js";
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.js";
@@ -12,9 +10,9 @@ import { Position } from "@components/PageComponents/Config/Position.js";
import { Power } from "@components/PageComponents/Config/Power.js";
import { User } from "@components/PageComponents/Config/User.js";
import { useDevice } from "@core/providers/useDevice.js";
import { Tab } from "@headlessui/react";
export const DeviceConfig = (): JSX.Element => {
const [selectedIndex, setSelectedIndex] = useState(0);
const { hardware } = useDevice();
const configSections = [
@@ -54,32 +52,31 @@ export const DeviceConfig = (): JSX.Element => {
];
return (
<Pane display="flex">
<Pane flexBasis={150} marginRight={24}>
<Tablist>
{configSections.map((Config, index) => (
<Tab
key={index}
direction="vertical"
isSelected={index === selectedIndex}
onSelect={() => setSelectedIndex(index)}
disabled={Config.disabled}
>
{Config.label}
</Tab>
))}
</Tablist>
</Pane>
<Pane flex="1">
<Tab.Group as="div" className="flex gap-3 w-full">
<Tab.List className="flex flex-col w-44 gap-1">
{configSections.map((Config, index) => (
<Pane
key={index}
display={index === selectedIndex ? "block" : "none"}
>
<Config.element />
</Pane>
<Tab key={index} as={Fragment}>
{({ selected }) => (
<div
className={`flex items-center px-3 py-2 text-sm font-medium rounded-md cursor-pointer ${
selected
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
}`}
>
{Config.label}
</div>
)}
</Tab>
))}
</Pane>
</Pane>
</Tab.List>
<Tab.Panels as={Fragment}>
{configSections.map((Config, index) => (
<Tab.Panel key={index} as={Fragment}>
<Config.element />
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
};

View File

@@ -1,7 +1,5 @@
import type React from "react";
import { useState } from "react";
import { Pane, Tab, Tablist } from "evergreen-ui";
import { Fragment, useState } from "react";
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage";
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js";
@@ -10,6 +8,7 @@ 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 { Tab } from "@headlessui/react";
export const ModuleConfig = (): JSX.Element => {
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -46,31 +45,31 @@ export const ModuleConfig = (): JSX.Element => {
];
return (
<Pane display="flex">
<Pane flexBasis={150} marginRight={24}>
<Tablist>
{configSections.map((Config, index) => (
<Tab
key={index}
direction="vertical"
isSelected={index === selectedIndex}
onSelect={() => setSelectedIndex(index)}
>
{Config.label}
</Tab>
))}
</Tablist>
</Pane>
<Pane flex="1">
<Tab.Group as="div" className="flex gap-3 w-full">
<Tab.List className="flex flex-col gap-1 w-44">
{configSections.map((Config, index) => (
<Pane
key={index}
display={index === selectedIndex ? "block" : "none"}
>
<Config.element />
</Pane>
<Tab key={index} as={Fragment}>
{({ selected }) => (
<div
className={`flex items-center px-3 py-2 text-sm font-medium rounded-md cursor-pointer ${
selected
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
}`}
>
{Config.label}
</div>
)}
</Tab>
))}
</Pane>
</Pane>
</Tab.List>
<Tab.Panels as={Fragment}>
{configSections.map((Config, index) => (
<Tab.Panel key={index} as={Fragment}>
<Config.element />
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
};

View File

@@ -1,8 +1,11 @@
import type React from "react";
import { ApplicationsIcon, CogIcon, CubeIcon } from "evergreen-ui";
import { TabbedContent, TabType } from "@components/layout/page/TabbedContent";
import {
Cog8ToothIcon,
CubeTransparentIcon,
WindowIcon,
} from "@heroicons/react/24/outline";
import { AppConfig } from "@pages/Config/AppConfig.js";
import { DeviceConfig } from "@pages/Config/DeviceConfig.js";
import { ModuleConfig } from "@pages/Config/ModuleConfig.js";
@@ -11,17 +14,17 @@ export const ConfigPage = (): JSX.Element => {
const tabs: TabType[] = [
{
name: "Device Config",
icon: CogIcon,
icon: <Cog8ToothIcon className="h-4" />,
element: DeviceConfig,
},
{
name: "Module Config",
icon: CubeIcon,
icon: <CubeTransparentIcon className="h-4" />,
element: ModuleConfig,
},
{
name: "App Config",
icon: ApplicationsIcon,
icon: <WindowIcon className="h-4" />,
element: AppConfig,
},
];

View File

@@ -1,17 +1,15 @@
import type React from "react";
import { Pane } from "evergreen-ui";
import { useDevice } from "@core/providers/useDevice.js";
export const Environment = (): JSX.Element => {
const { nodes } = useDevice();
return (
<Pane>
<div>
{nodes.map((node, index) => (
<Pane key={index}>{JSON.stringify(node.environmentMetrics)}</Pane>
<div key={index}>{JSON.stringify(node.environmentMetrics)}</div>
))}
</Pane>
</div>
);
};

View File

@@ -1,8 +1,6 @@
import type React from "react";
import { useEffect, useState } from "react";
import { Pane } from "evergreen-ui";
export interface File {
nameModified: string;
name: string;
@@ -33,9 +31,9 @@ export const FileBrowser = (): JSX.Element => {
});
return (
<Pane>
<div>
{data?.data.files.map((file) => (
<Pane key={file.name}>
<div key={file.name}>
<a
target="_blank"
rel="noopener noreferrer"
@@ -43,8 +41,8 @@ export const FileBrowser = (): JSX.Element => {
>
{file.name.replace("static/", "").replace(".gz", "")}
</a>
</Pane>
</div>
))}
</Pane>
</div>
);
};

View File

@@ -1,9 +1,12 @@
import type React from "react";
import { DocumentIcon, GanttChartIcon, RainIcon } from "evergreen-ui";
import { TabbedContent, TabType } from "@components/layout/page/TabbedContent";
import { useDevice } from "@core/providers/useDevice.js";
import {
CloudIcon,
DocumentIcon,
SignalIcon,
} from "@heroicons/react/24/outline";
import { Environment } from "@pages/Extensions/Environment.js";
import { FileBrowser } from "@pages/Extensions/FileBrowser";
@@ -13,19 +16,19 @@ export const ExtensionsPage = (): JSX.Element => {
const tabs: TabType[] = [
{
name: "File Browser",
icon: DocumentIcon,
icon: <DocumentIcon className="h-4" />,
element: FileBrowser,
disabled: !hardware.hasWifi,
},
{
name: "Range Test",
icon: GanttChartIcon,
icon: <SignalIcon className="h-4" />,
element: FileBrowser,
disabled: !hardware.hasWifi,
},
{
name: "Environment",
icon: RainIcon,
icon: <CloudIcon className="h-4" />,
element: Environment,
},
];

View File

@@ -1,6 +1,5 @@
import type React from "react";
import { Pane } from "evergreen-ui";
import JSONPretty from "react-json-pretty";
import { useDevice } from "@core/providers/useDevice.js";
@@ -11,9 +10,9 @@ export const InfoPage = (): JSX.Element => {
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
return (
<Pane>
<div>
<JSONPretty data={myNode} />
<JSONPretty data={hardware} />
</Pane>
</div>
);
};

View File

@@ -1,61 +1,29 @@
import type React from "react";
import {
Heading,
IconButton,
LocateIcon,
majorScale,
MapMarkerIcon,
Pane,
Text,
} from "evergreen-ui";
import maplibregl from "maplibre-gl";
import { Map, Marker, useMap } from "react-map-gl";
import { IconButton } from "@app/components/IconButton.js";
import { useDevice } from "@core/providers/useDevice.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { MapPinIcon } from "@heroicons/react/24/outline";
export const MapPage = (): JSX.Element => {
const { nodes, waypoints } = useDevice();
const { current: map } = useMap();
return (
<Pane
margin={majorScale(3)}
borderRadius={majorScale(1)}
elevation={1}
display="flex"
flexGrow={1}
flexDirection="column"
gap={majorScale(2)}
overflow="hidden"
position="relative"
>
<Pane
position="absolute"
zIndex={10}
right={0}
top={0}
borderRadius={majorScale(1)}
padding={majorScale(1)}
margin={majorScale(1)}
background="tint1"
width={majorScale(28)}
elevation={1}
overflow="hidden"
>
<Pane padding={majorScale(1)} background="tint2">
<Heading>Title</Heading>
</Pane>
<Pane display="flex" flexDirection="column" gap={majorScale(1)}>
<div className="flex-grow">
<div className="absolute z-10 right-0 top-0 m-2 rounded-md p-2 shadow-md bg-white">
<div className="font-medium text-lg p-1">Title</div>
<div className="flex flex-col gap-2">
{nodes.map((n) => (
<Pane key={n.data.num} display="flex" gap={majorScale(1)}>
<div key={n.data.num} className="flex gap-2">
<Hashicon value={n.data.num.toString()} size={24} />
<Text>{n.data.user?.longName}</Text>
<div>{n.data.user?.longName}</div>
<IconButton
icon={LocateIcon}
marginLeft="auto"
size="small"
icon={<MapPinIcon className="h-4" />}
size="sm"
onClick={() => {
if (n.data.position?.latitudeI) {
map?.flyTo({
@@ -68,10 +36,10 @@ export const MapPage = (): JSX.Element => {
}
}}
/>
</Pane>
</div>
))}
</Pane>
</Pane>
</div>
</div>
<Map
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
mapLib={maplibregl}
@@ -84,9 +52,9 @@ export const MapPage = (): JSX.Element => {
latitude={wp.latitudeI / 1e7}
anchor="bottom"
>
<Pane>
<MapMarkerIcon />
</Pane>
<div>
<MapPinIcon className="h-4" />
</div>
</Marker>
))}
{nodes.map((n) => {
@@ -104,6 +72,6 @@ export const MapPage = (): JSX.Element => {
}
})}
</Map>
</Pane>
</div>
);
};

View File

@@ -1,23 +1,19 @@
import type React from "react";
import {
CircleIcon,
EditIcon,
IconButton,
Pane,
RingIcon,
Tooltip,
} from "evergreen-ui";
import { IconButton } from "@app/components/IconButton.js";
import {
TabbedContent,
TabType,
} from "@components/layout/page/TabbedContent.js";
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.js";
import { useDevice } from "@core/providers/useDevice.js";
import {
EllipsisHorizontalCircleIcon,
PencilIcon,
XCircleIcon,
} from "@heroicons/react/24/outline";
import { Protobuf } from "@meshtastic/meshtasticjs";
import { ChannelChat } from "./ChannelChat.js";
export const MessagesPage = (): JSX.Element => {
const { channels, setActivePage } = useDevice();
@@ -28,29 +24,32 @@ export const MessagesPage = (): JSX.Element => {
: channel.config.index === 0
? "Primary"
: `Ch ${channel.config.index}`,
icon: channel.messages.length ? RingIcon : CircleIcon,
icon: channel.messages.length ? (
<EllipsisHorizontalCircleIcon className="h-4" />
) : (
<XCircleIcon className="h-4" />
),
element: () => <ChannelChat channel={channel} />,
disabled: channel.config.role === Protobuf.Channel_Role.DISABLED,
};
});
return (
<Pane display="flex" flexDirection="column" width="100%">
<div className="flex flex-col w-full">
<TabbedContent
tabs={tabs}
actions={[
() => (
<Tooltip content="Edit Channels">
<IconButton
icon={EditIcon}
onClick={() => {
setActivePage("channels");
}}
/>
</Tooltip>
<IconButton
variant="secondary"
icon={<PencilIcon className="h-4" />}
onClick={() => {
setActivePage("channels");
}}
/>
),
]}
/>
</Pane>
</div>
);
};

View File

@@ -1,103 +0,0 @@
import type React from "react";
import { ChangeEvent, useState } from "react";
import {
AddLocationIcon,
IconButton,
majorScale,
Pane,
Popover,
SendMessageIcon,
TextInputField,
Tooltip,
} from "evergreen-ui";
import { useDevice } from "@core/providers/useDevice.js";
import type { Channel } from "@core/stores/deviceStore.js";
import { Message } from "@pages/Messages/Message.js";
import { NewLocationMessage } from "@pages/Messages/NewLocationMessage.js";
export interface ChannelChatProps {
channel: Channel;
}
export const ChannelChat = ({ channel }: ChannelChatProps): JSX.Element => {
const { nodes, connection, ackMessage } = useDevice();
const [currentMessage, setCurrentMessage] = useState("");
const sendMessage = (): void => {
void connection?.sendText(
currentMessage,
undefined,
true,
channel.config.index,
(id) => {
ackMessage(channel.config.index, id);
return Promise.resolve();
}
);
setCurrentMessage("");
};
return (
<Pane display="flex" flexDirection="column" flexGrow={1}>
<Pane display="flex" flexDirection="column" flexGrow={1}>
{channel.messages.map((message, index) => (
<Message
key={index}
message={message}
lastMsgSameUser={
index === 0
? false
: channel.messages[index - 1].packet.from ===
message.packet.from
}
sender={
nodes.find((node) => node.data.num === message.packet.from)?.data
}
/>
))}
</Pane>
<Pane display="flex" gap={majorScale(1)}>
<form
style={{ display: "flex", flexGrow: 1 }}
onSubmit={(e): void => {
e.preventDefault();
sendMessage();
}}
>
<Pane display="flex" flexGrow={1} gap={majorScale(1)}>
<TextInputField
marginTop="auto"
minLength={2}
width="100%"
label=""
placeholder="Enter Message"
marginBottom={0}
value={currentMessage}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setCurrentMessage(e.target.value);
}}
/>
<Tooltip content="Send">
<IconButton
icon={SendMessageIcon}
marginTop={majorScale(2)}
width={majorScale(8)}
/>
</Tooltip>
</Pane>
</form>
<Tooltip content="Send Location">
<Popover content={<NewLocationMessage />}>
<IconButton
icon={AddLocationIcon}
marginTop={majorScale(2)}
width={majorScale(8)}
/>
</Popover>
</Tooltip>
</Pane>
</Pane>
);
};

View File

@@ -1,94 +0,0 @@
import type React from "react";
import {
CircleIcon,
FullCircleIcon,
majorScale,
Pane,
Small,
Strong,
Text,
} from "evergreen-ui";
import type { AllMessageTypes } from "@app/core/stores/deviceStore.js";
import { WaypointMessage } from "@app/pages/Messages/WaypointMessage.js";
import { useDevice } from "@core/providers/useDevice.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import type { Protobuf } from "@meshtastic/meshtasticjs";
export interface MessageProps {
lastMsgSameUser: boolean;
message: AllMessageTypes;
sender?: Protobuf.NodeInfo;
}
export const Message = ({
lastMsgSameUser,
message,
sender,
}: MessageProps): JSX.Element => {
const { setPeerInfoOpen, setActivePeer } = useDevice();
const openPeer = (): void => {
setActivePeer(message.packet.from);
setPeerInfoOpen(true);
};
return lastMsgSameUser ? (
<Pane display="flex" marginLeft={majorScale(3)}>
{message.ack ? (
<FullCircleIcon color="#9c9fab" marginY="auto" size={8} />
) : (
<CircleIcon color="#9c9fab" marginY="auto" size={8} />
)}
{"waypointID" in message ? (
<WaypointMessage waypointID={message.waypointID} />
) : (
<Text
color={message.ack ? "#474d66" : "#9c9fab"}
marginLeft={majorScale(2)}
paddingLeft={majorScale(1)}
borderLeft="3px solid #e6e6e6"
>
{message.text}
</Text>
)}
</Pane>
) : (
<Pane marginX={majorScale(2)} gap={majorScale(1)} marginTop={majorScale(1)}>
<Pane display="flex" gap={majorScale(1)}>
<Pane onClick={openPeer} cursor="pointer" width={majorScale(3)}>
<Hashicon value={(sender?.num ?? 0).toString()} size={32} />
</Pane>
<Strong onClick={openPeer} cursor="pointer" size={500}>
{sender?.user?.longName ?? "UNK"}
</Strong>
<Small>
{new Date(message.packet.rxTime).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</Small>
</Pane>
<Pane display="flex" marginLeft={majorScale(1)}>
{message.ack ? (
<FullCircleIcon color="#9c9fab" marginY="auto" size={8} />
) : (
<CircleIcon color="#9c9fab" marginY="auto" size={8} />
)}
{"waypointID" in message ? (
<WaypointMessage waypointID={message.waypointID} />
) : (
<Text
color={message.ack ? "#474d66" : "#9c9fab"}
marginLeft={majorScale(2)}
paddingLeft={majorScale(1)}
borderLeft="3px solid #e6e6e6"
>
{message.text}
</Text>
)}
</Pane>
</Pane>
);
};

View File

@@ -1,52 +0,0 @@
import type React from "react";
import {
Heading,
LocateIcon,
majorScale,
minorScale,
Pane,
Small,
Text,
} from "evergreen-ui";
import { useDevice } from "@app/core/providers/useDevice.js";
import { toMGRS } from "@core/utils/toMGRS.js";
export interface WaypointMessageProps {
waypointID: number;
}
export const WaypointMessage = ({
waypointID,
}: WaypointMessageProps): JSX.Element => {
const { waypoints } = useDevice();
const waypoint = waypoints.find((wp) => wp.id === waypointID);
return (
<Pane
marginLeft={majorScale(2)}
paddingLeft={majorScale(1)}
borderLeft="3px solid #e6e6e6"
>
<Pane
gap={majorScale(1)}
display="flex"
borderRadius={majorScale(1)}
elevation={1}
padding={minorScale(1)}
>
<LocateIcon color="#474d66" marginY="auto" />
<Pane>
<Pane display="flex" gap={majorScale(1)}>
<Heading>{waypoint?.name}</Heading>
<Text color="orange">
{toMGRS(waypoint?.latitudeI, waypoint?.longitudeI)}
</Text>
</Pane>
<Small>{waypoint?.description}</Small>
</Pane>
</Pane>
</Pane>
);
};

View File

@@ -1,16 +1,13 @@
import { IsBoolean, IsEnum } from "class-validator";
import { IsBoolean, IsEnum } from 'class-validator';
import { Protobuf } from "@meshtastic/meshtasticjs";
import { Protobuf } from '@meshtastic/meshtasticjs';
export class DeviceValidation implements Protobuf.Config_DeviceConfig {
@IsEnum(Protobuf.Config_DeviceConfig_Role)
role: Protobuf.Config_DeviceConfig_Role;
@IsBoolean()
serialDisabled: boolean;
@IsBoolean()
factoryReset: boolean;
serialEnabled: boolean;
@IsBoolean()
debugLogEnabled: boolean;

View File

@@ -3,10 +3,8 @@ import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export class LoRaValidation implements Protobuf.Config_LoRaConfig {
@IsInt()
@Min(0)
@Max(10)
txPower: number;
@IsBoolean()
usePreset: boolean;
@IsEnum(Protobuf.Config_LoRaConfig_ModemPreset)
modemPreset: Protobuf.Config_LoRaConfig_ModemPreset;
@@ -36,7 +34,11 @@ export class LoRaValidation implements Protobuf.Config_LoRaConfig {
hopLimit: number;
@IsBoolean()
txDisabled: boolean;
txEnabled: boolean;
@IsInt()
@Min(0)
txPower: number;
@IsArray()
ignoreIncoming: number[];

View File

@@ -1,19 +1,19 @@
import { IsBoolean, IsInt } from "class-validator";
import { IsBoolean, IsInt } from 'class-validator';
import type { Protobuf } from "@meshtastic/meshtasticjs";
import type { Protobuf } from '@meshtastic/meshtasticjs';
export class PositionValidation implements Protobuf.Config_PositionConfig {
@IsInt()
positionBroadcastSecs: number;
@IsBoolean()
positionBroadcastSmartDisabled: boolean;
positionBroadcastSmartEnabled: boolean;
@IsBoolean()
fixedPosition: boolean;
@IsBoolean()
gpsDisabled: boolean;
gpsEnabled: boolean;
@IsInt()
gpsUpdateInterval: number;

8
tailwind.config.cjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};