Browse Source

2.0 Overhaul start

pull/39/head
Sacha Weatherstone 4 years ago
parent
commit
a73eab1ea6
No known key found for this signature in database GPG Key ID: 7AB2D7E206124B31
  1. 5
      .prettierrc
  2. 26
      package.json
  3. 1283
      pnpm-lock.yaml
  4. 6
      postcss.config.js
  5. 35
      src/App.tsx
  6. 6
      src/DeviceWrapper.tsx
  7. 8
      src/PageRouter.tsx
  8. 43
      src/components/Button.tsx
  9. 48
      src/components/DeviceSelector.tsx
  10. 43
      src/components/Dialog/HelpDialog.tsx
  11. 126
      src/components/Dialog/PeersDialog.tsx
  12. 132
      src/components/Dialog/QRDialog.tsx
  13. 69
      src/components/Dialog/RegionDialog.tsx
  14. 63
      src/components/Dialog/index.tsx
  15. 33
      src/components/IconButton.tsx
  16. 34
      src/components/NewDevice.tsx
  17. 198
      src/components/PageComponents/Channel.tsx
  18. 54
      src/components/PageComponents/Config/Bluetooth.tsx
  19. 86
      src/components/PageComponents/Config/Device.tsx
  20. 51
      src/components/PageComponents/Config/Display.tsx
  21. 151
      src/components/PageComponents/Config/LoRa.tsx
  22. 69
      src/components/PageComponents/Config/Network.tsx
  23. 130
      src/components/PageComponents/Config/Position.tsx
  24. 85
      src/components/PageComponents/Config/Power.tsx
  25. 60
      src/components/PageComponents/Config/User.tsx
  26. 46
      src/components/PageComponents/Connect/BLE.tsx
  27. 75
      src/components/PageComponents/Connect/HTTP.tsx
  28. 66
      src/components/PageComponents/Connect/Serial.tsx
  29. 85
      src/components/PageComponents/Messages/ChannelChat.tsx
  30. 89
      src/components/PageComponents/Messages/Message.tsx
  31. 26
      src/components/PageComponents/Messages/NewLocationMessage.tsx
  32. 33
      src/components/PageComponents/Messages/WaypointMessage.tsx
  33. 137
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  34. 125
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  35. 98
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  36. 69
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  37. 75
      src/components/PageComponents/ModuleConfig/Serial.tsx
  38. 73
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  39. 94
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  40. 77
      src/components/PageNav.tsx
  41. 109
      src/components/Progress.tsx
  42. 80
      src/components/Sidebar.tsx
  43. 114
      src/components/SlideSheets/NewDevice.tsx
  44. 60
      src/components/SlideSheets/PeerInfo.tsx
  45. 18
      src/components/SlideSheets/tabs/nodes/Location.tsx
  46. 17
      src/components/SlideSheets/tabs/nodes/Overview.tsx
  47. 117
      src/components/Widgets/ConfiguringWidget.tsx
  48. 50
      src/components/Widgets/DeviceWidget.tsx
  49. 11
      src/components/Widgets/NodeInfoWidget.tsx
  50. 11
      src/components/Widgets/PeersWidget.tsx
  51. 11
      src/components/Widgets/PositionWidget.tsx
  52. 53
      src/components/form/Checkbox.tsx
  53. 79
      src/components/form/Form.tsx
  54. 75
      src/components/form/Input.tsx
  55. 65
      src/components/form/Select.tsx
  56. 48
      src/components/form/Toggle.tsx
  57. 76
      src/components/layout/AppLayout.tsx
  58. 167
      src/components/layout/Header.tsx
  59. 103
      src/components/layout/Sidebar/DeviceCard.tsx
  60. 122
      src/components/layout/Sidebar/index.tsx
  61. 88
      src/components/layout/page/SlideSheetTabbedContent.tsx
  62. 89
      src/components/layout/page/TabbedContent.tsx
  63. 18
      src/components/misc/NoDevice.tsx
  64. 6
      src/core/providers/useDevice.ts
  65. 4
      src/core/stores/appStore.ts
  66. 3
      src/core/stores/deviceStore.ts
  67. 3
      src/index.css
  68. 2
      src/index.tsx
  69. 30
      src/pages/Channels.tsx
  70. 205
      src/pages/Channels/Channel.tsx
  71. 53
      src/pages/Config/AppConfig.tsx
  72. 53
      src/pages/Config/DeviceConfig.tsx
  73. 51
      src/pages/Config/ModuleConfig.tsx
  74. 13
      src/pages/Config/index.tsx
  75. 8
      src/pages/Extensions/Environment.tsx
  76. 10
      src/pages/Extensions/FileBrowser.tsx
  77. 13
      src/pages/Extensions/Index.tsx
  78. 5
      src/pages/Info.tsx
  79. 66
      src/pages/Map.tsx
  80. 43
      src/pages/Messages.tsx
  81. 103
      src/pages/Messages/ChannelChat.tsx
  82. 94
      src/pages/Messages/Message.tsx
  83. 52
      src/pages/Messages/WaypointMessage.tsx
  84. 9
      src/validation/config/device.ts
  85. 12
      src/validation/config/lora.ts
  86. 8
      src/validation/config/position.ts
  87. 8
      tailwind.config.cjs

