Browse Source

WIP updates

pull/66/head
Sacha Weatherstone 4 years ago
parent
commit
e457cef3da
No known key found for this signature in database GPG Key ID: 7AB2D7E206124B31
  1. 15
      .github/workflows/main.yml
  2. 4
      .prettierignore
  3. 67
      package.json
  4. 1487
      pnpm-lock.yaml
  5. 6
      postcss.config.cjs
  6. 5
      prettier.config.cjs
  7. 8
      src/App.tsx
  8. 6
      src/DeviceWrapper.tsx
  9. 4
      src/PageRouter.tsx
  10. 8
      src/components/Button.tsx
  11. 68
      src/components/CommandPalette/Index.tsx
  12. 2
      src/components/CommandPalette/PaletteTransition.tsx
  13. 126
      src/components/Dialog/ImportDialog.tsx
  14. 8
      src/components/Dialog/QRDialog.tsx
  15. 50
      src/components/Drawer.tsx
  16. 2
      src/components/Dropdown.tsx
  17. 8
      src/components/NewDevice.tsx
  18. 132
      src/components/PageComponents/AppConfig/Map.tsx
  19. 42
      src/components/PageComponents/Channel.tsx
  20. 22
      src/components/PageComponents/Config/Bluetooth.tsx
  21. 33
      src/components/PageComponents/Config/Device.tsx
  22. 25
      src/components/PageComponents/Config/Display.tsx
  23. 26
      src/components/PageComponents/Config/LoRa.tsx
  24. 95
      src/components/PageComponents/Config/Network.tsx
  25. 170
      src/components/PageComponents/Config/Position.tsx
  26. 18
      src/components/PageComponents/Config/Power.tsx
  27. 19
      src/components/PageComponents/Config/User.tsx
  28. 4
      src/components/PageComponents/Connect/BLE.tsx
  29. 8
      src/components/PageComponents/Connect/HTTP.tsx
  30. 2
      src/components/PageComponents/Connect/Serial.tsx
  31. 6
      src/components/PageComponents/Messages/Message.tsx
  32. 17
      src/components/PageComponents/Messages/MessageInput.tsx
  33. 13
      src/components/PageComponents/Messages/NewLocationMessage.tsx
  34. 2
      src/components/PageComponents/Messages/WaypointMessage.tsx
  35. 20
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  36. 36
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  37. 20
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  38. 22
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  39. 30
      src/components/PageComponents/ModuleConfig/Serial.tsx
  40. 26
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  41. 20
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  42. 20
      src/components/PageNav.tsx
  43. 2
      src/components/Widgets/BatteryWidget.tsx
  44. 6
      src/components/Widgets/ConfiguringWidget.tsx
  45. 2
      src/components/Widgets/DeviceWidget.tsx
  46. 2
      src/components/Widgets/NodeInfoWidget.tsx
  47. 2
      src/components/Widgets/PeersWidget.tsx
  48. 82
      src/components/form/BitwiseSelect.tsx
  49. 2
      src/components/form/Form.tsx
  50. 2
      src/components/form/FormSection.tsx
  51. 133
      src/components/form/IPAddress.tsx
  52. 39
      src/components/form/InfoWrapper.tsx
  53. 20
      src/components/form/Input.tsx
  54. 32
      src/components/form/Select.tsx
  55. 32
      src/components/form/Toggle.tsx
  56. 2
      src/components/layout/page/TabbedContent.tsx
  57. 44
      src/core/stores/appStore.ts
  58. 16
      src/core/subscriptions.ts
  59. 10
      src/core/utils/bitwise.ts
  60. 2
      src/index.css
  61. 1
      src/index.tsx
  62. 20
      src/pages/Channels.tsx
  63. 18
      src/pages/Config/AppConfig.tsx
  64. 18
      src/pages/Config/DeviceConfig.tsx
  65. 20
      src/pages/Config/ModuleConfig.tsx
  66. 10
      src/pages/Config/index.tsx
  67. 10
      src/pages/Extensions/Index.tsx
  68. 18
      src/pages/Info.tsx
  69. 21
      src/pages/Map.tsx
  70. 6
      src/pages/Messages.tsx
  71. 2
      src/pages/Peers.tsx
  72. 22
      src/validation/appConfig/map.ts
  73. 8
      src/validation/config/device.ts
  74. 3
      src/validation/config/display.ts
  75. 9
      src/validation/config/network.ts
  76. 6
      src/validation/config/position.ts
  77. 8
      src/validation/moduleConfig/externalNotification.ts
  78. 8
      tailwind.config.cjs
  79. 14
      vite.config.ts

15
.github/workflows/main.yml

@ -19,13 +19,12 @@ jobs:
# Checks-out repository # Checks-out repository
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.0.1 - uses: pnpm/action-setup@v2.2.4
with: with:
version: 6.14.3 version: latest
- uses: actions/setup-node@v2 - uses: actions/setup-node@v3
with: with:
node-version: '16' node-version: latest
cache: 'pnpm'
- run: pnpm install - run: pnpm install
- run: pnpm format - run: pnpm format
- run: pnpm build - run: pnpm build
@ -33,10 +32,10 @@ jobs:
# Upload Artifact # Upload Artifact
- name: Upload Artifact - name: Upload Artifact
uses: 'marvinpinto/action-automatic-releases@latest' uses: "marvinpinto/action-automatic-releases@latest"
with: with:
repo_token: '${{ secrets.GITHUB_TOKEN }}' repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: 'latest' automatic_release_tag: "latest"
prerelease: false prerelease: false
files: | files: |
./dist/build.tar ./dist/build.tar

4
.prettierignore

@ -0,0 +1,4 @@
# Ignore artifacts:
dist
pnpm-lock.yaml
stats.html

67
package.json

@ -9,41 +9,42 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)", "package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)",
"format": "prettier --write 'src/**/*.{ts,tsx}' && eslint src/*.{ts,tsx}", "format:check": "prettier --check . && eslint src/**/*.{ts,tsx}",
"format:fix": "prettier --write . && eslint --fix src/**/*.{ts,tsx}",
"check:unimported": "unimported" "check:unimported": "unimported"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/meshtastic/meshtastic-web.git" "url": "git+https://github.com/meshtastic/web.git"
}, },
"bugs": { "bugs": {
"url": "https://github.com/meshtastic/meshtastic-web/issues" "url": "https://github.com/meshtastic/web/issues"
}, },
"homepage": "https://meshtastic.org", "homepage": "https://meshtastic.org",
"dependencies": { "dependencies": {
"@emeraldpay/hashicon-react": "^0.5.2", "@emeraldpay/hashicon-react": "^0.5.2",
"@headlessui/react": "^1.7.3", "@headlessui/react": "^1.7.4",
"@heroicons/react": "^2.0.12", "@heroicons/react": "^2.0.13",
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^2.9.10", "@hookform/resolvers": "^2.9.10",
"@meshtastic/eslint-config": "^1.0.8", "@meshtastic/meshtasticjs": "^0.7.0",
"@meshtastic/meshtasticjs": "^0.6.112",
"@tailwindcss/line-clamp": "^0.4.2", "@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.7", "@tailwindcss/typography": "^0.5.8",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"chart.js": "^3.9.1", "chart.js": "^4.0.1",
"chartjs-adapter-date-fns": "^2.0.0", "chartjs-adapter-date-fns": "^2.0.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"geodesy": "^2.4.0", "geodesy": "^2.4.0",
"immer": "^9.0.16", "immer": "^9.0.16",
"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",
"pretty-ms": "^8.0.0", "pretty-ms": "^8.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^4.3.1", "react-chartjs-2": "^5.0.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.38.0", "react-hook-form": "^7.39.6",
"react-hot-toast": "^2.4.0", "react-hot-toast": "^2.4.0",
"react-icons": "^4.6.0", "react-icons": "^4.6.0",
"react-json-pretty": "^2.2.0", "react-json-pretty": "^2.2.0",
@ -51,30 +52,38 @@
"react-map-gl": "^7.0.19", "react-map-gl": "^7.0.19",
"react-qrcode-logo": "^2.8.0", "react-qrcode-logo": "^2.8.0",
"rfc4648": "^1.5.2", "rfc4648": "^1.5.2",
"zustand": "4.1.3" "zustand": "4.1.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@types/chrome": "^0.0.200", "@types/chrome": "^0.0.203",
"@types/geodesy": "^2.2.3", "@types/geodesy": "^2.2.3",
"@types/node": "^18.11.7", "@types/node": "^18.11.9",
"@types/react": "^18.0.23", "@types/react": "^18.0.25",
"@types/react-dom": "^18.0.7", "@types/react-dom": "^18.0.9",
"@types/w3c-web-serial": "^1.0.3", "@types/w3c-web-serial": "^1.0.3",
"@types/web-bluetooth": "^0.0.16", "@types/web-bluetooth": "^0.0.16",
"@typescript-eslint/eslint-plugin": "^5.44.0",
"@typescript-eslint/parser": "^5.44.0",
"@vitejs/plugin-react": "^2.2.0", "@vitejs/plugin-react": "^2.2.0",
"autoprefixer": "^10.4.12", "autoprefixer": "^10.4.13",
"gzipper": "^7.1.0", "eslint": "^8.28.0",
"postcss": "^8.4.18", "eslint-config-prettier": "^8.5.0",
"prettier": "^2.7.1", "eslint-import-resolver-typescript": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.1.13", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-react-hooks": "^4.6.0",
"gzipper": "^7.2.0",
"postcss": "^8.4.19",
"prettier": "^2.8.0",
"prettier-plugin-tailwindcss": "^0.2.0",
"rollup-plugin-visualizer": "^5.8.3", "rollup-plugin-visualizer": "^5.8.3",
"tailwindcss": "^3.2.1", "tailwindcss": "^3.2.4",
"tar": "^6.1.11", "tar": "^6.1.12",
"tslib": "^2.4.0", "tslib": "^2.4.1",
"typescript": "^4.8.4", "typescript": "^4.9.3",
"unimported": "^1.22.0", "unimported": "^1.23.0",
"vite": "^3.2.0", "vite": "^3.2.4",
"vite-plugin-environment": "^1.1.3" "vite-plugin-environment": "^1.1.3"
} }
} }

1487
pnpm-lock.yaml

File diff suppressed because it is too large

6
postcss.config.cjs

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

5
prettier.config.cjs

@ -1,3 +1,4 @@
module.exports = { module.exports = {
plugins: [require('prettier-plugin-tailwindcss')], trailingComma: "none",
} plugins: [require("prettier-plugin-tailwindcss")]
};

8
src/App.tsx

@ -10,6 +10,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.js";
import { CommandPalette } from "./components/CommandPalette/Index.js"; import { CommandPalette } from "./components/CommandPalette/Index.js";
import { DeviceSelector } from "./components/DeviceSelector.js"; import { DeviceSelector } from "./components/DeviceSelector.js";
import { DialogManager } from "./components/Dialog/DialogManager.js"; import { DialogManager } from "./components/Dialog/DialogManager.js";
import { Drawer } from "./components/Drawer.js";
import { NewDevice } from "./components/NewDevice.js"; import { NewDevice } from "./components/NewDevice.js";
import { PageNav } from "./components/PageNav.js"; import { PageNav } from "./components/PageNav.js";
import { Sidebar } from "./components/Sidebar.js"; import { Sidebar } from "./components/Sidebar.js";
@ -30,14 +31,17 @@ export const App = (): JSX.Element => {
<CommandPalette /> <CommandPalette />
<Toaster <Toaster
toastOptions={{ toastOptions={{
duration: 2000, duration: 2000
}} }}
/> />
<DialogManager /> <DialogManager />
<Sidebar /> <Sidebar />
<PageNav /> <PageNav />
<MapProvider> <MapProvider>
<PageRouter /> <div className="flex h-full w-full flex-col overflow-y-auto">
<PageRouter />
<Drawer />
</div>
</MapProvider> </MapProvider>
</DeviceWrapper> </DeviceWrapper>
)} )}

6
src/DeviceWrapper.tsx