5
.prettierrc

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

26
package.json

@ -20,22 +20,23 @@
"homepage": "https://meshtastic.org", "homepage": "https://meshtastic.org",
"dependencies": { "dependencies": {
"@emeraldpay/hashicon-react": "^0.5.2", "@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/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", "base64-js": "^1.5.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"evergreen-ui": "^6.10.3",
"geodesy": "^2.4.0", "geodesy": "^2.4.0",
"immer": "^9.0.15", "immer": "^9.0.15",
"mapbox-gl": "npm:empty-npm-package@^1.0.0", "mapbox-gl": "npm:empty-npm-package@^1.0.0",
"maplibre-gl": "^2.4.0", "maplibre-gl": "^2.4.0",
"modern-css-reset": "^1.4.0",
"prettier": "^2.7.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^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-icons": "^4.4.0",
"react-json-pretty": "^2.2.0", "react-json-pretty": "^2.2.0",
"react-map-gl": "^7.0.19", "react-map-gl": "^7.0.19",
@ -44,20 +45,25 @@
"zustand": "4.1.1" "zustand": "4.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.0.196", "@types/chrome": "^0.0.197",
"@types/geodesy": "^2.2.3", "@types/geodesy": "^2.2.3",
"@types/node": "^18.7.16", "@types/node": "^18.7.18",
"@types/react": "^18.0.18", "@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@types/w3c-web-serial": "^1.0.2", "@types/w3c-web-serial": "^1.0.2",
"@types/web-bluetooth": "^0.0.15", "@types/web-bluetooth": "^0.0.15",
"@vitejs/plugin-react": "^2.1.0", "@vitejs/plugin-react": "^2.1.0",
"autoprefixer": "^10.4.11",
"gzipper": "^7.1.0", "gzipper": "^7.1.0",
"postcss": "^8.4.16",
"prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.13",
"rollup-plugin-visualizer": "^5.8.1", "rollup-plugin-visualizer": "^5.8.1",
"tailwindcss": "^3.1.8",
"tar": "^6.1.11", "tar": "^6.1.11",
"tslib": "^2.4.0", "tslib": "^2.4.0",
"typescript": "^4.8.3", "typescript": "^4.8.3",
"vite": "^3.1.0", "vite": "^3.1.3",
"vite-plugin-environment": "^1.1.2" "vite-plugin-environment": "^1.1.2"
} }
} }

1283
pnpm-lock.yaml

File diff suppressed because it is too large

6
postcss.config.js

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

35
src/App.tsx

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

6
src/DeviceWrapper.tsx

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

8
src/PageRouter.tsx

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

43
src/components/Button.tsx

@ -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>
);
};

48
src/components/DeviceSelector.tsx

@ -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>
);
};

43
src/components/Dialog/HelpDialog.tsx

@ -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>
);
};

126
src/components/Dialog/PeersDialog.tsx

@ -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>
);
};

132
src/components/Dialog/QRDialog.tsx

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

69
src/components/Dialog/RegionDialog.tsx

@ -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>
);
};

63
src/components/Dialog/index.tsx

@ -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>
);
};

33
src/components/IconButton.tsx

@ -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>
);
};

34
src/components/NewDevice.tsx

@ -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>
);
};

198
src/components/PageComponents/Channel.tsx

@ -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>
);
};

54
src/components/PageComponents/Config/Bluetooth.tsx

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

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

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

51
src/components/PageComponents/Config/Display.tsx

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

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

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

69
src/components/PageComponents/Config/Network.tsx

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

130
src/components/PageComponents/Config/Position.tsx

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

85
src/components/PageComponents/Config/Power.tsx

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

60
src/components/PageComponents/Config/User.tsx

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

46
src/components/SlideSheets/tabs/connect/BLE.tsx → src/components/PageComponents/Connect/BLE.tsx

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

75
src/components/SlideSheets/tabs/connect/HTTP.tsx → src/components/PageComponents/Connect/HTTP.tsx

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

66
src/components/SlideSheets/tabs/connect/Serial.tsx → src/components/PageComponents/Connect/Serial.tsx

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

85
src/components/PageComponents/Messages/ChannelChat.tsx

@ -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>
);
};

89
src/components/PageComponents/Messages/Message.tsx

@ -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>
);
};

26
src/pages/Messages/NewLocationMessage.tsx → src/components/PageComponents/Messages/NewLocationMessage.tsx

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

33
src/components/PageComponents/Messages/WaypointMessage.tsx

@ -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>
);
};

137
src/components/PageComponents/ModuleConfig/CannedMessage.tsx

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

125
src/components/PageComponents/ModuleConfig/ExternalNotification.tsx

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

98
src/components/PageComponents/ModuleConfig/MQTT.tsx

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

69
src/components/PageComponents/ModuleConfig/RangeTest.tsx

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

75
src/components/PageComponents/ModuleConfig/Serial.tsx

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

73
src/components/PageComponents/ModuleConfig/StoreForward.tsx

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

94
src/components/PageComponents/ModuleConfig/Telemetry.tsx

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

77
src/components/PageNav.tsx

@ -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>
);
};

109
src/components/Progress.tsx

@ -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>
);
};

80
src/components/Sidebar.tsx

@ -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>
);
};

114
src/components/SlideSheets/NewDevice.tsx

@ -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>
);
};

60
src/components/SlideSheets/PeerInfo.tsx

@ -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>
);
};

18
src/components/SlideSheets/tabs/nodes/Location.tsx

@ -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>
);
};

17
src/components/SlideSheets/tabs/nodes/Overview.tsx

@ -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>
);
};

117
src/components/Widgets/ConfiguringWidget.tsx

@ -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>
);
};

50
src/components/Widgets/DeviceWidget.tsx

@ -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>
);
};

11
src/components/Widgets/NodeInfoWidget.tsx

@ -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>
);
};

11
src/components/Widgets/PeersWidget.tsx

@ -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>
);
};

11
src/components/Widgets/PositionWidget.tsx

@ -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>
);
};

53
src/components/form/Checkbox.tsx

@ -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>
);
}
);

79
src/components/form/Form.tsx

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

75
src/components/form/Input.tsx

@ -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>
);
});

65
src/components/form/Select.tsx

@ -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>
);
});

48
src/components/form/Toggle.tsx

@ -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>
);
};

76
src/components/layout/AppLayout.tsx