@ -3,15 +3,15 @@ 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 DeviceWrapperProps {
children: React.ReactNode; children: React.ReactNode;
device: Device; device: Device;
} }
export const DeviceWrapper = ({ export const DeviceWrapper = ({
children, children,
device, device
}: DeviceProps): JSX.Element => { }: DeviceWrapperProps): JSX.Element => {
return ( return (
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider> <DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
); );

4
src/PageRouter.tsx

@ -14,7 +14,7 @@ import { PeersPage } from "./pages/Peers.js";
export const PageRouter = (): JSX.Element => { export const PageRouter = (): JSX.Element => {
const { activePage } = useDevice(); const { activePage } = useDevice();
return ( return (
<> <div className="flex-grow border-b">
{activePage === "messages" && <MessagesPage />} {activePage === "messages" && <MessagesPage />}
{activePage === "map" && <MapPage />} {activePage === "map" && <MapPage />}
{activePage === "extensions" && <ExtensionsPage />} {activePage === "extensions" && <ExtensionsPage />}
@ -23,6 +23,6 @@ export const PageRouter = (): JSX.Element => {
{activePage === "peers" && <PeersPage />} {activePage === "peers" && <PeersPage />}
{activePage === "info" && <InfoPage />} {activePage === "info" && <InfoPage />}
{activePage === "logs" && <LogsPage />} {activePage === "logs" && <LogsPage />}
</> </div>
); );
}; };

8
src/components/Button.tsx

@ -22,14 +22,18 @@ export const Button = ({
className={`flex w-full rounded-md border border-transparent px-3 focus:outline-none focus:ring-2 focus:ring-orange-500 ${ className={`flex w-full rounded-md border border-transparent px-3 focus:outline-none focus:ring-2 focus:ring-orange-500 ${
variant === "primary" variant === "primary"
? "bg-orange-600 text-white shadow-sm hover:bg-orange-700" ? "bg-orange-600 text-white shadow-sm hover:bg-orange-700"
: "bg-orange-100 text-orange-700 hover:bg-orange-200" : "bg-orange-200 text-orange-700 hover:bg-orange-200"
} ${ } ${
size === "sm" size === "sm"
? "h-8 text-sm" ? "h-8 text-sm"
: size === "md" : size === "md"
? "h-10 text-sm" ? "h-10 text-sm"
: "h-10 text-base" : "h-10 text-base"
} ${disabled ? "cursor-not-allowed bg-red-400 focus:ring-red-500" : ""}`} } ${
disabled
? "cursor-not-allowed bg-gray-400 hover:bg-gray-400 focus:ring-gray-500"
: ""
}`}
disabled={disabled} disabled={disabled}
{...rest} {...rest}
> >

68
src/components/CommandPalette/Index.tsx

@ -40,7 +40,7 @@ import {
TrashIcon, TrashIcon,
UsersIcon, UsersIcon,
WindowIcon, WindowIcon,
XCircleIcon, XCircleIcon
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { GroupView } from "./GroupView.js"; import { GroupView } from "./GroupView.js";
@ -77,58 +77,58 @@ export const CommandPalette = (): JSX.Element => {
icon: InboxIcon, icon: InboxIcon,
action() { action() {
setActivePage("messages"); setActivePage("messages");
}, }
}, },
{ {
name: "Map", name: "Map",
icon: MapIcon, icon: MapIcon,
action() { action() {
setActivePage("map"); setActivePage("map");
}, }
}, },
{ {
name: "Extensions", name: "Extensions",
icon: BeakerIcon, icon: BeakerIcon,
action() { action() {
setActivePage("extensions"); setActivePage("extensions");
}, }
}, },
{ {
name: "Config", name: "Config",
icon: Cog8ToothIcon, icon: Cog8ToothIcon,
action() { action() {
setActivePage("config"); setActivePage("config");
}, }
}, },
{ {
name: "Channels", name: "Channels",
icon: Square3Stack3DIcon, icon: Square3Stack3DIcon,
action() { action() {
setActivePage("channels"); setActivePage("channels");
}, }
}, },
{ {
name: "Peers", name: "Peers",
icon: UsersIcon, icon: UsersIcon,
action() { action() {
setActivePage("peers"); setActivePage("peers");
}, }
}, },
{ {
name: "Info", name: "Info",
icon: IdentificationIcon, icon: IdentificationIcon,
action() { action() {
setActivePage("info"); setActivePage("info");
}, }
}, },
{ {
name: "Logs", name: "Logs",
icon: DocumentTextIcon, icon: DocumentTextIcon,
action() { action() {
setActivePage("logs"); setActivePage("logs");
}, }
}, }
], ]
}, },
{ {
name: "Manage", name: "Manage",
@ -138,17 +138,17 @@ export const CommandPalette = (): JSX.Element => {
name: "[WIP] Switch Node", name: "[WIP] Switch Node",
icon: ArrowsRightLeftIcon, icon: ArrowsRightLeftIcon,
action() { action() {
alert('This feature is not implemented'); alert("This feature is not implemented");
}, }
}, },
{ {
name: "Connect New Node", name: "Connect New Node",
icon: PlusIcon, icon: PlusIcon,
action() { action() {
setSelectedDevice(0); setSelectedDevice(0);
}, }
}, }
], ]
}, },
{ {
name: "Contextual", name: "Contextual",
@ -159,20 +159,20 @@ export const CommandPalette = (): JSX.Element => {
icon: QrCodeIcon, icon: QrCodeIcon,
action() { action() {
setQRDialogOpen(true); setQRDialogOpen(true);
}, }
}, },
{ {
name: "Reset Peers", name: "Reset Peers",
icon: TrashIcon, icon: TrashIcon,
action() { action() {
if (connection) { if (connection) {
void toast.promise(connection.resetPeers(), { void toast.promise(connection.resetPeers({}), {
loading: "Resetting...", loading: "Resetting...",
success: "Succesfully reset peers", success: "Succesfully reset peers",
error: "No response received", error: "No response received"
}); });
} }
}, }
}, },
{ {
name: "Disconnect", name: "Disconnect",
@ -181,9 +181,9 @@ export const CommandPalette = (): JSX.Element => {
void connection?.disconnect(); void connection?.disconnect();
setSelectedDevice(0); setSelectedDevice(0);
removeDevice(selectedDevice ?? 0); removeDevice(selectedDevice ?? 0);
}, }
}, }
], ]
}, },
{ {
name: "Debug", name: "Debug",
@ -194,16 +194,16 @@ export const CommandPalette = (): JSX.Element => {
icon: ArrowPathIcon, icon: ArrowPathIcon,
action() { action() {
void connection?.configure(); void connection?.configure();
}, }
}, },
{ {
name: "[WIP] Clear Messages", name: "[WIP] Clear Messages",
icon: ArchiveBoxXMarkIcon, icon: ArchiveBoxXMarkIcon,
action() { action() {
alert('This feature is not implemented'); alert("This feature is not implemented");
}, }
}, }
], ]
}, },
{ {
name: "Application", name: "Application",
@ -213,11 +213,11 @@ export const CommandPalette = (): JSX.Element => {
name: "[WIP] Toggle Dark Mode", name: "[WIP] Toggle Dark Mode",
icon: MoonIcon, icon: MoonIcon,
action() { action() {
alert('This feature is not implemented'); alert("This feature is not implemented");
}, }
}, }
], ]
}, }
]; ];
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
@ -243,7 +243,7 @@ export const CommandPalette = (): JSX.Element => {
return `${group.name} ${command.name}` return `${group.name} ${command.name}`
.toLowerCase() .toLowerCase()
.includes(query.toLowerCase()); .includes(query.toLowerCase());
}), })
}; };
}) })
.filter((group) => group.commands.length); .filter((group) => group.commands.length);

2
src/components/CommandPalette/PaletteTransition.tsx