@ -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>
);
};

167
src/components/layout/Header.tsx

@ -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>
);
};

103
src/components/layout/Sidebar/DeviceCard.tsx

@ -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>
);
};

122
src/components/layout/Sidebar/index.tsx

@ -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>
);
};

88
src/components/layout/page/SlideSheetTabbedContent.tsx

@ -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>
</>
);
};

89
src/components/layout/page/TabbedContent.tsx

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

18
src/components/misc/NoDevice.tsx

@ -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>
);
};

6
src/core/providers/useDevice.ts

@ -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 DeviceContext = createContext<Device | undefined>(undefined);
export const useDevice = (): Device => { export const useDevice = (): Device => {
const context = useContext(DeviceContext); const context = useContext(DeviceContext);
if (context === undefined) { 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; return context;
}; };

4
src/core/stores/appStore.ts

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

3
src/core/stores/deviceStore.ts

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

3
src/index.css

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

2
src/index.tsx

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

30
src/pages/Channels/index.tsx → src/pages/Channels.tsx

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

205
src/pages/Channels/Channel.tsx

@ -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>
);
};

53
src/pages/Config/AppConfig.tsx

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

53
src/pages/Config/DeviceConfig.tsx

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

51
src/pages/Config/ModuleConfig.tsx

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

13
src/pages/Config/index.tsx

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

8
src/pages/Extensions/Environment.tsx

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

10
src/pages/Extensions/FileBrowser.tsx

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

13
src/pages/Extensions/Index.tsx

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

5
src/pages/Info/index.tsx → src/pages/Info.tsx

@ -1,6 +1,5 @@
import type React from "react"; import type React from "react";
import { Pane } from "evergreen-ui";
import JSONPretty from "react-json-pretty"; import JSONPretty from "react-json-pretty";
import { useDevice } from "@core/providers/useDevice.js"; 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); const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
return ( return (
<Pane> <div>
<JSONPretty data={myNode} /> <JSONPretty data={myNode} />
<JSONPretty data={hardware} /> <JSONPretty data={hardware} />
</Pane> </div>
); );
}; };

66
src/pages/Map/index.tsx → src/pages/Map.tsx

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

43
src/pages/Messages/index.tsx → src/pages/Messages.tsx

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

103
src/pages/Messages/ChannelChat.tsx

@ -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>
);
};

94
src/pages/Messages/Message.tsx

@ -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>
);
};

52
src/pages/Messages/WaypointMessage.tsx

@ -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>
);
};

9
src/validation/config/device.ts

@ -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 { export class DeviceValidation implements Protobuf.Config_DeviceConfig {
@IsEnum(Protobuf.Config_DeviceConfig_Role) @IsEnum(Protobuf.Config_DeviceConfig_Role)
role: Protobuf.Config_DeviceConfig_Role; role: Protobuf.Config_DeviceConfig_Role;
@IsBoolean() @IsBoolean()
serialDisabled: boolean; serialEnabled: boolean;
@IsBoolean()
factoryReset: boolean;
@IsBoolean() @IsBoolean()
debugLogEnabled: boolean; debugLogEnabled: boolean;

12
src/validation/config/lora.ts

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

8
src/validation/config/position.ts

@ -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 { export class PositionValidation implements Protobuf.Config_PositionConfig {
@IsInt() @IsInt()
positionBroadcastSecs: number; positionBroadcastSecs: number;
@IsBoolean() @IsBoolean()
positionBroadcastSmartDisabled: boolean; positionBroadcastSmartEnabled: boolean;
@IsBoolean() @IsBoolean()
fixedPosition: boolean; fixedPosition: boolean;
@IsBoolean() @IsBoolean()
gpsDisabled: boolean; gpsEnabled: boolean;
@IsInt() @IsInt()
gpsUpdateInterval: number; gpsUpdateInterval: number;

8
tailwind.config.cjs

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