@ -8,7 +8,7 @@ export interface PaletteTransitionProps {
} }
export const PaletteTransition = ({ export const PaletteTransition = ({
children, children
}: PaletteTransitionProps): JSX.Element => { }: PaletteTransitionProps): JSX.Element => {
return ( return (
<> <>

126
src/components/Dialog/ImportDialog.tsx

@ -0,0 +1,126 @@
import type React from "react";
import { useEffect, useState } from "react";
import { fromByteArray } from "base64-js";
import { toast } from "react-hot-toast";
import { QRCode } from "react-qrcode-logo";
import { Dialog } from "@headlessui/react";
import { ClipboardIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { Protobuf } from "@meshtastic/meshtasticjs";
import { Checkbox } from "../form/Checkbox.js";
import { Input } from "../form/Input.js";
import { IconButton } from "../IconButton.js";
export interface ImportDialogProps {
isOpen: boolean;
close: () => void;
loraConfig?: Protobuf.Config_LoRaConfig;
channels: Protobuf.Channel[];
}
export const ImportDialog = ({
isOpen,
close,
loraConfig,
channels
}: ImportDialogProps): JSX.Element => {
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
const [QRCodeURL, setQRCodeURL] = useState<string>("");
useEffect(() => {
const channelsToEncode = channels
.filter((channel) => selectedChannels.includes(channel.index))
.map((channel) => channel.settings)
.filter((ch): ch is Protobuf.ChannelSettings => !!ch);
const encoded = Protobuf.ChannelSet.toBinary({
loraConfig,
settings: channelsToEncode
});
const base64 = fromByteArray(encoded)
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
setQRCodeURL(`https://meshtastic.org/e/#${base64}`);
}, [channels, selectedChannels, loraConfig]);
return (
<Dialog open={isOpen} onClose={close}>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel>
<div className="divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow">
<div className="flex px-4 py-5 sm:px-6">
<div>
<h1 className="text-lg font-bold">Generate QR Code</h1>
<h5 className="text-sm text-slate-600">
The current LoRa configuration will also be shared.
</h5>
</div>
<IconButton
onClick={close}
className="my-auto ml-auto"
size="sm"
variant="secondary"
icon={<XMarkIcon className="h-4" />}
/>
</div>
<div className="flex gap-3 px-4 py-5 sm:p-6">
<div className="flex w-40 flex-col gap-1">
{channels.map((channel) => (
<Checkbox
key={channel.index}
disabled={
channel.index === 0 ||
channel.role === Protobuf.Channel_Role.DISABLED
}
label={
channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.index}`
}
checked={selectedChannels.includes(channel.index)}
onChange={() => {
if (selectedChannels.includes(channel.index)) {
setSelectedChannels(
selectedChannels.filter((c) => c !== channel.index)
);
} else {
setSelectedChannels([
...selectedChannels,
channel.index
]);
}
}}
/>
))}
</div>
<QRCode value={QRCodeURL} size={200} qrStyle="dots" />
</div>
<div className="px-4 py-4 sm:px-6">
<Input
label="Sharable URL"
value={QRCodeURL}
disabled
action={{
icon: <ClipboardIcon className="h-4" />,
action() {
void navigator.clipboard.writeText(QRCodeURL);
toast.success("Copied URL to Clipboard");
}
}}
/>
</div>
{/* </Card> */}
</div>
</Dialog.Panel>
</div>
</Dialog>
);
};

8
src/components/Dialog/QRDialog.tsx

@ -24,7 +24,7 @@ export const QRDialog = ({
isOpen, isOpen,
close, close,
loraConfig, loraConfig,
channels, channels
}: QRDialogProps): JSX.Element => { }: QRDialogProps): JSX.Element => {
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]); const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
const [QRCodeURL, setQRCodeURL] = useState<string>(""); const [QRCodeURL, setQRCodeURL] = useState<string>("");
@ -37,7 +37,7 @@ export const QRDialog = ({
const encoded = Protobuf.ChannelSet.toBinary( const encoded = Protobuf.ChannelSet.toBinary(
Protobuf.ChannelSet.create({ Protobuf.ChannelSet.create({
loraConfig, loraConfig,
settings: channelsToEncode, settings: channelsToEncode
}) })
); );
const base64 = fromByteArray(encoded) const base64 = fromByteArray(encoded)
@ -94,7 +94,7 @@ export const QRDialog = ({
} else { } else {
setSelectedChannels([ setSelectedChannels([
...selectedChannels, ...selectedChannels,
channel.index, channel.index
]); ]);
} }
}} }}
@ -114,7 +114,7 @@ export const QRDialog = ({
action() { action() {
void navigator.clipboard.writeText(QRCodeURL); void navigator.clipboard.writeText(QRCodeURL);
toast.success("Copied URL to Clipboard"); toast.success("Copied URL to Clipboard");
}, }
}} }}
/> />
</div> </div>

50
src/components/Drawer.tsx

@ -0,0 +1,50 @@
import type React from "react";
import { useState } from "react";
import { useDevice } from "@app/core/providers/useDevice.js";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline";
export const Drawer = (): JSX.Element => {
const [drawerOpen, setDrawerOpen] = useState(false);
const tabs = [{ title: "Notifications" }, { title: "Debug" }];
const { config, moduleConfig, hardware, nodes, waypoints, connection } =
useDevice();
const [serialLogs, setSerialLogs] = useState<string>("");
connection?.onDeviceDebugLog.subscribe((packet) => {
setSerialLogs(serialLogs + new TextDecoder().decode(packet));
});
return (
<div className={`shadow-md ${drawerOpen ? "h-40" : "h-8"}`}>
<div className="flex h-8 bg-slate-50">
<div
onClick={() => {
setDrawerOpen(!drawerOpen);
}}
className="ml-auto flex px-2 hover:cursor-pointer hover:bg-slate-100"
>
<div className="m-auto">
{drawerOpen ? (
<ChevronDownIcon className="h-4 text-gray-700" />
) : (
<ChevronUpIcon className="h-4 text-gray-700" />
)}
</div>
</div>
</div>
<div className={`${drawerOpen ? "flex" : "hidden"}`}>
<div>
{serialLogs.split("\n").map((line, index) => (
<div key={index} className="text-sm">
{line}
</div>
))}
</div>
</div>
</div>
);
};

2
src/components/Dropdown.tsx

@ -18,7 +18,7 @@ export const Dropdown = ({
stat, stat,
icon, icon,
defaultOpen, defaultOpen,
children, children
}: DropdownProps): JSX.Element => { }: DropdownProps): JSX.Element => {
return ( return (
<Disclosure defaultOpen={defaultOpen}> <Disclosure defaultOpen={defaultOpen}>

8
src/components/NewDevice.tsx

@ -15,13 +15,13 @@ export const NewDevice = () => {
element: BLE, element: BLE,
disabled: !navigator.bluetooth, disabled: !navigator.bluetooth,
disabledMessage: disabledMessage:
"WebBluetooth is currently only supported by Chromium based browsers: https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility", "WebBluetooth is currently only supported by Chromium based browsers: https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility"
}, },
{ {
name: "HTTP", name: "HTTP",
icon: <FiWifi className="h-4" />, icon: <FiWifi className="h-4" />,
element: HTTP, element: HTTP,
disabled: false, disabled: false
}, },
{ {
name: "Serial", name: "Serial",
@ -29,8 +29,8 @@ export const NewDevice = () => {
element: Serial, element: Serial,
disabled: !navigator.serial, disabled: !navigator.serial,
disabledMessage: disabledMessage:
"WebSerial is currently only supported by Chromium based browsers: https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility", "WebSerial is currently only supported by Chromium based browsers: https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility"
}, }
]); ]);
return ( return (

132
src/components/PageComponents/AppConfig/Map.tsx

@ -0,0 +1,132 @@
import type React from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { Button } from "@app/components/Button.js";
import { InfoWrapper } from "@app/components/form/InfoWrapper.js";
import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js";
import { IconButton } from "@app/components/IconButton.js";
import { useAppStore } from "@app/core/stores/appStore.js";
import { MapValidation } from "@app/validation/appConfig/map.js";
import { Form } from "@components/form/Form";
import { TrashIcon } from "@heroicons/react/24/outline";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
export const Map = (): JSX.Element => {
const { rasterSources, setRasterSources } = useAppStore();
const {
register,
handleSubmit,
formState: { errors, isDirty },
control,
reset
} = useForm<MapValidation>({
defaultValues: {
// wmsSources: wmsSources ?? [
// {
// url: "",
// tileSize: 512,
// type: "raster"
// }
// ]
},
resolver: classValidatorResolver(MapValidation)
});
const { fields, append, remove, insert } = useFieldArray({
control,
name: "rasterSources"
});
const onSubmit = handleSubmit((data) => {
setRasterSources(data.rasterSources);
});
// useEffect(() => {
// reset(rasterSources);
// }, [reset, rasterSources]);
return (
<Form
title="Map Config"
breadcrumbs={["App Config", "Map"]}
reset={() =>
reset({
rasterSources
})
}
dirty={isDirty}
onSubmit={onSubmit}
>
<InfoWrapper label="WMS Sources">
<div className="flex flex-col gap-2">
{fields.map((field, index) => (
<div key={field.id} className="flex w-full gap-2">
<Controller
name={`rasterSources.${index}.enabled`}
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle checked={value} {...rest} />
)}
/>
<Input
placeholder="Name"
error={
errors.rasterSources
? errors.rasterSources[index]?.title?.message
: undefined
}
{...register(`rasterSources.${index}.title`)}
/>
<Input
placeholder="Tile Size"
type="number"
error={
errors.rasterSources
? errors.rasterSources[index]?.tileSize?.message
: undefined
}
{...register(`rasterSources.${index}.tileSize`, {
valueAsNumber: true
})}
/>
<Input
placeholder="URL"
error={
errors.rasterSources
? errors.rasterSources[index]?.tiles?.message
: undefined
}
{...register(`rasterSources.${index}.tiles`)}
/>
<IconButton
className="shrink-0"
icon={<TrashIcon className="w-4" />}
onClick={() => {
remove(index);
}}
/>
</div>
))}
<Button
variant="secondary"
onClick={() => {
append({
enabled: true,
title: "",
tiles: [
"https://img.nj.gov/imagerywms/Natural2015?bbox={bbox-epsg-3857}&format=image/png&service=WMS&version=1.1.1&request=GetMap&srs=EPSG:3857&transparent=true&width=256&height=256&layers=Natural2015"
],
tileSize: 512
});
}}
>
New Source
</Button>
</div>
</InfoWrapper>
</Form>
);
};

42
src/components/PageComponents/Channel.tsx

@ -14,7 +14,7 @@ import { useDevice } from "@core/providers/useDevice.js";
import { import {
ArrowPathIcon, ArrowPathIcon,
EyeIcon, EyeIcon,
EyeSlashIcon, EyeSlashIcon
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
@ -34,39 +34,39 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control,
setValue, setValue
} = useForm<ChannelSettingsValidation>({ } = useForm<ChannelSettingsValidation>({
defaultValues: { defaultValues: {
enabled: [ enabled: [
Protobuf.Channel_Role.SECONDARY, Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY, Protobuf.Channel_Role.PRIMARY
].find((role) => role === channel?.role) ].find((role) => role === channel?.role)
? true ? true
: false, : false,
...channel?.settings, ...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0))
}, },
resolver: classValidatorResolver(ChannelSettingsValidation), resolver: classValidatorResolver(ChannelSettingsValidation)
}); });
useEffect(() => { useEffect(() => {
reset({ reset({
enabled: [ enabled: [
Protobuf.Channel_Role.SECONDARY, Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY, Protobuf.Channel_Role.PRIMARY
].find((role) => role === channel?.role) ].find((role) => role === channel?.role)
? true ? true
: false, : false,
...channel?.settings, ...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0))
}); });
}, [channel, reset]); }, [channel, reset]);
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setChannel( connection.setChannel({
{ channel: {
role: role:
channel?.role === Protobuf.Channel_Role.PRIMARY channel?.role === Protobuf.Channel_Role.PRIMARY
? Protobuf.Channel_Role.PRIMARY ? Protobuf.Channel_Role.PRIMARY
@ -76,18 +76,18 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
index: channel?.index, index: channel?.index,
settings: { settings: {
...data, ...data,
psk: toByteArray(data.psk ?? ""), psk: toByteArray(data.psk ?? "")
}, }
}, },
(): Promise<void> => { callback: (): Promise<void> => {
reset({ ...data }); reset({ ...data });
return Promise.resolve(); return Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Channel", success: "Saved Channel",
error: "No response received", error: "No response received"
} }
); );
} }
@ -102,18 +102,18 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
? channel.settings.name ? channel.settings.name
: channel.role === Protobuf.Channel_Role.PRIMARY : channel.role === Protobuf.Channel_Role.PRIMARY
? "Primary" ? "Primary"
: `Channel: ${channel.index}`, : `Channel: ${channel.index}`
]} ]}
reset={() => reset={() =>
reset({ reset({
enabled: [ enabled: [
Protobuf.Channel_Role.SECONDARY, Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY, Protobuf.Channel_Role.PRIMARY
].find((role) => role === channel?.role) ].find((role) => role === channel?.role)
? true ? true
: false, : false,
...channel?.settings, ...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0))
}) })
} }
dirty={isDirty} dirty={isDirty}
@ -153,8 +153,10 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
action: () => { action: () => {
const key = new Uint8Array(keySize / 8); const key = new Uint8Array(keySize / 8);
crypto.getRandomValues(key); crypto.getRandomValues(key);
setValue("psk", fromByteArray(key)); setValue("psk", fromByteArray(key), {
}, shouldDirty: true
});
}
}} }}
> >
<option value={128}>128 Bit</option> <option value={128}>128 Bit</option>
@ -173,7 +175,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
), ),
action: () => { action: () => {
setPskHidden(!pskHidden); setPskHidden(!pskHidden);
}, }
}} }}
error={errors.psk?.message} error={errors.psk?.message}
{...register("psk")} {...register("psk")}

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

@ -22,10 +22,10 @@ export const Bluetooth = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
control, control,
reset, reset
} = useForm<BluetoothValidation>({ } = useForm<BluetoothValidation>({
defaultValues: config.bluetooth, defaultValues: config.bluetooth,
resolver: classValidatorResolver(BluetoothValidation), resolver: classValidatorResolver(BluetoothValidation)
}); });
useEffect(() => { useEffect(() => {
@ -35,22 +35,22 @@ export const Bluetooth = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setConfig( connection.setConfig({
{ config: {
payloadVariant: { payloadVariant: {
oneofKind: "bluetooth", oneofKind: "bluetooth",
bluetooth: data, bluetooth: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Bluetooth Config, Restarting Node", success: "Saved Bluetooth Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -59,7 +59,7 @@ export const Bluetooth = (): JSX.Element => {
const pairingMode = useWatch({ const pairingMode = useWatch({
control, control,
name: "mode", name: "mode",
defaultValue: Protobuf.Config_BluetoothConfig_PairingMode.RANDOM_PIN, defaultValue: Protobuf.Config_BluetoothConfig_PairingMode.RANDOM_PIN
}); });
return ( return (
@ -98,7 +98,7 @@ export const Bluetooth = (): JSX.Element => {
description="Pin to use when pairing" description="Pin to use when pairing"
type="number" type="number"
{...register("fixedPin", { {...register("fixedPin", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
</Form> </Form>

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

@ -4,6 +4,7 @@ import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Input } from "@app/components/form/Input.js";
import { Select } from "@app/components/form/Select.js"; import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.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";
@ -20,10 +21,10 @@ export const Device = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
control, control,
reset, reset
} = useForm<DeviceValidation>({ } = useForm<DeviceValidation>({
defaultValues: config.device, defaultValues: config.device,
resolver: classValidatorResolver(DeviceValidation), resolver: classValidatorResolver(DeviceValidation)
}); });
useEffect(() => { useEffect(() => {
@ -33,22 +34,22 @@ export const Device = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setConfig( connection.setConfig({
{ config: {
payloadVariant: { payloadVariant: {
oneofKind: "device", oneofKind: "device",
device: data, device: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Device Config, Restarting Node", success: "Saved Device Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -93,6 +94,20 @@ export const Device = (): JSX.Element => {
/> />
)} )}
/> />
<Input
label="Button Pin"
description="Button pin override"
type="number"
error={errors.buttonGpio?.message}
{...register("buttonGpio", { valueAsNumber: true })}
/>
<Input
label="Buzzer Pin"
description="Buzzer pin override"
type="number"
error={errors.buzzerGpio?.message}
{...register("buzzerGpio", { valueAsNumber: true })}
/>
</Form> </Form>
); );
}; };

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

@ -21,10 +21,10 @@ export const Display = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<Protobuf.Config_DisplayConfig>({ } = useForm<Protobuf.Config_DisplayConfig>({
defaultValues: config.display, defaultValues: config.display,
resolver: classValidatorResolver(DisplayValidation), resolver: classValidatorResolver(DisplayValidation)
}); });
useEffect(() => { useEffect(() => {
@ -34,22 +34,22 @@ export const Display = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setConfig( connection.setConfig({
{ config: {
payloadVariant: { payloadVariant: {
oneofKind: "display", oneofKind: "display",
display: data, display: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Display Config, Restarting Node", success: "Saved Display Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -115,6 +115,13 @@ export const Display = (): JSX.Element => {
> >
{renderOptions(Protobuf.Config_DisplayConfig_DisplayUnits)} {renderOptions(Protobuf.Config_DisplayConfig_DisplayUnits)}
</Select> </Select>
<Select
label="OLED Type"
description="Type of OLED screen attached to the device"
{...register("oled", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_DisplayConfig_OledType)}
</Select>
</Form> </Form>
); );
}; };

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

@ -23,16 +23,16 @@ export const LoRa = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
control, control,
reset, reset
} = useForm<LoRaValidation>({ } = useForm<LoRaValidation>({
defaultValues: config.lora, defaultValues: config.lora,
resolver: classValidatorResolver(LoRaValidation), resolver: classValidatorResolver(LoRaValidation)
}); });
const usePreset = useWatch({ const usePreset = useWatch({
control, control,
name: "usePreset", name: "usePreset",
defaultValue: true, defaultValue: true
}); });
useEffect(() => { useEffect(() => {
@ -42,22 +42,22 @@ export const LoRa = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setConfig( connection.setConfig({
{ config: {
payloadVariant: { payloadVariant: {
oneofKind: "lora", oneofKind: "lora",
lora: data, lora: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved LoRa Config, Restarting Node", success: "Saved LoRa Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -99,7 +99,7 @@ export const LoRa = (): JSX.Element => {
suffix="MHz" suffix="MHz"
error={errors.bandwidth?.message} error={errors.bandwidth?.message}
{...register("bandwidth", { {...register("bandwidth", {
valueAsNumber: true, valueAsNumber: true
})} })}
disabled={usePreset} disabled={usePreset}
/> />
@ -110,7 +110,7 @@ export const LoRa = (): JSX.Element => {
suffix="CPS" suffix="CPS"
error={errors.spreadFactor?.message} error={errors.spreadFactor?.message}
{...register("spreadFactor", { {...register("spreadFactor", {
valueAsNumber: true, valueAsNumber: true
})} })}
disabled={usePreset} disabled={usePreset}
/> />
@ -120,7 +120,7 @@ export const LoRa = (): JSX.Element => {
type="number" type="number"
error={errors.codingRate?.message} error={errors.codingRate?.message}
{...register("codingRate", { {...register("codingRate", {
valueAsNumber: true, valueAsNumber: true
})} })}
disabled={usePreset} disabled={usePreset}
/> />

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

@ -6,12 +6,14 @@ import { toast } from "react-hot-toast";
import { FormSection } from "@app/components/form/FormSection.js"; import { FormSection } from "@app/components/form/FormSection.js";
import { Input } from "@app/components/form/Input.js"; import { Input } from "@app/components/form/Input.js";
import { IPAddress } from "@app/components/form/IPAddress.js";
import { Select } from "@app/components/form/Select.js"; import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.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";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/providers/useDevice.js";
import { ErrorMessage } from "@hookform/error-message";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
@ -22,22 +24,22 @@ export const Network = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
control, control,
reset, reset
} = useForm<NetworkValidation>({ } = useForm<NetworkValidation>({
defaultValues: config.network, defaultValues: config.network,
resolver: classValidatorResolver(NetworkValidation), resolver: classValidatorResolver(NetworkValidation)
}); });
const wifiEnabled = useWatch({ const wifiEnabled = useWatch({
control, control,
name: "wifiEnabled", name: "wifiEnabled",
defaultValue: false, defaultValue: false
}); });
const ethEnabled = useWatch({ const ethEnabled = useWatch({
control, control,
name: "ethEnabled", name: "ethEnabled",
defaultValue: false, defaultValue: false
}); });
useEffect(() => { useEffect(() => {
@ -45,24 +47,32 @@ export const Network = (): JSX.Element => {
}, [reset, config.network]); }, [reset, config.network]);
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
console.log(data);
if (connection) { if (connection) {
const tmp = Protobuf.Config_NetworkConfig.create({
ethEnabled: true,
ethMode: Protobuf.Config_NetworkConfig_EthMode.DHCP
});
void toast.promise( void toast.promise(
connection.setConfig( connection
{ .setConfig({
payloadVariant: { config: {
oneofKind: "network", payloadVariant: {
network: data, oneofKind: "network",
network: tmp
}
}, },
}, callback: async () => {
async () => { reset({ ...data });
reset({ ...data }); await Promise.resolve();
await Promise.resolve(); }
} })
), .catch((e) => console.log(e)),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Network Config, Restarting Node", success: "Saved Network Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -76,6 +86,19 @@ export const Network = (): JSX.Element => {
dirty={isDirty} dirty={isDirty}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<ErrorMessage errors={errors} name="wifiEnabled" />
<ErrorMessage errors={errors} name="wifiMode" />
<ErrorMessage errors={errors} name="wifiSsid" />
<ErrorMessage errors={errors} name="wifiPsk" />
<ErrorMessage errors={errors} name="ntpServer" />
<ErrorMessage errors={errors} name="ethEnabled" />
<ErrorMessage errors={errors} name="ethMode" />
<ErrorMessage errors={errors} name="ethConfig" />
<ErrorMessage errors={errors} name="ip" />
<ErrorMessage errors={errors} name="gateway" />
<ErrorMessage errors={errors} name="subnet" />
<ErrorMessage errors={errors} name="dns" />
<FormSection title="WiFi Config"> <FormSection title="WiFi Config">
<Controller <Controller
name="wifiEnabled" name="wifiEnabled"
@ -83,26 +106,18 @@ export const Network = (): JSX.Element => {
render={({ field: { value, ...rest } }) => ( render={({ field: { value, ...rest } }) => (
<Toggle <Toggle
label="WiFi Enabled" label="WiFi Enabled"
description="Enable or disbale the WiFi radio" description="Enable or disable the WiFi radio"
checked={value} checked={value}
{...rest} {...rest}
/> />
)} )}
/> />
<Select
label="WiFi Mode"
description="How the WiFi radio should be used"
disabled={!wifiEnabled}
{...register("wifiMode", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_NetworkConfig_WiFiMode)}
</Select>
<Input <Input
label="SSID" label="SSID"
description="Network name" description="Network name"
error={errors.wifiSsid?.message} error={errors.wifiSsid?.message}
disabled={!wifiEnabled} disabled={!wifiEnabled}
{...register("wifiSsid")} {...register("wifiSsid", { disabled: !wifiEnabled })}
/> />
<Input <Input
label="PSK" label="PSK"
@ -110,7 +125,7 @@ export const Network = (): JSX.Element => {
description="Network password" description="Network password"
error={errors.wifiPsk?.message} error={errors.wifiPsk?.message}
disabled={!wifiEnabled} disabled={!wifiEnabled}
{...register("wifiPsk")} {...register("wifiPsk", { disabled: !wifiEnabled })}
/> />
</FormSection> </FormSection>
<FormSection title="Ethernet Config"> <FormSection title="Ethernet Config">
@ -130,35 +145,43 @@ export const Network = (): JSX.Element => {
label="Ethernet Mode" label="Ethernet Mode"
description="Address assignment selection" description="Address assignment selection"
disabled={!ethEnabled} disabled={!ethEnabled}
{...register("ethMode", { valueAsNumber: true })} {...register("ethMode", {
valueAsNumber: true,
disabled: !ethEnabled
})}
> >
{renderOptions(Protobuf.Config_NetworkConfig_EthMode)} {renderOptions(Protobuf.Config_NetworkConfig_EthMode)}
</Select> </Select>
</FormSection> </FormSection>
<FormSection title="IP Config"> <FormSection title="IP Config">
<IPAddress label="IP" description="IP Address" />
<Input <Input
label="IP" label="IP"
type="number"
description="IP Address" description="IP Address"
error={errors.ethConfig?.ip?.message} error={errors.ipv4Config?.ip?.message}
{...register("ethConfig.ip")} {...register("ipv4Config.ip", { valueAsNumber: true })}
/> />
<Input <Input
label="Gateway" label="Gateway"
type="number"
description="Default Gateway" description="Default Gateway"
error={errors.ethConfig?.gateway?.message} error={errors.ipv4Config?.gateway?.message}
{...register("ethConfig.gateway")} {...register("ipv4Config.gateway", { valueAsNumber: true })}
/> />
<Input <Input
label="Subnet" label="Subnet"
type="number"
description="Subnet Mask" description="Subnet Mask"
error={errors.ethConfig?.subnet?.message} error={errors.ipv4Config?.subnet?.message}
{...register("ethConfig.subnet")} {...register("ipv4Config.subnet", { valueAsNumber: true })}
/> />
<Input <Input
label="DNS" label="DNS"
type="number"
description="DNS Server" description="DNS Server"
error={errors.ethConfig?.dns?.message} error={errors.ipv4Config?.dns?.message}
{...register("ethConfig.dns")} {...register("ipv4Config.dns", { valueAsNumber: true })}
/> />
</FormSection> </FormSection>
<Input <Input

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

@ -4,6 +4,7 @@ import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form"; import { Controller, useForm, useWatch } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { BitwiseSelect } from "@app/components/form/BitwiseSelect.js";
import { FormSection } from "@app/components/form/FormSection.js"; import { FormSection } from "@app/components/form/FormSection.js";
import { Input } from "@app/components/form/Input.js"; import { Input } from "@app/components/form/Input.js";
import { Toggle } from "@app/components/form/Toggle.js"; import { Toggle } from "@app/components/form/Toggle.js";
@ -23,21 +24,21 @@ export const Position = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<PositionValidation>({ } = useForm<PositionValidation>({
defaultValues: { defaultValues: {
fixedAlt: myNode?.data.position?.altitude, fixedAlt: myNode?.data.position?.altitude,
fixedLat: (myNode?.data.position?.latitudeI ?? 0) / 1e7, fixedLat: (myNode?.data.position?.latitudeI ?? 0) / 1e7,
fixedLng: (myNode?.data.position?.longitudeI ?? 0) / 1e7, fixedLng: (myNode?.data.position?.longitudeI ?? 0) / 1e7,
...config.position, ...config.position
}, },
resolver: classValidatorResolver(PositionValidation), resolver: classValidatorResolver(PositionValidation)
}); });
const fixedPositionEnabled = useWatch({ const fixedPositionEnabled = useWatch({
control, control,
name: "fixedPosition", name: "fixedPosition",
defaultValue: false, defaultValue: false
}); });
useEffect(() => { useEffect(() => {
@ -45,7 +46,7 @@ export const Position = (): JSX.Element => {
fixedAlt: myNode?.data.position?.altitude, fixedAlt: myNode?.data.position?.altitude,
fixedLat: (myNode?.data.position?.latitudeI ?? 0) / 1e7, fixedLat: (myNode?.data.position?.latitudeI ?? 0) / 1e7,
fixedLng: (myNode?.data.position?.longitudeI ?? 0) / 1e7, fixedLng: (myNode?.data.position?.longitudeI ?? 0) / 1e7,
...config.position, ...config.position
}); });
}, [reset, config.position, myNode?.data.position]); }, [reset, config.position, myNode?.data.position]);
@ -59,49 +60,41 @@ export const Position = (): JSX.Element => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.sendPacket( connection.setPosition({
Protobuf.Position.toBinary( position: Protobuf.Position.create({
Protobuf.Position.create({ altitude: fixedAlt,
altitude: fixedAlt, latitudeI: fixedLat * 1e7,
latitudeI: fixedLat * 1e7, longitudeI: fixedLng * 1e7
longitudeI: fixedLng * 1e7, }),
}) callback: async () => {
),
Protobuf.PortNum.POSITION_APP,
undefined,
true,
undefined,
true,
false,
async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Channel", success: "Saved Channel",
error: "No response received", error: "No response received"
} }
); );
if (configHasChanged) { if (configHasChanged) {
void toast.promise( void toast.promise(
connection.setConfig( connection.setConfig({
{ config: {
payloadVariant: { payloadVariant: {
oneofKind: "position", oneofKind: "position",
position: rest, position: rest
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Position Config, Restarting Node", success: "Saved Position Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -201,94 +194,65 @@ export const Position = (): JSX.Element => {
error={errors.gpsAttemptTime?.message} error={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 => {
const { value, onChange, ...rest } = field; const { value, onChange, ...rest } = field;
const { error } = fieldState; const { error } = fieldState;
const options = Object.entries( // const options = Object.entries(
Protobuf.Config_PositionConfig_PositionFlags // Protobuf.Config_PositionConfig_PositionFlags
) // )
.filter((value) => typeof value[1] !== "number") // .filter((value) => typeof value[1] !== "number")
.filter( // .filter(
(value) => // (value) =>
parseInt(value[0]) !== // parseInt(value[0]) !==
Protobuf.Config_PositionConfig_PositionFlags.UNSET // Protobuf.Config_PositionConfig_PositionFlags.UNSET
) // )
.map((value) => { // .map((value) => {
return { // return {
value: parseInt(value[0]), // value: parseInt(value[0]),
label: value[1].toString().replace("POS_", "").toLowerCase(), // label: value[1].toString().replace("POS_", "").toLowerCase(),
}; // };
}); // });
const selected = bitwiseDecode( // const selected = bitwiseDecode(
value, // value,
Protobuf.Config_PositionConfig_PositionFlags // Protobuf.Config_PositionConfig_PositionFlags
).map((flag) => // ).map((flag) =>
Protobuf.Config_PositionConfig_PositionFlags[flag] // Protobuf.Config_PositionConfig_PositionFlags[flag]
.replace("POS_", "") // .replace("POS_", "")
.toLowerCase() // .toLowerCase()
); // );
// onChange={(e: { value: number; label: string }[]): void => // onChange={(e: { value: number; label: string }[]): void =>
// onChange(bitwiseEncode(e.map((v) => v.value))) // onChange(bitwiseEncode(e.map((v) => v.value)))
// } // }
return ( return (
<FormField <BitwiseSelect
label="Position Flags" label="Position Flags"
description="Description" description="Description"
isInvalid={!!errors.positionFlags?.message} error={error?.message}
validationMessage={errors.positionFlags?.message} selected={value}
> decodeEnun={Protobuf.Config_PositionConfig_PositionFlags}
<SelectMenu onChange={onChange}
isMultiSelect />
title="Select multiple names"
options={options}
selected={selected}
// onSelect={(item) => {
// const selected = [...selectedItemsState, item.value]
// const selectedItems = selected
// const selectedItemsLength = selectedItems.length
// let selectedNames = ''
// if (selectedItemsLength === 0) {
// selectedNames = ''
// } else if (selectedItemsLength === 1) {
// selectedNames = selectedItems.toString()
// } else if (selectedItemsLength > 1) {
// selectedNames = selectedItemsLength.toString() + ' selected...'
// }
// setSelectedItems(selectedItems)
// setSelectedItemNames(selectedNames)
// }}
// onDeselect={(item) => {
// const deselectedItemIndex = selectedItemsState.indexOf(item.value)
// const selectedItems = selectedItemsState.filter((_item, i) => i !== deselectedItemIndex)
// const selectedItemsLength = selectedItems.length
// let selectedNames = ''
// if (selectedItemsLength === 0) {
// selectedNames = ''
// } else if (selectedItemsLength === 1) {
// selectedNames = selectedItems.toString()
// } else if (selectedItemsLength > 1) {
// selectedNames = selectedItemsLength.toString() + ' selected...'
// }
// setSelectedItems(selectedItems)
// setSelectedItemNames(selectedNames)
// }}
>
<Button>
{selected.map(
(item, index) =>
`${item}${index !== selected.length - 1 ? ", " : ""}`
)}
</Button>
</SelectMenu>
</FormField>
); );
}} }}
/> */} />
<Input
label="RX Pin"
description="GPS Module RX pin override"
type="number"
error={errors.rxGpio?.message}
{...register("rxGpio", { valueAsNumber: true })}
/>
<Input
label="TX Pin"
description="GPS Module TX pin override"
type="number"
error={errors.txGpio?.message}
{...register("txGpio", { valueAsNumber: true })}
/>
</Form> </Form>
); );
}; };

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

@ -19,10 +19,10 @@ export const Power = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<PowerValidation>({ } = useForm<PowerValidation>({
defaultValues: config.power, defaultValues: config.power,
resolver: classValidatorResolver(PowerValidation), resolver: classValidatorResolver(PowerValidation)
}); });
useEffect(() => { useEffect(() => {
@ -32,22 +32,22 @@ export const Power = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setConfig( connection.setConfig({
{ config: {
payloadVariant: { payloadVariant: {
oneofKind: "power", oneofKind: "power",
power: data, power: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Power Config, Restarting Node", success: "Saved Power Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }

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

@ -25,31 +25,34 @@ export const User = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<UserValidation>({ } = useForm<UserValidation>({
defaultValues: myNode?.data.user, defaultValues: myNode?.data.user,
resolver: classValidatorResolver(UserValidation), resolver: classValidatorResolver(UserValidation)
}); });
useEffect(() => { useEffect(() => {
reset({ reset({
longName: myNode?.data.user?.longName, longName: myNode?.data.user?.longName,
shortName: myNode?.data.user?.shortName, shortName: myNode?.data.user?.shortName,
isLicensed: myNode?.data.user?.isLicensed, isLicensed: myNode?.data.user?.isLicensed
}); });
}, [reset, myNode]); }, [reset, myNode]);
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection && myNode?.data.user) { if (connection && myNode?.data.user) {
void toast.promise( void toast.promise(
connection.setOwner({ ...myNode.data.user, ...data }, async () => { connection.setOwner({
reset({ ...data }); owner: { ...myNode.data.user, ...data },
await Promise.resolve(); callback: async () => {
reset({ ...data });
await Promise.resolve();
}
}), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved User, Restarting Node", success: "Saved User, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -63,7 +66,7 @@ export const User = (): JSX.Element => {
reset({ reset({
longName: myNode?.data.user?.longName, longName: myNode?.data.user?.longName,
shortName: myNode?.data.user?.shortName, shortName: myNode?.data.user?.shortName,
isLicensed: myNode?.data.user?.isLicensed, isLicensed: myNode?.data.user?.isLicensed
}); });
}} }}
dirty={isDirty} dirty={isDirty}

4
src/components/PageComponents/Connect/BLE.tsx

@ -29,7 +29,7 @@ export const BLE = (): JSX.Element => {
setSelectedDevice(id); setSelectedDevice(id);
const connection = new IBLEConnection(id); const connection = new IBLEConnection(id);
await connection.connect({ await connection.connect({
device: BLEDevice, device: BLEDevice
}); });
device.addConnection(connection); device.addConnection(connection);
subscribeAll(device, connection); subscribeAll(device, connection);
@ -58,7 +58,7 @@ export const BLE = (): JSX.Element => {
onClick={() => { onClick={() => {
void navigator.bluetooth void navigator.bluetooth
.requestDevice({ .requestDevice({
filters: [{ services: [Constants.serviceUUID] }], filters: [{ services: [Constants.serviceUUID] }]
}) })
.then((device) => { .then((device) => {
const exists = bleDevices.findIndex((d) => d.id === device.id); const exists = bleDevices.findIndex((d) => d.id === device.id);

8
src/components/PageComponents/Connect/HTTP.tsx

@ -21,14 +21,14 @@ export const HTTP = (): JSX.Element => {
}>({ }>({
defaultValues: { defaultValues: {
ip: "meshtastic.local", ip: "meshtastic.local",
tls: location.protocol === "https:", tls: location.protocol === "https:"
}, }
}); });
const TLSEnabled = useWatch({ const TLSEnabled = useWatch({
control, control,
name: "tls", name: "tls",
defaultValue: location.protocol === "https:", defaultValue: location.protocol === "https:"
}); });
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
@ -40,7 +40,7 @@ export const HTTP = (): JSX.Element => {
void connection.connect({ void connection.connect({
address: data.ip, address: data.ip,
fetchInterval: 2000, fetchInterval: 2000,
tls: data.tls, tls: data.tls
}); });
device.addConnection(connection); device.addConnection(connection);
subscribeAll(device, connection); subscribeAll(device, connection);

2
src/components/PageComponents/Connect/Serial.tsx

@ -38,7 +38,7 @@ export const Serial = (): JSX.Element => {
.connect({ .connect({
port, port,
baudRate: undefined, baudRate: undefined,
concurrentLogOutput: true, concurrentLogOutput: true
}) })
.catch((e: Error) => console.log(`Unable to Connect: ${e.message}`)); .catch((e: Error) => console.log(`Unable to Connect: ${e.message}`));
device.addConnection(connection); device.addConnection(connection);

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

@ -6,7 +6,7 @@ import type { AllMessageTypes } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { import {
CheckCircleIcon, CheckCircleIcon,
EllipsisHorizontalCircleIcon, EllipsisHorizontalCircleIcon
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import type { Protobuf } from "@meshtastic/meshtasticjs"; import type { Protobuf } from "@meshtastic/meshtasticjs";
@ -19,7 +19,7 @@ export interface MessageProps {
export const Message = ({ export const Message = ({
lastMsgSameUser, lastMsgSameUser,
message, message,
sender, sender
}: MessageProps): JSX.Element => { }: MessageProps): JSX.Element => {
const { setPeerInfoOpen, setActivePeer, connection } = useDevice(); const { setPeerInfoOpen, setActivePeer, connection } = useDevice();
@ -62,7 +62,7 @@ export const Message = ({
<span className="text-sm"> <span className="text-sm">
{new Date(message.packet.rxTime).toLocaleTimeString(undefined, { {new Date(message.packet.rxTime).toLocaleTimeString(undefined, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit"
})} })}
</span> </span>
</div> </div>

17
src/components/PageComponents/Messages/MessageInput.tsx

@ -20,21 +20,20 @@ export const MessageInput = ({ channel }: MessageInputProps): JSX.Element => {
message: string; message: string;
}>({ }>({
defaultValues: { defaultValues: {
message: "", message: ""
}, }
}); });
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
void connection?.sendText( void connection?.sendText({
data.message, text: data.message,
undefined, wantAck: true,
true, channel: channel.config.index as Types.ChannelNumber,
channel.config.index as Types.ChannelNumber, callback: (id) => {
(id) => {
ackMessage(channel.config.index, id); ackMessage(channel.config.index, id);
return Promise.resolve(); return Promise.resolve();
} }
); });
}); });
return ( return (

13
src/components/PageComponents/Messages/NewLocationMessage.tsx

@ -10,7 +10,7 @@ import { Protobuf } from "@meshtastic/meshtasticjs";
enum LocationType { enum LocationType {
MGRS, MGRS,
LatLng, LatLng,
DecimalDegrees, DecimalDegrees
} }
export const NewLocationMessage = (): JSX.Element => { export const NewLocationMessage = (): JSX.Element => {
@ -31,14 +31,15 @@ export const NewLocationMessage = (): JSX.Element => {
<Input label="Coordinates" /> <Input label="Coordinates" />
<Button <Button
onClick={() => { onClick={() => {
void connection?.sendWaypoint( void connection?.sendWaypoint({
Protobuf.Waypoint.create({ waypoint: Protobuf.Waypoint.create({
latitudeI: Math.floor(3.89103 * 1e7), latitudeI: Math.floor(3.89103 * 1e7),
longitudeI: Math.floor(105.87005 * 1e7), longitudeI: Math.floor(105.87005 * 1e7),
name: "TEST", name: "TEST",
description: "This is a description", description: "This is a description"
}) }),
); destination: "broadcast"
});
}} }}
> >
Send Send

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

@ -9,7 +9,7 @@ export interface WaypointMessageProps {
} }
export const WaypointMessage = ({ export const WaypointMessage = ({
waypointID, waypointID
}: WaypointMessageProps): JSX.Element => { }: WaypointMessageProps): JSX.Element => {
const { waypoints } = useDevice(); const { waypoints } = useDevice();
const waypoint = waypoints.find((wp) => wp.id === waypointID); const waypoint = waypoints.find((wp) => wp.id === waypointID);

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

@ -21,16 +21,16 @@ export const CannedMessage = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<CannedMessageValidation>({ } = useForm<CannedMessageValidation>({
defaultValues: moduleConfig.cannedMessage, defaultValues: moduleConfig.cannedMessage,
resolver: classValidatorResolver(CannedMessageValidation), resolver: classValidatorResolver(CannedMessageValidation)
}); });
const moduleEnabled = useWatch({ const moduleEnabled = useWatch({
control, control,
name: "rotary1Enabled", name: "rotary1Enabled",
defaultValue: false, defaultValue: false
}); });
useEffect(() => { useEffect(() => {
@ -40,22 +40,22 @@ export const CannedMessage = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setModuleConfig( connection.setModuleConfig({
{ moduleConfig: {
payloadVariant: { payloadVariant: {
oneofKind: "cannedMessage", oneofKind: "cannedMessage",
cannedMessage: data, cannedMessage: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Canned Message Config, Restarting Node", success: "Saved Canned Message Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }

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

@ -18,10 +18,10 @@ export const ExternalNotification = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<ExternalNotificationValidation>({ } = useForm<ExternalNotificationValidation>({
defaultValues: moduleConfig.externalNotification, defaultValues: moduleConfig.externalNotification,
resolver: classValidatorResolver(ExternalNotificationValidation), resolver: classValidatorResolver(ExternalNotificationValidation)
}); });
useEffect(() => { useEffect(() => {
reset(moduleConfig.externalNotification); reset(moduleConfig.externalNotification);
@ -30,22 +30,22 @@ export const ExternalNotification = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setModuleConfig( connection.setModuleConfig({
{ moduleConfig: {
payloadVariant: { payloadVariant: {
oneofKind: "externalNotification", oneofKind: "externalNotification",
externalNotification: data, externalNotification: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved External Notification Config, Restarting Node", success: "Saved External Notification Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -54,7 +54,7 @@ export const ExternalNotification = (): JSX.Element => {
const moduleEnabled = useWatch({ const moduleEnabled = useWatch({
control, control,
name: "enabled", name: "enabled",
defaultValue: false, defaultValue: false
}); });
return ( return (
@ -84,7 +84,7 @@ export const ExternalNotification = (): JSX.Element => {
suffix="ms" suffix="ms"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("outputMs", { {...register("outputMs", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Input <Input
@ -93,7 +93,7 @@ export const ExternalNotification = (): JSX.Element => {
description="Max transmit power in dBm" description="Max transmit power in dBm"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("output", { {...register("output", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Controller <Controller
@ -132,6 +132,18 @@ export const ExternalNotification = (): JSX.Element => {
/> />
)} )}
/> />
<Controller
name="usePwm"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Use PWM"
description="Description"
checked={value}
{...rest}
/>
)}
/>
</Form> </Form>
); );
}; };

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

@ -18,16 +18,16 @@ export const MQTT = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<MQTTValidation>({ } = useForm<MQTTValidation>({
defaultValues: moduleConfig.mqtt, defaultValues: moduleConfig.mqtt,
resolver: classValidatorResolver(MQTTValidation), resolver: classValidatorResolver(MQTTValidation)
}); });
const moduleEnabled = useWatch({ const moduleEnabled = useWatch({
control, control,
name: "enabled", name: "enabled",
defaultValue: false, defaultValue: false
}); });
useEffect(() => { useEffect(() => {
@ -37,22 +37,22 @@ export const MQTT = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setModuleConfig( connection.setModuleConfig({
{ moduleConfig: {
payloadVariant: { payloadVariant: {
oneofKind: "mqtt", oneofKind: "mqtt",
mqtt: data, mqtt: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved MQTT Config, Restarting Node", success: "Saved MQTT Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }

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

@ -18,10 +18,10 @@ export const RangeTest = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<RangeTestValidation>({ } = useForm<RangeTestValidation>({
defaultValues: moduleConfig.rangeTest, defaultValues: moduleConfig.rangeTest,
resolver: classValidatorResolver(RangeTestValidation), resolver: classValidatorResolver(RangeTestValidation)
}); });
useEffect(() => { useEffect(() => {
@ -31,22 +31,22 @@ export const RangeTest = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setModuleConfig( connection.setModuleConfig({
{ moduleConfig: {
payloadVariant: { payloadVariant: {
oneofKind: "rangeTest", oneofKind: "rangeTest",
rangeTest: data, rangeTest: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Range Test Config, Restarting Node", success: "Saved Range Test Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -55,7 +55,7 @@ export const RangeTest = (): JSX.Element => {
const moduleEnabled = useWatch({ const moduleEnabled = useWatch({
control, control,
name: "enabled", name: "enabled",
defaultValue: false, defaultValue: false
}); });
return ( return (
@ -85,7 +85,7 @@ export const RangeTest = (): JSX.Element => {
disabled={!moduleEnabled} disabled={!moduleEnabled}
suffix="Seconds" suffix="Seconds"
{...register("sender", { {...register("sender", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Controller <Controller

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

@ -18,10 +18,10 @@ export const Serial = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<SerialValidation>({ } = useForm<SerialValidation>({
defaultValues: moduleConfig.serial, defaultValues: moduleConfig.serial,
resolver: classValidatorResolver(SerialValidation), resolver: classValidatorResolver(SerialValidation)
}); });
useEffect(() => { useEffect(() => {
@ -31,22 +31,22 @@ export const Serial = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setModuleConfig( connection.setModuleConfig({
{ moduleConfig: {
payloadVariant: { payloadVariant: {
oneofKind: "serial", oneofKind: "serial",
serial: data, serial: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Serial Config, Restarting Node", success: "Saved Serial Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -55,7 +55,7 @@ export const Serial = (): JSX.Element => {
const moduleEnabled = useWatch({ const moduleEnabled = useWatch({
control, control,
name: "enabled", name: "enabled",
defaultValue: false, defaultValue: false
}); });
return ( return (
@ -96,7 +96,7 @@ export const Serial = (): JSX.Element => {
description="Max transmit power in dBm" description="Max transmit power in dBm"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("rxd", { {...register("rxd", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Input <Input
@ -105,7 +105,7 @@ export const Serial = (): JSX.Element => {
description="Max transmit power in dBm" description="Max transmit power in dBm"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("txd", { {...register("txd", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Input <Input
@ -114,7 +114,7 @@ export const Serial = (): JSX.Element => {
description="Max transmit power in dBm" description="Max transmit power in dBm"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("baud", { {...register("baud", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Input <Input
@ -123,7 +123,7 @@ export const Serial = (): JSX.Element => {
description="Max transmit power in dBm" description="Max transmit power in dBm"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("timeout", { {...register("timeout", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Input <Input
@ -132,7 +132,7 @@ export const Serial = (): JSX.Element => {
description="Max transmit power in dBm" description="Max transmit power in dBm"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("mode", { {...register("mode", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
</Form> </Form>

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

@ -18,10 +18,10 @@ export const StoreForward = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<StoreForwardValidation>({ } = useForm<StoreForwardValidation>({
defaultValues: moduleConfig.storeForward, defaultValues: moduleConfig.storeForward,
resolver: classValidatorResolver(StoreForwardValidation), resolver: classValidatorResolver(StoreForwardValidation)
}); });
useEffect(() => { useEffect(() => {
@ -31,22 +31,22 @@ export const StoreForward = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setModuleConfig( connection.setModuleConfig({
{ moduleConfig: {
payloadVariant: { payloadVariant: {
oneofKind: "storeForward", oneofKind: "storeForward",
storeForward: data, storeForward: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Store & Forward Config, Restarting Node", success: "Saved Store & Forward Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -55,7 +55,7 @@ export const StoreForward = (): JSX.Element => {
const moduleEnabled = useWatch({ const moduleEnabled = useWatch({
control, control,
name: "enabled", name: "enabled",
defaultValue: false, defaultValue: false
}); });
return ( return (
@ -97,7 +97,7 @@ export const StoreForward = (): JSX.Element => {
suffix="Records" suffix="Records"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("records", { {...register("records", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Input <Input
@ -106,7 +106,7 @@ export const StoreForward = (): JSX.Element => {
description="Max transmit power in dBm" description="Max transmit power in dBm"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("historyReturnMax", { {...register("historyReturnMax", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Input <Input
@ -115,7 +115,7 @@ export const StoreForward = (): JSX.Element => {
description="Max transmit power in dBm" description="Max transmit power in dBm"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("historyReturnWindow", { {...register("historyReturnWindow", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
</Form> </Form>

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

@ -18,10 +18,10 @@ export const Telemetry = (): JSX.Element => {
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control, control
} = useForm<TelemetryValidation>({ } = useForm<TelemetryValidation>({
defaultValues: moduleConfig.telemetry, defaultValues: moduleConfig.telemetry,
resolver: classValidatorResolver(TelemetryValidation), resolver: classValidatorResolver(TelemetryValidation)
}); });
useEffect(() => { useEffect(() => {
@ -31,22 +31,22 @@ export const Telemetry = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.setModuleConfig( connection.setModuleConfig({
{ moduleConfig: {
payloadVariant: { payloadVariant: {
oneofKind: "telemetry", oneofKind: "telemetry",
telemetry: data, telemetry: data
}, }
}, },
async () => { callback: async () => {
reset({ ...data }); reset({ ...data });
await Promise.resolve(); await Promise.resolve();
} }
), }),
{ {
loading: "Saving...", loading: "Saving...",
success: "Saved Telemetry Config, Restarting Node", success: "Saved Telemetry Config, Restarting Node",
error: "No response received", error: "No response received"
} }
); );
} }
@ -90,7 +90,7 @@ export const Telemetry = (): JSX.Element => {
suffix="Seconds" suffix="Seconds"
type="number" type="number"
{...register("environmentUpdateInterval", { {...register("environmentUpdateInterval", {
valueAsNumber: true, valueAsNumber: true
})} })}
/> />
<Controller <Controller

20
src/components/PageNav.tsx

@ -10,7 +10,7 @@ import {
InboxIcon, InboxIcon,
MapIcon, MapIcon,
Square3Stack3DIcon, Square3Stack3DIcon,
UsersIcon, UsersIcon
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
export const PageNav = (): JSX.Element => { export const PageNav = (): JSX.Element => {
@ -26,43 +26,43 @@ export const PageNav = (): JSX.Element => {
{ {
name: "Messages", name: "Messages",
icon: <InboxIcon />, icon: <InboxIcon />,
page: "messages", page: "messages"
}, },
{ {
name: "Map", name: "Map",
icon: <MapIcon />, icon: <MapIcon />,
page: "map", page: "map"
}, },
{ {
name: "Extensions", name: "Extensions",
icon: <BeakerIcon />, icon: <BeakerIcon />,
page: "extensions", page: "extensions"
}, },
{ {
name: "Config", name: "Config",
icon: <Cog8ToothIcon />, icon: <Cog8ToothIcon />,
page: "config", page: "config"
}, },
{ {
name: "Channels", name: "Channels",
icon: <Square3Stack3DIcon />, icon: <Square3Stack3DIcon />,
page: "channels", page: "channels"
}, },
{ {
name: "Peers", name: "Peers",
icon: <UsersIcon />, icon: <UsersIcon />,
page: "peers", page: "peers"
}, },
{ {
name: "Info", name: "Info",
icon: <IdentificationIcon />, icon: <IdentificationIcon />,
page: "info", page: "info"
}, },
{ {
name: "Logs", name: "Logs",
icon: <DocumentTextIcon />, icon: <DocumentTextIcon />,
page: "logs", page: "logs"
}, }
]; ];
return ( return (

2
src/components/Widgets/BatteryWidget.tsx

@ -13,7 +13,7 @@ export interface BatteryWidgetProps {
export const BatteryWidget = ({ export const BatteryWidget = ({
batteryLevel, batteryLevel,
voltage, voltage
}: BatteryWidgetProps): JSX.Element => { }: BatteryWidgetProps): JSX.Element => {
const { nodes, hardware } = useDevice(); const { nodes, hardware } = useDevice();

6
src/components/Widgets/ConfiguringWidget.tsx

@ -14,7 +14,7 @@ export const ConfiguringWidget = (): JSX.Element => {
moduleConfig, moduleConfig,
setReady, setReady,
nodes, nodes,
connection, connection
} = useDevice(); } = useDevice();
useEffect(() => { useEffect(() => {
@ -32,7 +32,7 @@ export const ConfiguringWidget = (): JSX.Element => {
channels, channels,
hardware.maxChannels, hardware.maxChannels,
hardware.myNodeNum, hardware.myNodeNum,
setReady, setReady
]); ]);
return ( return (
@ -80,7 +80,7 @@ export interface StatusIndicatorProps {
const StatusIndicator = ({ const StatusIndicator = ({
title, title,
current, current,
total, total
}: StatusIndicatorProps): JSX.Element => { }: StatusIndicatorProps): JSX.Element => {
return ( return (
<li className="relative"> <li className="relative">

2
src/components/Widgets/DeviceWidget.tsx

@ -19,7 +19,7 @@ export const DeviceWidget = ({
nodeNum, nodeNum,
disconnected, disconnected,
disconnect, disconnect,
reconnect, reconnect
}: DeviceWidgetProps): JSX.Element => { }: DeviceWidgetProps): JSX.Element => {
return ( return (
<Card className="relative shrink-0 flex-col"> <Card className="relative shrink-0 flex-col">

2
src/components/Widgets/NodeInfoWidget.tsx

@ -11,7 +11,7 @@ export interface NodeInfoWidgetProps {
} }
export const NodeInfoWidget = ({ export const NodeInfoWidget = ({
hardware, hardware
}: NodeInfoWidgetProps): JSX.Element => { }: NodeInfoWidgetProps): JSX.Element => {
return ( return (
<Card className="flex-col"> <Card className="flex-col">

2
src/components/Widgets/PeersWidget.tsx

@ -3,7 +3,7 @@ import type React from "react";
import { useDevice } from "@app/core/providers/useDevice.js"; import { useDevice } from "@app/core/providers/useDevice.js";
import { import {
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
UserGroupIcon, UserGroupIcon
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import type { Protobuf } from "@meshtastic/meshtasticjs"; import type { Protobuf } from "@meshtastic/meshtasticjs";

82
src/components/form/BitwiseSelect.tsx

@ -0,0 +1,82 @@
import React, { useState } from "react";
import {
bitwiseDecode,
bitwiseEncode,
enumLike
} from "@app/core/utils/bitwise.js";
import { Listbox } from "@headlessui/react";
import { Protobuf } from "@meshtastic/meshtasticjs";
import { InfoWrapper } from "./InfoWrapper.js";
export interface BitwiseSelectProps {
label?: string;
description?: string;
error?: string;
selected: number;
decodeEnun: enumLike;
onChange: (value: number) => void;
}
export const BitwiseSelect = ({
label,
description,
error,
selected,
decodeEnun,
onChange
}: BitwiseSelectProps): JSX.Element => {
const [decodedSelected, setDecodedSelected] = useState<string[]>([]);
const options = Object.entries(decodeEnun)
.filter((value) => typeof value[1] !== "number")
.map((value) => {
return {
value: parseInt(value[0]),
label: value[1]
.toString()
.replace("POS_", "")
.toLowerCase()
.toLocaleUpperCase() //TODO: Investigate
};
});
React.useEffect(() => {
setDecodedSelected(
bitwiseDecode(selected, Protobuf.Config_PositionConfig_PositionFlags).map(
(flag) =>
Protobuf.Config_PositionConfig_PositionFlags[flag]
.replace("POS_", "")
.toLowerCase()
)
);
}, [selected]);
return (
<InfoWrapper label={label} description={description} error={error}>
<Listbox
value={bitwiseDecode(selected, decodeEnun)}
onChange={(value) => {
onChange(bitwiseEncode(value));
}}
multiple
>
<Listbox.Button
className={`flex h-10 w-full items-center gap-2 rounded-md border-transparent bg-orange-100 px-3 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500`}
>
{decodedSelected.map((option) => (
<span className="rounded-md bg-orange-300 p-1">{option}</span>
))}
</Listbox.Button>
<Listbox.Options>
{options.map((option) => (
<Listbox.Option key={option.value} value={option.value}>
{option.label}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
</InfoWrapper>
);
};

2
src/components/form/Form.tsx

@ -7,7 +7,7 @@ import { Button } from "@components/Button.js";
import { import {
ArrowUturnLeftIcon, ArrowUturnLeftIcon,
ChevronRightIcon, ChevronRightIcon,
HomeIcon, HomeIcon
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
export interface FormProps extends HTMLProps<HTMLFormElement> { export interface FormProps extends HTMLProps<HTMLFormElement> {

2
src/components/form/FormSection.tsx

@ -7,7 +7,7 @@ export interface FormSectionProps {
export const FormSection = ({ export const FormSection = ({
title, title,
children, children
}: FormSectionProps): JSX.Element => { }: FormSectionProps): JSX.Element => {
return ( return (
<div className="relative"> <div className="relative">

133
src/components/form/IPAddress.tsx

@ -0,0 +1,133 @@
import React, { InputHTMLAttributes, useState } from "react";
import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
export interface IPAddressProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
description?: string;
prefix?: string;
suffix?: string;
action?: {
icon: JSX.Element;
action: () => void;
};
error?: string;
}
export const IPAddress = ({
label,
description,
action,
error,
disabled,
...rest
}: IPAddressProps): JSX.Element => {
const [value, setValue] = useState<[number, number, number, number]>([
0, 0, 0, 0
]);
// const getRange = (el) => {
// var cuRange, tbRange, headRange, range, dupRange, ret = {};
// if (el.setSelectionRange) {
// // standard
// ret.begin = el.selectionStart;
// ret.end = el.selectionEnd;
// ret.result = el.value.substring(ret.begin, ret.end);
// } else if (document.selection) {
// // ie
// if (el.tagName.toLowerCase() === 'input') {
// cuRange = document.selection.createRange();
// tbRange = el.createTextRange();
// tbRange.collapse(true);
// tbRange.select();
// headRange = document.selection.createRange();
// headRange.setEndPoint('EndToEnd', cuRange);
// ret.begin = headRange.text.length - cuRange.text.length;
// ret.end = headRange.text.length;
// ret.result = cuRange.text;
// cuRange.select();
// } else if (el.tagName.toLowerCase() === 'textarea') {
// range = document.selection.createRange();
// dupRange = range.duplicate();
// dupRange.moveToElementText(el);
// dupRange.setEndPoint('EndToEnd', range);
// ret.begin = dupRange.text.length - range.text.length;
// ret.end = dupRange.text.length;
// ret.result = range.text;
// }
// }
// el.focus();
// return ret;
// }
// const isValidIPItemValue = (val) => {
// val = parseInt(val);
// return !isNaN(val) && val >= 0 && val <= 255;
// }
// const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, index: number) => {
// /* 37 = ←, 39 = →, 8 = backspace, 110 or 190 = . */
// let domId = index;
// if ((event.keyCode === 37 || event.keyCode === 8) && getRange(event.target).end === 0 && index > 0) { domId = index - 1; }
// if (event.keyCode === 39 && getRange(event.target).end === event.target.value.length && index < 3) { domId = index + 1; }
// if (event.keyCode === 110 || event.keyCode === 190) {
// event.preventDefault();
// if(i < 3) {
// domId = i + 1;
// }
// }
// this[`_input-${domId}`].focus();
// }
// useEffect(() => {
// }, [])
// const ip = value.map(val => isNaN(val) ? '' : val).join('.');
return (
<div>
{/* Label */}
<label className="block text-sm font-medium text-gray-700">{label}</label>
{/* */}
<div className="relative flex gap-1 rounded-md">
{value.map((octet, index) => (
<>
<input
key={index}
// ref={ref}
className={`flex h-10 w-full rounded-md border-transparent bg-orange-100 px-3 text-sm shadow-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 ${
action ? "rounded-r-none" : ""
} ${
disabled
? "cursor-not-allowed bg-orange-50 text-orange-200"
: ""
}`}
disabled={disabled}
{...rest}
/>
{index !== 3 && <i className="text-xl">.</i>}
</>
))}
{action && (
<button
type="button"
onClick={action.action}
className="relative -ml-px inline-flex items-center space-x-2 rounded-r-md bg-orange-200 px-4 py-2 text-sm font-medium hover:bg-orange-300 focus:outline-none focus:ring-2 focus:ring-orange-500"
>
{action.icon}
</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">{description}</p>
)}
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
);
};

39
src/components/form/InfoWrapper.tsx

@ -0,0 +1,39 @@
import type React from "react";
import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
export interface InfoWrapperProps {
label?: string;
description?: string;
error?: string;
children: React.ReactNode;
}
export const InfoWrapper = ({
label,
description,
error,
children
}: InfoWrapperProps): JSX.Element => {
return (
<div className="w-full">
{/* Label */}
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
{/* */}
{children}
{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>
)}
{description && (
<p className="mt-2 text-sm text-gray-500">{description}</p>
)}
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
);
};

20
src/components/form/Input.tsx

@ -3,16 +3,17 @@ import { forwardRef, InputHTMLAttributes } from "react";
import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { import { InfoWrapper, InfoWrapperProps } from "./InfoWrapper.js";
label: string;
description?: string; export interface InputProps
extends InputHTMLAttributes<HTMLInputElement>,
Omit<InfoWrapperProps, "children"> {
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
action?: { action?: {
icon: JSX.Element; icon: JSX.Element;
action: () => void; action: () => void;
}; };
error?: string;
} }
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
@ -29,10 +30,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
ref ref
) { ) {
return ( return (
<div> <InfoWrapper label={label} description={description} error={error}>
{/* Label */}
<label className="block text-sm font-medium text-gray-700">{label}</label>
{/* */}
<div className="relative flex rounded-md shadow-sm"> <div className="relative flex rounded-md shadow-sm">
{prefix && ( {prefix && (
<span className="inline-flex items-center rounded-l-md border-gray-300 bg-orange-200 px-3 font-mono text-sm"> <span className="inline-flex items-center rounded-l-md border-gray-300 bg-orange-200 px-3 font-mono text-sm">
@ -70,10 +68,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
</div> </div>
)} )}
</div> </div>
{description && ( </InfoWrapper>
<p className="mt-2 text-sm text-gray-500">{description}</p>
)}
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
); );
}); });

32
src/components/form/Select.tsx

@ -1,17 +1,16 @@
import type React from "react"; import type React from "react";
import { forwardRef, SelectHTMLAttributes } from "react"; import { forwardRef, SelectHTMLAttributes } from "react";
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> { import { InfoWrapper, InfoWrapperProps } from "./InfoWrapper.js";
label: string;
description?: string; export interface SelectProps
extends SelectHTMLAttributes<HTMLSelectElement>,
Omit<InfoWrapperProps, "children"> {
options?: string[]; options?: string[];
prefix?: string;
suffix?: string;
action?: { action?: {
icon: JSX.Element; icon: JSX.Element;
action: () => void; action: () => void;
}; };
error?: string;
} }
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Input( export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Input(
@ -19,30 +18,22 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Input(
label, label,
description, description,
options, options,
prefix,
suffix,
action, action,
error,
disabled, disabled,
error,
children, children,
...rest ...rest
}: SelectProps, }: SelectProps,
ref ref
) { ) {
return ( return (
<div> <InfoWrapper label={label} description={description} error={error}>
<label
htmlFor="location"
className="block text-sm font-medium text-gray-700"
>
{label}
</label>
<div className="flex rounded-md shadow-sm"> <div className="flex rounded-md shadow-sm">
<select <select
ref={ref} ref={ref}
className={`flex h-10 w-full rounded-md border-transparent bg-orange-100 px-3 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 ${ className={`flex h-10 w-full rounded-md border-transparent bg-orange-100 px-3 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 ${
prefix ? "rounded-l-none" : "" action ? "rounded-r-none" : ""
} ${action ? "rounded-r-none" : ""} ${ } ${
disabled ? "cursor-not-allowed bg-orange-50 text-orange-200" : "" disabled ? "cursor-not-allowed bg-orange-50 text-orange-200" : ""
}`} }`}
disabled={disabled} disabled={disabled}
@ -64,9 +55,6 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Input(
</button> </button>
)} )}
</div> </div>
{description && ( </InfoWrapper>
<p className="mt-2 text-sm text-gray-500">{description}</p>
)}
</div>
); );
}); });

32
src/components/form/Toggle.tsx

@ -3,33 +3,37 @@ import type React from "react";
import { Switch } from "@headlessui/react"; import { Switch } from "@headlessui/react";
export interface ToggleProps { export interface ToggleProps {
label: string;
description: string;
checked: boolean; checked: boolean;
label?: string;
description?: string;
disabled?: boolean; disabled?: boolean;
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
} }
export const Toggle = ({ export const Toggle = ({
checked,
label, label,
description, description,
checked,
disabled, disabled,
onChange, onChange
}: ToggleProps): JSX.Element => { }: ToggleProps): JSX.Element => {
return ( return (
<Switch.Group as="div" className="flex items-center justify-between"> <Switch.Group as="div" className="flex items-center justify-between">
<span className="flex flex-grow flex-col"> <span className="flex flex-grow flex-col">
<Switch.Label {label && (
as="span" <Switch.Label
className="text-sm font-medium text-gray-900" as="span"
passive className="text-sm font-medium text-gray-900"
> passive
{label} >
</Switch.Label> {label}
<Switch.Description as="span" className="text-sm text-gray-500"> </Switch.Label>
{description} )}
</Switch.Description> {description && (
<Switch.Description as="span" className="text-sm text-gray-500">
{description}
</Switch.Description>
)}
</span> </span>
<Switch <Switch
checked={checked} checked={checked}

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

@ -19,7 +19,7 @@ export interface TabbedContentProps {
export const TabbedContent = ({ export const TabbedContent = ({
tabs, tabs,
actions, actions
}: TabbedContentProps): JSX.Element => { }: TabbedContentProps): JSX.Element => {
return ( return (
<Tab.Group as="div" className="flex flex-grow flex-col gap-2 p-4"> <Tab.Group as="div" className="flex flex-grow flex-col gap-2 p-4">

44
src/core/stores/appStore.ts

@ -1,11 +1,24 @@
import { produce } from "immer";
import create from "zustand"; import create from "zustand";
export interface RasterSource {
enabled: boolean;
title: string;
tiles: string[];
tileSize: number;
}
interface AppState { interface AppState {
selectedDevice: number; selectedDevice: number;
devices: { devices: {
id: number; id: number;
num: number; num: number;
}[]; }[];
rasterSources: RasterSource[];
setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void;
removeRasterSource: (index: number) => void;
setSelectedDevice: (deviceId: number) => void; setSelectedDevice: (deviceId: number) => void;
addDevice: (device: { id: number; num: number }) => void; addDevice: (device: { id: number; num: number }) => void;
@ -16,17 +29,40 @@ export const useAppStore = create<AppState>()((set) => ({
selectedDevice: 0, selectedDevice: 0,
devices: [], devices: [],
currentPage: "messages", currentPage: "messages",
rasterSources: [],
setRasterSources: (sources: RasterSource[]) => {
set(
produce<AppState>((draft) => {
draft.rasterSources = sources;
})
);
},
addRasterSource: (source: RasterSource) => {
set(
produce<AppState>((draft) => {
draft.rasterSources.push(source);
})
);
},
removeRasterSource: (index: number) => {
set(
produce<AppState>((draft) => {
draft.rasterSources.splice(index, 1);
})
);
},
setSelectedDevice: (deviceId) => setSelectedDevice: (deviceId) =>
set(() => ({ set(() => ({
selectedDevice: deviceId, selectedDevice: deviceId
})), })),
addDevice: (device) => addDevice: (device) =>
set((state) => ({ set((state) => ({
devices: [...state.devices, device], devices: [...state.devices, device]
})), })),
removeDevice: (deviceId) => removeDevice: (deviceId) =>
set((state) => ({ set((state) => ({
devices: state.devices.filter((device) => device.id !== deviceId), devices: state.devices.filter((device) => device.id !== deviceId)
})), }))
})); }));

16
src/core/subscriptions.ts

@ -1,14 +1,13 @@
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import type { Device } from "@core/stores/deviceStore.js"; import type { Device } from "@core/stores/deviceStore.js";
import { Protobuf, Types } from "@meshtastic/meshtasticjs"; import { Types } from "@meshtastic/meshtasticjs";
export const subscribeAll = ( export const subscribeAll = (
device: Device, device: Device,
connection: Types.ConnectionType connection: Types.ConnectionType
) => { ) => {
let myNodeNum = 0; let myNodeNum = 0;
connection.setLogLevel(Protobuf.LogRecord_Level.TRACE);
// onLogEvent // onLogEvent
// onMeshHeartbeat // onMeshHeartbeat
@ -43,26 +42,31 @@ export const subscribeAll = (
device.addWaypointMessage({ device.addWaypointMessage({
waypointID: data.id, waypointID: data.id,
ack: rest.packet.from !== myNodeNum, ack: rest.packet.from !== myNodeNum,
...rest, ...rest
}); });
}); });
connection.onMyNodeInfo.subscribe((nodeInfo) => { connection.onMyNodeInfo.subscribe((nodeInfo) => {
console.log("^^^^^^^ GOT MY NODE INFO");
device.setHardware(nodeInfo); device.setHardware(nodeInfo);
myNodeNum = nodeInfo.myNodeNum; myNodeNum = nodeInfo.myNodeNum;
}); });
connection.onUserPacket.subscribe((user) => { connection.onUserPacket.subscribe((user) => {
console.log("^^^^^^^ GOT USER");
device.addUser(user); device.addUser(user);
}); });
connection.onPositionPacket.subscribe((position) => { connection.onPositionPacket.subscribe((position) => {
console.log("^^^^^^^ GOT POSITION");
device.addPosition(position); device.addPosition(position);
}); });
connection.onNodeInfoPacket.subscribe((nodeInfo) => { connection.onNodeInfoPacket.subscribe((nodeInfo) => {
console.log("^^^^^^^ GOT NODE INFO");
toast(`New Node Discovered: ${nodeInfo.data.user?.shortName ?? "UNK"}`, { toast(`New Node Discovered: ${nodeInfo.data.user?.shortName ?? "UNK"}`, {
icon: "🔎", icon: "🔎"
}); });
device.addNodeInfo(nodeInfo); device.addNodeInfo(nodeInfo);
}); });
@ -71,7 +75,7 @@ export const subscribeAll = (
device.addChannel({ device.addChannel({
config: channel.data, config: channel.data,
lastInterraction: new Date(), lastInterraction: new Date(),
messages: [], messages: []
}); });
}); });
connection.onConfigPacket.subscribe((config) => { connection.onConfigPacket.subscribe((config) => {
@ -84,7 +88,7 @@ export const subscribeAll = (
connection.onMessagePacket.subscribe((messagePacket) => { connection.onMessagePacket.subscribe((messagePacket) => {
device.addMessage({ device.addMessage({
...messagePacket, ...messagePacket,
ack: messagePacket.packet.from !== myNodeNum, ack: messagePacket.packet.from !== myNodeNum
}); });
}); });
}; };

10
src/core/utils/bitwise.ts

@ -1,9 +1,15 @@
export interface enumLike {
[key: number]: string | number;
}
export const bitwiseEncode = (enumValues: number[]): number => { export const bitwiseEncode = (enumValues: number[]): number => {
return enumValues.reduce((acc, curr) => acc | curr, 0); return enumValues.reduce((acc, curr) => acc | curr, 0);
}; };
export const bitwiseDecode = (value: number, decodeEnum: object): number[] => { export const bitwiseDecode = (
value: number,
decodeEnum: enumLike
): number[] => {
const enumValues = Object.keys(decodeEnum).map(Number).filter(Boolean); const enumValues = Object.keys(decodeEnum).map(Number).filter(Boolean);
return enumValues.map((b) => value & b).filter(Boolean); return enumValues.map((b) => value & b).filter(Boolean);
}; };

2
src/index.css

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

1
src/index.tsx

@ -9,6 +9,7 @@ import { createRoot } from "react-dom/client";
import { App } from "@app/App.js"; import { App } from "@app/App.js";
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
const container = document.getElementById("root") as HTMLElement; const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container); const root = createRoot(container);

20
src/pages/Channels.tsx

@ -4,7 +4,10 @@ import { Channel } from "@app/components/PageComponents/Channel.js";
import { Button } from "@components/Button.js"; import { Button } from "@components/Button.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 {
ArrowDownOnSquareStackIcon,
QrCodeIcon
} from "@heroicons/react/24/outline";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
export const ChannelsPage = (): JSX.Element => { export const ChannelsPage = (): JSX.Element => {
@ -17,7 +20,7 @@ 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}`,
element: () => <Channel channel={channel.config} />, element: () => <Channel channel={channel.config} />
}; };
}); });
@ -25,6 +28,17 @@ export const ChannelsPage = (): JSX.Element => {
<TabbedContent <TabbedContent
tabs={tabs} tabs={tabs}
actions={[ actions={[
() => (
<Button
variant="secondary"
iconBefore={<ArrowDownOnSquareStackIcon className="w-4" />}
onClick={() => {
setQRDialogOpen(true);
}}
>
Import
</Button>
),
() => ( () => (
<Button <Button
variant="secondary" variant="secondary"
@ -35,7 +49,7 @@ export const ChannelsPage = (): JSX.Element => {
> >
QR Code QR Code
</Button> </Button>
), )
]} ]}
/> />
); );

18
src/pages/Config/AppConfig.tsx

@ -1,25 +1,15 @@
import type React from "react"; import type React from "react";
import { Fragment } from "react"; import { Fragment } from "react";
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js"; import { Map } from "@components/PageComponents/AppConfig/Map.js";
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.js";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
export const AppConfig = (): JSX.Element => { export const AppConfig = (): JSX.Element => {
const configSections = [ const configSections = [
{ {
label: "Interface", label: "Map",
element: MQTT, element: Map
}, }
{
label: "Logging",
element: Serial,
},
{
label: "Language",
element: ExternalNotification,
},
]; ];
return ( return (

18
src/pages/Config/DeviceConfig.tsx

@ -18,37 +18,37 @@ export const DeviceConfig = (): JSX.Element => {
const configSections = [ const configSections = [
{ {
label: "User", label: "User",
element: User, element: User
}, },
{ {
label: "Device", label: "Device",
element: Device, element: Device
}, },
{ {
label: "Position", label: "Position",
element: Position, element: Position
}, },
{ {
label: "Power", label: "Power",
element: Power, element: Power
}, },
{ {
label: "Network", label: "Network",
element: Network, element: Network,
disabled: !hardware.hasWifi, disabled: !hardware.hasWifi
}, },
{ {
label: "Display", label: "Display",
element: Display, element: Display
}, },
{ {
label: "LoRa", label: "LoRa",
element: LoRa, element: LoRa
}, },
{ {
label: "Bluetooth", label: "Bluetooth",
element: Bluetooth, element: Bluetooth
}, }
]; ];
return ( return (

20
src/pages/Config/ModuleConfig.tsx

@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import { Fragment, useState } from "react"; import { Fragment } from "react";
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";
@ -11,37 +11,35 @@ import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js"
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
export const ModuleConfig = (): JSX.Element => { export const ModuleConfig = (): JSX.Element => {
const [selectedIndex, setSelectedIndex] = useState(0);
const configSections = [ const configSections = [
{ {
label: "MQTT", label: "MQTT",
element: MQTT, element: MQTT
}, },
{ {
label: "Serial", label: "Serial",
element: Serial, element: Serial
}, },
{ {
label: "External Notification", label: "External Notification",
element: ExternalNotification, element: ExternalNotification
}, },
{ {
label: "Store & Forward", label: "Store & Forward",
element: StoreForward, element: StoreForward
}, },
{ {
label: "Range Test", label: "Range Test",
element: RangeTest, element: RangeTest
}, },
{ {
label: "Telemetry", label: "Telemetry",
element: Telemetry, element: Telemetry
}, },
{ {
label: "Canned Message", label: "Canned Message",
element: CannedMessage, element: CannedMessage
}, }
]; ];
return ( return (

10
src/pages/Config/index.tsx

@ -4,7 +4,7 @@ import { TabbedContent, TabType } from "@components/layout/page/TabbedContent";
import { import {
Cog8ToothIcon, Cog8ToothIcon,
CubeTransparentIcon, CubeTransparentIcon,
WindowIcon, WindowIcon
} from "@heroicons/react/24/outline"; } 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";
@ -15,18 +15,18 @@ export const ConfigPage = (): JSX.Element => {
{ {
name: "Device Config", name: "Device Config",
icon: <Cog8ToothIcon className="h-4" />, icon: <Cog8ToothIcon className="h-4" />,
element: DeviceConfig, element: DeviceConfig
}, },
{ {
name: "Module Config", name: "Module Config",
icon: <CubeTransparentIcon className="h-4" />, icon: <CubeTransparentIcon className="h-4" />,
element: ModuleConfig, element: ModuleConfig
}, },
{ {
name: "App Config", name: "App Config",
icon: <WindowIcon className="h-4" />, icon: <WindowIcon className="h-4" />,
element: AppConfig, element: AppConfig
}, }
]; ];
return <TabbedContent tabs={tabs} />; return <TabbedContent tabs={tabs} />;

10
src/pages/Extensions/Index.tsx

@ -5,7 +5,7 @@ import { useDevice } from "@core/providers/useDevice.js";
import { import {
CloudIcon, CloudIcon,
DocumentIcon, DocumentIcon,
SignalIcon, SignalIcon
} from "@heroicons/react/24/outline"; } 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";
@ -18,19 +18,19 @@ export const ExtensionsPage = (): JSX.Element => {
name: "File Browser", name: "File Browser",
icon: <DocumentIcon className="h-4" />, icon: <DocumentIcon className="h-4" />,
element: FileBrowser, element: FileBrowser,
disabled: !hardware.hasWifi, disabled: !hardware.hasWifi
}, },
{ {
name: "Range Test", name: "Range Test",
icon: <SignalIcon className="h-4" />, icon: <SignalIcon className="h-4" />,
element: FileBrowser, element: FileBrowser,
disabled: !hardware.hasWifi, disabled: !hardware.hasWifi
}, },
{ {
name: "Environment", name: "Environment",
icon: <CloudIcon className="h-4" />, icon: <CloudIcon className="h-4" />,
element: Environment, element: Environment
}, }
]; ];
return <TabbedContent tabs={tabs} />; return <TabbedContent tabs={tabs} />;

18
src/pages/Info.tsx

@ -5,7 +5,7 @@ import { JSONTree } from "react-json-tree";
import { import {
TabbedContent, TabbedContent,
TabType, TabType
} from "@app/components/layout/page/TabbedContent.js"; } from "@app/components/layout/page/TabbedContent.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/providers/useDevice.js";
import { EyeIcon } from "@heroicons/react/24/outline"; import { EyeIcon } from "@heroicons/react/24/outline";
@ -24,32 +24,32 @@ export const InfoPage = (): JSX.Element => {
{ {
name: "Config", name: "Config",
icon: <EyeIcon className="h-4" />, icon: <EyeIcon className="h-4" />,
element: () => <JSONTree theme="monokai" data={config} />, element: () => <JSONTree theme="monokai" data={config} />
}, },
{ {
name: "Module Config", name: "Module Config",
icon: <EyeIcon className="h-4" />, icon: <EyeIcon className="h-4" />,
element: () => <JSONTree theme="monokai" data={moduleConfig} />, element: () => <JSONTree theme="monokai" data={moduleConfig} />
}, },
{ {
name: "Hardware", name: "Hardware",
icon: <EyeIcon className="h-4" />, icon: <EyeIcon className="h-4" />,
element: () => <JSONTree theme="monokai" data={hardware} />, element: () => <JSONTree theme="monokai" data={hardware} />
}, },
{ {
name: "Nodes", name: "Nodes",
icon: <EyeIcon className="h-4" />, icon: <EyeIcon className="h-4" />,
element: () => <JSONTree theme="monokai" data={nodes} />, element: () => <JSONTree theme="monokai" data={nodes} />
}, },
{ {
name: "Waypoints", name: "Waypoints",
icon: <EyeIcon className="h-4" />, icon: <EyeIcon className="h-4" />,
element: () => <JSONTree theme="monokai" data={waypoints} />, element: () => <JSONTree theme="monokai" data={waypoints} />
}, },
{ {
name: "Connection", name: "Connection",
icon: <EyeIcon className="h-4" />, icon: <EyeIcon className="h-4" />,
element: () => <JSONTree theme="monokai" data={connection} />, element: () => <JSONTree theme="monokai" data={connection} />
}, },
{ {
name: "Serial Logs", name: "Serial Logs",
@ -62,8 +62,8 @@ export const InfoPage = (): JSX.Element => {
</div> </div>
))} ))}
</div> </div>
), )
}, }
]; ];
return <TabbedContent tabs={tabs} />; return <TabbedContent tabs={tabs} />;

21
src/pages/Map.tsx

@ -1,25 +1,27 @@
import type React from "react"; import type React from "react";
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { Map, Marker, useMap } from "react-map-gl"; import { Layer, Map, Marker, Source, useMap } from "react-map-gl";
import { base16 } from "rfc4648"; import { base16 } from "rfc4648";
import { Card } from "@app/components/Card.js"; import { Card } from "@app/components/Card.js";
import { IconButton } from "@app/components/IconButton.js"; import { IconButton } from "@app/components/IconButton.js";
import { Mono } from "@app/components/Mono.js"; import { Mono } from "@app/components/Mono.js";
import { useAppStore } from "@app/core/stores/appStore.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 { import {
EllipsisHorizontalCircleIcon, EllipsisHorizontalCircleIcon,
MapPinIcon, MapPinIcon
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
export const MapPage = (): JSX.Element => { export const MapPage = (): JSX.Element => {
const { nodes, waypoints } = useDevice(); const { nodes, waypoints } = useDevice();
const { rasterSources } = useAppStore();
const { current: map } = useMap(); const { current: map } = useMap();
return ( return (
<div className="flex-grow"> <div className="h-full flex-grow">
<div className="absolute right-0 top-0 z-10 m-2"> <div className="absolute right-0 top-0 z-10 m-2">
<Card className="flex-col p-3"> <Card className="flex-col p-3">
<div className="p-1 text-lg font-medium">Title</div> <div className="p-1 text-lg font-medium">Title</div>
@ -51,9 +53,9 @@ export const MapPage = (): JSX.Element => {
map?.flyTo({ map?.flyTo({
center: [ center: [
n.data.position.longitudeI / 1e7, n.data.position.longitudeI / 1e7,
n.data.position.latitudeI / 1e7, n.data.position.latitudeI / 1e7
], ],
zoom: 10, zoom: 10
}); });
} }
}} }}
@ -62,6 +64,10 @@ export const MapPage = (): JSX.Element => {
</div> </div>
))} ))}
</div> </div>
{/* */}
{rasterSources.map((source, index) => (
<div key={index}>{source.title}Tst</div>
))}
</Card> </Card>
</div> </div>
<Map <Map
@ -81,6 +87,11 @@ export const MapPage = (): JSX.Element => {
</div> </div>
</Marker> </Marker>
))} ))}
{rasterSources.map((source, index) => (
<Source key={index} type="raster" {...source}>
<Layer type="raster" />
</Source>
))}
{nodes.map((n) => { {nodes.map((n) => {
if (n.data.position?.latitudeI) { if (n.data.position?.latitudeI) {
return ( return (

6
src/pages/Messages.tsx

@ -3,7 +3,7 @@ import type React from "react";
import { IconButton } from "@app/components/IconButton.js"; import { IconButton } from "@app/components/IconButton.js";
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 { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/providers/useDevice.js";
@ -21,7 +21,7 @@ export const MessagesPage = (): JSX.Element => {
? "Primary" ? "Primary"
: `Ch ${channel.config.index}`, : `Ch ${channel.config.index}`,
element: () => <ChannelChat channel={channel} />, element: () => <ChannelChat channel={channel} />,
disabled: channel.config.role === Protobuf.Channel_Role.DISABLED, disabled: channel.config.role === Protobuf.Channel_Role.DISABLED
}; };
}); });
@ -38,7 +38,7 @@ export const MessagesPage = (): JSX.Element => {
setActivePage("channels"); setActivePage("channels");
}} }}
/> />
), )
]} ]}
/> />
</div> </div>

2
src/pages/Peers.tsx

@ -108,7 +108,7 @@ export const PeersPage = (): JSX.Element => {
onClick={() => { onClick={() => {
if (connection) { if (connection) {
void toast.promise( void toast.promise(
connection.getMetadata(node.data.num), connection.getMetadata({ nodeNum: node.data.num }),
{ {
loading: "Requesting Metadata...", loading: "Requesting Metadata...",
success: "Recieved Metadata", success: "Recieved Metadata",

22
src/validation/appConfig/map.ts

@ -0,0 +1,22 @@
import { IsArray, IsBoolean, IsNumber, IsString } from "class-validator";
import type { RasterSource } from "@app/core/stores/appStore.js";
export class MapValidation {
@IsArray()
rasterSources: MapValidation_RasterSources[];
}
export class MapValidation_RasterSources implements RasterSource {
@IsBoolean()
enabled: boolean;
@IsString()
title: string;
// @IsUrl()
tiles: string[];
@IsNumber()
tileSize: number;
}

8
src/validation/config/device.ts

@ -1,4 +1,4 @@
import { IsBoolean, IsEnum } from "class-validator"; import { IsBoolean, IsEnum, IsInt } from "class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
@ -11,4 +11,10 @@ export class DeviceValidation implements Protobuf.Config_DeviceConfig {
@IsBoolean() @IsBoolean()
debugLogEnabled: boolean; debugLogEnabled: boolean;
@IsInt()
buttonGpio: number;
@IsInt()
buzzerGpio: number;
} }

3
src/validation/config/display.ts

@ -20,4 +20,7 @@ export class DisplayValidation implements Protobuf.Config_DisplayConfig {
@IsEnum(Protobuf.Config_DisplayConfig_DisplayUnits) @IsEnum(Protobuf.Config_DisplayConfig_DisplayUnits)
units: Protobuf.Config_DisplayConfig_DisplayUnits; units: Protobuf.Config_DisplayConfig_DisplayUnits;
@IsEnum(Protobuf.Config_DisplayConfig_OledType)
oled: Protobuf.Config_DisplayConfig_OledType;
} }

9
src/validation/config/network.ts

@ -6,14 +6,11 @@ export class NetworkValidation implements Protobuf.Config_NetworkConfig {
@IsBoolean() @IsBoolean()
wifiEnabled: boolean; wifiEnabled: boolean;
@IsEnum(Protobuf.Config_NetworkConfig_WiFiMode) @Length(1, 33)
wifiMode: Protobuf.Config_NetworkConfig_WiFiMode;
@Length(0, 33) //min 1
@IsOptional({}) @IsOptional({})
wifiSsid: string; wifiSsid: string;
@Length(0, 64) //min 8 @Length(8, 64)
@IsOptional() @IsOptional()
wifiPsk: string; wifiPsk: string;
@ -26,7 +23,7 @@ export class NetworkValidation implements Protobuf.Config_NetworkConfig {
@IsEnum(Protobuf.Config_NetworkConfig_EthMode) @IsEnum(Protobuf.Config_NetworkConfig_EthMode)
ethMode: Protobuf.Config_NetworkConfig_EthMode; ethMode: Protobuf.Config_NetworkConfig_EthMode;
ethConfig: NetworkValidation_IpV4Config; ipv4Config: NetworkValidation_IpV4Config;
} }
export class NetworkValidation_IpV4Config export class NetworkValidation_IpV4Config

6
src/validation/config/position.ts

@ -24,6 +24,12 @@ export class PositionValidation implements Protobuf.Config_PositionConfig {
@IsInt() @IsInt()
positionFlags: number; positionFlags: number;
@IsInt()
rxGpio: number;
@IsInt()
txGpio: number;
// fixed position fields // fixed position fields
@IsNumber() @IsNumber()
fixedAlt: number; fixedAlt: number;

8
src/validation/moduleConfig/externalNotification.ts

@ -1,4 +1,4 @@
import { IsInt } from "class-validator"; import { IsBoolean, IsInt } from "class-validator";
import type { Protobuf } from "@meshtastic/meshtasticjs"; import type { Protobuf } from "@meshtastic/meshtasticjs";
@ -13,9 +13,15 @@ export class ExternalNotificationValidation
@IsInt() @IsInt()
output: number; output: number;
@IsBoolean()
active: boolean; active: boolean;
@IsBoolean()
alertMessage: boolean; alertMessage: boolean;
@IsBoolean()
alertBell: boolean; alertBell: boolean;
@IsBoolean()
usePwm: boolean;
} }

8
tailwind.config.cjs

@ -12,10 +12,10 @@ module.exports = {
"Consolas", "Consolas",
"Liberation Mono", "Liberation Mono",
"Courier New", "Courier New",
"monospace", "monospace"
], ]
}, },
extend: {}, extend: {}
}, },
plugins: [require("@tailwindcss/forms")], plugins: [require("@tailwindcss/forms")]
}; };

14
vite.config.ts

@ -18,15 +18,15 @@ export default defineConfig({
plugins: [ plugins: [
react(), react(),
EnvironmentPlugin({ EnvironmentPlugin({
COMMIT_HASH: hash, COMMIT_HASH: hash
}), })
], ],
build: { build: {
target: "esnext", target: "esnext",
assetsDir: "", assetsDir: "",
rollupOptions: { rollupOptions: {
plugins: [visualizer()], plugins: [visualizer()]
}, }
}, },
resolve: { resolve: {
alias: { alias: {
@ -34,7 +34,7 @@ export default defineConfig({
"@pages": resolve(__dirname, "./src/pages"), "@pages": resolve(__dirname, "./src/pages"),
"@components": resolve(__dirname, "./src/components"), "@components": resolve(__dirname, "./src/components"),
"@core": resolve(__dirname, "./src/core"), "@core": resolve(__dirname, "./src/core"),
"@layouts": resolve(__dirname, "./src/layouts"), "@layouts": resolve(__dirname, "./src/layouts")
}, }
}, }
}); });

Loading…
Cancel
Save