163 changed files with 6346 additions and 14360 deletions
@ -1,2 +0,0 @@ |
|||
VITE_PUBLIC_DEVICE_IP= |
|||
VITE_PUBLIC_HOSTED= |
|||
@ -1,5 +1,4 @@ |
|||
dist |
|||
node_modules |
|||
.env |
|||
stats.html |
|||
.vercel |
|||
|
|||
@ -1 +1 @@ |
|||
"@meshtastic/eslint-config/prettier" |
|||
{} |
|||
|
|||
File diff suppressed because it is too large
@ -1,6 +0,0 @@ |
|||
module.exports = { |
|||
plugins: { |
|||
tailwindcss: {}, |
|||
autoprefixer: {}, |
|||
}, |
|||
}; |
|||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 893 B |
@ -1,30 +1,17 @@ |
|||
import type React from 'react'; |
|||
import type React from "react"; |
|||
|
|||
import { Connection } from '@components/Connection'; |
|||
import { BottomNav } from '@components/menu/BottomNav'; |
|||
import { useRoute } from '@core/router'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Extensions } from '@pages/Extensions/Index'; |
|||
import { MapPage } from '@pages/map'; |
|||
import { Messages } from '@pages/Messages'; |
|||
import { NotFound } from '@pages/NotFound'; |
|||
import { Pane } from "evergreen-ui"; |
|||
|
|||
export const App = (): JSX.Element => { |
|||
const route = useRoute(); |
|||
const appState = useAppSelector((state) => state.app); |
|||
import { AppLayout } from "@components/layout/AppLayout.js"; |
|||
|
|||
import { PageRouter } from "./PageRouter.js"; |
|||
|
|||
export const App = (): JSX.Element => { |
|||
return ( |
|||
<div className={`h-screen w-screen ${appState.darkMode ? 'dark' : ''}`}> |
|||
<Connection /> |
|||
<div className="flex h-full flex-col"> |
|||
<div className="flex min-h-0 w-full flex-grow"> |
|||
{route.name === 'messages' && <Messages />} |
|||
{route.name === 'map' && <MapPage />} |
|||
{route.name === 'extensions' && <Extensions />} |
|||
{route.name === false && <NotFound />} |
|||
</div> |
|||
<BottomNav /> |
|||
</div> |
|||
</div> |
|||
<Pane display="flex"> |
|||
<AppLayout> |
|||
<PageRouter /> |
|||
</AppLayout> |
|||
</Pane> |
|||
); |
|||
}; |
|||
|
|||
@ -0,0 +1,84 @@ |
|||
import type React from "react"; |
|||
|
|||
import { Device, DeviceContext } from "./core/stores/deviceStore.js"; |
|||
|
|||
export interface DeviceProps { |
|||
children: React.ReactNode; |
|||
device: Device; |
|||
} |
|||
|
|||
// const cleanupListeners = (connection: IConnection): void => {
|
|||
// connection.onMeshPacket.cancelAll();
|
|||
// connection.onDeviceStatus.cancelAll();
|
|||
// connection.onMyNodeInfo.cancelAll();
|
|||
// connection.onUserPacket.cancelAll();
|
|||
// connection.onPositionPacket.cancelAll();
|
|||
// connection.onNodeInfoPacket.cancelAll();
|
|||
// connection.onAdminPacket.cancelAll();
|
|||
// connection.onMeshHeartbeat.cancelAll();
|
|||
// connection.onTextPacket.cancelAll();
|
|||
// };
|
|||
|
|||
export const DeviceWrapper = ({ |
|||
children, |
|||
device, |
|||
}: DeviceProps): JSX.Element => { |
|||
// const fetchConfig = useCallback(async (): Promise<void> => {
|
|||
// /**
|
|||
// * Get Config
|
|||
// */
|
|||
// await device.connection?.getConfig(
|
|||
// Protobuf.AdminMessage_ConfigType.DEVICE_CONFIG
|
|||
// );
|
|||
// await device.connection?.getConfig(
|
|||
// Protobuf.AdminMessage_ConfigType.WIFI_CONFIG
|
|||
// );
|
|||
// await device.connection?.getConfig(
|
|||
// Protobuf.AdminMessage_ConfigType.POSITION_CONFIG
|
|||
// );
|
|||
// await device.connection?.getConfig(
|
|||
// Protobuf.AdminMessage_ConfigType.DISPLAY_CONFIG
|
|||
// );
|
|||
// await device.connection?.getConfig(
|
|||
// Protobuf.AdminMessage_ConfigType.LORA_CONFIG
|
|||
// );
|
|||
// await device.connection?.getConfig(
|
|||
// Protobuf.AdminMessage_ConfigType.POWER_CONFIG
|
|||
// );
|
|||
|
|||
// /**
|
|||
// * Get Module Config
|
|||
// */
|
|||
// await device.connection?.getModuleConfig(
|
|||
// Protobuf.AdminMessage_ModuleConfigType.MQTT_CONFIG
|
|||
// );
|
|||
// await device.connection?.getModuleConfig(
|
|||
// Protobuf.AdminMessage_ModuleConfigType.SERIAL_CONFIG
|
|||
// );
|
|||
// await device.connection?.getModuleConfig(
|
|||
// Protobuf.AdminMessage_ModuleConfigType.EXTNOTIF_CONFIG
|
|||
// );
|
|||
// await device.connection?.getModuleConfig(
|
|||
// Protobuf.AdminMessage_ModuleConfigType.STOREFORWARD_CONFIG
|
|||
// );
|
|||
// await device.connection?.getModuleConfig(
|
|||
// Protobuf.AdminMessage_ModuleConfigType.RANGETEST_CONFIG
|
|||
// );
|
|||
// await device.connection?.getModuleConfig(
|
|||
// Protobuf.AdminMessage_ModuleConfigType.TELEMETRY_CONFIG
|
|||
// );
|
|||
// await device.connection?.getModuleConfig(
|
|||
// Protobuf.AdminMessage_ModuleConfigType.CANNEDMSG_CONFIG
|
|||
// );
|
|||
// }, [device.connection]);
|
|||
|
|||
// useEffect(() => {
|
|||
// if (device.ready) {
|
|||
// void fetchConfig();
|
|||
// }
|
|||
// }, [device.ready, fetchConfig]);
|
|||
|
|||
return ( |
|||
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider> |
|||
); |
|||
}; |
|||
@ -0,0 +1,22 @@ |
|||
import type React from "react"; |
|||
|
|||
import { useDevice } from "./core/stores/deviceStore.js"; |
|||
import { ChannelsPage } from "./pages/Channels/index.js"; |
|||
import { ConfigPage } from "./pages/Config/index.js"; |
|||
import { ExtensionsPage } from "./pages/Extensions/Index.js"; |
|||
import { InfoPage } from "./pages/Info/index.js"; |
|||
import { MessagesPage } from "./pages/Messages/index.js"; |
|||
|
|||
export const PageRouter = (): JSX.Element => { |
|||
const { activePage } = useDevice(); |
|||
return ( |
|||
<> |
|||
{activePage === "messages" && <MessagesPage />} |
|||
{/* {activePage === "map" && <MapPage />} */} |
|||
{activePage === "extensions" && <ExtensionsPage />} |
|||
{activePage === "config" && <ConfigPage />} |
|||
{activePage === "channels" && <ChannelsPage />} |
|||
{activePage === "info" && <InfoPage />} |
|||
</> |
|||
); |
|||
}; |
|||
@ -1,150 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect } from 'react'; |
|||
|
|||
import { m } from 'framer-motion'; |
|||
|
|||
import { BLE } from '@components/connection/BLE'; |
|||
import { HTTP } from '@components/connection/HTTP'; |
|||
import { Serial } from '@components/connection/Serial'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { Modal } from '@components/generic/Modal'; |
|||
import { connectionUrl, setConnection } from '@core/connection'; |
|||
import { |
|||
closeConnectionModal, |
|||
connType, |
|||
setConnectionParams, |
|||
setConnType, |
|||
} from '@core/slices/appSlice'; |
|||
import { useAppDispatch } from '@hooks/useAppDispatch'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Types } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const Connection = (): JSX.Element => { |
|||
const dispatch = useAppDispatch(); |
|||
|
|||
const meshtasticState = useAppSelector((state) => state.meshtastic); |
|||
const appState = useAppSelector((state) => state.app); |
|||
const chromiunm = !!window.chrome; |
|||
|
|||
useEffect(() => { |
|||
if (!import.meta.env.VITE_PUBLIC_HOSTED) { |
|||
dispatch( |
|||
setConnectionParams({ |
|||
type: connType.HTTP, |
|||
params: { |
|||
address: connectionUrl, |
|||
tls: false, |
|||
receiveBatchRequests: false, |
|||
fetchInterval: 2000, |
|||
}, |
|||
}), |
|||
); |
|||
void setConnection(connType.HTTP); |
|||
} |
|||
}, [dispatch]); |
|||
|
|||
useEffect(() => { |
|||
if (meshtasticState.ready) { |
|||
dispatch(closeConnectionModal()); |
|||
} |
|||
}, [meshtasticState.ready, dispatch]); |
|||
|
|||
return ( |
|||
<Modal |
|||
title="Connect to a device" |
|||
open={appState.connectionModalOpen} |
|||
onClose={(): void => { |
|||
dispatch(closeConnectionModal()); |
|||
}} |
|||
> |
|||
<div className="flex max-w-3xl flex-col gap-4 md:flex-row"> |
|||
<div className="flex flex-col md:w-1/2"> |
|||
<div className="flex flex-grow flex-col space-y-2"> |
|||
<Select |
|||
label="Connection Method" |
|||
optionsEnum={connType} |
|||
value={appState.connType} |
|||
onChange={(e): void => { |
|||
dispatch(setConnType(parseInt(e.target.value))); |
|||
}} |
|||
disabled={ |
|||
meshtasticState.deviceStatus === |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED |
|||
} |
|||
/> |
|||
{appState.connType === connType.HTTP && ( |
|||
<HTTP |
|||
connecting={ |
|||
meshtasticState.deviceStatus === |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED |
|||
} |
|||
/> |
|||
)} |
|||
|
|||
{appState.connType === connType.BLE && |
|||
(chromiunm ? ( |
|||
<BLE |
|||
connecting={ |
|||
meshtasticState.deviceStatus === |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED |
|||
} |
|||
/> |
|||
) : ( |
|||
<div className="rounded-md border border-red-500 bg-red-500 bg-opacity-10 p-8 dark:text-white"> |
|||
<p>Unsupported.</p> |
|||
<p>Please use a Chromium based browser.</p> |
|||
</div> |
|||
))} |
|||
|
|||
{appState.connType === connType.SERIAL && |
|||
(chromiunm ? ( |
|||
<Serial |
|||
connecting={ |
|||
meshtasticState.deviceStatus === |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED |
|||
} |
|||
/> |
|||
) : ( |
|||
<div className="rounded-md border border-red-500 bg-red-500 bg-opacity-10 p-8 dark:text-white"> |
|||
<p>Unsupported.</p> |
|||
<p>Please use a Chromium based browser.</p> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</div> |
|||
<div className="md:w-1/2"> |
|||
<div className="h-96 overflow-y-auto rounded-md border border-gray-400 bg-gray-200 p-2 drop-shadow-md dark:border-gray-600 dark:bg-tertiaryDark dark:text-gray-400"> |
|||
{meshtasticState.logs.length === 0 && ( |
|||
<div className="flex h-full w-full"> |
|||
<m.img |
|||
initial={{ opacity: 0 }} |
|||
animate={{ opacity: 1 }} |
|||
exit={{ opacity: 0 }} |
|||
className="m-auto h-40 w-40 text-green-500" |
|||
src={ |
|||
appState.darkMode ? '/View_Code_Dark.svg' : '/View_Code.svg' |
|||
} |
|||
/> |
|||
</div> |
|||
)} |
|||
{meshtasticState.logs |
|||
.filter((log) => { |
|||
return ![ |
|||
Types.Emitter.handleFromRadio, |
|||
Types.Emitter.handleMeshPacket, |
|||
Types.Emitter.sendPacket, |
|||
].includes(log.emitter); |
|||
}) |
|||
.map((log, index) => ( |
|||
<div key={index} className="flex"> |
|||
<div className="truncate font-mono text-sm"> |
|||
{log.message} |
|||
</div> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</Modal> |
|||
); |
|||
}; |
|||
@ -0,0 +1,141 @@ |
|||
import type React from "react"; |
|||
|
|||
import { |
|||
Dialog, |
|||
HelperManagementIcon, |
|||
IconButton, |
|||
majorScale, |
|||
MoreIcon, |
|||
Table, |
|||
TagIcon, |
|||
Tooltip, |
|||
} from "evergreen-ui"; |
|||
|
|||
import { useDevice } from "@app/core/stores/deviceStore.js"; |
|||
import { toMGRS } from "@app/core/utils/toMGRS.js"; |
|||
import { Hashicon } from "@emeraldpay/hashicon-react"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export interface PeersDialogProps { |
|||
isOpen: boolean; |
|||
close: () => void; |
|||
} |
|||
|
|||
export const PeersDialog = ({ |
|||
isOpen, |
|||
close, |
|||
}: PeersDialogProps): JSX.Element => { |
|||
const { hardware, nodes, connection } = useDevice(); |
|||
|
|||
return ( |
|||
<Dialog |
|||
isShown={isOpen} |
|||
title="Peers" |
|||
onCloseComplete={close} |
|||
hasFooter={false} |
|||
width={majorScale(120)} |
|||
> |
|||
<Table> |
|||
<Table.Head> |
|||
<Table.HeaderCell flexBasis={48} flexShrink={0} flexGrow={0} /> |
|||
<Table.TextHeaderCell flexBasis={96} flexShrink={0} flexGrow={0}> |
|||
Number |
|||
</Table.TextHeaderCell> |
|||
<Table.TextHeaderCell flexBasis={116} flexShrink={0} flexGrow={0}> |
|||
Name |
|||
</Table.TextHeaderCell> |
|||
<Table.TextHeaderCell flexBasis={48} flexShrink={0} flexGrow={0}> |
|||
SNR |
|||
</Table.TextHeaderCell> |
|||
<Table.TextHeaderCell>Location</Table.TextHeaderCell> |
|||
<Table.TextHeaderCell>Last Heard</Table.TextHeaderCell> |
|||
<Table.TextHeaderCell>Actions</Table.TextHeaderCell> |
|||
</Table.Head> |
|||
<Table.Body height={240}> |
|||
{nodes |
|||
.filter((n) => n.data.num !== hardware.myNodeNum) |
|||
.map((node) => ( |
|||
<Table.Row |
|||
key={node.data.num} |
|||
isSelectable |
|||
onSelect={() => alert(node.data.num)} |
|||
> |
|||
<Table.Cell flexBasis={48} flexShrink={0} flexGrow={0}> |
|||
<Hashicon |
|||
value={node.data.num.toString()} |
|||
size={majorScale(3)} |
|||
/> |
|||
</Table.Cell> |
|||
<Table.TextCell flexBasis={96} flexShrink={0} flexGrow={0}> |
|||
{node.data.num} |
|||
</Table.TextCell> |
|||
<Table.TextCell flexBasis={116} flexShrink={0} flexGrow={0}> |
|||
{node.data.user?.longName} |
|||
</Table.TextCell> |
|||
<Table.TextCell flexBasis={48} flexShrink={0} flexGrow={0}> |
|||
{node.data.snr} |
|||
</Table.TextCell> |
|||
<Table.TextCell> |
|||
{toMGRS( |
|||
node.data.position?.latitudeI, |
|||
node.data.position?.longitudeI |
|||
)} |
|||
</Table.TextCell> |
|||
<Table.TextCell> |
|||
{new Date(node.data.lastHeard * 1000).toLocaleString()} |
|||
</Table.TextCell> |
|||
<Table.Cell gap={majorScale(1)}> |
|||
<Tooltip content="Manage"> |
|||
<IconButton icon={HelperManagementIcon} /> |
|||
</Tooltip> |
|||
<IconButton |
|||
icon={TagIcon} |
|||
onClick={() => { |
|||
void connection?.sendPacket( |
|||
Protobuf.AdminMessage.toBinary({ |
|||
variant: { |
|||
oneofKind: "getConfigRequest", |
|||
getConfigRequest: |
|||
Protobuf.AdminMessage_ConfigType.LORA_CONFIG, |
|||
}, |
|||
}), |
|||
Protobuf.PortNum.ADMIN_APP, |
|||
node.data.num, |
|||
true, |
|||
7, |
|||
true, |
|||
false, |
|||
async (test) => { |
|||
console.log(test); |
|||
|
|||
console.log("got response"); |
|||
return Promise.resolve(); |
|||
} |
|||
); |
|||
}} |
|||
/> |
|||
<IconButton icon={MoreIcon} /> |
|||
</Table.Cell> |
|||
</Table.Row> |
|||
))} |
|||
</Table.Body> |
|||
</Table> |
|||
{/* <Pane |
|||
key={node.data.num} |
|||
display="flex" |
|||
borderRadius={majorScale(1)} |
|||
elevation={1} |
|||
gap={majorScale(1)} |
|||
padding={majorScale(1)} |
|||
> |
|||
|
|||
<Heading>{node.data.user?.longName}</Heading> |
|||
{node.metrics.airUtilTx} |
|||
{node.metrics.} |
|||
{node.metrics.channelUtilization} |
|||
{node.metrics.} |
|||
{node.data.} |
|||
</Pane> */} |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -0,0 +1,112 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { fromByteArray } from "base64-js"; |
|||
import { |
|||
Checkbox, |
|||
ClipboardIcon, |
|||
Dialog, |
|||
FormField, |
|||
IconButton, |
|||
majorScale, |
|||
Pane, |
|||
TextInputField, |
|||
Tooltip, |
|||
} from "evergreen-ui"; |
|||
import { QRCode } from "react-qrcode-logo"; |
|||
|
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export interface QRDialogProps { |
|||
isOpen: boolean; |
|||
close: () => void; |
|||
loraConfig?: Protobuf.Config_LoRaConfig; |
|||
channels: Protobuf.Channel[]; |
|||
} |
|||
|
|||
export const QRDialog = ({ |
|||
isOpen, |
|||
close, |
|||
loraConfig, |
|||
channels, |
|||
}: QRDialogProps): JSX.Element => { |
|||
const [selectedChannels, setSelectedChannels] = useState<number[]>([]); |
|||
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( |
|||
Protobuf.ChannelSet.create({ |
|||
loraConfig, |
|||
settings: channelsToEncode, |
|||
}) |
|||
); |
|||
const base64 = fromByteArray(encoded); |
|||
|
|||
setQRCodeURL(`https://www.meshtastic.org/e/#${base64}`); |
|||
}, [channels, selectedChannels, loraConfig]); |
|||
|
|||
return ( |
|||
<Dialog |
|||
isShown={isOpen} |
|||
title="Generate QR Code" |
|||
onCloseComplete={close} |
|||
hasFooter={false} |
|||
> |
|||
<Pane display="flex"> |
|||
<FormField |
|||
width="12rem" |
|||
label="Channels to include" |
|||
description="The current LoRa configuration will also be shared." |
|||
> |
|||
{channels.map((channel) => ( |
|||
<Checkbox |
|||
key={channel.index} |
|||
disabled={channel.role === Protobuf.Channel_Role.DISABLED} |
|||
label={ |
|||
channel.settings?.name.length |
|||
? channel.settings.name |
|||
: channel.role === Protobuf.Channel_Role.PRIMARY |
|||
? "Primary" |
|||
: `Channel: ${channel.index}` |
|||
} |
|||
checked={selectedChannels.includes(channel.index)} |
|||
onChange={() => { |
|||
if (selectedChannels.includes(channel.index)) { |
|||
setSelectedChannels( |
|||
selectedChannels.filter((c) => c !== channel.index) |
|||
); |
|||
} else { |
|||
setSelectedChannels([...selectedChannels, channel.index]); |
|||
} |
|||
}} |
|||
/> |
|||
))} |
|||
</FormField> |
|||
<Pane |
|||
display="flex" |
|||
flexDirection="column" |
|||
flexGrow={1} |
|||
margin={majorScale(1)} |
|||
> |
|||
<Pane display="flex" margin="auto"> |
|||
<QRCode value={QRCodeURL} qrStyle="dots" /> |
|||
</Pane> |
|||
<Pane display="flex" gap={majorScale(1)}> |
|||
<TextInputField |
|||
label="Sharable URL" |
|||
value={QRCodeURL} |
|||
width="100%" |
|||
/> |
|||
<Tooltip content="Copy to Clipboard"> |
|||
<IconButton icon={ClipboardIcon} marginTop="1.6rem" /> |
|||
</Tooltip> |
|||
</Pane> |
|||
</Pane> |
|||
</Pane> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -1,16 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import type { FallbackProps } from 'react-error-boundary'; |
|||
|
|||
export const ErrorFallback = ({ |
|||
error, |
|||
resetErrorBoundary, |
|||
}: FallbackProps): JSX.Element => { |
|||
return ( |
|||
<div role="alert"> |
|||
<p>Something went wrong:</p> |
|||
<pre>{error.message}</pre> |
|||
<button onClick={resetErrorBoundary}>Try again</button> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,137 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useRef } from 'react'; |
|||
|
|||
import { ScaleControl } from 'mapbox-gl'; |
|||
|
|||
import { MapboxContext } from '@components/MapBox/mapboxContext'; |
|||
import { MapStyles } from '@core/mapStyles'; |
|||
import { |
|||
setBearing, |
|||
setLatLng, |
|||
setMapStyle, |
|||
setPitch, |
|||
setZoom, |
|||
} from '@core/slices/mapSlice'; |
|||
import { useAppDispatch } from '@hooks/useAppDispatch'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { useCreateMapbox } from '@hooks/useCreateMapbox'; |
|||
|
|||
export type MapboxProviderProps = { |
|||
children: React.ReactNode; |
|||
}; |
|||
|
|||
export const MapboxProvider = ({ |
|||
children, |
|||
}: MapboxProviderProps): JSX.Element => { |
|||
const darkMode = useAppSelector((state) => state.app.darkMode); |
|||
const mapState = useAppSelector((state) => state.map); |
|||
const dispatch = useAppDispatch(); |
|||
const ref = useRef<HTMLDivElement>(null); |
|||
|
|||
const map = useCreateMapbox({ |
|||
ref, |
|||
accessToken: |
|||
'pk.eyJ1Ijoic2FjaGF3IiwiYSI6ImNrNW9meXozZjBsdW0zbHBjM2FnNnV6cmsifQ.3E4n8eFGD9ZOFo-XDVeZnQ', |
|||
options: { |
|||
center: mapState.latLng, |
|||
zoom: mapState.zoom, |
|||
bearing: mapState.bearing, |
|||
pitch: mapState.pitch, |
|||
style: MapStyles[mapState.style].data, |
|||
}, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
map?.on('load', () => { |
|||
map.addControl(new ScaleControl()); |
|||
}); |
|||
map?.on('styledata', () => { |
|||
if (!map.getSource('mapbox-dem')) { |
|||
map.addSource('mapbox-dem', { |
|||
type: 'raster-dem', |
|||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1', |
|||
tileSize: 512, |
|||
maxzoom: 14, |
|||
}); |
|||
} |
|||
map.setTerrain({ |
|||
source: 'mapbox-dem', |
|||
exaggeration: mapState.exaggeration ? 1.5 : 0, |
|||
}); |
|||
}); |
|||
map?.on('dragend', (e) => { |
|||
dispatch(setLatLng(e.target.getCenter())); |
|||
}); |
|||
map?.on('zoomend', (e) => { |
|||
dispatch(setZoom(e.target.getZoom())); |
|||
}); |
|||
map?.on('rotate', (e) => { |
|||
dispatch(setBearing(e.target.getBearing())); |
|||
}); |
|||
map?.on('pitch', (e) => { |
|||
dispatch(setPitch(e.target.getPitch())); |
|||
}); |
|||
}, [dispatch, map, mapState.exaggeration]); |
|||
|
|||
useEffect(() => { |
|||
const center = map?.getCenter(); |
|||
if (center !== mapState.latLng) { |
|||
map?.setCenter(mapState.latLng); |
|||
} |
|||
}, [map, mapState.latLng]); |
|||
|
|||
useEffect(() => { |
|||
if (['Light', 'Dark'].includes(mapState.style)) { |
|||
dispatch(setMapStyle(darkMode ? 'Dark' : 'Light')); |
|||
} |
|||
}, [dispatch, darkMode, mapState.style]); |
|||
|
|||
/** |
|||
* Hill Shading |
|||
*/ |
|||
useEffect(() => { |
|||
if (map?.loaded()) { |
|||
if (mapState.hillShade) { |
|||
map.addLayer( |
|||
{ |
|||
id: 'hillshading', |
|||
source: 'mapbox-dem', |
|||
type: 'hillshade', |
|||
// insert below waterway-river-canal-shadow;
|
|||
// where hillshading sits in the Mapbox Outdoors style
|
|||
}, |
|||
'waterway-river-canal-shadow', |
|||
); |
|||
} else { |
|||
map.removeLayer('hillshading'); |
|||
} |
|||
} |
|||
}, [map, mapState.hillShade]); |
|||
|
|||
/** |
|||
* Exaggeration |
|||
*/ |
|||
useEffect(() => { |
|||
if (map?.loaded()) { |
|||
map.setTerrain({ |
|||
source: 'mapbox-dem', |
|||
exaggeration: mapState.exaggeration ? 1.5 : 0, |
|||
}); |
|||
} |
|||
}, [map, mapState.exaggeration]); |
|||
|
|||
/** |
|||
* Map Style |
|||
*/ |
|||
useEffect(() => { |
|||
if (map?.loaded()) { |
|||
map.setStyle(MapStyles[mapState.style].data); |
|||
} |
|||
}, [map, mapState.style]); |
|||
|
|||
return ( |
|||
<MapboxContext.Provider value={{ map, ref }}> |
|||
{children} |
|||
</MapboxContext.Provider> |
|||
); |
|||
}; |
|||
@ -1,13 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { createContext } from 'react'; |
|||
|
|||
import type { Map } from 'mapbox-gl'; |
|||
|
|||
export interface MapboxContextValue { |
|||
ref: React.Ref<HTMLDivElement>; |
|||
map?: Map; |
|||
} |
|||
|
|||
export const MapboxContext = createContext<MapboxContextValue>( |
|||
{} as MapboxContextValue, |
|||
); |
|||
@ -0,0 +1,117 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { |
|||
FormField, |
|||
SelectField, |
|||
Switch, |
|||
TextInputField, |
|||
toaster, |
|||
} from "evergreen-ui"; |
|||
import { Controller, useForm } from "react-hook-form"; |
|||
|
|||
import { DeviceValidation } from "@app/validation/config/device.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { renderOptions } from "@core/utils/selectEnumOptions.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const Device = (): JSX.Element => { |
|||
const { config, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
control, |
|||
reset, |
|||
} = useForm<DeviceValidation>({ |
|||
defaultValues: config.device, |
|||
resolver: classValidatorResolver(DeviceValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(config.device); |
|||
}, [reset, config.device]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "device", |
|||
device: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
toaster.success("Successfully updated device config"); |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<SelectField |
|||
label="Role" |
|||
description="This is a description." |
|||
isInvalid={!!errors.role?.message} |
|||
validationMessage={errors.role?.message} |
|||
{...register("role", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions(Protobuf.Config_DeviceConfig_Role)} |
|||
</SelectField> |
|||
<FormField |
|||
label="Serial Console Disabled" |
|||
description="Description" |
|||
isInvalid={!!errors.serialDisabled?.message} |
|||
validationMessage={errors.serialDisabled?.message} |
|||
> |
|||
<Controller |
|||
name="serialDisabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Factory Reset Device" |
|||
description="Description" |
|||
isInvalid={!!errors.factoryReset?.message} |
|||
validationMessage={errors.factoryReset?.message} |
|||
> |
|||
<Controller |
|||
name="factoryReset" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Enabled Debug Log" |
|||
description="Description" |
|||
isInvalid={!!errors.debugLogEnabled?.message} |
|||
validationMessage={errors.debugLogEnabled?.message} |
|||
> |
|||
<Controller |
|||
name="debugLogEnabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
label="NTP Server" |
|||
description="This is a description." |
|||
isInvalid={!!errors.ntpServer?.message} |
|||
validationMessage={errors.ntpServer?.message} |
|||
{...register("ntpServer")} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,72 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { SelectField, TextInputField } from "evergreen-ui"; |
|||
import { useForm } from "react-hook-form"; |
|||
|
|||
import { DisplayValidation } from "@app/validation/config/display.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { renderOptions } from "@core/utils/selectEnumOptions.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const Display = (): JSX.Element => { |
|||
const { config, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
} = useForm<Protobuf.Config_DisplayConfig>({ |
|||
defaultValues: config.display, |
|||
resolver: classValidatorResolver(DisplayValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(config.display); |
|||
}, [reset, config.display]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "display", |
|||
display: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<TextInputField |
|||
label="Screen Timeout" |
|||
description="This is a description." |
|||
hint="Seconds" |
|||
type="number" |
|||
{...register("screenOnSecs", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Carousel Delay" |
|||
description="This is a description." |
|||
hint="Seconds" |
|||
type="number" |
|||
{...register("autoScreenCarouselSecs", { valueAsNumber: true })} |
|||
/> |
|||
<SelectField |
|||
label="GPS Display Units" |
|||
description="This is a description." |
|||
{...register("gpsFormat", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions(Protobuf.Config_DisplayConfig_GpsCoordinateFormat)} |
|||
</SelectField> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,163 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm } from "react-hook-form"; |
|||
|
|||
import { LoRaValidation } from "@app/validation/config/lora.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { renderOptions } from "@core/utils/selectEnumOptions.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const LoRa = (): JSX.Element => { |
|||
const { config, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const [usePreset, setUsePreset] = useState(true); |
|||
|
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
control, |
|||
reset, |
|||
} = useForm<LoRaValidation>({ |
|||
defaultValues: config.lora, |
|||
resolver: classValidatorResolver(LoRaValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(config.lora); |
|||
}, [reset, config.lora]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "lora", |
|||
lora: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<FormField |
|||
label="Use Preset" |
|||
description="Description" |
|||
isInvalid={!!errors.txDisabled?.message} |
|||
validationMessage={errors.txDisabled?.message} |
|||
> |
|||
<Switch |
|||
height={24} |
|||
marginLeft="auto" |
|||
checked={usePreset} |
|||
onChange={(e) => setUsePreset(e.target.checked)} |
|||
/> |
|||
</FormField> |
|||
<SelectField |
|||
display={usePreset ? "block" : "none"} |
|||
label="Preset" |
|||
description="This is a description." |
|||
isInvalid={!!errors.modemPreset?.message} |
|||
validationMessage={errors.modemPreset?.message} |
|||
{...register("modemPreset", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)} |
|||
</SelectField> |
|||
|
|||
<TextInputField |
|||
display={usePreset ? "none" : "block"} |
|||
label="Bandwidth" |
|||
description="Max transmit power in dBm" |
|||
type="number" |
|||
hint="MHz" |
|||
isInvalid={!!errors.bandwidth?.message} |
|||
validationMessage={errors.bandwidth?.message} |
|||
{...register("bandwidth", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
display={usePreset ? "none" : "block"} |
|||
label="Spread Factor" |
|||
description="Max transmit power in dBm" |
|||
type="number" |
|||
hint="CPS" |
|||
isInvalid={!!errors.spreadFactor?.message} |
|||
validationMessage={errors.spreadFactor?.message} |
|||
{...register("spreadFactor", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
display={usePreset ? "none" : "block"} |
|||
label="Coding Rate" |
|||
description="Max transmit power in dBm" |
|||
type="number" |
|||
isInvalid={!!errors.codingRate?.message} |
|||
validationMessage={errors.codingRate?.message} |
|||
{...register("codingRate", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
label="Transmit Power" |
|||
description="Max transmit power in dBm" |
|||
type="number" |
|||
isInvalid={!!errors.txPower?.message} |
|||
validationMessage={errors.txPower?.message} |
|||
{...register("txPower", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Hop Count" |
|||
description="This is a description." |
|||
hint="Hops" |
|||
type="number" |
|||
isInvalid={!!errors.hopLimit?.message} |
|||
validationMessage={errors.hopLimit?.message} |
|||
{...register("hopLimit", { valueAsNumber: true })} |
|||
/> |
|||
<FormField |
|||
label="Transmit Disabled" |
|||
description="Description" |
|||
isInvalid={!!errors.txDisabled?.message} |
|||
validationMessage={errors.txDisabled?.message} |
|||
> |
|||
<Controller |
|||
name="txDisabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
label="Frequency Offset" |
|||
description="This is a description." |
|||
hint="Hz" |
|||
type="number" |
|||
isInvalid={!!errors.frequencyOffset?.message} |
|||
validationMessage={errors.frequencyOffset?.message} |
|||
{...register("frequencyOffset", { valueAsNumber: true })} |
|||
/> |
|||
<SelectField |
|||
label="Region" |
|||
description="This is a description." |
|||
isInvalid={!!errors.region?.message} |
|||
validationMessage={errors.region?.message} |
|||
{...register("region", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)} |
|||
</SelectField> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,224 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { |
|||
Button, |
|||
FormField, |
|||
SelectMenu, |
|||
Switch, |
|||
TextInputField, |
|||
} from "evergreen-ui"; |
|||
import { Controller, useForm } from "react-hook-form"; |
|||
|
|||
import { PositionValidation } from "@app/validation/config/position.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { bitwiseDecode } from "@core/utils/bitwise"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const Position = (): JSX.Element => { |
|||
const { config, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<PositionValidation>({ |
|||
defaultValues: config.position, |
|||
resolver: classValidatorResolver(PositionValidation), |
|||
// defaultValues: {
|
|||
// ...preferences,
|
|||
// positionBroadcastSecs:
|
|||
// preferences.positionBroadcastSecs === 0
|
|||
// ? preferences.role === Protobuf.Role.Router
|
|||
// ? 43200
|
|||
// : 900
|
|||
// : preferences.positionBroadcastSecs,
|
|||
// },
|
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(config.position); |
|||
}, [reset, config.position]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "position", |
|||
position: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<TextInputField |
|||
hint="Seconds" |
|||
label="Broadcast Interval" |
|||
description="This is a description." |
|||
type="number" |
|||
isInvalid={!!errors.positionBroadcastSecs?.message} |
|||
validationMessage={errors.positionBroadcastSecs?.message} |
|||
{...register("positionBroadcastSecs", { valueAsNumber: true })} |
|||
/> |
|||
<FormField |
|||
label="Disable Smart Position" |
|||
description="Description" |
|||
isInvalid={!!errors.positionBroadcastSmartDisabled?.message} |
|||
validationMessage={errors.positionBroadcastSmartDisabled?.message} |
|||
> |
|||
<Controller |
|||
name="positionBroadcastSmartDisabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Use Fixed Position" |
|||
description="Description" |
|||
isInvalid={!!errors.fixedPosition?.message} |
|||
validationMessage={errors.fixedPosition?.message} |
|||
> |
|||
<Controller |
|||
name="fixedPosition" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Disable GPS" |
|||
description="Description" |
|||
isInvalid={!!errors.gpsDisabled?.message} |
|||
validationMessage={errors.gpsDisabled?.message} |
|||
> |
|||
<Controller |
|||
name="gpsDisabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
hint="Seconds" |
|||
label="GPS Update Interval" |
|||
description="This is a description." |
|||
type="number" |
|||
isInvalid={!!errors.gpsUpdateInterval?.message} |
|||
validationMessage={errors.gpsUpdateInterval?.message} |
|||
{...register("gpsUpdateInterval", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Last GPS Attempt" |
|||
description="This is a description." |
|||
type="number" |
|||
isInvalid={!!errors.gpsAttemptTime?.message} |
|||
validationMessage={errors.gpsAttemptTime?.message} |
|||
{...register("gpsAttemptTime", { valueAsNumber: true })} |
|||
/> |
|||
<Controller |
|||
name="positionFlags" |
|||
control={control} |
|||
render={({ field, fieldState }): JSX.Element => { |
|||
const { value, onChange, ...rest } = field; |
|||
const { error } = fieldState; |
|||
const options = Object.entries( |
|||
Protobuf.Config_PositionConfig_PositionFlags |
|||
) |
|||
.filter((value) => typeof value[1] !== "number") |
|||
.filter( |
|||
(value) => |
|||
parseInt(value[0]) !== |
|||
Protobuf.Config_PositionConfig_PositionFlags.POS_UNDEFINED |
|||
) |
|||
.map((value) => { |
|||
return { |
|||
value: parseInt(value[0]), |
|||
label: value[1].toString().replace("POS_", "").toLowerCase(), |
|||
}; |
|||
}); |
|||
|
|||
const selected = bitwiseDecode( |
|||
value, |
|||
Protobuf.Config_PositionConfig_PositionFlags |
|||
).map((flag) => |
|||
Protobuf.Config_PositionConfig_PositionFlags[flag] |
|||
.replace("POS_", "") |
|||
.toLowerCase() |
|||
); |
|||
// onChange={(e: { value: number; label: string }[]): void =>
|
|||
// onChange(bitwiseEncode(e.map((v) => v.value)))
|
|||
// }
|
|||
return ( |
|||
<FormField |
|||
label="Position Flags" |
|||
description="Description" |
|||
isInvalid={!!errors.positionFlags?.message} |
|||
validationMessage={errors.positionFlags?.message} |
|||
> |
|||
<SelectMenu |
|||
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> |
|||
); |
|||
}} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,137 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm } from "react-hook-form"; |
|||
|
|||
import { PowerValidation } from "@app/validation/config/power.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { renderOptions } from "@core/utils/selectEnumOptions.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const Power = (): JSX.Element => { |
|||
const { config, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<PowerValidation>({ |
|||
defaultValues: config.power, |
|||
resolver: classValidatorResolver(PowerValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(config.power); |
|||
}, [reset, config.power]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "power", |
|||
power: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<SelectField |
|||
label="Charge current" |
|||
description="This is a description." |
|||
isInvalid={!!errors.chargeCurrent?.message} |
|||
validationMessage={errors.chargeCurrent?.message} |
|||
{...register("chargeCurrent", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions(Protobuf.Config_PowerConfig_ChargeCurrent)} |
|||
</SelectField> |
|||
<TextInputField |
|||
label="Shutdown on battery delay" |
|||
description="This is a description." |
|||
hint="Seconds" |
|||
type="number" |
|||
isInvalid={!!errors.onBatteryShutdownAfterSecs?.message} |
|||
validationMessage={errors.onBatteryShutdownAfterSecs?.message} |
|||
{...register("onBatteryShutdownAfterSecs", { valueAsNumber: true })} |
|||
/> |
|||
<FormField |
|||
label="Power Saving" |
|||
description="Description" |
|||
isInvalid={!!errors.isPowerSaving?.message} |
|||
validationMessage={errors.isPowerSaving?.message} |
|||
> |
|||
<Controller |
|||
name="isPowerSaving" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
label="ADC Multiplier Override ratio" |
|||
description="This is a description." |
|||
type="number" |
|||
isInvalid={!!errors.adcMultiplierOverride?.message} |
|||
validationMessage={errors.adcMultiplierOverride?.message} |
|||
{...register("adcMultiplierOverride", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Minimum Wake Time" |
|||
description="This is a description." |
|||
hint="Seconds" |
|||
type="number" |
|||
isInvalid={!!errors.minWakeSecs?.message} |
|||
validationMessage={errors.minWakeSecs?.message} |
|||
{...register("minWakeSecs", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Mesh SDS Timeout" |
|||
description="This is a description." |
|||
hint="Seconds" |
|||
type="number" |
|||
isInvalid={!!errors.meshSdsTimeoutSecs?.message} |
|||
validationMessage={errors.meshSdsTimeoutSecs?.message} |
|||
{...register("meshSdsTimeoutSecs", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="SDS" |
|||
description="This is a description." |
|||
hint="Seconds" |
|||
type="number" |
|||
isInvalid={!!errors.sdsSecs?.message} |
|||
validationMessage={errors.sdsSecs?.message} |
|||
{...register("sdsSecs", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="LS" |
|||
description="This is a description." |
|||
hint="Seconds" |
|||
type="number" |
|||
isInvalid={!!errors.lsSecs?.message} |
|||
validationMessage={errors.lsSecs?.message} |
|||
{...register("lsSecs", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Wait Bluetooth" |
|||
description="This is a description." |
|||
hint="Seconds" |
|||
type="number" |
|||
isInvalid={!!errors.waitBluetoothSecs?.message} |
|||
validationMessage={errors.waitBluetoothSecs?.message} |
|||
{...register("waitBluetoothSecs", { valueAsNumber: true })} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,141 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm } from "react-hook-form"; |
|||
import { base16 } from "rfc4648"; |
|||
|
|||
import { UserValidation } from "@app/validation/config/user.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { renderOptions } from "@core/utils/selectEnumOptions.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const User = (): JSX.Element => { |
|||
const { hardware, nodes, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
|
|||
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum); |
|||
|
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<UserValidation>({ |
|||
defaultValues: myNode?.data.user, |
|||
resolver: classValidatorResolver(UserValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset({ |
|||
longName: myNode?.data.user?.longName, |
|||
shortName: myNode?.data.user?.shortName, |
|||
isLicensed: myNode?.data.user?.isLicensed, |
|||
}); |
|||
}, [reset, myNode]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
|
|||
if (myNode?.data.user) { |
|||
void connection?.setOwner({ ...myNode.data.user, ...data }, async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
{JSON.stringify(errors.antAzimuth?.message)} |
|||
{JSON.stringify(errors.antGainDbi?.message)} |
|||
{JSON.stringify(errors.hwModel?.message)} |
|||
{JSON.stringify(errors.id?.message)} |
|||
{JSON.stringify(errors.isLicensed?.message)} |
|||
{JSON.stringify(errors.longName?.message)} |
|||
{JSON.stringify(errors.shortName?.message)} |
|||
{JSON.stringify(errors.txPowerDbm?.message)} |
|||
<TextInputField |
|||
label="Device ID" |
|||
description="Preset unique identifier for this device." |
|||
isInvalid={!!errors.id?.message} |
|||
validationMessage={errors.id?.message} |
|||
{...register("id")} |
|||
readOnly |
|||
/> |
|||
<TextInputField |
|||
label="Device Name" |
|||
description="Personalised name for this device." |
|||
{...register("longName")} |
|||
/> |
|||
<TextInputField |
|||
label="Short Name" |
|||
description="This is a description." |
|||
maxLength={3} |
|||
{...register("shortName")} |
|||
/> |
|||
<TextInputField |
|||
label="Mac Address" |
|||
description="This is a description." |
|||
disabled |
|||
value={ |
|||
base16 |
|||
.stringify(myNode?.data.user?.macaddr ?? []) |
|||
.match(/.{1,2}/g) |
|||
?.join(":") ?? "" |
|||
} |
|||
readOnly |
|||
/> |
|||
<SelectField |
|||
label="Hardware" |
|||
description="This is a description." |
|||
disabled |
|||
isInvalid={!!errors.hwModel?.message} |
|||
validationMessage={errors.hwModel?.message} |
|||
{...register("hwModel", { valueAsNumber: true })} |
|||
// readOnly
|
|||
> |
|||
{renderOptions(Protobuf.HardwareModel)} |
|||
</SelectField> |
|||
<FormField |
|||
label="Licenced Operator?" |
|||
description="Description" |
|||
isInvalid={!!errors.isLicensed?.message} |
|||
validationMessage={errors.isLicensed?.message} |
|||
> |
|||
<Controller |
|||
name="isLicensed" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
label="Transmit Power" |
|||
description="This is a description." |
|||
hint="dBm" |
|||
type="number" |
|||
{...register("txPowerDbm", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Antenna Gain" |
|||
description="This is a description." |
|||
hint="dBi" |
|||
type="number" |
|||
{...register("antGainDbi", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Antenna Azimuth" |
|||
description="This is a description." |
|||
hint="°" |
|||
type="number" |
|||
{...register("antAzimuth", { valueAsNumber: true })} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,94 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, Switch, TextInputField, toaster } from "evergreen-ui"; |
|||
import { Controller, useForm } from "react-hook-form"; |
|||
|
|||
import { WiFiValidation } from "@app/validation/config/wifi.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
|
|||
export const WiFi = (): JSX.Element => { |
|||
const { config, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
control, |
|||
reset, |
|||
} = useForm<WiFiValidation>({ |
|||
defaultValues: config.wifi, |
|||
resolver: classValidatorResolver(WiFiValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(config.wifi); |
|||
}, [reset, config.wifi]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "wifi", |
|||
wifi: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
toaster.success("Your source is now sending data"); |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<TextInputField |
|||
label="SSID" |
|||
description="This is a description." |
|||
isInvalid={!!errors.ssid?.message} |
|||
validationMessage={errors.ssid?.message} |
|||
{...register("ssid", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="PSK" |
|||
description="This is a description." |
|||
type="password" |
|||
isInvalid={!!errors.psk?.message} |
|||
validationMessage={errors.psk?.message} |
|||
{...register("psk", { valueAsNumber: true })} |
|||
/> |
|||
<FormField |
|||
label="Enable WiFi AP" |
|||
description="Description" |
|||
isInvalid={!!errors.apMode?.message} |
|||
validationMessage={errors.apMode?.message} |
|||
> |
|||
<Controller |
|||
name="apMode" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Don't broadcast SSID" |
|||
description="Description" |
|||
isInvalid={!!errors.apHidden?.message} |
|||
validationMessage={errors.apHidden?.message} |
|||
> |
|||
<Controller |
|||
name="apHidden" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,96 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, Switch, TextInputField, toaster } from "evergreen-ui"; |
|||
import { Controller, useForm } from "react-hook-form"; |
|||
|
|||
import { WiFiValidation } from "@app/validation/config/wifi.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
|
|||
export const WiFi = (): JSX.Element => { |
|||
const { config, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
control, |
|||
reset, |
|||
} = useForm<WiFiValidation>({ |
|||
defaultValues: config.wifi, |
|||
resolver: classValidatorResolver(WiFiValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(config.wifi); |
|||
}, [reset, config.wifi]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "wifi", |
|||
wifi: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
toaster.success("Successfully updated WiFi config"); |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<TextInputField |
|||
label="SSID" |
|||
description="This is a description." |
|||
isInvalid={!!errors.ssid?.message} |
|||
validationMessage={errors.ssid?.message} |
|||
{...register("ssid")} |
|||
/> |
|||
|
|||
<TextInputField |
|||
label="PSK" |
|||
type="password" |
|||
description="This is a description." |
|||
isInvalid={!!errors.psk?.message} |
|||
validationMessage={errors.psk?.message} |
|||
{...register("psk")} |
|||
/> |
|||
<FormField |
|||
label="Enable WiFi AP" |
|||
description="Description" |
|||
isInvalid={!!errors.apMode?.message} |
|||
validationMessage={errors.apMode?.message} |
|||
> |
|||
<Controller |
|||
name="apMode" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
|
|||
<FormField |
|||
label="Don't broadcast SSID" |
|||
description="Description" |
|||
isInvalid={!!errors.apHidden?.message} |
|||
validationMessage={errors.apHidden?.message} |
|||
> |
|||
<Controller |
|||
name="apHidden" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,171 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm, useWatch } from "react-hook-form"; |
|||
|
|||
import { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { renderOptions } from "@core/utils/selectEnumOptions.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const CannedMessage = (): JSX.Element => { |
|||
const { moduleConfig, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<CannedMessageValidation>({ |
|||
defaultValues: moduleConfig.cannedMessage, |
|||
resolver: classValidatorResolver(CannedMessageValidation), |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: "rotary1Enabled", |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(moduleConfig.cannedMessage); |
|||
}, [reset, moduleConfig.cannedMessage]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "cannedMessage", |
|||
cannedMessage: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<FormField |
|||
label="Module Enabled" |
|||
description="This is a description." |
|||
isInvalid={!!errors.enabled?.message} |
|||
validationMessage={errors.enabled?.message} |
|||
> |
|||
<Controller |
|||
name="enabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Rotary Encoder #1 Enabled" |
|||
description="This is a description." |
|||
isInvalid={!!errors.rotary1Enabled?.message} |
|||
validationMessage={errors.rotary1Enabled?.message} |
|||
> |
|||
<Controller |
|||
name="rotary1Enabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
label="Encoder Pin A" |
|||
description="Max transmit power in dBm" |
|||
type="number" |
|||
disabled={moduleEnabled} |
|||
{...register("inputbrokerPinA", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Encoder Pin B" |
|||
description="Max transmit power in dBm" |
|||
type="number" |
|||
disabled={moduleEnabled} |
|||
{...register("inputbrokerPinB", { valueAsNumber: true })} |
|||
/> |
|||
<TextInputField |
|||
label="Endoer Pin Press" |
|||
description="Max transmit power in dBm" |
|||
type="number" |
|||
disabled={moduleEnabled} |
|||
{...register("inputbrokerPinPress", { valueAsNumber: true })} |
|||
/> |
|||
<SelectField |
|||
label="Clockwise event" |
|||
description="This is a description." |
|||
disabled={moduleEnabled} |
|||
{...register("inputbrokerEventCw", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions( |
|||
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar |
|||
)} |
|||
</SelectField> |
|||
<SelectField |
|||
label="Counter Clockwise event" |
|||
description="This is a description." |
|||
disabled={moduleEnabled} |
|||
{...register("inputbrokerEventCcw", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions( |
|||
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar |
|||
)} |
|||
</SelectField> |
|||
<SelectField |
|||
label="Press event" |
|||
description="This is a description." |
|||
disabled={moduleEnabled} |
|||
{...register("inputbrokerEventPress", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions( |
|||
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar |
|||
)} |
|||
</SelectField> |
|||
<FormField |
|||
label="Up Down enabled" |
|||
description="This is a description." |
|||
isInvalid={!!errors.updown1Enabled?.message} |
|||
validationMessage={errors.updown1Enabled?.message} |
|||
> |
|||
<Controller |
|||
name="updown1Enabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
label="Allow Input Source" |
|||
description="Max transmit power in dBm" |
|||
disabled={moduleEnabled} |
|||
{...register("allowInputSource")} |
|||
/> |
|||
<FormField |
|||
label="Send Bell" |
|||
description="This is a description." |
|||
isInvalid={!!errors.sendBell?.message} |
|||
validationMessage={errors.sendBell?.message} |
|||
> |
|||
<Controller |
|||
name="sendBell" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,134 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm, useWatch } from "react-hook-form"; |
|||
|
|||
import { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
|
|||
export const ExternalNotification = (): JSX.Element => { |
|||
const { moduleConfig, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<ExternalNotificationValidation>({ |
|||
defaultValues: moduleConfig.externalNotification, |
|||
resolver: classValidatorResolver(ExternalNotificationValidation), |
|||
}); |
|||
useEffect(() => { |
|||
reset(moduleConfig.externalNotification); |
|||
}, [reset, moduleConfig.externalNotification]); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
setLoading(true); |
|||
await connection?.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "externalNotification", |
|||
externalNotification: data, |
|||
}, |
|||
}, |
|||
async (): Promise<void> => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: "enabled", |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<FormField |
|||
label="Module Enabled" |
|||
description="Description" |
|||
isInvalid={!!errors.enabled?.message} |
|||
validationMessage={errors.enabled?.message} |
|||
> |
|||
<Controller |
|||
name="enabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
type="number" |
|||
label="Output MS" |
|||
description="Max transmit power in dBm" |
|||
hint="ms" |
|||
disabled={!moduleEnabled} |
|||
{...register("outputMs", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
type="number" |
|||
label="Output" |
|||
description="Max transmit power in dBm" |
|||
disabled={!moduleEnabled} |
|||
{...register("output", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<FormField |
|||
label="Active" |
|||
description="Description" |
|||
disabled={!moduleEnabled} |
|||
isInvalid={!!errors.active?.message} |
|||
validationMessage={errors.active?.message} |
|||
> |
|||
<Controller |
|||
name="active" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Message" |
|||
description="Description" |
|||
disabled={!moduleEnabled} |
|||
isInvalid={!!errors.alertMessage?.message} |
|||
validationMessage={errors.alertMessage?.message} |
|||
> |
|||
<Controller |
|||
name="alertMessage" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Bell" |
|||
description="Description" |
|||
disabled={!moduleEnabled} |
|||
isInvalid={!!errors.alertBell?.message} |
|||
validationMessage={errors.alertBell?.message} |
|||
> |
|||
<Controller |
|||
name="alertBell" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,105 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm, useWatch } from "react-hook-form"; |
|||
|
|||
import { MQTTValidation } from "@app/validation/moduleConfig/mqtt.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
|
|||
export const MQTT = (): JSX.Element => { |
|||
const { moduleConfig, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<MQTTValidation>({ |
|||
defaultValues: moduleConfig.mqtt, |
|||
resolver: classValidatorResolver(MQTTValidation), |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: "disabled", |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(moduleConfig.mqtt); |
|||
}, [reset, moduleConfig.mqtt]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "mqtt", |
|||
mqtt: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<FormField |
|||
label="Module Disabled" |
|||
description="Description" |
|||
isInvalid={!!errors.disabled?.message} |
|||
validationMessage={errors.disabled?.message} |
|||
> |
|||
<Controller |
|||
name="disabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
label="MQTT Server Address" |
|||
description="Max transmit power in dBm" |
|||
disabled={moduleEnabled} |
|||
{...register("address")} |
|||
/> |
|||
<TextInputField |
|||
label="MQTT Username" |
|||
description="Max transmit power in dBm" |
|||
disabled={moduleEnabled} |
|||
{...register("username")} |
|||
/> |
|||
<TextInputField |
|||
label="MQTT Password" |
|||
description="Max transmit power in dBm" |
|||
type="password" |
|||
autoComplete="off" |
|||
disabled={moduleEnabled} |
|||
{...register("password")} |
|||
/> |
|||
<FormField |
|||
label="Encryption Enabled" |
|||
description="Description" |
|||
disabled={moduleEnabled} |
|||
isInvalid={!!errors.encryptionEnabled?.message} |
|||
validationMessage={errors.encryptionEnabled?.message} |
|||
> |
|||
<Controller |
|||
name="encryptionEnabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,96 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm, useWatch } from "react-hook-form"; |
|||
|
|||
import { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
|
|||
export const RangeTest = (): JSX.Element => { |
|||
const { moduleConfig, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<RangeTestValidation>({ |
|||
defaultValues: moduleConfig.rangeTest, |
|||
resolver: classValidatorResolver(RangeTestValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(moduleConfig.rangeTest); |
|||
}, [reset, moduleConfig.rangeTest]); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
setLoading(true); |
|||
await connection?.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "rangeTest", |
|||
rangeTest: data, |
|||
}, |
|||
}, |
|||
async (): Promise<void> => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: "enabled", |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<FormField |
|||
label="Module Enabled" |
|||
description="Description" |
|||
isInvalid={!!errors.enabled?.message} |
|||
validationMessage={errors.enabled?.message} |
|||
> |
|||
<Controller |
|||
name="enabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
type="number" |
|||
label="Message Interval" |
|||
description="Max transmit power in dBm" |
|||
disabled={!moduleEnabled} |
|||
hint="Seconds" |
|||
{...register("sender", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<FormField |
|||
label="Save CSV to storage" |
|||
description="Description" |
|||
disabled={!moduleEnabled} |
|||
isInvalid={!!errors.save?.message} |
|||
validationMessage={errors.save?.message} |
|||
> |
|||
<Controller |
|||
name="save" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,131 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm, useWatch } from "react-hook-form"; |
|||
|
|||
import { SerialValidation } from "@app/validation/moduleConfig/serial.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
|
|||
export const Serial = (): JSX.Element => { |
|||
const { moduleConfig, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<SerialValidation>({ |
|||
defaultValues: moduleConfig.serial, |
|||
resolver: classValidatorResolver(SerialValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(moduleConfig.serial); |
|||
}, [reset, moduleConfig.serial]); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
setLoading(true); |
|||
await connection?.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "serial", |
|||
serial: data, |
|||
}, |
|||
}, |
|||
async (): Promise<void> => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: "enabled", |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<FormField |
|||
label="Module Enabled" |
|||
description="Description" |
|||
isInvalid={!!errors.enabled?.message} |
|||
validationMessage={errors.enabled?.message} |
|||
> |
|||
<Controller |
|||
name="enabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Echo" |
|||
description="Description" |
|||
disabled={!moduleEnabled} |
|||
isInvalid={!!errors.echo?.message} |
|||
validationMessage={errors.echo?.message} |
|||
> |
|||
<Controller |
|||
name="echo" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
type="number" |
|||
label="RX" |
|||
description="Max transmit power in dBm" |
|||
disabled={!moduleEnabled} |
|||
{...register("rxd", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
type="number" |
|||
label="TX Pin" |
|||
description="Max transmit power in dBm" |
|||
disabled={!moduleEnabled} |
|||
{...register("txd", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
type="number" |
|||
label="Baud Rate" |
|||
description="Max transmit power in dBm" |
|||
disabled={!moduleEnabled} |
|||
{...register("baud", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
type="number" |
|||
label="Timeout" |
|||
description="Max transmit power in dBm" |
|||
disabled={!moduleEnabled} |
|||
{...register("timeout", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
type="number" |
|||
label="Mode" |
|||
description="Max transmit power in dBm" |
|||
disabled={!moduleEnabled} |
|||
{...register("mode", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,114 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm, useWatch } from "react-hook-form"; |
|||
|
|||
import { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
|
|||
export const StoreForward = (): JSX.Element => { |
|||
const { moduleConfig, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<StoreForwardValidation>({ |
|||
defaultValues: moduleConfig.storeForward, |
|||
resolver: classValidatorResolver(StoreForwardValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(moduleConfig.storeForward); |
|||
}, [reset, moduleConfig.storeForward]); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
setLoading(true); |
|||
await connection?.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "storeForward", |
|||
storeForward: data, |
|||
}, |
|||
}, |
|||
async (): Promise<void> => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: "enabled", |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<FormField |
|||
label="Module Enabled" |
|||
description="Description" |
|||
isInvalid={!!errors.enabled?.message} |
|||
validationMessage={errors.enabled?.message} |
|||
> |
|||
<Controller |
|||
name="enabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Heartbeat Enabled" |
|||
description="Description" |
|||
disabled={!moduleEnabled} |
|||
isInvalid={!!errors.heartbeat?.message} |
|||
validationMessage={errors.heartbeat?.message} |
|||
> |
|||
<Controller |
|||
name="heartbeat" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
type="number" |
|||
label="Number of records" |
|||
description="Max transmit power in dBm" |
|||
hint="Records" |
|||
disabled={!moduleEnabled} |
|||
{...register("records", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
type="number" |
|||
label="History return max" |
|||
description="Max transmit power in dBm" |
|||
disabled={!moduleEnabled} |
|||
{...register("historyReturnMax", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
type="number" |
|||
label="History return window" |
|||
description="Max transmit power in dBm" |
|||
disabled={!moduleEnabled} |
|||
{...register("historyReturnWindow", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,135 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm } from "react-hook-form"; |
|||
|
|||
import { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { renderOptions } from "@core/utils/selectEnumOptions.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const Telemetry = (): JSX.Element => { |
|||
const { moduleConfig, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
reset, |
|||
control, |
|||
} = useForm<TelemetryValidation>({ |
|||
defaultValues: moduleConfig.telemetry, |
|||
resolver: classValidatorResolver(TelemetryValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(moduleConfig.telemetry); |
|||
}, [reset, moduleConfig.telemetry]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "telemetry", |
|||
telemetry: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<FormField |
|||
label="Measurement Enabled" |
|||
description="Description" |
|||
isInvalid={!!errors.environmentMeasurementEnabled?.message} |
|||
validationMessage={errors.environmentMeasurementEnabled?.message} |
|||
> |
|||
<Controller |
|||
name="environmentMeasurementEnabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<FormField |
|||
label="Displayed on Screen" |
|||
description="Description" |
|||
isInvalid={!!errors.environmentScreenEnabled?.message} |
|||
validationMessage={errors.environmentScreenEnabled?.message} |
|||
> |
|||
<Controller |
|||
name="environmentScreenEnabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<TextInputField |
|||
label="Read Error Count Threshold" |
|||
description="Max transmit power in dBm" |
|||
type="number" |
|||
{...register("environmentReadErrorCountThreshold", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
label="Update Interval" |
|||
description="Max transmit power in dBm" |
|||
hint="Seconds" |
|||
type="number" |
|||
{...register("environmentUpdateInterval", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<TextInputField |
|||
label="Recovery Interval" |
|||
description="Max transmit power in dBm" |
|||
hint="Seconds" |
|||
type="number" |
|||
{...register("environmentRecoveryInterval", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<FormField |
|||
label="Display Farenheit" |
|||
description="Description" |
|||
isInvalid={!!errors.environmentDisplayFahrenheit?.message} |
|||
validationMessage={errors.environmentDisplayFahrenheit?.message} |
|||
> |
|||
<Controller |
|||
name="environmentDisplayFahrenheit" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
<SelectField |
|||
label="Sensor Type" |
|||
description="This is a description." |
|||
{...register("environmentSensorType", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions(Protobuf.TelemetrySensorType)} |
|||
</SelectField> |
|||
<TextInputField |
|||
label="Sensor Pin" |
|||
description="Max transmit power in dBm" |
|||
type="number" |
|||
{...register("environmentSensorPin", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,88 @@ |
|||
import React, { useEffect } from "react"; |
|||
|
|||
import { majorScale, Pane, Spinner, StatusIndicator } from "evergreen-ui"; |
|||
|
|||
import { useDevice } from "@app/core/stores/deviceStore.js"; |
|||
|
|||
export const Progress = (): JSX.Element => { |
|||
const { hardware, channels, config, moduleConfig, setReady, nodes } = |
|||
useDevice(); |
|||
|
|||
useEffect(() => { |
|||
if ( |
|||
hardware.myNodeNum !== 0 && |
|||
Object.keys(config).length === 7 && |
|||
Object.keys(moduleConfig).length === 7 && |
|||
channels.length === hardware.maxChannels |
|||
) { |
|||
setReady(true); |
|||
} |
|||
}, [ |
|||
config, |
|||
moduleConfig, |
|||
channels, |
|||
hardware.maxChannels, |
|||
hardware.myNodeNum, |
|||
setReady, |
|||
]); |
|||
|
|||
return ( |
|||
<Pane |
|||
display="flex" |
|||
flexGrow={1} |
|||
margin={majorScale(3)} |
|||
borderRadius={majorScale(1)} |
|||
elevation={1} |
|||
background="white" |
|||
> |
|||
<Pane display="flex" margin="auto" gap={majorScale(6)}> |
|||
<Pane |
|||
marginY="auto" |
|||
display="flex" |
|||
height="72px" |
|||
width="72px" |
|||
minWidth="72px" |
|||
backgroundColor="#F8E3DA" |
|||
borderRadius="50%" |
|||
> |
|||
<Spinner height="32px" width="32px" margin="auto" /> |
|||
</Pane> |
|||
<Pane> |
|||
<Pane display="flex" flexDirection="column"> |
|||
<StatusIndicator |
|||
color={hardware.myNodeNum !== 0 ? "success" : "disabled"} |
|||
> |
|||
Device Info |
|||
</StatusIndicator> |
|||
<StatusIndicator |
|||
color={Object.keys(config).length === 7 ? "success" : "disabled"} |
|||
> |
|||
Device Config {`(${Object.keys(config).length - 1} / 6)`} |
|||
</StatusIndicator> |
|||
<StatusIndicator |
|||
color={ |
|||
Object.keys(moduleConfig).length === 7 ? "success" : "disabled" |
|||
} |
|||
> |
|||
Module Config {`(${Object.keys(moduleConfig).length - 1} / 6)`} |
|||
</StatusIndicator> |
|||
<StatusIndicator color={nodes.length ? "success" : "disabled"}> |
|||
Peers ({nodes.length}) |
|||
</StatusIndicator> |
|||
<StatusIndicator |
|||
color={ |
|||
channels.length > 0 && channels.length === hardware.maxChannels |
|||
? "success" |
|||
: "disabled" |
|||
} |
|||
> |
|||
Channels{" "} |
|||
{hardware.myNodeNum !== 0 && |
|||
`(${channels.length} / ${hardware.maxChannels})`} |
|||
</StatusIndicator> |
|||
</Pane> |
|||
</Pane> |
|||
</Pane> |
|||
</Pane> |
|||
); |
|||
}; |
|||
@ -0,0 +1,113 @@ |
|||
import type React from "react"; |
|||
import { useState } from "react"; |
|||
|
|||
import { |
|||
Heading, |
|||
majorScale, |
|||
Pane, |
|||
Paragraph, |
|||
SideSheet, |
|||
Tab, |
|||
Tablist, |
|||
} from "evergreen-ui"; |
|||
import type { IconType } from "react-icons"; |
|||
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi"; |
|||
|
|||
import { BLE } from "../connect/BLE.js"; |
|||
import { HTTP } from "../connect/HTTP.js"; |
|||
import { Serial } from "../connect/Serial.js"; |
|||
|
|||
export interface NewDeviceProps { |
|||
open: boolean; |
|||
onClose: () => void; |
|||
} |
|||
|
|||
export interface CloseProps { |
|||
close: () => void; |
|||
} |
|||
|
|||
export type connType = "http" | "ble" | "serial"; |
|||
|
|||
export interface Tab { |
|||
name: connType; |
|||
icon: IconType; |
|||
displayName: string; |
|||
element: ({ close }: CloseProps) => JSX.Element; |
|||
} |
|||
|
|||
export const NewDevice = ({ open, onClose }: NewDeviceProps) => { |
|||
const [selectedConnType, setSelectedConnType] = useState<connType>("ble"); |
|||
|
|||
const tabs: Tab[] = [ |
|||
{ |
|||
name: "ble", |
|||
icon: FiBluetooth, |
|||
displayName: "BLE", |
|||
element: BLE, |
|||
}, |
|||
{ |
|||
name: "http", |
|||
icon: FiWifi, |
|||
displayName: "HTTP", |
|||
element: HTTP, |
|||
}, |
|||
{ |
|||
name: "serial", |
|||
icon: FiTerminal, |
|||
displayName: "Serial", |
|||
element: Serial, |
|||
}, |
|||
]; |
|||
|
|||
return ( |
|||
<SideSheet |
|||
isShown={open} |
|||
onCloseComplete={onClose} |
|||
containerProps={{ |
|||
display: "flex", |
|||
flex: "1", |
|||
flexDirection: "column", |
|||
}} |
|||
> |
|||
<Pane zIndex={1} flexShrink={0} elevation={1} backgroundColor="white"> |
|||
<Pane padding={16} borderBottom="muted"> |
|||
<Heading size={600}>Connect new device</Heading> |
|||
<Paragraph size={400} color="muted"> |
|||
Optional description or sub title |
|||
</Paragraph> |
|||
</Pane> |
|||
<Pane display="flex" padding={8}> |
|||
<Tablist> |
|||
{tabs.map((TabData, index) => ( |
|||
<Tab |
|||
key={index} |
|||
gap={5} |
|||
isSelected={selectedConnType === TabData.name} |
|||
onSelect={() => setSelectedConnType(TabData.name)} |
|||
> |
|||
<> |
|||
<TabData.icon /> |
|||
{TabData.displayName} |
|||
</> |
|||
</Tab> |
|||
))} |
|||
</Tablist> |
|||
</Pane> |
|||
</Pane> |
|||
<Pane display="flex" overflowY="scroll" background="tint1" padding={16}> |
|||
{tabs.map((TabData, index) => ( |
|||
<Pane |
|||
key={index} |
|||
borderRadius={majorScale(1)} |
|||
backgroundColor="white" |
|||
elevation={1} |
|||
flexGrow={1} |
|||
display={selectedConnType === TabData.name ? "block" : "none"} |
|||
> |
|||
<TabData.element close={onClose} /> |
|||
</Pane> |
|||
))} |
|||
</Pane> |
|||
</SideSheet> |
|||
); |
|||
}; |
|||
@ -1,60 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { m } from 'framer-motion'; |
|||
import type { Link } from 'type-route'; |
|||
|
|||
export interface TabProps { |
|||
link: Link; |
|||
icon: React.ReactNode; |
|||
title: string; |
|||
active: boolean; |
|||
activeRight: boolean; |
|||
activeLeft: boolean; |
|||
} |
|||
|
|||
export const Tab = ({ |
|||
link, |
|||
icon, |
|||
title, |
|||
active, |
|||
activeRight, |
|||
activeLeft, |
|||
}: TabProps): JSX.Element => { |
|||
return ( |
|||
<div |
|||
className={`max-w-[10rem] md:flex-grow ${ |
|||
active |
|||
? 'bg-white dark:bg-primaryDark' |
|||
: 'bg-gray-300 dark:bg-secondaryDark' |
|||
}`}
|
|||
> |
|||
<div |
|||
className={`group flex flex-grow cursor-pointer select-none py-2 hover:underline dark:text-white ${ |
|||
active |
|||
? 'z-10 rounded-t-lg bg-gray-300 shadow-inner dark:bg-secondaryDark' |
|||
: 'bg-white drop-shadow-md dark:bg-primaryDark' |
|||
} ${activeRight ? 'rounded-br-lg' : ''} ${ |
|||
activeLeft ? 'rounded-bl-lg' : '' |
|||
}`}
|
|||
{...(link && link)} |
|||
> |
|||
<div |
|||
className={`my-auto w-full px-3 ${ |
|||
active || activeLeft |
|||
? '' |
|||
: 'border-l border-gray-400 dark:border-gray-600' |
|||
}`}
|
|||
> |
|||
<m.div |
|||
className="flex gap-2" |
|||
whileHover={{ scale: 1.01 }} |
|||
whileTap={{ scale: 0.99 }} |
|||
> |
|||
<div className="my-auto">{icon}</div> |
|||
<div className="hidden md:flex">{title}</div> |
|||
</m.div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,35 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { Tab, TabProps } from '@components/Tab'; |
|||
|
|||
export interface TabsProps { |
|||
tabs: Omit<TabProps, 'activeLeft' | 'activeRight'>[]; |
|||
} |
|||
|
|||
export const Tabs = ({ tabs }: TabsProps): JSX.Element => { |
|||
return ( |
|||
<div className="flex flex-grow bg-gray-300 dark:bg-secondaryDark"> |
|||
<div |
|||
className={`h-full w-2 bg-white dark:bg-primaryDark ${ |
|||
tabs[0].active ? 'rounded-br-lg' : '' |
|||
}`}
|
|||
/> |
|||
{tabs.map((tab, index) => ( |
|||
<Tab |
|||
key={index} |
|||
link={tab.link} |
|||
title={tab.title} |
|||
icon={tab.icon} |
|||
active={tab.active} |
|||
activeLeft={tabs[index - 1]?.active} |
|||
activeRight={tabs[index + 1]?.active} |
|||
/> |
|||
))} |
|||
<div |
|||
className={`h-full flex-grow bg-white drop-shadow-md dark:bg-primaryDark ${ |
|||
tabs[tabs.length - 1].active ? 'rounded-bl-lg' : '' |
|||
}`}
|
|||
/> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,85 @@ |
|||
import type React from "react"; |
|||
import { useCallback, useEffect, useState } from "react"; |
|||
|
|||
import { Button, majorScale, Pane } from "evergreen-ui"; |
|||
import { FiPlusCircle } from "react-icons/fi"; |
|||
|
|||
import { useDeviceStore } from "@app/core/stores/deviceStore.js"; |
|||
import { subscribeAll } from "@app/core/subscriptions.js"; |
|||
import { randId } from "@app/core/utils/randId.js"; |
|||
import { Constants, IBLEConnection } from "@meshtastic/meshtasticjs"; |
|||
|
|||
import type { CloseProps } from "../SlideSheets/NewDevice.js"; |
|||
|
|||
export const BLE = ({ close }: CloseProps): JSX.Element => { |
|||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]); |
|||
const { addDevice } = useDeviceStore(); |
|||
|
|||
const updateBleDeviceList = useCallback(async (): Promise<void> => { |
|||
setBleDevices(await navigator.bluetooth.getDevices()); |
|||
}, []); |
|||
|
|||
navigator.bluetooth.addEventListener("advertisementreceived", (e) => { |
|||
console.log(e); |
|||
}); |
|||
|
|||
navigator.bluetooth.addEventListener("availabilitychanged", (e) => { |
|||
console.log(e); |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
void updateBleDeviceList(); |
|||
}, [updateBleDeviceList]); |
|||
|
|||
const onConnect = async (BLEDevice: BluetoothDevice) => { |
|||
const id = randId(); |
|||
const device = addDevice(id); |
|||
const connection = new IBLEConnection(id); |
|||
await connection.connect({ |
|||
device: BLEDevice, |
|||
}); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection); |
|||
close(); |
|||
}; |
|||
|
|||
return ( |
|||
<Pane |
|||
display="flex" |
|||
flexDirection="column" |
|||
padding={majorScale(2)} |
|||
gap={majorScale(2)} |
|||
> |
|||
{bleDevices.map((device, index) => ( |
|||
<Button |
|||
key={index} |
|||
onClick={() => { |
|||
void onConnect(device); |
|||
}} |
|||
> |
|||
{device.name} |
|||
</Button> |
|||
))} |
|||
|
|||
<Button |
|||
appearance="primary" |
|||
gap={majorScale(1)} |
|||
onClick={() => { |
|||
void navigator.bluetooth |
|||
.requestDevice({ |
|||
filters: [{ services: [Constants.SERVICE_UUID] }], |
|||
}) |
|||
.then((device) => { |
|||
const exists = bleDevices.findIndex((d) => d.id === device.id); |
|||
if (exists === -1) { |
|||
setBleDevices(bleDevices.concat(device)); |
|||
} |
|||
}); |
|||
}} |
|||
> |
|||
New device |
|||
<FiPlusCircle /> |
|||
</Button> |
|||
</Pane> |
|||
); |
|||
}; |
|||
@ -0,0 +1,60 @@ |
|||
import type React from "react"; |
|||
|
|||
import { Button, majorScale, Pane, TextInputField } from "evergreen-ui"; |
|||
import { useForm } from "react-hook-form"; |
|||
import { FiPlusCircle } from "react-icons/fi"; |
|||
|
|||
import { useDeviceStore } from "@app/core/stores/deviceStore.js"; |
|||
import { subscribeAll } from "@app/core/subscriptions.js"; |
|||
import { randId } from "@app/core/utils/randId.js"; |
|||
import { IHTTPConnection } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export interface HTTPProps { |
|||
close: () => void; |
|||
} |
|||
|
|||
export const HTTP = ({ close }: HTTPProps): JSX.Element => { |
|||
const { addDevice } = useDeviceStore(); |
|||
const { register, handleSubmit } = useForm<{ |
|||
ip: string; |
|||
tls: boolean; |
|||
}>({ |
|||
defaultValues: { |
|||
ip: "meshtastic.local", |
|||
tls: false, |
|||
}, |
|||
}); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
const id = randId(); |
|||
const device = addDevice(id); |
|||
const connection = new IHTTPConnection(id); |
|||
// TODO: Promise never resolves
|
|||
void connection.connect({ |
|||
address: data.ip, |
|||
fetchInterval: 2000, |
|||
tls: data.tls, |
|||
}); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection); |
|||
close(); |
|||
}); |
|||
|
|||
return ( |
|||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|||
<form onSubmit={onSubmit}> |
|||
<Pane |
|||
display="flex" |
|||
flexDirection="column" |
|||
padding={majorScale(2)} |
|||
gap={majorScale(2)} |
|||
> |
|||
<TextInputField label="IP Address/Hostname" {...register("ip")} /> |
|||
<Button appearance="primary" gap={majorScale(1)} type="submit"> |
|||
Connect |
|||
<FiPlusCircle /> |
|||
</Button> |
|||
</Pane> |
|||
</form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,102 @@ |
|||
import type React from "react"; |
|||
import { useCallback, useEffect, useState } from "react"; |
|||
|
|||
import { Button, majorScale, Pane } from "evergreen-ui"; |
|||
import { FiPlusCircle } from "react-icons/fi"; |
|||
|
|||
import { subscribeAll } from "@app/core/subscriptions.js"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.js"; |
|||
import { randId } from "@core/utils/randId.js"; |
|||
import { ISerialConnection } from "@meshtastic/meshtasticjs"; |
|||
|
|||
import type { CloseProps } from "../SlideSheets/NewDevice.js"; |
|||
|
|||
interface USBID { |
|||
id: number; |
|||
name: string; |
|||
} |
|||
|
|||
export const Serial = ({ close }: CloseProps): JSX.Element => { |
|||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]); |
|||
const { addDevice } = useDeviceStore(); |
|||
|
|||
const updateSerialPortList = useCallback(async () => { |
|||
setSerialPorts(await navigator.serial.getPorts()); |
|||
}, []); |
|||
|
|||
navigator.serial.addEventListener("connect", () => { |
|||
void updateSerialPortList(); |
|||
}); |
|||
navigator.serial.addEventListener("disconnect", () => { |
|||
void updateSerialPortList(); |
|||
}); |
|||
useEffect(() => { |
|||
void updateSerialPortList(); |
|||
}, [updateSerialPortList]); |
|||
|
|||
const onConnect = async (port: SerialPort) => { |
|||
const id = randId(); |
|||
const device = addDevice(id); |
|||
const connection = new ISerialConnection(id); |
|||
await connection.connect({ |
|||
port, |
|||
baudRate: 115200, |
|||
}); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection); |
|||
close(); |
|||
}; |
|||
|
|||
const VID: USBID[] = [ |
|||
{ |
|||
id: 9114, |
|||
name: "TBA", |
|||
}, |
|||
]; |
|||
|
|||
const PID: USBID[] = [ |
|||
{ |
|||
id: 32809, |
|||
name: "TBA", |
|||
}, |
|||
]; |
|||
|
|||
return ( |
|||
<Pane |
|||
display="flex" |
|||
flexDirection="column" |
|||
padding={majorScale(2)} |
|||
gap={majorScale(2)} |
|||
> |
|||
{serialPorts.map((port, index) => ( |
|||
<Button |
|||
key={index} |
|||
gap={5} |
|||
onClick={() => { |
|||
void onConnect(port); |
|||
}} |
|||
> |
|||
{VID.find((id) => id.id === port.getInfo().usbVendorId ?? 0)?.name ?? |
|||
"Unknown"}{" "} |
|||
-{" "} |
|||
{PID.find((id) => id.id === port.getInfo().usbProductId ?? 0)?.name ?? |
|||
"Unknown"} |
|||
<FiPlusCircle /> |
|||
</Button> |
|||
))} |
|||
|
|||
<Button |
|||
appearance="primary" |
|||
gap={majorScale(1)} |
|||
onClick={() => { |
|||
void navigator.serial.requestPort().then((port) => { |
|||
setSerialPorts(serialPorts.concat(port)); |
|||
}); |
|||
}} |
|||
> |
|||
New device |
|||
<FiPlusCircle /> |
|||
</Button> |
|||
</Pane> |
|||
); |
|||
}; |
|||
@ -1,79 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useCallback, useEffect, useState } from 'react'; |
|||
|
|||
import { useForm } from 'react-hook-form'; |
|||
import { FiArrowRightCircle } from 'react-icons/fi'; |
|||
|
|||
import { Button } from '@components/generic/button/Button'; |
|||
import { IconButton } from '@components/generic/button/IconButton'; |
|||
import { connection, setConnection } from '@core/connection'; |
|||
import { connType } from '@core/slices/appSlice'; |
|||
import { IBLEConnection } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export interface BLEProps { |
|||
connecting: boolean; |
|||
} |
|||
|
|||
export const BLE = ({ connecting }: BLEProps): JSX.Element => { |
|||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]); |
|||
|
|||
const { handleSubmit } = useForm<{ |
|||
device?: BluetoothDevice; |
|||
}>(); |
|||
|
|||
const updateBleDeviceList = useCallback(async (): Promise<void> => { |
|||
const ble = new IBLEConnection(); |
|||
const devices = await ble.getDevices(); |
|||
setBleDevices(devices); |
|||
}, []); |
|||
|
|||
useEffect(() => { |
|||
void updateBleDeviceList(); |
|||
}, [updateBleDeviceList]); |
|||
|
|||
const onSubmit = handleSubmit(async () => { |
|||
await setConnection(connType.BLE); |
|||
}); |
|||
|
|||
return ( |
|||
<form onSubmit={onSubmit} className="flex flex-grow flex-col"> |
|||
<div className="flex flex-grow flex-col gap-2 overflow-y-auto rounded-md border border-gray-400 bg-gray-200 p-2 dark:border-gray-600 dark:bg-tertiaryDark dark:text-gray-400"> |
|||
{bleDevices.length > 0 ? ( |
|||
bleDevices.map((device, index) => ( |
|||
<div |
|||
className="flex justify-between rounded-md bg-white p-2 dark:bg-primaryDark dark:text-white" |
|||
key={index} |
|||
> |
|||
<div className="my-auto">{device.name}</div> |
|||
<IconButton |
|||
nested |
|||
onClick={async (): Promise<void> => { |
|||
await setConnection(connType.BLE); |
|||
}} |
|||
icon={<FiArrowRightCircle />} |
|||
disabled={connecting} |
|||
/> |
|||
</div> |
|||
)) |
|||
) : ( |
|||
<div className="m-auto"> |
|||
<p>No previously connected devices found</p> |
|||
</div> |
|||
)} |
|||
</div> |
|||
<Button |
|||
className="mt-2 ml-auto" |
|||
onClick={async (): Promise<void> => { |
|||
if (connecting) { |
|||
await connection.disconnect(); |
|||
} else { |
|||
await onSubmit(); |
|||
} |
|||
}} |
|||
border |
|||
> |
|||
{connecting ? 'Disconnect' : 'Connect'} |
|||
</Button> |
|||
</form> |
|||
); |
|||
}; |
|||
@ -1,93 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { useForm, useWatch } from 'react-hook-form'; |
|||
|
|||
import { Button } from '@components/generic/button/Button'; |
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { connection, connectionUrl, setConnection } from '@core/connection'; |
|||
import { connType, setConnectionParams } from '@core/slices/appSlice'; |
|||
import { useAppDispatch } from '@hooks/useAppDispatch'; |
|||
|
|||
export interface HTTPProps { |
|||
connecting: boolean; |
|||
} |
|||
|
|||
export const HTTP = ({ connecting }: HTTPProps): JSX.Element => { |
|||
const dispatch = useAppDispatch(); |
|||
|
|||
const { register, handleSubmit, control } = useForm<{ |
|||
ipSource: 'local' | 'remote'; |
|||
ip?: string; |
|||
tls: boolean; |
|||
}>({ |
|||
defaultValues: { |
|||
ipSource: 'local', |
|||
ip: connectionUrl, |
|||
tls: false, |
|||
}, |
|||
}); |
|||
|
|||
const watchIpSource = useWatch({ |
|||
control, |
|||
name: 'ipSource', |
|||
defaultValue: 'local', |
|||
}); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
if (data.ip) { |
|||
localStorage.setItem('connectionUrl', data.ip); |
|||
} |
|||
dispatch( |
|||
setConnectionParams({ |
|||
type: connType.HTTP, |
|||
params: { |
|||
address: data.ip ?? connectionUrl, |
|||
tls: data.tls, |
|||
fetchInterval: 2000, |
|||
}, |
|||
}), |
|||
); |
|||
await setConnection(connType.HTTP); |
|||
}); |
|||
|
|||
return ( |
|||
<form onSubmit={onSubmit}> |
|||
<Select |
|||
label="Host Source" |
|||
options={[ |
|||
{ |
|||
name: 'Local', |
|||
value: 'local', |
|||
}, |
|||
{ |
|||
name: 'Remote', |
|||
value: 'remote', |
|||
}, |
|||
]} |
|||
disabled={connecting} |
|||
{...register('ipSource')} |
|||
/> |
|||
{watchIpSource === 'local' ? ( |
|||
<Input label="Host" value={connectionUrl} disabled /> |
|||
) : ( |
|||
<Input label="Host" disabled={connecting} {...register('ip')} /> |
|||
)} |
|||
<Checkbox label="Use TLS?" disabled={connecting} {...register('tls')} /> |
|||
<Button |
|||
className="mt-2 ml-auto" |
|||
onClick={async (): Promise<void> => { |
|||
if (connecting) { |
|||
await connection.disconnect(); |
|||
} else { |
|||
await onSubmit(); |
|||
} |
|||
}} |
|||
border |
|||
> |
|||
{connecting ? 'Disconnect' : 'Connect'} |
|||
</Button> |
|||
</form> |
|||
); |
|||
}; |
|||
@ -1,95 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useCallback, useEffect, useState } from 'react'; |
|||
|
|||
import { useForm } from 'react-hook-form'; |
|||
import { FiArrowRightCircle } from 'react-icons/fi'; |
|||
|
|||
import { Button } from '@components/generic/button/Button'; |
|||
import { IconButton } from '@components/generic/button/IconButton'; |
|||
import { connection, setConnection } from '@core/connection'; |
|||
import { connType, setConnectionParams } from '@core/slices/appSlice'; |
|||
import { useAppDispatch } from '@hooks/useAppDispatch'; |
|||
import { ISerialConnection } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export interface SerialProps { |
|||
connecting: boolean; |
|||
} |
|||
|
|||
export const Serial = ({ connecting }: SerialProps): JSX.Element => { |
|||
const [serialDevices, setSerialDevices] = useState<SerialPort[]>([]); |
|||
const dispatch = useAppDispatch(); |
|||
|
|||
const { handleSubmit } = useForm<{ |
|||
device?: SerialPort; |
|||
}>(); |
|||
|
|||
const updateSerialDeviceList = useCallback(async (): Promise<void> => { |
|||
const serial = new ISerialConnection(); |
|||
const devices = await serial.getPorts(); |
|||
setSerialDevices(devices); |
|||
}, []); |
|||
|
|||
useEffect(() => { |
|||
void updateSerialDeviceList(); |
|||
}, [updateSerialDeviceList]); |
|||
|
|||
const onSubmit = handleSubmit(async () => { |
|||
await setConnection(connType.SERIAL); |
|||
}); |
|||
|
|||
return ( |
|||
<form onSubmit={onSubmit} className="flex flex-grow flex-col"> |
|||
<div className="flex flex-grow flex-col gap-2 overflow-y-auto rounded-md border border-gray-400 bg-gray-200 p-2 dark:border-gray-600 dark:bg-tertiaryDark dark:text-gray-400"> |
|||
{serialDevices.length > 0 ? ( |
|||
serialDevices.map((device, index) => ( |
|||
<div |
|||
className="flex justify-between rounded-md bg-white p-2 dark:bg-primaryDark dark:text-white" |
|||
key={index} |
|||
> |
|||
<div className="my-auto flex gap-4"> |
|||
<p> |
|||
Vendor: <small>{device.getInfo().usbVendorId}</small> |
|||
</p> |
|||
<p> |
|||
Device: <small>{device.getInfo().usbProductId}</small> |
|||
</p> |
|||
</div> |
|||
<IconButton |
|||
onClick={async (): Promise<void> => { |
|||
dispatch( |
|||
setConnectionParams({ |
|||
type: connType.SERIAL, |
|||
params: { |
|||
port: device, |
|||
}, |
|||
}), |
|||
); |
|||
await setConnection(connType.SERIAL); |
|||
}} |
|||
disabled={connecting} |
|||
icon={<FiArrowRightCircle />} |
|||
/> |
|||
</div> |
|||
)) |
|||
) : ( |
|||
<div className="m-auto"> |
|||
<p>No previously connected devices found</p> |
|||
</div> |
|||
)} |
|||
</div> |
|||
<Button |
|||
className="mt-2 ml-auto" |
|||
onClick={async (): Promise<void> => { |
|||
if (connecting) { |
|||
await connection.disconnect(); |
|||
} else { |
|||
await onSubmit(); |
|||
} |
|||
}} |
|||
border |
|||
> |
|||
{connecting ? 'Disconnect' : 'Connect'} |
|||
</Button> |
|||
</form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,49 @@ |
|||
import type React from "react"; |
|||
import type { HTMLProps } from "react"; |
|||
|
|||
import { Button, majorScale, Pane, Spinner } from "evergreen-ui"; |
|||
import { FiSave } from "react-icons/fi"; |
|||
|
|||
export interface FormProps extends HTMLProps<HTMLFormElement> { |
|||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => Promise<void>; |
|||
loading: boolean; |
|||
dirty: boolean; |
|||
} |
|||
|
|||
export const Form = ({ |
|||
loading, |
|||
dirty, |
|||
children, |
|||
onSubmit, |
|||
...props |
|||
}: FormProps): JSX.Element => { |
|||
return ( |
|||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|||
<form onSubmit={onSubmit} style={{ position: "relative" }} {...props}> |
|||
{loading && ( |
|||
<Pane |
|||
position="absolute" |
|||
display="flex" |
|||
width="100%" |
|||
height="100%" |
|||
backgroundColor="rgba(67, 90, 111, 0.2)" |
|||
zIndex={10} |
|||
borderRadius={majorScale(1)} |
|||
> |
|||
<Spinner margin="auto" /> |
|||
</Pane> |
|||
)} |
|||
{children} |
|||
<Pane display="flex" marginTop={majorScale(2)}> |
|||
<Button |
|||
type="submit" |
|||
marginLeft="auto" |
|||
disabled={!dirty} |
|||
iconBefore={<FiSave />} |
|||
> |
|||
Save |
|||
</Button> |
|||
</Pane> |
|||
</form> |
|||
); |
|||
}; |
|||
@ -1,48 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { m } from 'framer-motion'; |
|||
|
|||
export interface CardProps { |
|||
className?: string; |
|||
title?: string; |
|||
actions?: React.ReactNode; |
|||
children: React.ReactNode; |
|||
border?: boolean; |
|||
} |
|||
|
|||
export const Card = ({ |
|||
className, |
|||
title, |
|||
actions, |
|||
border, |
|||
children, |
|||
}: CardProps): JSX.Element => { |
|||
return ( |
|||
<div |
|||
className={`flex h-full w-full flex-col rounded-md drop-shadow-md ${ |
|||
border ? 'border border-gray-400 dark:border-gray-600' : '' |
|||
} ${className ?? ''}`}
|
|||
> |
|||
{(title || actions) && ( |
|||
<div className="w-full select-none justify-between rounded-t-md border-b border-gray-400 bg-gray-200 p-2 px-2 text-lg font-medium dark:border-gray-600 dark:bg-tertiaryDark dark:text-white"> |
|||
<div className="handle flex h-8 justify-between"> |
|||
<div className="my-auto ml-2 truncate">{title}</div> |
|||
{actions} |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
<m.div |
|||
className={`flex flex-grow select-none flex-col gap-4 bg-white p-4 dark:bg-primaryDark ${ |
|||
title || actions ? 'rounded-b-md' : 'rounded-md' |
|||
}`}
|
|||
initial={{ opacity: 0 }} |
|||
animate={{ opacity: 1 }} |
|||
exit={{ opacity: 0 }} |
|||
transition={{ duration: 0.1 }} |
|||
> |
|||
{children} |
|||
</m.div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,23 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { m } from 'framer-motion'; |
|||
|
|||
export interface ContextItem { |
|||
title: string; |
|||
icon: JSX.Element; |
|||
} |
|||
|
|||
export const ContextItem = ({ title, icon }: ContextItem): JSX.Element => { |
|||
return ( |
|||
<div className="cursor-pointer first:rounded-t-md last:rounded-b-md hover:dark:bg-secondaryDark"> |
|||
<m.div |
|||
whileHover={{ scale: 1.01 }} |
|||
whileTap={{ scale: 0.99 }} |
|||
className="flex gap-2 p-2" |
|||
> |
|||
<div className="my-auto">{icon}</div> |
|||
<div className="truncate">{title}</div> |
|||
</m.div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,57 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useState } from 'react'; |
|||
|
|||
import { FiActivity, FiAperture, FiTag } from 'react-icons/fi'; |
|||
|
|||
import { ContextItem } from '@components/generic/ContextItem'; |
|||
|
|||
export interface ContextMenuProps { |
|||
items?: JSX.Element; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const ContextMenu = ({ |
|||
items, |
|||
children, |
|||
}: ContextMenuProps): JSX.Element => { |
|||
const [visible, setVisible] = useState(false); |
|||
const [position, setPosition] = useState({ x: 0, y: 0 }); |
|||
|
|||
return ( |
|||
<div |
|||
className="h-full" |
|||
onContextMenu={(e): void => { |
|||
e.preventDefault(); |
|||
|
|||
setVisible(false); |
|||
const newPosition = { |
|||
x: e.pageX, |
|||
y: e.pageY, |
|||
}; |
|||
|
|||
setPosition(newPosition); |
|||
setVisible(true); |
|||
}} |
|||
onClick={(): void => { |
|||
setVisible(false); |
|||
}} |
|||
> |
|||
{children} |
|||
|
|||
{visible && ( |
|||
<div |
|||
style={{ top: position.y, left: position.x }} |
|||
className="fixed z-50 w-60 gap-2 divide-y divide-gray-300 rounded-md border border-gray-400 font-medium drop-shadow-md backdrop-blur-xl dark:divide-gray-600 dark:border-gray-600 dark:text-gray-400" |
|||
> |
|||
{items} |
|||
<ContextItem title="Menu item" icon={<FiActivity />} /> |
|||
<ContextItem title="Menu item 2" icon={<FiAperture />} /> |
|||
<ContextItem |
|||
title="Menu item 3 with a very long name that should wrap" |
|||
icon={<FiTag />} |
|||
/> |
|||
</div> |
|||
)} |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,9 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
export const Loading = (): JSX.Element => { |
|||
return ( |
|||
<div className="absolute top-0 bottom-0 left-0 right-0 z-10 flex rounded-md backdrop-blur-sm backdrop-filter"> |
|||
<div className="m-auto text-lg font-medium text-gray-400">Loading</div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,72 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { AnimatePresence, m } from 'framer-motion'; |
|||
import { FiX } from 'react-icons/fi'; |
|||
|
|||
import { IconButton } from '@components/generic/button/IconButton'; |
|||
import { Card, CardProps } from '@components/generic/Card'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
|
|||
export interface ModalProps extends CardProps { |
|||
open: boolean; |
|||
bgDismiss?: boolean; |
|||
onClose: () => void; |
|||
} |
|||
|
|||
export const Modal = ({ |
|||
open, |
|||
bgDismiss, |
|||
onClose, |
|||
actions, |
|||
...props |
|||
}: ModalProps): JSX.Element => { |
|||
const darkMode = useAppSelector((state) => state.app.darkMode); |
|||
|
|||
return ( |
|||
<AnimatePresence> |
|||
{open && ( |
|||
<m.div |
|||
className={`fixed inset-0 ${darkMode ? 'dark' : ''} ${ |
|||
open ? 'z-30' : 'z-0' |
|||
}`}
|
|||
> |
|||
<m.div |
|||
initial={{ opacity: 0 }} |
|||
animate={{ opacity: 1 }} |
|||
exit={{ opacity: 0 }} |
|||
transition={{ duration: 0.1 }} |
|||
className="fixed h-full w-full backdrop-blur-md backdrop-brightness-75 backdrop-filter" |
|||
onClick={(): void => { |
|||
bgDismiss && onClose(); |
|||
}} |
|||
/> |
|||
<m.div className="text-center "> |
|||
<span |
|||
className="inline-block h-screen align-middle " |
|||
aria-hidden="true" |
|||
> |
|||
​ |
|||
</span> |
|||
<div className="inline-block w-full max-w-3xl align-middle"> |
|||
<Card |
|||
border |
|||
actions={ |
|||
<div className="flex gap-2"> |
|||
{actions} |
|||
<IconButton |
|||
tooltip="Close" |
|||
icon={<FiX />} |
|||
onClick={onClose} |
|||
/> |
|||
</div> |
|||
} |
|||
className="relative flex-col" |
|||
{...props} |
|||
/> |
|||
</div> |
|||
</m.div> |
|||
</m.div> |
|||
)} |
|||
</AnimatePresence> |
|||
); |
|||
}; |
|||
@ -1,85 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useState } from 'react'; |
|||
|
|||
import { AnimatePresence, m } from 'framer-motion'; |
|||
import { FiArrowUp } from 'react-icons/fi'; |
|||
|
|||
export interface CollapsibleSectionProps { |
|||
title: string; |
|||
icon?: JSX.Element; |
|||
status?: boolean; |
|||
children: JSX.Element; |
|||
} |
|||
|
|||
export const CollapsibleSection = ({ |
|||
title, |
|||
icon, |
|||
status, |
|||
children, |
|||
}: CollapsibleSectionProps): JSX.Element => { |
|||
const [open, setOpen] = useState(false); |
|||
const toggleOpen = (): void => setOpen(!open); |
|||
return ( |
|||
<m.div> |
|||
<m.div |
|||
layout |
|||
onClick={toggleOpen} |
|||
className={`w-full cursor-pointer select-none overflow-hidden border-l-4 border-b bg-gray-200 p-2 text-sm font-medium dark:border-primaryDark dark:bg-tertiaryDark dark:text-gray-400 ${ |
|||
open |
|||
? 'border-l-primary dark:border-l-primary' |
|||
: 'border-gray-400 dark:border-secondaryDark' |
|||
}`}
|
|||
> |
|||
<m.div |
|||
layout |
|||
whileHover={{ scale: 1.01 }} |
|||
whileTap={{ scale: 0.99 }} |
|||
className="my-auto flex justify-between gap-2" |
|||
> |
|||
<m.div className="flex gap-2"> |
|||
<m.div className="my-auto flex gap-2"> |
|||
{status !== undefined ? ( |
|||
<> |
|||
<div |
|||
className={`my-auto h-2 w-2 rounded-full ${ |
|||
status ? 'bg-green-500' : 'bg-red-500' |
|||
}`}
|
|||
/> |
|||
{icon} |
|||
</> |
|||
) : ( |
|||
<>{icon}</> |
|||
)} |
|||
</m.div> |
|||
{title} |
|||
</m.div> |
|||
<m.div |
|||
animate={open ? 'open' : 'closed'} |
|||
initial={{ rotate: 180 }} |
|||
variants={{ |
|||
open: { rotate: 0 }, |
|||
closed: { rotate: 180 }, |
|||
}} |
|||
transition={{ type: 'just' }} |
|||
className="my-auto" |
|||
> |
|||
<FiArrowUp /> |
|||
</m.div> |
|||
</m.div> |
|||
</m.div> |
|||
<AnimatePresence> |
|||
{open && ( |
|||
<m.div |
|||
className="p-2" |
|||
layout |
|||
initial={{ opacity: 0 }} |
|||
animate={{ opacity: 1 }} |
|||
exit={{ opacity: 0 }} |
|||
> |
|||
{children} |
|||
</m.div> |
|||
)} |
|||
</AnimatePresence> |
|||
</m.div> |
|||
); |
|||
}; |
|||
@ -1,54 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useState } from 'react'; |
|||
|
|||
import { m } from 'framer-motion'; |
|||
import { FiChevronRight } from 'react-icons/fi'; |
|||
|
|||
export interface ExternalSectionProps { |
|||
title: string; |
|||
icon?: JSX.Element; |
|||
active?: boolean; |
|||
onClick: () => void; |
|||
} |
|||
|
|||
export const ExternalSection = ({ |
|||
title, |
|||
icon, |
|||
active, |
|||
onClick, |
|||
}: ExternalSectionProps): JSX.Element => { |
|||
const [open, setOpen] = useState(false); |
|||
const toggleOpen = (): void => setOpen(!open); |
|||
return ( |
|||
<m.div |
|||
onClick={(): void => { |
|||
onClick(); |
|||
}} |
|||
> |
|||
<m.div |
|||
layout |
|||
className={`w-full cursor-pointer select-none overflow-hidden border-l-4 bg-gray-200 dark:bg-tertiaryDark dark:text-gray-400 ${ |
|||
active |
|||
? 'border-l-primary dark:border-l-primary' |
|||
: 'border-gray-400 dark:border-secondaryDark' |
|||
}`}
|
|||
> |
|||
<m.div |
|||
layout |
|||
onClick={toggleOpen} |
|||
whileHover={{ scale: 1.01 }} |
|||
whileTap={{ scale: 0.99 }} |
|||
className="flex justify-between gap-2 border-b border-gray-400 p-2 text-sm font-medium dark:border-primaryDark" |
|||
> |
|||
<m.div className="flex gap-2 "> |
|||
<m.div className="my-auto">{icon}</m.div> |
|||
{title} |
|||
</m.div> |
|||
<m.div className="my-auto"> |
|||
<FiChevronRight /> |
|||
</m.div> |
|||
</m.div> |
|||
</m.div> |
|||
</m.div> |
|||
); |
|||
}; |
|||
@ -1,61 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { AnimatePresence, AnimateSharedLayout, m } from 'framer-motion'; |
|||
import { FiArrowLeft } from 'react-icons/fi'; |
|||
|
|||
import { IconButton } from '@components/generic/button/IconButton'; |
|||
|
|||
export interface SidebarOverlayProps { |
|||
title: string; |
|||
open: boolean; |
|||
close: () => void; |
|||
direction: 'x' | 'y'; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const SidebarOverlay = ({ |
|||
title, |
|||
open, |
|||
close, |
|||
direction, |
|||
children, |
|||
}: SidebarOverlayProps): JSX.Element => { |
|||
return ( |
|||
<AnimatePresence> |
|||
{open && ( |
|||
<m.div |
|||
className="absolute z-30 flex h-full w-full flex-col bg-white dark:bg-primaryDark" |
|||
animate={direction === 'x' ? { translateX: 0 } : { translateY: 0 }} |
|||
initial={ |
|||
direction === 'x' ? { translateX: '-100%' } : { translateY: '100%' } |
|||
} |
|||
exit={ |
|||
direction === 'x' ? { translateX: '-100%' } : { translateY: '100%' } |
|||
} |
|||
transition={{ type: 'just' }} |
|||
> |
|||
{/* @ts-expect-error */} |
|||
<AnimateSharedLayout> |
|||
{/* <div className="flex gap-2 border-b border-gray-400 p-2 dark:border-gray-600"> */} |
|||
<div className="bg-white px-1 pt-1 drop-shadow-md dark:bg-primaryDark"> |
|||
<div className="flex h-10 gap-1"> |
|||
<div className="my-auto"> |
|||
<IconButton |
|||
onClick={(): void => { |
|||
close(); |
|||
}} |
|||
icon={<FiArrowLeft />} |
|||
/> |
|||
</div> |
|||
<div className="my-auto text-lg font-medium dark:text-white"> |
|||
{title} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div className="flex-grow overflow-y-auto">{children}</div> |
|||
</AnimateSharedLayout> |
|||
</m.div> |
|||
)} |
|||
</AnimatePresence> |
|||
); |
|||
}; |
|||
@ -1,17 +0,0 @@ |
|||
import 'tippy.js/dist/tippy.css'; |
|||
|
|||
import type React from 'react'; |
|||
|
|||
import Tippy, { TippyProps } from '@tippyjs/react'; |
|||
|
|||
export const Tooltip = ({ |
|||
children, |
|||
content, |
|||
...props |
|||
}: TippyProps): JSX.Element => { |
|||
return ( |
|||
<Tippy content={content} {...props}> |
|||
<div>{children}</div> |
|||
</Tippy> |
|||
); |
|||
}; |
|||
@ -1,79 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useState } from 'react'; |
|||
|
|||
import { m } from 'framer-motion'; |
|||
import { FiCheck } from 'react-icons/fi'; |
|||
|
|||
export enum ButtonSize { |
|||
Small = 'small', |
|||
Medium = 'medium', |
|||
Large = 'large', |
|||
} |
|||
|
|||
export interface ButtonProps { |
|||
icon?: JSX.Element; |
|||
border?: boolean; |
|||
className?: string; |
|||
disabled?: boolean; |
|||
children?: React.ReactNode; |
|||
size?: ButtonSize; |
|||
onClick?: () => void; |
|||
confirmAction?: () => void; |
|||
} |
|||
|
|||
export const Button = ({ |
|||
icon, |
|||
className, |
|||
border, |
|||
size = ButtonSize.Medium, |
|||
confirmAction, |
|||
onClick, |
|||
disabled, |
|||
children, |
|||
}: ButtonProps): JSX.Element => { |
|||
const [hasConfirmed, setHasConfirmed] = useState(false); |
|||
|
|||
const handleConfirm = (): void => { |
|||
if (typeof confirmAction == 'function') { |
|||
if (hasConfirmed) { |
|||
void confirmAction(); |
|||
} |
|||
setHasConfirmed(true); |
|||
setTimeout(() => { |
|||
setHasConfirmed(false); |
|||
}, 3000); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<m.button |
|||
whileHover={{ scale: 1.01 }} |
|||
whileTap={{ scale: 0.97 }} |
|||
onClick={handleConfirm} |
|||
className={`flex select-none items-center space-x-3 rounded-md border border-transparent text-sm focus-within:border-primary focus-within:shadow-border dark:text-white dark:focus-within:border-primary
|
|||
${ |
|||
size === ButtonSize.Small |
|||
? 'p-0' |
|||
: size === ButtonSize.Medium |
|||
? 'p-2' |
|||
: 'p-4' |
|||
} |
|||
${ |
|||
disabled |
|||
? 'cursor-not-allowed bg-white dark:bg-primaryDark' |
|||
: 'cursor-pointer hover:bg-white hover:drop-shadow-md dark:hover:bg-secondaryDark' |
|||
} ${border ? 'border-gray-400 dark:border-gray-200' : ''} ${ |
|||
className ?? '' |
|||
}`}
|
|||
onClickCapture={onClick} |
|||
> |
|||
{icon && ( |
|||
<div className="text-gray-500 dark:text-gray-400"> |
|||
{hasConfirmed ? <FiCheck /> : icon} |
|||
</div> |
|||
)} |
|||
|
|||
<span>{children}</span> |
|||
</m.button> |
|||
); |
|||
}; |
|||
@ -1,50 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { m } from 'framer-motion'; |
|||
|
|||
import { Tooltip } from '@components/generic/Tooltip'; |
|||
|
|||
type DefaulButtonProps = JSX.IntrinsicElements['button']; |
|||
|
|||
export interface IconButtonProps extends DefaulButtonProps { |
|||
icon: React.ReactNode; |
|||
tooltip?: string; |
|||
nested?: boolean; |
|||
active?: boolean; |
|||
} |
|||
|
|||
export const IconButton = ({ |
|||
icon, |
|||
tooltip, |
|||
nested, |
|||
active, |
|||
disabled, |
|||
className, |
|||
...props |
|||
}: IconButtonProps): JSX.Element => { |
|||
return ( |
|||
<Tooltip disabled={!tooltip} content={tooltip}> |
|||
<button |
|||
type="button" |
|||
disabled={disabled} |
|||
className={`rounded-md p-2 hover:bg-gray-300 ${ |
|||
active ? 'bg-gray-300 dark:bg-secondaryDark' : '' |
|||
} ${ |
|||
nested ? 'dark:hover:bg-primaryDark' : 'dark:hover:bg-secondaryDark' |
|||
} ${ |
|||
disabled ? 'cursor-not-allowed text-gray-400 dark:text-gray-700' : '' |
|||
} ${className ?? ''}`}
|
|||
{...props} |
|||
> |
|||
<m.div |
|||
whileHover={{ scale: 1.01 }} |
|||
whileTap={{ scale: 0.95 }} |
|||
className="my-auto text-gray-600 dark:text-gray-400" |
|||
> |
|||
{icon} |
|||
</m.div> |
|||
<span className="sr-only">Refresh</span> |
|||
</button> |
|||
</Tooltip> |
|||
); |
|||
}; |
|||
@ -1,49 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { forwardRef } from 'react'; |
|||
|
|||
import { Label } from '@components/generic/form/Label'; |
|||
|
|||
type DefaultInputProps = JSX.IntrinsicElements['input']; |
|||
|
|||
export interface CheckboxProps extends DefaultInputProps { |
|||
action?: (enabled: boolean) => void; |
|||
label: string; |
|||
valid?: boolean; |
|||
validationMessage?: string; |
|||
error?: boolean; |
|||
} |
|||
|
|||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>( |
|||
function Input( |
|||
{ label, valid, validationMessage, id, error, ...props }: CheckboxProps, |
|||
ref, |
|||
) { |
|||
return ( |
|||
<div className="flex w-full flex-col"> |
|||
<Label label={label} /> |
|||
<div className="ml-auto"> |
|||
<input |
|||
ref={ref} |
|||
type="checkbox" |
|||
id={id} |
|||
className={`h-8 w-8 appearance-none rounded-md border border-gray-400 transition duration-200 ease-in-out checked:border-transparent checked:bg-primary focus-within:shadow-border focus:outline-none dark:border-gray-200 ${ |
|||
props.disabled |
|||
? 'border-gray-400 bg-gray-300 text-gray-500 dark:border-gray-700 dark:bg-secondaryDark dark:text-gray-400' |
|||
: '' |
|||
} ${ |
|||
error |
|||
? 'border-red-500' |
|||
: props.disabled |
|||
? 'border-gray-200' |
|||
: 'focus-within:border-primary hover:border-primary dark:focus-within:border-primary dark:hover:border-primary' |
|||
}`}
|
|||
{...props} |
|||
/> |
|||
</div> |
|||
{!valid && ( |
|||
<div className="text-sm text-gray-600">{validationMessage}</div> |
|||
)} |
|||
</div> |
|||
); |
|||
}, |
|||
); |
|||
@ -1,36 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { FiSave } from 'react-icons/fi'; |
|||
|
|||
import { IconButton } from '@components/generic/button/IconButton'; |
|||
import { Loading } from '@components/generic/Loading'; |
|||
|
|||
export interface FormProps { |
|||
submit: () => Promise<void>; |
|||
loading: boolean; |
|||
dirty: boolean; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const Form = ({ |
|||
submit, |
|||
loading, |
|||
dirty, |
|||
children, |
|||
}: FormProps): JSX.Element => { |
|||
return ( |
|||
<form |
|||
onSubmit={(e): void => { |
|||
e.preventDefault(); |
|||
}} |
|||
> |
|||
{loading && <Loading />} |
|||
{children} |
|||
<div className="flex w-full bg-white dark:bg-secondaryDark"> |
|||
<div className="ml-auto p-2"> |
|||
<IconButton disabled={dirty} onClick={submit} icon={<FiSave />} /> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
); |
|||
}; |
|||
@ -1,41 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { forwardRef } from 'react'; |
|||
|
|||
import { InputWrapper } from '@components/generic/form/InputWrapper'; |
|||
import { Label } from '@components/generic/form/Label'; |
|||
|
|||
type DefaultInputProps = JSX.IntrinsicElements['input']; |
|||
|
|||
export interface InputProps extends DefaultInputProps { |
|||
label?: string; |
|||
error?: string; |
|||
action?: JSX.Element; |
|||
prefix?: string; |
|||
suffix?: string; |
|||
} |
|||
|
|||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( |
|||
{ label, error, action, suffix, className, disabled, ...props }: InputProps, |
|||
ref, |
|||
) { |
|||
return ( |
|||
<div className="w-full"> |
|||
{label && <Label label={label} error={error} />} |
|||
<InputWrapper error={error} disabled={disabled}> |
|||
<input |
|||
ref={ref} |
|||
className={`h-10 w-full bg-transparent px-3 py-2 focus:outline-none disabled:cursor-not-allowed dark:text-white ${ |
|||
className ?? '' |
|||
}`}
|
|||
{...props} |
|||
/> |
|||
{suffix && ( |
|||
<span className="my-auto mr-3 text-sm font-medium text-gray-500 dark:text-gray-400"> |
|||
{suffix} |
|||
</span> |
|||
)} |
|||
{action && <div className="mr-1 flex">{action}</div>} |
|||
</InputWrapper> |
|||
</div> |
|||
); |
|||
}); |
|||
@ -1,29 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
export interface LabelProps { |
|||
error?: string; |
|||
disabled?: boolean; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const InputWrapper = ({ |
|||
error, |
|||
disabled, |
|||
children, |
|||
}: LabelProps): JSX.Element => ( |
|||
<div |
|||
className={`flex w-full rounded-md border border-gray-400 transition duration-200 ease-in-out dark:border-gray-200 ${ |
|||
disabled |
|||
? 'border-gray-400 bg-gray-300 text-gray-500 dark:border-gray-700 dark:bg-secondaryDark dark:text-gray-400' |
|||
: '' |
|||
} ${ |
|||
error |
|||
? 'border-red-500 dark:border-red-500' |
|||
: disabled |
|||
? '' |
|||
: ' focus-within:border-primary focus-within:shadow-border hover:border-primary dark:focus-within:border-primary dark:hover:border-primary' |
|||
}`}
|
|||
> |
|||
{children} |
|||
</div> |
|||
); |
|||
@ -1,14 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
export interface LabelProps { |
|||
label: string; |
|||
error?: string; |
|||
} |
|||
|
|||
export const Label = ({ label, error }: LabelProps): JSX.Element => ( |
|||
<label className="flex py-1 text-xs font-semibold text-gray-500 dark:text-gray-400"> |
|||
{label} |
|||
{error && <span className="ml-2 text-red-500">{error}</span>} |
|||
<div className="my-auto ml-2 h-0.5 flex-grow rounded-full bg-gray-300 dark:bg-gray-700" /> |
|||
</label> |
|||
); |
|||
@ -1,69 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { forwardRef } from 'react'; |
|||
|
|||
import { InputWrapper } from '@components/generic/form/InputWrapper'; |
|||
import { Label } from '@components/generic/form/Label'; |
|||
|
|||
type DefaultSelectProps = JSX.IntrinsicElements['select']; |
|||
|
|||
export interface SelectProps extends DefaultSelectProps { |
|||
options?: { |
|||
name: string | number; |
|||
value: string | number; |
|||
}[]; |
|||
optionsEnum?: { [s: string]: string | number }; |
|||
label?: string; |
|||
error?: string; |
|||
small?: boolean; |
|||
} |
|||
|
|||
export const Select = forwardRef<HTMLSelectElement, SelectProps>( |
|||
({ options, optionsEnum, label, error, small, ...props }, ref) => { |
|||
const optionsEnumValues = optionsEnum |
|||
? Object.entries(optionsEnum).filter( |
|||
(value) => typeof value[1] === 'number', |
|||
) |
|||
: []; |
|||
return ( |
|||
<div> |
|||
{label && <Label label={label} error={error} />} |
|||
<InputWrapper error={error} disabled={props.disabled}> |
|||
<select |
|||
ref={ref} |
|||
className={`w-full rounded-md bg-transparent focus:border-primary focus:outline-none disabled:cursor-not-allowed dark:text-white ${ |
|||
small ? 'm-1' : 'mx-2 h-10' |
|||
}`}
|
|||
disabled={ |
|||
props.disabled |
|||
? true |
|||
: !(optionsEnumValues.length || options?.length) |
|||
} |
|||
{...props} |
|||
> |
|||
{!(optionsEnumValues.length || options?.length) && ( |
|||
<option key="loading" className="dark:bg-gray-700"> |
|||
Loading |
|||
</option> |
|||
)} |
|||
{optionsEnumValues.length && |
|||
optionsEnumValues.map(([name, value], index) => ( |
|||
<option key={index} className="dark:bg-gray-700" value={value}> |
|||
{name} |
|||
</option> |
|||
))} |
|||
{options && |
|||
options.map((option, index) => ( |
|||
<option |
|||
key={index} |
|||
className="dark:bg-gray-700" |
|||
value={option.value} |
|||
> |
|||
{option.name} |
|||
</option> |
|||
))} |
|||
</select> |
|||
</InputWrapper> |
|||
</div> |
|||
); |
|||
}, |
|||
); |
|||
@ -0,0 +1,74 @@ |
|||
import type React from "react"; |
|||
|
|||
import { majorScale, Pane } from "evergreen-ui"; |
|||
|
|||
import { useAppStore } from "@app/core/stores/appStore.js"; |
|||
import { DeviceWrapper } from "@app/DeviceWrapper.js"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.js"; |
|||
|
|||
import { NoDevice } from "../misc/NoDevice.js"; |
|||
import { Progress } from "../Progress.js"; |
|||
import { Header } from "./Header.js"; |
|||
import { Sidebar } from "./Sidebar/index.js"; |
|||
|
|||
export interface AppLayoutProps { |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const AppLayout = ({ children }: AppLayoutProps): JSX.Element => { |
|||
const { getDevices } = useDeviceStore(); |
|||
const { selectedDevice } = useAppStore(); |
|||
|
|||
const devices = getDevices(); |
|||
|
|||
return ( |
|||
<Pane |
|||
width="100vw" |
|||
display="flex" |
|||
background="tint1" |
|||
flexDirection="column" |
|||
minHeight="100vh" |
|||
> |
|||
<Header /> |
|||
<Pane display="flex" flex={1} height="100%" width="100%"> |
|||
{devices.length ? ( |
|||
devices.map((device, index) => ( |
|||
<Pane |
|||
key={index} |
|||
width="100%" |
|||
height="100%" |
|||
display={index === selectedDevice ? "grid" : "none"} |
|||
gap={majorScale(3)} |
|||
gridTemplateColumns="16rem 1fr" |
|||
> |
|||
<DeviceWrapper device={device}> |
|||
{device && device.ready ? ( |
|||
<> |
|||
<Sidebar /> |
|||
<Pane height="100%" display="flex"> |
|||
{children} |
|||
</Pane> |
|||
</> |
|||
) : ( |
|||
<> |
|||
<Pane |
|||
width="100%" |
|||
flexGrow={1} |
|||
margin={majorScale(3)} |
|||
borderRadius={majorScale(1)} |
|||
background="white" |
|||
elevation={1} |
|||
/> |
|||
<Progress /> |
|||
</> |
|||
)} |
|||
</DeviceWrapper> |
|||
</Pane> |
|||
)) |
|||
) : ( |
|||
<NoDevice /> |
|||
)} |
|||
</Pane> |
|||
</Pane> |
|||
); |
|||
}; |
|||
@ -0,0 +1,147 @@ |
|||
import type React from "react"; |
|||
import { useState } from "react"; |
|||
|
|||
import { |
|||
Button, |
|||
CrossIcon, |
|||
GlobeIcon, |
|||
IconButton, |
|||
Link, |
|||
majorScale, |
|||
Pane, |
|||
PlusIcon, |
|||
StatusIndicator, |
|||
Tab, |
|||
Tablist, |
|||
Tooltip, |
|||
} from "evergreen-ui"; |
|||
import { FiGithub } from "react-icons/fi"; |
|||
|
|||
import { useAppStore } from "@app/core/stores/appStore.js"; |
|||
import { NewDevice } from "@components/SlideSheets/NewDevice.js"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.js"; |
|||
import { Hashicon } from "@emeraldpay/hashicon-react"; |
|||
import { Types } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const Header = (): JSX.Element => { |
|||
const { getDevices, removeDevice } = useDeviceStore(); |
|||
const [newConnectionOpen, setNewConnectionOpen] = useState(false); |
|||
const { selectedDevice, setSelectedDevice } = useAppStore(); |
|||
|
|||
return ( |
|||
<Pane |
|||
is="nav" |
|||
width="100%" |
|||
position="sticky" |
|||
top={0} |
|||
backgroundColor="white" |
|||
zIndex={10} |
|||
height={majorScale(8)} |
|||
flexShrink={0} |
|||
display="flex" |
|||
alignItems="center" |
|||
borderBottom="muted" |
|||
> |
|||
<NewDevice |
|||
open={newConnectionOpen} |
|||
onClose={() => { |
|||
setNewConnectionOpen(false); |
|||
}} |
|||
/> |
|||
<Pane |
|||
display="flex" |
|||
alignItems="center" |
|||
width={majorScale(12)} |
|||
marginRight={majorScale(22)} |
|||
> |
|||
<Link href="/"> |
|||
<Pane |
|||
is="img" |
|||
width={100} |
|||
height={28} |
|||
src="/Logo_Black.svg" |
|||
cursor="pointer" |
|||
/> |
|||
</Link> |
|||
</Pane> |
|||
<Tablist display="flex" marginX={majorScale(4)}> |
|||
{getDevices().map((device, index) => ( |
|||
<Tab |
|||
key={index} |
|||
gap={majorScale(1)} |
|||
isSelected={index === selectedDevice} |
|||
onSelect={() => { |
|||
setSelectedDevice(index); |
|||
}} |
|||
> |
|||
<Hashicon value={device.hardware.myNodeNum.toString()} size={20} /> |
|||
{device.nodes.find((n) => n.data.num === device.hardware.myNodeNum) |
|||
?.data.user?.shortName ?? "UNK"} |
|||
<StatusIndicator |
|||
color={ |
|||
[ |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED, |
|||
Types.DeviceStatusEnum.DEVICE_CONFIGURED, |
|||
Types.DeviceStatusEnum.DEVICE_CONFIGURING, |
|||
].includes(device.status) |
|||
? "success" |
|||
: [ |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTING, |
|||
Types.DeviceStatusEnum.DEVICE_RECONNECTING, |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED, |
|||
].includes(device.status) |
|||
? "warning" |
|||
: "danger" |
|||
} |
|||
/> |
|||
</Tab> |
|||
))} |
|||
</Tablist> |
|||
<Pane |
|||
display="flex" |
|||
marginLeft="auto" |
|||
gap={majorScale(1)} |
|||
marginRight={majorScale(2)} |
|||
> |
|||
<Tooltip content="Connect new device"> |
|||
<Button |
|||
display="inline-flex" |
|||
marginY="auto" |
|||
appearance="primary" |
|||
iconBefore={<PlusIcon />} |
|||
onClick={() => { |
|||
setNewConnectionOpen(true); |
|||
}} |
|||
> |
|||
New |
|||
</Button> |
|||
</Tooltip> |
|||
{getDevices().length !== 0 && ( |
|||
<Tooltip content="Disconnect active device"> |
|||
<Button |
|||
iconAfter={CrossIcon} |
|||
onClick={() => { |
|||
removeDevice(selectedDevice ?? 0); |
|||
}} |
|||
> |
|||
Disconnect |
|||
</Button> |
|||
</Tooltip> |
|||
)} |
|||
<Tooltip content="Visit GitHub"> |
|||
<Link |
|||
target="_blank" |
|||
href="https://github.com/meshtastic/meshtastic-web" |
|||
> |
|||
<IconButton icon={FiGithub} /> |
|||
</Link> |
|||
</Tooltip> |
|||
<Tooltip content="Visit Meshtastic.org"> |
|||
<Link target="_blank" href="https://meshtastic.org/"> |
|||
<IconButton icon={GlobeIcon} /> |
|||
</Link> |
|||
</Tooltip> |
|||
</Pane> |
|||
</Pane> |
|||
); |
|||
}; |
|||
@ -0,0 +1,103 @@ |
|||
import type React from "react"; |
|||
|
|||
import { |
|||
Badge, |
|||
Heading, |
|||
Link, |
|||
majorScale, |
|||
MapMarkerIcon, |
|||
Pane, |
|||
} from "evergreen-ui"; |
|||
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi"; |
|||
|
|||
import { toMGRS } from "@app/core/utils/toMGRS.js"; |
|||
import { useDevice } from "@core/stores/deviceStore.js"; |
|||
import { Hashicon } from "@emeraldpay/hashicon-react"; |
|||
import { Types } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const DeviceCard = (): JSX.Element => { |
|||
const { hardware, nodes, status, connection } = useDevice(); |
|||
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum); |
|||
|
|||
return ( |
|||
<Pane |
|||
display="flex" |
|||
flexGrow={1} |
|||
flexDirection="column" |
|||
marginTop="auto" |
|||
gap={majorScale(1)} |
|||
> |
|||
<Pane display="flex" gap={majorScale(2)}> |
|||
<Hashicon value={hardware.myNodeNum.toString()} size={42} /> |
|||
<Pane flexGrow={1}> |
|||
<Heading>{myNode?.data.user?.longName}</Heading> |
|||
<Link |
|||
target="_blank" |
|||
href="https://github.com/meshtastic/meshtastic-web/releases/" |
|||
> |
|||
<Badge |
|||
color="green" |
|||
width="100%" |
|||
marginRight={8} |
|||
display="flex" |
|||
marginTop={4} |
|||
> |
|||
{hardware.firmwareVersion} |
|||
</Badge> |
|||
</Link> |
|||
</Pane> |
|||
</Pane> |
|||
<Pane display="flex" gap={majorScale(1)}> |
|||
<MapMarkerIcon /> |
|||
<Badge |
|||
color={myNode?.data.position?.latitudeI ? "green" : "red"} |
|||
display="flex" |
|||
width="100%" |
|||
> |
|||
{toMGRS( |
|||
myNode?.data.position?.latitudeI, |
|||
myNode?.data.position?.longitudeI |
|||
)} |
|||
</Badge> |
|||
</Pane> |
|||
<Pane display="flex" gap={majorScale(1)}> |
|||
{connection?.connType === "ble" && <FiBluetooth />} |
|||
{connection?.connType === "http" && <FiWifi />} |
|||
{connection?.connType === "serial" && <FiTerminal />} |
|||
<Badge |
|||
color={ |
|||
[ |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED, |
|||
Types.DeviceStatusEnum.DEVICE_CONFIGURED, |
|||
Types.DeviceStatusEnum.DEVICE_CONFIGURING, |
|||
].includes(status) |
|||
? "green" |
|||
: [ |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTING, |
|||
Types.DeviceStatusEnum.DEVICE_RECONNECTING, |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED, |
|||
].includes(status) |
|||
? "orange" |
|||
: "red" |
|||
} |
|||
display="flex" |
|||
width="100%" |
|||
> |
|||
{[ |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED, |
|||
Types.DeviceStatusEnum.DEVICE_CONFIGURED, |
|||
Types.DeviceStatusEnum.DEVICE_CONFIGURING, |
|||
].includes(status) |
|||
? "Connected" |
|||
: [ |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTING, |
|||
Types.DeviceStatusEnum.DEVICE_RECONNECTING, |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED, |
|||
].includes(status) |
|||
? "Connecting" |
|||
: "Disconnected"} |
|||
</Badge> |
|||
</Pane> |
|||
</Pane> |
|||
); |
|||
}; |
|||
@ -1,62 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const Device = (): JSX.Element => { |
|||
const deviceConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.config.device, |
|||
); |
|||
const [loading, setLoading] = useState(false); |
|||
const { register, handleSubmit, formState, reset } = |
|||
useForm<Protobuf.Config_DeviceConfig>({ |
|||
defaultValues: deviceConfig, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(deviceConfig); |
|||
}, [reset, deviceConfig]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'device', |
|||
device: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Checkbox |
|||
label="Serial Console Disabled" |
|||
{...register('serialDisabled')} |
|||
/> |
|||
<Checkbox label="Factory Reset Device" {...register('factoryReset')} /> |
|||
<Checkbox label="Enabled Debug Log" {...register('debugLogEnabled')} /> |
|||
<Checkbox |
|||
label="Disable Serial COnsole" |
|||
{...register('serialDisabled')} |
|||
/> |
|||
<Select |
|||
label="Role" |
|||
optionsEnum={Protobuf.Config_DeviceConfig_Role} |
|||
{...register('role', { valueAsNumber: true })} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,64 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm } from 'react-hook-form'; |
|||
|
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const Display = (): JSX.Element => { |
|||
const displayConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.config.display, |
|||
); |
|||
const [loading, setLoading] = useState(false); |
|||
const { register, handleSubmit, formState, reset } = |
|||
useForm<Protobuf.Config_DisplayConfig>({ |
|||
defaultValues: displayConfig, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(displayConfig); |
|||
}, [reset, displayConfig]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'display', |
|||
display: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Input |
|||
label="Screen Timeout" |
|||
type="number" |
|||
suffix="Seconds" |
|||
{...register('screenOnSecs', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Carousel Delay" |
|||
type="number" |
|||
suffix="Seconds" |
|||
{...register('autoScreenCarouselSecs', { valueAsNumber: true })} |
|||
/> |
|||
<Select |
|||
label="GPS Display Units" |
|||
optionsEnum={Protobuf.Config_DisplayConfig_GpsCoordinateFormat} |
|||
{...register('gpsFormat', { valueAsNumber: true })} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,182 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useState } from 'react'; |
|||
|
|||
import { |
|||
FiActivity, |
|||
FiAlignLeft, |
|||
FiBell, |
|||
FiFastForward, |
|||
FiLayers, |
|||
FiLayout, |
|||
FiMapPin, |
|||
FiMessageSquare, |
|||
FiPackage, |
|||
FiPower, |
|||
FiRss, |
|||
FiSmartphone, |
|||
FiTv, |
|||
FiUser, |
|||
FiWifi, |
|||
} from 'react-icons/fi'; |
|||
|
|||
import { CollapsibleSection } from '@components/generic/Sidebar/CollapsibleSection'; |
|||
import { ExternalSection } from '@components/generic/Sidebar/ExternalSection'; |
|||
import { SidebarOverlay } from '@components/generic/Sidebar/SidebarOverlay'; |
|||
import { ChannelsGroup } from '@components/layout/Sidebar/Settings/channels/ChannelsGroup'; |
|||
import { Display } from '@components/layout/Sidebar/Settings/Display'; |
|||
import { Position } from '@app/components/layout/Sidebar/Settings/Position'; |
|||
import { Interface } from '@components/layout/Sidebar/Settings/Interface'; |
|||
import { LoRa } from '@components/layout/Sidebar/Settings/LoRa'; |
|||
import { CannedMessage } from '@components/layout/Sidebar/Settings/modules/CannedMessage'; |
|||
import { ExternalNotificationsSettingsPlanel } from '@components/layout/Sidebar/Settings/modules/ExternalNotifications'; |
|||
import { MQTT } from '@components/layout/Sidebar/Settings/modules/MQTT'; |
|||
import { RangeTestSettingsPanel } from '@components/layout/Sidebar/Settings/modules/RangeTest'; |
|||
import { SerialSettingsPanel } from '@components/layout/Sidebar/Settings/modules/Serial'; |
|||
import { StoreForwardSettingsPanel } from '@components/layout/Sidebar/Settings/modules/StoreForward'; |
|||
import { Telemetry } from '@components/layout/Sidebar/Settings/modules/Telemetry'; |
|||
import { Power } from '@components/layout/Sidebar/Settings/Power'; |
|||
import { User } from '@components/layout/Sidebar/Settings/User'; |
|||
import { WiFi } from '@components/layout/Sidebar/Settings/WiFi'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
|
|||
export interface SettingsProps { |
|||
open: boolean; |
|||
setOpen: (open: boolean) => void; |
|||
} |
|||
|
|||
export const Settings = ({ open, setOpen }: SettingsProps): JSX.Element => { |
|||
const [modulesOpen, setModulesOpen] = useState(false); |
|||
const [channelsOpen, setChannelsOpen] = useState(false); |
|||
const moduleConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.moduleConfig, |
|||
); |
|||
|
|||
const hasGps = true; |
|||
const hasWifi = true; |
|||
|
|||
return ( |
|||
<> |
|||
<SidebarOverlay |
|||
title="Settings" |
|||
open={open} |
|||
close={(): void => { |
|||
setOpen(false); |
|||
}} |
|||
direction="y" |
|||
> |
|||
<CollapsibleSection icon={<FiUser />} title="User"> |
|||
<User /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection icon={<FiSmartphone />} title="Device"> |
|||
<WiFi /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection icon={<FiMapPin />} title="Position"> |
|||
<Position /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection icon={<FiPower />} title="Power"> |
|||
<Power /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection icon={<FiWifi />} title="WiFi"> |
|||
<WiFi /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection icon={<FiTv />} title="Display"> |
|||
<Display /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection icon={<FiRss />} title="LoRa"> |
|||
<LoRa /> |
|||
</CollapsibleSection> |
|||
<ExternalSection |
|||
onClick={(): void => { |
|||
setChannelsOpen(true); |
|||
}} |
|||
icon={<FiLayers />} |
|||
title="Channels" |
|||
/> |
|||
<ExternalSection |
|||
onClick={(): void => { |
|||
setModulesOpen(true); |
|||
}} |
|||
icon={<FiPackage />} |
|||
title="Modules" |
|||
/> |
|||
<CollapsibleSection icon={<FiLayout />} title="Interface"> |
|||
<Interface /> |
|||
</CollapsibleSection> |
|||
</SidebarOverlay> |
|||
|
|||
{/* Modules */} |
|||
<SidebarOverlay |
|||
title="Modules" |
|||
open={modulesOpen} |
|||
close={(): void => { |
|||
setModulesOpen(false); |
|||
}} |
|||
direction="x" |
|||
> |
|||
<CollapsibleSection |
|||
icon={<FiWifi />} |
|||
title="MQTT" |
|||
status={!moduleConfig.mqtt.disabled} |
|||
> |
|||
<MQTT /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection |
|||
icon={<FiAlignLeft />} |
|||
title="Serial" |
|||
status={moduleConfig.serial.enabled} |
|||
> |
|||
<SerialSettingsPanel /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection |
|||
icon={<FiBell />} |
|||
title="External Notifications" |
|||
status={moduleConfig.extNotification.enabled} |
|||
> |
|||
<ExternalNotificationsSettingsPlanel /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection |
|||
icon={<FiFastForward />} |
|||
title="Store & Forward" |
|||
status={moduleConfig.storeForward.enabled} |
|||
> |
|||
<StoreForwardSettingsPanel /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection |
|||
icon={<FiRss />} |
|||
title="Range Test" |
|||
status={moduleConfig.rangeTest.enabled} |
|||
> |
|||
<RangeTestSettingsPanel /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection |
|||
icon={<FiActivity />} |
|||
title="Telemetry" |
|||
status={true} |
|||
> |
|||
<Telemetry /> |
|||
</CollapsibleSection> |
|||
<CollapsibleSection |
|||
icon={<FiMessageSquare />} |
|||
title="Canned Message" |
|||
status={moduleConfig.cannedMessage.enabled} |
|||
> |
|||
<CannedMessage /> |
|||
</CollapsibleSection> |
|||
</SidebarOverlay> |
|||
{/* End Modules */} |
|||
|
|||
{/* Channels */} |
|||
<SidebarOverlay |
|||
title="Channels" |
|||
open={channelsOpen} |
|||
close={(): void => { |
|||
setChannelsOpen(false); |
|||
}} |
|||
direction="x" |
|||
> |
|||
<ChannelsGroup /> |
|||
</SidebarOverlay> |
|||
{/* End Channels */} |
|||
</> |
|||
); |
|||
}; |
|||
@ -1,28 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { Select } from '@components/generic/form/Select'; |
|||
|
|||
export const Interface = (): JSX.Element => { |
|||
return ( |
|||
<Select |
|||
label="Language" |
|||
options={[ |
|||
{ |
|||
name: 'English', |
|||
value: 'en', |
|||
}, |
|||
{ |
|||
name: '日本', |
|||
value: 'jp', |
|||
}, |
|||
{ |
|||
name: 'Português', |
|||
value: 'pt', |
|||
}, |
|||
]} |
|||
onChange={(e): void => { |
|||
console.log('changed language'); |
|||
}} |
|||
/> |
|||
); |
|||
}; |
|||
@ -1,141 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const LoRa = (): JSX.Element => { |
|||
const myNodeNum = useAppSelector( |
|||
(state) => state.meshtastic.radio.hardware.myNodeNum, |
|||
); |
|||
const loraConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.config.lora, |
|||
); |
|||
const [loading, setLoading] = useState(false); |
|||
const [usePreset, setUsePreset] = useState(true); |
|||
// const { register, handleSubmit, formState, reset } =
|
|||
// useForm<Protobuf.RadioConfig_UserPreferences>({
|
|||
// defaultValues: preferences,
|
|||
// });
|
|||
const { register, handleSubmit, formState, reset } = |
|||
useForm<Protobuf.Config_LoRaConfig>({ |
|||
defaultValues: loraConfig, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(loraConfig); |
|||
}, [reset, loraConfig]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
|
|||
const packet = Protobuf.AdminMessage.create({ |
|||
variant: { |
|||
oneofKind: 'setConfig', |
|||
setConfig: { |
|||
payloadVariant: { |
|||
oneofKind: 'lora', |
|||
lora: data, |
|||
}, |
|||
}, |
|||
}, |
|||
}); |
|||
|
|||
void connection.sendPacket( |
|||
Protobuf.AdminMessage.toBinary(packet), |
|||
Protobuf.PortNum.ADMIN_APP, |
|||
myNodeNum, |
|||
true, |
|||
0, |
|||
true, |
|||
false, |
|||
async (num) => { |
|||
return await Promise.resolve(); |
|||
}, |
|||
); |
|||
// void connection.setPreferences(data, async () => {
|
|||
// reset({ ...data });
|
|||
// setLoading(false);
|
|||
// await Promise.resolve();
|
|||
// });
|
|||
}); |
|||
return ( |
|||
<> |
|||
<Checkbox |
|||
checked={usePreset} |
|||
label="Use Presets" |
|||
onChange={(e): void => setUsePreset(e.target.checked)} |
|||
/> |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
{usePreset ? ( |
|||
<Select |
|||
label="Preset" |
|||
optionsEnum={Protobuf.Config_LoRaConfig_ModemPreset} |
|||
{...register('modemPreset', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
) : ( |
|||
<> |
|||
<Input |
|||
label="Bandwidth" |
|||
type="number" |
|||
suffix="MHz" |
|||
{...register('bandwidth', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
label="Spread Factor" |
|||
type="number" |
|||
suffix="CPS" |
|||
min={7} |
|||
max={12} |
|||
{...register('spreadFactor', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
label="Coding Rate" |
|||
type="number" |
|||
{...register('codingRate', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
</> |
|||
)} |
|||
<Input |
|||
label="Transmit Power" |
|||
type="number" |
|||
suffix="dBm" |
|||
{...register('txPower', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Hop Count" |
|||
type="number" |
|||
suffix="Hops" |
|||
{...register('hopLimit', { valueAsNumber: true })} |
|||
/> |
|||
<Checkbox label="Transmit Disabled" {...register('txDisabled')} /> |
|||
<Input |
|||
label="Frequency Offset" |
|||
type="number" |
|||
suffix="Hz" |
|||
{...register('frequencyOffset', { valueAsNumber: true })} |
|||
/> |
|||
<Select |
|||
label="Region" |
|||
optionsEnum={Protobuf.Config_LoRaConfig_RegionCode} |
|||
{...register('region', { valueAsNumber: true })} |
|||
/> |
|||
</Form> |
|||
</> |
|||
); |
|||
}; |
|||
@ -1,140 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { Controller, useForm } from 'react-hook-form'; |
|||
import { MultiSelect } from 'react-multi-select-component'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { Label } from '@components/generic/form/Label'; |
|||
import { connection } from '@core/connection'; |
|||
import { bitwiseDecode, bitwiseEncode } from '@core/utils/bitwise'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const Position = (): JSX.Element => { |
|||
const positionConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.config.position, |
|||
); |
|||
const [loading, setLoading] = useState(false); |
|||
const { register, handleSubmit, formState, reset, control } = |
|||
useForm<Protobuf.Config_PositionConfig>({ |
|||
defaultValues: positionConfig, |
|||
// defaultValues: {
|
|||
// ...preferences,
|
|||
// positionBroadcastSecs:
|
|||
// preferences.positionBroadcastSecs === 0
|
|||
// ? preferences.role === Protobuf.Role.Router
|
|||
// ? 43200
|
|||
// : 900
|
|||
// : preferences.positionBroadcastSecs,
|
|||
// },
|
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(positionConfig); |
|||
}, [reset, positionConfig]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'position', |
|||
position: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Input |
|||
label="Broadcast Interval" |
|||
type="number" |
|||
suffix="Seconds" |
|||
{...register('positionBroadcastSecs', { valueAsNumber: true })} |
|||
/> |
|||
<Checkbox |
|||
label="Disable Smart Position" |
|||
{...register('positionBroadcastSmartDisabled')} |
|||
/> |
|||
<Checkbox label="Use Fixed Position" {...register('fixedPosition')} /> |
|||
<Checkbox |
|||
label="Disable Location Sharing" |
|||
{...register('locationShareDisabled')} |
|||
/> |
|||
<Checkbox label="Disable GPS" {...register('gpsDisabled')} /> |
|||
<Input |
|||
label="GPS Update Interval" |
|||
type="number" |
|||
suffix="Seconds" |
|||
{...register('gpsUpdateInterval', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Last GPS Attempt" |
|||
disabled |
|||
{...register('gpsAttemptTime', { valueAsNumber: true })} |
|||
/> |
|||
<Checkbox label="Accept 2D Fix" {...register('gpsAccept2D')} /> |
|||
<Input |
|||
label="Max DOP" |
|||
type="number" |
|||
{...register('gpsMaxDop', { valueAsNumber: true })} |
|||
/> |
|||
<Controller |
|||
name="positionFlags" |
|||
control={control} |
|||
render={({ field, fieldState }): JSX.Element => { |
|||
const { value, onChange, ...rest } = field; |
|||
const { error } = fieldState; |
|||
const label = 'Position Flags'; |
|||
return ( |
|||
<div className="w-full"> |
|||
{label && <Label label={label} error={error?.message} />} |
|||
<MultiSelect |
|||
options={Object.entries( |
|||
Protobuf.Config_PositionConfig_PositionFlags, |
|||
) |
|||
.filter((value) => typeof value[1] !== 'number') |
|||
.filter( |
|||
(value) => |
|||
parseInt(value[0]) !== |
|||
Protobuf.Config_PositionConfig_PositionFlags |
|||
.POS_UNDEFINED, |
|||
) |
|||
.map((value) => { |
|||
return { |
|||
value: parseInt(value[0]), |
|||
label: value[1].toString().replace('POS_', ''), |
|||
}; |
|||
})} |
|||
value={bitwiseDecode( |
|||
value, |
|||
Protobuf.Config_PositionConfig_PositionFlags, |
|||
).map((flag) => { |
|||
return { |
|||
value: flag, |
|||
label: Protobuf.Config_PositionConfig_PositionFlags[ |
|||
flag |
|||
].replace('POS_', ''), |
|||
}; |
|||
})} |
|||
onChange={(e: { value: number; label: string }[]): void => |
|||
onChange(bitwiseEncode(e.map((v) => v.value))) |
|||
} |
|||
labelledBy="Select" |
|||
/> |
|||
</div> |
|||
); |
|||
}} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,124 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const Power = (): JSX.Element => { |
|||
const powerConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.config.power, |
|||
); |
|||
const deviceConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.config.device, |
|||
); |
|||
const [loading, setLoading] = useState(false); |
|||
const { register, handleSubmit, formState, reset } = |
|||
useForm<Protobuf.Config_PowerConfig>({ |
|||
defaultValues: powerConfig, |
|||
// defaultValues: {
|
|||
// ...preferences,
|
|||
// isLowPower:
|
|||
// preferences.role === Protobuf.Role.Router
|
|||
// ? true
|
|||
// : preferences.isLowPower,
|
|||
// },
|
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(powerConfig); |
|||
}, [reset, powerConfig]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'power', |
|||
power: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Select |
|||
label="Charge current" |
|||
optionsEnum={Protobuf.Config_PowerConfig_ChargeCurrent} |
|||
{...register('chargeCurrent', { valueAsNumber: true })} |
|||
/> |
|||
<Checkbox |
|||
label="Powered by low power source (solar)" |
|||
disabled={ |
|||
deviceConfig.role === Protobuf.Config_DeviceConfig_Role.Router |
|||
} |
|||
validationMessage={ |
|||
deviceConfig.role === Protobuf.Config_DeviceConfig_Role.Router |
|||
? 'Enabled by default in router mode' |
|||
: '' |
|||
} |
|||
{...register('isLowPower')} |
|||
/> |
|||
<Checkbox label="Always Powered" {...register('isAlwaysPowered')} /> |
|||
<Input |
|||
label="Shutdown on battery delay" |
|||
type="number" |
|||
suffix="Seconds" |
|||
{...register('onBatteryShutdownAfterSecs', { valueAsNumber: true })} |
|||
/> |
|||
<Checkbox label="Power Saving" {...register('isPowerSaving')} /> |
|||
<Input |
|||
label="ADC Multiplier Override ratio" |
|||
type="number" |
|||
{...register('adcMultiplierOverride', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Minumum Wake Time" |
|||
suffix="Seconds" |
|||
type="number" |
|||
{...register('minWakeSecs', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Phone Timeout" |
|||
suffix="Seconds" |
|||
type="number" |
|||
{...register('phoneTimeoutSecs', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Mesh SDS Timeout" |
|||
suffix="Seconds" |
|||
type="number" |
|||
{...register('meshSdsTimeoutSecs', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="SDS" |
|||
suffix="Seconds" |
|||
type="number" |
|||
{...register('sdsSecs', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="LS" |
|||
suffix="Seconds" |
|||
type="number" |
|||
{...register('lsSecs', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Wait Bluetooth" |
|||
suffix="Seconds" |
|||
type="number" |
|||
{...register('waitBluetoothSecs', { valueAsNumber: true })} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,106 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm } from 'react-hook-form'; |
|||
import { base16 } from 'rfc4648'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const User = (): JSX.Element => { |
|||
const [loading, setLoading] = useState(false); |
|||
const myNodeNum = useAppSelector( |
|||
(state) => state.meshtastic.radio.hardware, |
|||
).myNodeNum; |
|||
const node = useAppSelector((state) => state.meshtastic.nodes).find( |
|||
(node) => node.data.num === myNodeNum, |
|||
); |
|||
const { register, handleSubmit, formState, reset } = useForm<{ |
|||
longName: string; |
|||
shortName: string; |
|||
isLicensed: boolean; |
|||
antAzimuth: number; |
|||
antGainDbi: number; |
|||
txPowerDbm: number; |
|||
}>({ |
|||
defaultValues: { |
|||
longName: node?.data.user?.longName, |
|||
shortName: node?.data.user?.shortName, |
|||
isLicensed: node?.data.user?.isLicensed, |
|||
antAzimuth: node?.data.user?.antAzimuth, |
|||
antGainDbi: node?.data.user?.antGainDbi, |
|||
txPowerDbm: node?.data.user?.txPowerDbm, |
|||
}, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset({ |
|||
longName: node?.data.user?.longName, |
|||
shortName: node?.data.user?.shortName, |
|||
isLicensed: node?.data.user?.isLicensed, |
|||
}); |
|||
}, [reset, node]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
|
|||
if (node?.data.user) { |
|||
void connection.setOwner({ ...node.data.user, ...data }, async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Input label="Device ID" value={node?.data.user?.id} disabled /> |
|||
<Input label="Device Name" {...register('longName')} /> |
|||
<Input label="Short Name" maxLength={3} {...register('shortName')} /> |
|||
<Input |
|||
label="Mac Address" |
|||
defaultValue={ |
|||
base16 |
|||
.stringify(node?.data.user?.macaddr ?? []) |
|||
.match(/.{1,2}/g) |
|||
?.join(':') ?? '' |
|||
} |
|||
disabled |
|||
/> |
|||
<Input |
|||
label="Hardware (DEPRECATED)" |
|||
value={ |
|||
Protobuf.HardwareModel[ |
|||
node?.data.user?.hwModel ?? Protobuf.HardwareModel.UNSET |
|||
] |
|||
} |
|||
disabled |
|||
/> |
|||
<Checkbox label="Licenced Operator?" {...register('isLicensed')} /> |
|||
<Input |
|||
label="Transmit Power" |
|||
suffix="dBm" |
|||
type="number" |
|||
{...register('txPowerDbm', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Antenna Gain" |
|||
suffix="dBi" |
|||
type="number" |
|||
{...register('antGainDbi', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Antenna Azimuth" |
|||
suffix="°" |
|||
type="number" |
|||
{...register('antAzimuth', { valueAsNumber: true })} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,62 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm, useWatch } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import type { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const WiFi = (): JSX.Element => { |
|||
const wifiConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.config.wifi, |
|||
); |
|||
const [loading, setLoading] = useState(false); |
|||
const { register, handleSubmit, formState, reset, control } = |
|||
useForm<Protobuf.Config_WiFiConfig>({ |
|||
defaultValues: wifiConfig, |
|||
}); |
|||
|
|||
const WifiApMode = useWatch({ |
|||
control, |
|||
name: 'apMode', |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(wifiConfig); |
|||
}, [reset, wifiConfig]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'wifi', |
|||
wifi: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Checkbox label="Enable WiFi AP" {...register('apMode')} /> |
|||
<Input label="WiFi SSID" disabled={WifiApMode} {...register('ssid')} /> |
|||
<Input |
|||
type="password" |
|||
autoComplete="off" |
|||
label="WiFi PSK" |
|||
disabled={WifiApMode} |
|||
{...register('psk')} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,127 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { fromByteArray, toByteArray } from 'base64-js'; |
|||
import { useForm } from 'react-hook-form'; |
|||
import { MdRefresh, MdVisibility, MdVisibilityOff } from 'react-icons/md'; |
|||
|
|||
import { IconButton } from '@components/generic/button/IconButton'; |
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { connection } from '@core/connection'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export interface SettingsPanelProps { |
|||
channel: Protobuf.Channel; |
|||
} |
|||
|
|||
export const Channels = ({ channel }: SettingsPanelProps): JSX.Element => { |
|||
const [loading, setLoading] = useState(false); |
|||
const [keySize, setKeySize] = useState<128 | 256>(256); |
|||
const [pskHidden, setPskHidden] = useState(true); |
|||
|
|||
const { register, handleSubmit, setValue, formState, reset } = useForm< |
|||
Omit<Protobuf.ChannelSettings, 'psk'> & { psk: string; enabled: boolean } |
|||
>({ |
|||
defaultValues: { |
|||
enabled: [ |
|||
Protobuf.Channel_Role.SECONDARY, |
|||
Protobuf.Channel_Role.PRIMARY, |
|||
].find((role) => role === channel?.role) |
|||
? true |
|||
: false, |
|||
...channel?.settings, |
|||
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), |
|||
}, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset({ |
|||
enabled: [ |
|||
Protobuf.Channel_Role.SECONDARY, |
|||
Protobuf.Channel_Role.PRIMARY, |
|||
].find((role) => role === channel?.role) |
|||
? true |
|||
: false, |
|||
...channel?.settings, |
|||
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), |
|||
}); |
|||
}, [channel, reset]); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
setLoading(true); |
|||
const channelData = Protobuf.Channel.create({ |
|||
role: |
|||
channel?.role === Protobuf.Channel_Role.PRIMARY |
|||
? Protobuf.Channel_Role.PRIMARY |
|||
: data.enabled |
|||
? Protobuf.Channel_Role.SECONDARY |
|||
: Protobuf.Channel_Role.DISABLED, |
|||
index: channel?.index, |
|||
settings: { |
|||
...data, |
|||
psk: toByteArray(data.psk ?? ''), |
|||
}, |
|||
}); |
|||
|
|||
await connection.setChannel(channelData, (): Promise<void> => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
return Promise.resolve(); |
|||
}); |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
{channel?.index !== 0 && ( |
|||
<> |
|||
<Checkbox |
|||
label="Enabled" |
|||
{...register('enabled', { valueAsNumber: true })} |
|||
/> |
|||
<Input label="Name" {...register('name')} /> |
|||
</> |
|||
)} |
|||
|
|||
<Select |
|||
label="Key Size" |
|||
options={[ |
|||
{ name: '128 Bit', value: 128 }, |
|||
{ name: '256 Bit', value: 256 }, |
|||
]} |
|||
value={keySize} |
|||
onChange={(e): void => { |
|||
setKeySize(parseInt(e.target.value) as 128 | 256); |
|||
}} |
|||
/> |
|||
<Input |
|||
label="Pre-Shared Key" |
|||
type={pskHidden ? 'password' : 'text'} |
|||
disabled |
|||
action={ |
|||
<> |
|||
<IconButton |
|||
onClick={(): void => { |
|||
setPskHidden(!pskHidden); |
|||
}} |
|||
icon={pskHidden ? <MdVisibility /> : <MdVisibilityOff />} |
|||
/> |
|||
<IconButton |
|||
onClick={(): void => { |
|||
const key = new Uint8Array(keySize); |
|||
crypto.getRandomValues(key); |
|||
setValue('psk', fromByteArray(key)); |
|||
}} |
|||
icon={<MdRefresh />} |
|||
/> |
|||
</> |
|||
} |
|||
{...register('psk')} |
|||
/> |
|||
<Checkbox label="Uplink Enabled" {...register('uplinkEnabled')} /> |
|||
<Checkbox label="Downlink Enabled" {...register('downlinkEnabled')} /> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,43 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { CollapsibleSection } from '@components/generic/Sidebar/CollapsibleSection'; |
|||
import { Channels } from '@components/layout/Sidebar/Settings/channels/Channels'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const ChannelsGroup = (): JSX.Element => { |
|||
const channels = useAppSelector((state) => state.meshtastic.radio.channels); |
|||
|
|||
return ( |
|||
<> |
|||
{channels.map((channel) => { |
|||
return ( |
|||
<div key={channel.index}> |
|||
<CollapsibleSection |
|||
title={ |
|||
channel.settings?.name.length |
|||
? channel.settings.name |
|||
: channel.role === Protobuf.Channel_Role.PRIMARY |
|||
? 'Primary' |
|||
: `Channel: ${channel.index}` |
|||
} |
|||
icon={ |
|||
<div |
|||
className={`h-3 w-3 rounded-full ${ |
|||
channel.role === Protobuf.Channel_Role.PRIMARY |
|||
? 'bg-orange-500' |
|||
: channel.role === Protobuf.Channel_Role.SECONDARY |
|||
? 'bg-green-500' |
|||
: 'bg-gray-500' |
|||
}`}
|
|||
/> |
|||
} |
|||
> |
|||
<Channels channel={channel} /> |
|||
</CollapsibleSection> |
|||
</div> |
|||
); |
|||
})} |
|||
</> |
|||
); |
|||
}; |
|||
@ -1,102 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm, useWatch } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const CannedMessage = (): JSX.Element => { |
|||
const cannedMessageConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.moduleConfig.cannedMessage, |
|||
); |
|||
const [loading, setLoading] = useState(false); |
|||
const { register, handleSubmit, formState, reset, control } = |
|||
useForm<Protobuf.ModuleConfig_CannedMessageConfig>({ |
|||
defaultValues: cannedMessageConfig, |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: 'rotary1Enabled', |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(cannedMessageConfig); |
|||
}, [reset, cannedMessageConfig]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'cannedMessage', |
|||
cannedMessage: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Checkbox label="Module Enabled" {...register('enabled')} /> |
|||
<Checkbox |
|||
label="Rotary Encoder #1 Enabled" |
|||
{...register('rotary1Enabled')} |
|||
/> |
|||
<Input |
|||
label="Encoder Pin A" |
|||
type="number" |
|||
disabled={moduleEnabled} |
|||
{...register('inputbrokerPinA', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Encoder Pin B" |
|||
type="number" |
|||
disabled={moduleEnabled} |
|||
{...register('inputbrokerPinB', { valueAsNumber: true })} |
|||
/> |
|||
<Input |
|||
label="Endoer Pin Press" |
|||
type="number" |
|||
disabled={moduleEnabled} |
|||
{...register('inputbrokerPinPress', { valueAsNumber: true })} |
|||
/> |
|||
<Select |
|||
label="Clockwise event" |
|||
disabled={moduleEnabled} |
|||
optionsEnum={Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar} |
|||
{...register('inputbrokerEventCw', { valueAsNumber: true })} |
|||
/> |
|||
<Select |
|||
label="Counter Clockwise event" |
|||
disabled={moduleEnabled} |
|||
optionsEnum={Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar} |
|||
{...register('inputbrokerEventCcw', { valueAsNumber: true })} |
|||
/> |
|||
<Select |
|||
label="Press event" |
|||
disabled={moduleEnabled} |
|||
optionsEnum={Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar} |
|||
{...register('inputbrokerEventPress', { valueAsNumber: true })} |
|||
/> |
|||
<Checkbox label="Up Down enabled" {...register('updown1Enabled')} /> |
|||
<Input |
|||
label="Allow Input Source" |
|||
disabled={moduleEnabled} |
|||
{...register('allowInputSource')} |
|||
/> |
|||
<Checkbox label="Send Bell" {...register('sendBell')} /> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,89 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm, useWatch } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import type { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const ExternalNotificationsSettingsPlanel = (): JSX.Element => { |
|||
const [loading, setLoading] = useState(false); |
|||
|
|||
const extNotificationConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.moduleConfig.extNotification, |
|||
); |
|||
|
|||
const { register, handleSubmit, formState, reset, control } = |
|||
useForm<Protobuf.ModuleConfig_ExternalNotificationConfig>({ |
|||
defaultValues: extNotificationConfig, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(extNotificationConfig); |
|||
}, [reset, extNotificationConfig]); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
setLoading(true); |
|||
await connection.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'externalNotification', |
|||
externalNotification: data, |
|||
}, |
|||
}, |
|||
async (): Promise<void> => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: 'enabled', |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Checkbox label="Module Enabled" {...register('enabled')} /> |
|||
<Input |
|||
type="number" |
|||
label="Output MS" |
|||
suffix="ms" |
|||
disabled={!moduleEnabled} |
|||
{...register('outputMs', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
type="number" |
|||
label="Output" |
|||
disabled={!moduleEnabled} |
|||
{...register('output', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Checkbox |
|||
label="Active" |
|||
disabled={!moduleEnabled} |
|||
{...register('active')} |
|||
/> |
|||
<Checkbox |
|||
label="Message" |
|||
disabled={!moduleEnabled} |
|||
{...register('alertMessage')} |
|||
/> |
|||
<Checkbox |
|||
label="Bell" |
|||
disabled={!moduleEnabled} |
|||
{...register('alertBell')} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,76 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm, useWatch } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import type { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const MQTT = (): JSX.Element => { |
|||
const mqttConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.moduleConfig.mqtt, |
|||
); |
|||
const [loading, setLoading] = useState(false); |
|||
const { register, handleSubmit, formState, reset, control } = |
|||
useForm<Protobuf.ModuleConfig_MQTTConfig>({ |
|||
defaultValues: mqttConfig, |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: 'disabled', |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(mqttConfig); |
|||
}, [reset, mqttConfig]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'mqtt', |
|||
mqtt: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Checkbox label="Module Disabled" {...register('disabled')} /> |
|||
<Input |
|||
label="MQTT Server Address" |
|||
disabled={moduleEnabled} |
|||
{...register('address')} |
|||
/> |
|||
<Input |
|||
label="MQTT Username" |
|||
disabled={moduleEnabled} |
|||
{...register('username')} |
|||
/> |
|||
<Input |
|||
label="MQTT Password" |
|||
type="password" |
|||
autoComplete="off" |
|||
disabled={moduleEnabled} |
|||
{...register('password')} |
|||
/> |
|||
<Checkbox |
|||
label="Encryption Enabled" |
|||
disabled={moduleEnabled} |
|||
{...register('encryptionEnabled')} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,71 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm, useWatch } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import type { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const RangeTestSettingsPanel = (): JSX.Element => { |
|||
const [loading, setLoading] = useState(false); |
|||
|
|||
const rangeTestConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.moduleConfig.rangeTest, |
|||
); |
|||
|
|||
const { register, handleSubmit, formState, reset, control } = |
|||
useForm<Protobuf.ModuleConfig_RangeTestConfig>({ |
|||
defaultValues: rangeTestConfig, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(rangeTestConfig); |
|||
}, [reset, rangeTestConfig]); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
setLoading(true); |
|||
await connection.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'rangeTest', |
|||
rangeTest: data, |
|||
}, |
|||
}, |
|||
async (): Promise<void> => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: 'enabled', |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Checkbox label="Module Enabled" {...register('enabled')} /> |
|||
<Input |
|||
type="number" |
|||
label="Message Interval" |
|||
disabled={!moduleEnabled} |
|||
suffix="Seconds" |
|||
{...register('sender', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Checkbox |
|||
label="Save CSV to storage" |
|||
disabled={!moduleEnabled} |
|||
{...register('save')} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,99 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm, useWatch } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import type { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const SerialSettingsPanel = (): JSX.Element => { |
|||
const [loading, setLoading] = useState(false); |
|||
|
|||
const serialConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.moduleConfig.serial, |
|||
); |
|||
|
|||
const { register, handleSubmit, formState, reset, control } = |
|||
useForm<Protobuf.ModuleConfig_SerialConfig>({ |
|||
defaultValues: serialConfig, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(serialConfig); |
|||
}, [reset, serialConfig]); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
setLoading(true); |
|||
await connection.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'serial', |
|||
serial: data, |
|||
}, |
|||
}, |
|||
async (): Promise<void> => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: 'enabled', |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Checkbox label="Module Enabled" {...register('enabled')} /> |
|||
<Checkbox label="Echo" disabled={!moduleEnabled} {...register('echo')} /> |
|||
|
|||
<Input |
|||
type="number" |
|||
label="RX" |
|||
disabled={!moduleEnabled} |
|||
{...register('rxd', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
type="number" |
|||
label="TX Pin" |
|||
disabled={!moduleEnabled} |
|||
{...register('txd', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
type="number" |
|||
label="Baud Rate" |
|||
disabled={!moduleEnabled} |
|||
{...register('baud', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
type="number" |
|||
label="Timeout" |
|||
disabled={!moduleEnabled} |
|||
{...register('timeout', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
type="number" |
|||
label="Mode" |
|||
disabled={!moduleEnabled} |
|||
{...register('mode', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,87 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm, useWatch } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import type { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const StoreForwardSettingsPanel = (): JSX.Element => { |
|||
const [loading, setLoading] = useState(false); |
|||
|
|||
const storeForwardConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.moduleConfig.storeForward, |
|||
); |
|||
|
|||
const { register, handleSubmit, formState, reset, control } = |
|||
useForm<Protobuf.ModuleConfig_StoreForwardConfig>({ |
|||
defaultValues: storeForwardConfig, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(storeForwardConfig); |
|||
}, [reset, storeForwardConfig]); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
setLoading(true); |
|||
await connection.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'storeForward', |
|||
storeForward: data, |
|||
}, |
|||
}, |
|||
async (): Promise<void> => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
|
|||
const moduleEnabled = useWatch({ |
|||
control, |
|||
name: 'enabled', |
|||
defaultValue: false, |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Checkbox label="Module Enabled" {...register('enabled')} /> |
|||
<Checkbox |
|||
label="Heartbeat Enabled" |
|||
disabled={!moduleEnabled} |
|||
{...register('heartbeat')} |
|||
/> |
|||
<Input |
|||
type="number" |
|||
label="Number of records" |
|||
suffix="Records" |
|||
disabled={!moduleEnabled} |
|||
{...register('records', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
type="number" |
|||
label="History return max" |
|||
disabled={!moduleEnabled} |
|||
{...register('historyReturnMax', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
type="number" |
|||
label="History return window" |
|||
disabled={!moduleEnabled} |
|||
{...register('historyReturnWindow', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,97 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect, useState } from 'react'; |
|||
|
|||
import { useForm } from 'react-hook-form'; |
|||
|
|||
import { Checkbox } from '@components/generic/form/Checkbox'; |
|||
import { Form } from '@components/generic/form/Form'; |
|||
import { Input } from '@components/generic/form/Input'; |
|||
import { Select } from '@components/generic/form/Select'; |
|||
import { connection } from '@core/connection'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const Telemetry = (): JSX.Element => { |
|||
const telemetryConfig = useAppSelector( |
|||
(state) => state.meshtastic.radio.moduleConfig.telemetry, |
|||
); |
|||
const [loading, setLoading] = useState(false); |
|||
const { register, handleSubmit, formState, reset, control } = |
|||
useForm<Protobuf.ModuleConfig_TelemetryConfig>({ |
|||
defaultValues: telemetryConfig, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(telemetryConfig); |
|||
}, [reset, telemetryConfig]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection.setModuleConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: 'telemetry', |
|||
telemetry: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
}, |
|||
); |
|||
}); |
|||
return ( |
|||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}> |
|||
<Checkbox |
|||
label="Measurement Enabled" |
|||
{...register('environmentMeasurementEnabled')} |
|||
/> |
|||
<Checkbox |
|||
label="Displayed on Screen" |
|||
{...register('environmentScreenEnabled')} |
|||
/> |
|||
<Input |
|||
label="Read Error Count Threshold" |
|||
type="number" |
|||
{...register('environmentReadErrorCountThreshold', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
label="Update Interval" |
|||
suffix="Seconds" |
|||
type="number" |
|||
{...register('environmentUpdateInterval', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
label="Recovery Interval" |
|||
suffix="Seconds" |
|||
type="number" |
|||
{...register('environmentRecoveryInterval', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Checkbox |
|||
label="Display Farenheit" |
|||
{...register('environmentDisplayFahrenheit')} |
|||
/> |
|||
<Select |
|||
label="Sensor Type" |
|||
optionsEnum={Protobuf.TelemetrySensorType} |
|||
{...register('environmentSensorType', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
<Input |
|||
label="Sensor Pin" |
|||
type="number" |
|||
{...register('environmentSensorPin', { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -1,38 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { m } from 'framer-motion'; |
|||
|
|||
export interface SidebarItemProps { |
|||
selected: boolean; |
|||
setSelected: () => void; |
|||
actions?: React.ReactNode; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const SidebarItem = ({ |
|||
selected, |
|||
setSelected, |
|||
actions, |
|||
children, |
|||
}: SidebarItemProps): JSX.Element => { |
|||
return ( |
|||
<div |
|||
onClick={(): void => { |
|||
setSelected(); |
|||
}} |
|||
className={`mx-2 flex cursor-pointer select-none rounded-md border bg-gray-200 p-2 shadow-md first:mt-2 last:mb-2 hover:border-primary dark:bg-tertiaryDark dark:hover:border-primary ${ |
|||
selected ? 'border-primary' : 'border-gray-400 dark:border-gray-600' |
|||
}`}
|
|||
> |
|||
<m.div |
|||
className="flex w-full justify-between" |
|||
whileHover={{ scale: 1.01 }} |
|||
whileTap={{ scale: 0.99 }} |
|||
> |
|||
<div className="flex gap-2">{children}</div> |
|||
|
|||
<div className="flex gap-1">{actions}</div> |
|||
</m.div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,33 +1,120 @@ |
|||
import type React from 'react'; |
|||
import type React from "react"; |
|||
import { useState } from "react"; |
|||
|
|||
import { Settings } from '@components/layout/Sidebar/Settings/Index'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { |
|||
ArrayIcon, |
|||
GlobeIcon, |
|||
IconComponent, |
|||
InboxIcon, |
|||
InfoSignIcon, |
|||
LabTestIcon, |
|||
LayersIcon, |
|||
majorScale, |
|||
Pane, |
|||
SettingsIcon, |
|||
Tab, |
|||
Tablist, |
|||
} from "evergreen-ui"; |
|||
|
|||
export interface SidebarProps { |
|||
children: React.ReactNode; |
|||
setSettingsOpen: (settingsOpen: boolean) => void; |
|||
settingsOpen: boolean; |
|||
import { PeersDialog } from "@app/components/Dialog/PeersDialog.js"; |
|||
import { Page, useDevice } from "@app/core/stores/deviceStore.js"; |
|||
|
|||
import { DeviceCard } from "./DeviceCard.js"; |
|||
|
|||
interface NavLink { |
|||
name: string; |
|||
icon: IconComponent; |
|||
page: Page; |
|||
disabled?: boolean; |
|||
} |
|||
|
|||
export const Sidebar = ({ |
|||
settingsOpen, |
|||
setSettingsOpen, |
|||
children, |
|||
}: SidebarProps): JSX.Element => { |
|||
const appState = useAppSelector((state) => state.app); |
|||
export const Sidebar = (): JSX.Element => { |
|||
const { activePage, setActivePage } = useDevice(); |
|||
const [PeersDialogOpen, setPeersDialogOpen] = useState(false); |
|||
|
|||
const navLinks: NavLink[] = [ |
|||
{ |
|||
name: "Messages", |
|||
icon: InboxIcon, |
|||
page: "messages", |
|||
}, |
|||
{ |
|||
name: "Map", |
|||
icon: GlobeIcon, |
|||
page: "map", |
|||
disabled: true, |
|||
}, |
|||
{ |
|||
name: "Extensions", |
|||
icon: LabTestIcon, |
|||
page: "extensions", |
|||
}, |
|||
{ |
|||
name: "Config", |
|||
icon: SettingsIcon, |
|||
page: "config", |
|||
}, |
|||
{ |
|||
name: "Channels", |
|||
icon: LayersIcon, |
|||
page: "channels", |
|||
}, |
|||
{ |
|||
name: "Info", |
|||
icon: InfoSignIcon, |
|||
page: "info", |
|||
}, |
|||
]; |
|||
|
|||
return ( |
|||
<div |
|||
className={`absolute z-20 h-full w-full flex-grow flex-col md:relative md:flex md:w-96 ${ |
|||
appState.mobileNavOpen ? 'flex' : 'hidden' |
|||
}`}
|
|||
<Pane |
|||
display="flex" |
|||
flexDirection="column" |
|||
width="100%" |
|||
flexGrow={1} |
|||
margin={majorScale(3)} |
|||
padding={majorScale(2)} |
|||
borderRadius={majorScale(1)} |
|||
background="white" |
|||
elevation={1} |
|||
> |
|||
<div className="flex h-full w-full flex-col drop-shadow-xl dark:bg-primaryDark"> |
|||
<div className="relative flex-grow gap-1"> |
|||
<div className="absolute h-full w-full">{children}</div> |
|||
<Settings open={settingsOpen} setOpen={setSettingsOpen} /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<Tablist> |
|||
{navLinks.map((Link) => ( |
|||
<Tab |
|||
key={Link.name} |
|||
gap={majorScale(2)} |
|||
disabled={Link.disabled} |
|||
direction="vertical" |
|||
isSelected={Link.page === activePage} |
|||
onSelect={() => { |
|||
setActivePage(Link.page); |
|||
}} |
|||
> |
|||
<Link.icon /> |
|||
{Link.name} |
|||
</Tab> |
|||
))} |
|||
<Tab |
|||
gap={5} |
|||
direction="vertical" |
|||
isSelected={PeersDialogOpen} |
|||
onSelect={() => { |
|||
setPeersDialogOpen(true); |
|||
}} |
|||
> |
|||
<ArrayIcon /> |
|||
Peers |
|||
</Tab> |
|||
</Tablist> |
|||
<PeersDialog |
|||
isOpen={PeersDialogOpen} |
|||
close={() => { |
|||
setPeersDialogOpen(false); |
|||
}} |
|||
/> |
|||
<Pane display="flex" flexGrow={1}> |
|||
<DeviceCard /> |
|||
</Pane> |
|||
</Pane> |
|||
); |
|||
}; |
|||
|
|||
@ -1,91 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useState } from 'react'; |
|||
|
|||
import { ErrorBoundary } from 'react-error-boundary'; |
|||
import { FiMessageCircle, FiSettings } from 'react-icons/fi'; |
|||
import { RiRoadMapLine } from 'react-icons/ri'; |
|||
import { VscExtensions } from 'react-icons/vsc'; |
|||
|
|||
import { ErrorFallback } from '@components/ErrorFallback'; |
|||
import { IconButton } from '@components/generic/button/IconButton'; |
|||
import { Sidebar } from '@components/layout/Sidebar'; |
|||
import type { TabProps } from '@components/Tab'; |
|||
import { Tabs } from '@components/Tabs'; |
|||
import { routes, useRoute } from '@core/router'; |
|||
|
|||
export interface LayoutProps { |
|||
title: string; |
|||
icon: React.ReactNode; |
|||
sidebarContents: React.ReactNode; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const Layout = ({ |
|||
title, |
|||
icon, |
|||
sidebarContents, |
|||
children, |
|||
}: LayoutProps): JSX.Element => { |
|||
const [settingsOpen, setSettingsOpen] = useState(false); |
|||
|
|||
const route = useRoute(); |
|||
|
|||
const tabs: Omit<TabProps, 'activeLeft' | 'activeRight'>[] = [ |
|||
{ |
|||
title: 'Messages', |
|||
icon: <FiMessageCircle />, |
|||
link: routes.messages().link, |
|||
active: route.name === 'messages', |
|||
}, |
|||
{ |
|||
title: 'Map', |
|||
icon: <RiRoadMapLine />, |
|||
link: routes.map().link, |
|||
active: route.name === 'map', |
|||
}, |
|||
{ |
|||
title: 'Extensions', |
|||
icon: <VscExtensions />, |
|||
link: routes.extensions().link, |
|||
active: route.name === 'extensions', |
|||
}, |
|||
]; |
|||
|
|||
return ( |
|||
<div className="relative flex w-full overflow-hidden bg-white dark:bg-secondaryDark"> |
|||
<div className="flex flex-grow"> |
|||
<Sidebar settingsOpen={settingsOpen} setSettingsOpen={setSettingsOpen}> |
|||
<div className="bg-white px-1 pt-1 drop-shadow-md dark:bg-primaryDark"> |
|||
<div className="flex h-10 gap-1"> |
|||
<div className="my-auto"> |
|||
<IconButton icon={icon} /> |
|||
</div> |
|||
<div className="my-auto text-lg font-medium dark:text-white"> |
|||
{title} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div className="flex flex-col gap-2">{sidebarContents}</div> |
|||
</Sidebar> |
|||
</div> |
|||
<ErrorBoundary FallbackComponent={ErrorFallback}> |
|||
<div className="flex h-full w-full flex-col bg-gray-300 dark:bg-secondaryDark"> |
|||
<div className="flex w-full bg-white pt-1 dark:bg-primaryDark"> |
|||
<div className="z-10 -mr-2 h-8"> |
|||
<IconButton |
|||
className="m-1" |
|||
icon={<FiSettings />} |
|||
onClick={(): void => { |
|||
setSettingsOpen(!settingsOpen); |
|||
}} |
|||
active={settingsOpen} |
|||
/> |
|||
</div> |
|||
<Tabs tabs={tabs} /> |
|||
</div> |
|||
<div className="flex flex-grow">{children}</div> |
|||
</div> |
|||
</ErrorBoundary> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,74 @@ |
|||
import type React from "react"; |
|||
|
|||
import { IconComponent, majorScale, Pane, Tab, Tablist } from "evergreen-ui"; |
|||
|
|||
export interface Tab { |
|||
key: number; |
|||
name: string; |
|||
icon: IconComponent; |
|||
element: () => JSX.Element; |
|||
disabled?: boolean; |
|||
} |
|||
|
|||
export interface TabbedContentProps { |
|||
active: number; |
|||
setActive: (index: number) => void; |
|||
tabs: Tab[]; |
|||
actions?: (() => JSX.Element)[]; |
|||
} |
|||
|
|||
export const TabbedContent = ({ |
|||
active, |
|||
setActive, |
|||
tabs, |
|||
actions, |
|||
}: TabbedContentProps): JSX.Element => { |
|||
return ( |
|||
<Pane |
|||
margin={majorScale(3)} |
|||
borderRadius={majorScale(1)} |
|||
background="white" |
|||
elevation={1} |
|||
display="flex" |
|||
flexGrow={1} |
|||
flexDirection="column" |
|||
padding={majorScale(2)} |
|||
gap={majorScale(2)} |
|||
> |
|||
<Pane borderBottom="muted" paddingBottom={majorScale(2)}> |
|||
<Pane display="flex"> |
|||
<Tablist> |
|||
{tabs.map((Entry) => ( |
|||
<Tab |
|||
key={Entry.key} |
|||
disabled={Entry.disabled} |
|||
gap={5} |
|||
onSelect={() => setActive(Entry.key)} |
|||
isSelected={active === Entry.key} |
|||
> |
|||
<Entry.icon /> |
|||
{Entry.name} |
|||
</Tab> |
|||
))} |
|||
</Tablist> |
|||
|
|||
<Pane marginLeft="auto"> |
|||
{actions?.map((Action, index) => ( |
|||
<Action key={index} /> |
|||
))} |
|||
</Pane> |
|||
</Pane> |
|||
</Pane> |
|||
{tabs.map((Entry) => ( |
|||
<Pane |
|||
key={Entry.key} |
|||
display={active === Entry.key ? "flex" : "none"} |
|||
flexDirection="column" |
|||
flexGrow={1} |
|||
> |
|||
<Entry.element /> |
|||
</Pane> |
|||
))} |
|||
</Pane> |
|||
); |
|||
}; |
|||
@ -1,203 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useState } from 'react'; |
|||
|
|||
import { |
|||
FiBluetooth, |
|||
FiCpu, |
|||
FiGitBranch, |
|||
FiMenu, |
|||
FiMoon, |
|||
FiSun, |
|||
FiWifi, |
|||
FiX, |
|||
} from 'react-icons/fi'; |
|||
import { |
|||
IoBatteryChargingOutline, |
|||
IoBatteryDeadOutline, |
|||
IoBatteryFullOutline, |
|||
} from 'react-icons/io5'; |
|||
import { MdUpgrade } from 'react-icons/md'; |
|||
import { |
|||
RiArrowDownLine, |
|||
RiArrowUpDownLine, |
|||
RiArrowUpLine, |
|||
} from 'react-icons/ri'; |
|||
|
|||
import { BottomNavItem } from '@components/menu/BottomNavItem'; |
|||
import { VersionInfo } from '@components/modals/VersionInfo'; |
|||
import { |
|||
connType, |
|||
openConnectionModal, |
|||
setDarkModeEnabled, |
|||
toggleMobileNav, |
|||
} from '@core/slices/appSlice'; |
|||
import { useAppDispatch } from '@hooks/useAppDispatch'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
import { Protobuf, Types } from '@meshtastic/meshtasticjs'; |
|||
|
|||
export const BottomNav = (): JSX.Element => { |
|||
const [showVersionInfo, setShowVersionInfo] = useState(false); |
|||
const dispatch = useAppDispatch(); |
|||
const meshtasticState = useAppSelector((state) => state.meshtastic); |
|||
const appState = useAppSelector((state) => state.app); |
|||
const primaryChannelSettings = useAppSelector( |
|||
(state) => state.meshtastic.radio.channels, |
|||
).find((channel) => channel.role === Protobuf.Channel_Role.PRIMARY)?.settings; |
|||
const metrics = |
|||
meshtasticState.nodes[meshtasticState.radio.hardware.myNodeNum]?.metrics; |
|||
|
|||
return ( |
|||
<div className="z-20 flex justify-between divide-x divide-gray-400 border-t border-gray-400 bg-white dark:divide-gray-600 dark:border-gray-600 dark:bg-secondaryDark"> |
|||
<BottomNavItem tooltip="Meshtastic WebUI"> |
|||
<img |
|||
title="Logo" |
|||
className="my-auto w-5" |
|||
src={appState.darkMode ? '/Logo_White.svg' : '/Logo_Black.svg'} |
|||
/> |
|||
</BottomNavItem> |
|||
|
|||
<BottomNavItem |
|||
tooltip="Connection Status" |
|||
onClick={(): void => { |
|||
dispatch(openConnectionModal()); |
|||
}} |
|||
className={ |
|||
[ |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTED, |
|||
Types.DeviceStatusEnum.DEVICE_CONFIGURED, |
|||
].includes(meshtasticState.deviceStatus) |
|||
? 'bg-primary dark:bg-primary' |
|||
: [ |
|||
Types.DeviceStatusEnum.DEVICE_CONNECTING, |
|||
Types.DeviceStatusEnum.DEVICE_RECONNECTING, |
|||
Types.DeviceStatusEnum.DEVICE_CONFIGURING, |
|||
].includes(meshtasticState.deviceStatus) |
|||
? 'bg-yellow-400 dark:bg-yellow-400' |
|||
: '' |
|||
} |
|||
> |
|||
{appState.connType === connType.BLE ? ( |
|||
<FiBluetooth className="h-4" /> |
|||
) : appState.connType === connType.SERIAL ? ( |
|||
<FiCpu className="h-4" /> |
|||
) : ( |
|||
<FiWifi className="h-4" /> |
|||
)} |
|||
<div className="truncate text-xs font-medium"> |
|||
{meshtasticState.nodes.find( |
|||
(node) => |
|||
node.data.num === meshtasticState.radio.hardware.myNodeNum, |
|||
)?.data.user?.longName ?? 'Disconnected'} |
|||
</div> |
|||
</BottomNavItem> |
|||
|
|||
<BottomNavItem tooltip="Battery Level"> |
|||
{!metrics?.batteryLevel ? ( |
|||
<IoBatteryDeadOutline className="h-4" /> |
|||
) : metrics?.batteryLevel > 50 ? ( |
|||
<IoBatteryFullOutline className="h-4" /> |
|||
) : metrics?.batteryLevel > 0 ? ( |
|||
<IoBatteryFullOutline className="h-4" /> |
|||
) : ( |
|||
<IoBatteryChargingOutline className="h-4" /> |
|||
)} |
|||
|
|||
<div className="truncate text-xs font-medium"> |
|||
{metrics?.batteryLevel |
|||
? `${metrics?.batteryLevel}% - ${metrics?.voltage}v` |
|||
: 'No Battery'} |
|||
</div> |
|||
</BottomNavItem> |
|||
|
|||
<BottomNavItem tooltip="Network Utilization"> |
|||
<div className="m-auto h-3 w-3 rounded-full bg-primary" /> |
|||
<div className="truncate text-xs font-medium"> |
|||
{`${metrics?.airUtilTx ?? 0}% - Air`} | |
|||
</div> |
|||
|
|||
<div |
|||
className={`m-auto h-3 w-3 rounded-full ${ |
|||
!metrics?.channelUtilization |
|||
? 'bg-primary' |
|||
: metrics?.channelUtilization > 50 |
|||
? 'bg-red-400' |
|||
: metrics?.channelUtilization > 24 |
|||
? 'bg-yellow-400' |
|||
: 'bg-primary' |
|||
}`}
|
|||
/> |
|||
<div className="truncate text-xs font-medium"> |
|||
{`${metrics?.channelUtilization ?? 0}% - Ch`} |
|||
</div> |
|||
</BottomNavItem> |
|||
|
|||
<BottomNavItem tooltip="MQTT Status"> |
|||
{primaryChannelSettings?.uplinkEnabled && |
|||
primaryChannelSettings?.downlinkEnabled && |
|||
!meshtasticState.radio.moduleConfig.mqtt.disabled ? ( |
|||
<RiArrowUpDownLine className="h-4" /> |
|||
) : primaryChannelSettings?.uplinkEnabled && |
|||
!meshtasticState.radio.moduleConfig.mqtt.disabled ? ( |
|||
<RiArrowUpLine className="h-4" /> |
|||
) : primaryChannelSettings?.downlinkEnabled && |
|||
!meshtasticState.radio.moduleConfig.mqtt.disabled ? ( |
|||
<RiArrowDownLine className="h-4" /> |
|||
) : ( |
|||
<FiX className="h-4" /> |
|||
)} |
|||
</BottomNavItem> |
|||
|
|||
<div className="flex-grow"> |
|||
<BottomNavItem |
|||
onClick={(): void => { |
|||
dispatch(toggleMobileNav()); |
|||
}} |
|||
className="md:hidden" |
|||
> |
|||
{appState.mobileNavOpen ? ( |
|||
<FiX className="m-auto h-4" /> |
|||
) : ( |
|||
<FiMenu className="m-auto h-4" /> |
|||
)} |
|||
</BottomNavItem> |
|||
</div> |
|||
|
|||
<BottomNavItem |
|||
tooltip={ |
|||
appState.updateAvaliable ? 'Update Avaliable' : 'Current Commit' |
|||
} |
|||
onClick={(): void => { |
|||
setShowVersionInfo(true); |
|||
}} |
|||
className={appState.updateAvaliable ? 'animate-pulse' : ''} |
|||
> |
|||
{appState.updateAvaliable ? ( |
|||
<MdUpgrade className="h-4" /> |
|||
) : ( |
|||
<FiGitBranch className="h-4" /> |
|||
)} |
|||
<p className="text-xs opacity-60">{process.env.COMMIT_HASH}</p> |
|||
</BottomNavItem> |
|||
|
|||
<BottomNavItem |
|||
tooltip="Toggle Theme" |
|||
onClick={(): void => { |
|||
dispatch(setDarkModeEnabled(!appState.darkMode)); |
|||
}} |
|||
> |
|||
{appState.darkMode ? ( |
|||
<FiSun className="h-4" /> |
|||
) : ( |
|||
<FiMoon className="h-4" /> |
|||
)} |
|||
</BottomNavItem> |
|||
|
|||
<VersionInfo |
|||
modalOpen={showVersionInfo} |
|||
onClose={(): void => { |
|||
setShowVersionInfo(false); |
|||
}} |
|||
/> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,32 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { m } from 'framer-motion'; |
|||
|
|||
import { Tooltip } from '@components/generic/Tooltip'; |
|||
|
|||
export interface BottomNavItemProps { |
|||
tooltip?: string; |
|||
onClick?: () => void; |
|||
className?: string; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const BottomNavItem = ({ |
|||
tooltip, |
|||
onClick, |
|||
className, |
|||
children, |
|||
}: BottomNavItemProps) => { |
|||
return ( |
|||
<Tooltip disabled={!tooltip} content={tooltip}> |
|||
<div |
|||
onClick={onClick} |
|||
className={`group flex h-full cursor-pointer select-none p-1 hover:bg-gray-300 dark:text-white dark:hover:bg-primaryDark ${className}`} |
|||
> |
|||
<m.div className="flex w-full gap-1" whileTap={{ scale: 0.99 }}> |
|||
{children} |
|||
</m.div> |
|||
</div> |
|||
</Tooltip> |
|||
); |
|||
}; |
|||
@ -1,31 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
import { FiCheck, FiClipboard } from 'react-icons/fi'; |
|||
import useCopyClipboard from 'react-use-clipboard'; |
|||
|
|||
import type { ButtonProps } from '@components/generic/button/Button'; |
|||
import { IconButton } from '@components/generic/button/IconButton'; |
|||
|
|||
export interface CopyButtonProps extends ButtonProps { |
|||
data: string; |
|||
} |
|||
|
|||
export const CopyButton = ({ |
|||
data, |
|||
...props |
|||
}: CopyButtonProps): JSX.Element => { |
|||
const [isCopied, setCopied] = useCopyClipboard(data, { |
|||
successDuration: 1000, |
|||
}); |
|||
|
|||
return ( |
|||
<IconButton |
|||
placeholder={``} |
|||
onClick={(): void => { |
|||
setCopied(); |
|||
}} |
|||
icon={isCopied ? <FiCheck /> : <FiClipboard />} |
|||
{...props} |
|||
/> |
|||
); |
|||
}; |
|||
@ -0,0 +1,18 @@ |
|||
import type React from "react"; |
|||
|
|||
import { DisableIcon, EmptyState, Pane } from "evergreen-ui"; |
|||
|
|||
export const NoDevice = (): JSX.Element => { |
|||
return ( |
|||
<Pane elevation={1} margin="auto"> |
|||
<EmptyState |
|||
title="No Device Connected" |
|||
orientation="horizontal" |
|||
background="light" |
|||
icon={<DisableIcon color="#EBAC91" />} |
|||
iconBgColor="#F8E3DA" |
|||
description="You must connect a Meshtastic device to continue." |
|||
/> |
|||
</Pane> |
|||
); |
|||
}; |
|||
@ -1,110 +0,0 @@ |
|||
import type React from 'react'; |
|||
import { useEffect } from 'react'; |
|||
|
|||
import { MdUpgrade } from 'react-icons/md'; |
|||
import useSWR from 'swr'; |
|||
|
|||
import { IconButton } from '@components/generic/button/IconButton'; |
|||
import { Modal } from '@components/generic/Modal'; |
|||
import { connectionUrl } from '@core/connection'; |
|||
import { setUpdateAvaliable } from '@core/slices/appSlice'; |
|||
import { fetcher } from '@core/utils/fetcher'; |
|||
import { useAppDispatch } from '@hooks/useAppDispatch'; |
|||
import { useAppSelector } from '@hooks/useAppSelector'; |
|||
|
|||
export interface Commit { |
|||
sha: string; |
|||
node_id: string; |
|||
commit: { |
|||
author: string; |
|||
committer: { |
|||
date: string; |
|||
email: string; |
|||
mame: string; |
|||
}; |
|||
message: string; |
|||
tree: { |
|||
sha: string; |
|||
url: string; |
|||
}; |
|||
url: string; |
|||
comment_count: number; |
|||
}; |
|||
url: string; |
|||
html_url: string; |
|||
comments_url: string; |
|||
} |
|||
|
|||
export interface VersionInfoProps { |
|||
modalOpen: boolean; |
|||
onClose: () => void; |
|||
} |
|||
|
|||
export const VersionInfo = ({ |
|||
modalOpen, |
|||
onClose, |
|||
}: VersionInfoProps): JSX.Element => { |
|||
const appState = useAppSelector((state) => state.app); |
|||
const dispatch = useAppDispatch(); |
|||
|
|||
const { data } = useSWR<Commit[]>( |
|||
'https://api.github.com/repos/meshtastic/meshtastic-web/commits?per_page=100', |
|||
fetcher, |
|||
{ |
|||
revalidateOnFocus: false, |
|||
}, |
|||
); |
|||
|
|||
useEffect(() => { |
|||
if (data) { |
|||
const index = data.findIndex( |
|||
(commit) => commit.sha.substring(0, 7) === process.env.COMMIT_HASH, |
|||
); |
|||
|
|||
if (index === -1 || index > 0) { |
|||
dispatch(setUpdateAvaliable(true)); |
|||
} |
|||
} |
|||
}, [data, dispatch]); |
|||
|
|||
return ( |
|||
<Modal |
|||
open={modalOpen} |
|||
title="Version Info" |
|||
bgDismiss |
|||
actions={ |
|||
// TODO: Check if version is hosted, and merge pwa update button here
|
|||
appState.updateAvaliable && ( |
|||
<a href={`http://${connectionUrl}/admin/spiffs`}> |
|||
<IconButton tooltip="Update now" icon={<MdUpgrade />} /> |
|||
</a> |
|||
) |
|||
} |
|||
onClose={(): void => { |
|||
onClose(); |
|||
}} |
|||
> |
|||
<div className="flex h-96 flex-col gap-1 overflow-y-auto dark:text-white"> |
|||
{data && |
|||
data.map((commit) => ( |
|||
<div |
|||
key={commit.sha} |
|||
className={`flex gap-2 rounded-md border border-transparent py-1 px-2 hover:border-primary ${ |
|||
commit.sha.substring(0, 7) === process.env.COMMIT_HASH |
|||
? 'bg-primary' |
|||
: 'dark:bg-secondaryDark' |
|||
}`}
|
|||
> |
|||
<div className="my-auto text-xs dark:text-gray-400"> |
|||
{new Date(commit.commit.committer.date).toLocaleDateString()} |
|||
</div> |
|||
<div className="my-auto font-mono text-sm"> |
|||
{commit.sha.substring(0, 7)} |
|||
</div> |
|||
<div className="truncate">{commit.commit.message}</div> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</Modal> |
|||
); |
|||
}; |
|||
@ -1,29 +0,0 @@ |
|||
.ReloadPrompt-container { |
|||
padding: 0; |
|||
margin: 0; |
|||
width: 0; |
|||
height: 0; |
|||
} |
|||
.ReloadPrompt-toast { |
|||
position: fixed; |
|||
right: 0; |
|||
bottom: 0; |
|||
margin: 16px; |
|||
padding: 12px; |
|||
border: 1px solid #8885; |
|||
border-radius: 4px; |
|||
z-index: 100; |
|||
text-align: left; |
|||
box-shadow: 3px 4px 5px 0 #8885; |
|||
background-color: white; |
|||
} |
|||
.ReloadPrompt-toast-message { |
|||
margin-bottom: 8px; |
|||
} |
|||
.ReloadPrompt-toast-button { |
|||
border: 1px solid #8885; |
|||
outline: none; |
|||
margin-right: 5px; |
|||
border-radius: 2px; |
|||
padding: 3px 10px; |
|||
} |
|||
@ -1,60 +0,0 @@ |
|||
import './ReloadPrompt.css'; |
|||
|
|||
// eslint-disable-next-line no-use-before-define
|
|||
import type React from 'react'; |
|||
|
|||
// eslint-disable-next-line import/no-unresolved
|
|||
import { useRegisterSW } from 'virtual:pwa-register/react'; |
|||
|
|||
export const ReloadPrompt = (): JSX.Element => { |
|||
const { |
|||
offlineReady: [offlineReady, setOfflineReady], |
|||
needRefresh: [needRefresh, setNeedRefresh], |
|||
updateServiceWorker, |
|||
} = useRegisterSW({ |
|||
onRegistered(r) { |
|||
// eslint-disable-next-line prefer-template
|
|||
console.log(`SW Registered:`, r); |
|||
}, |
|||
onRegisterError(error) { |
|||
console.log('SW registration error', error); |
|||
}, |
|||
}); |
|||
|
|||
const close = (): void => { |
|||
setOfflineReady(false); |
|||
setNeedRefresh(false); |
|||
}; |
|||
|
|||
return ( |
|||
<div className="ReloadPrompt-container"> |
|||
{(offlineReady || needRefresh) && ( |
|||
<div className="ReloadPrompt-toast"> |
|||
<div className="ReloadPrompt-message"> |
|||
{offlineReady ? ( |
|||
<span>App ready to work offline</span> |
|||
) : ( |
|||
<span> |
|||
New content available, click on reload button to update. |
|||
</span> |
|||
)} |
|||
</div> |
|||
{needRefresh && ( |
|||
<button |
|||
className="ReloadPrompt-toast-button" |
|||
onClick={(): Promise<void> => updateServiceWorker(true)} |
|||
> |
|||
Reload |
|||
</button> |
|||
)} |
|||
<button |
|||
className="ReloadPrompt-toast-button" |
|||
onClick={(): void => close()} |
|||
> |
|||
Close |
|||
</button> |
|||
</div> |
|||
)} |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,205 +0,0 @@ |
|||
import { connType } from '@core/slices/appSlice'; |
|||
import { |
|||
addChannel, |
|||
addChat, |
|||
addLogEvent, |
|||
addMessage, |
|||
addNode, |
|||
addPosition, |
|||
addUser, |
|||
resetState, |
|||
setConfig, |
|||
setDeviceStatus, |
|||
setLastMeshInterraction, |
|||
setModuleConfig, |
|||
setMyNodeInfo, |
|||
setReady, |
|||
updateLastInteraction, |
|||
} from '@core/slices/meshtasticSlice'; |
|||
import { store } from '@core/store'; |
|||
import { |
|||
IBLEConnection, |
|||
IHTTPConnection, |
|||
ISerialConnection, |
|||
Protobuf, |
|||
SettingsManager, |
|||
Types, |
|||
} from '@meshtastic/meshtasticjs'; |
|||
|
|||
type connectionType = IBLEConnection | IHTTPConnection | ISerialConnection; |
|||
|
|||
export let connection: connectionType = new IHTTPConnection(); |
|||
|
|||
const appState = store.getState().app; |
|||
|
|||
export const connectionUrl = appState.connectionParams.HTTP.address; |
|||
|
|||
export const setConnection = async (conn: connType): Promise<void> => { |
|||
await connection.disconnect(); |
|||
cleanupListeners(); |
|||
switch (conn) { |
|||
case connType.HTTP: |
|||
connection = new IHTTPConnection(); |
|||
break; |
|||
case connType.BLE: |
|||
connection = new IBLEConnection(); |
|||
break; |
|||
case connType.SERIAL: |
|||
connection = new ISerialConnection(); |
|||
break; |
|||
} |
|||
registerListeners(); |
|||
const connectionParams = store.getState().app.connectionParams; |
|||
console.log(connectionParams); |
|||
|
|||
switch (conn) { |
|||
case connType.HTTP: |
|||
await connection.connect(connectionParams.HTTP); |
|||
break; |
|||
case connType.BLE: |
|||
await connection.connect( |
|||
// @ts-ignore tmp
|
|||
connectionParams.BLE, |
|||
); |
|||
break; |
|||
case connType.SERIAL: |
|||
await connection.connect( |
|||
// @ts-ignore tmp
|
|||
connectionParams.SERIAL, |
|||
); |
|||
break; |
|||
} |
|||
}; |
|||
|
|||
export const cleanupListeners = (): void => { |
|||
connection.onMeshPacket.cancelAll(); |
|||
connection.onDeviceStatus.cancelAll(); |
|||
connection.onMyNodeInfo.cancelAll(); |
|||
connection.onUserPacket.cancelAll(); |
|||
connection.onPositionPacket.cancelAll(); |
|||
connection.onNodeInfoPacket.cancelAll(); |
|||
connection.onAdminPacket.cancelAll(); |
|||
connection.onMeshHeartbeat.cancelAll(); |
|||
connection.onTextPacket.cancelAll(); |
|||
}; |
|||
|
|||
const registerListeners = (): void => { |
|||
SettingsManager.debugMode = Protobuf.LogRecord_Level.TRACE; |
|||
|
|||
connection.onLogEvent.subscribe((log) => { |
|||
store.dispatch(addLogEvent(log)); |
|||
}); |
|||
|
|||
connection.onDeviceStatus.subscribe((status) => { |
|||
store.dispatch(setDeviceStatus(status)); |
|||
|
|||
if (status === Types.DeviceStatusEnum.DEVICE_CONFIGURED) { |
|||
store.dispatch(setReady(true)); |
|||
void connection.getConfig(Protobuf.AdminMessage_ConfigType.DEVICE_CONFIG); |
|||
void connection.getConfig(Protobuf.AdminMessage_ConfigType.WIFI_CONFIG); |
|||
void connection.getConfig( |
|||
Protobuf.AdminMessage_ConfigType.POSITION_CONFIG, |
|||
); |
|||
void connection.getConfig( |
|||
Protobuf.AdminMessage_ConfigType.DISPLAY_CONFIG, |
|||
); |
|||
void connection.getConfig(Protobuf.AdminMessage_ConfigType.LORA_CONFIG); |
|||
void connection.getConfig(Protobuf.AdminMessage_ConfigType.POWER_CONFIG); |
|||
} |
|||
if (status === Types.DeviceStatusEnum.DEVICE_DISCONNECTED) { |
|||
store.dispatch(setReady(false)); |
|||
store.dispatch(resetState()); |
|||
cleanupListeners(); |
|||
} |
|||
}); |
|||
|
|||
connection.onMyNodeInfo.subscribe((nodeInfo) => { |
|||
store.dispatch(setMyNodeInfo(nodeInfo)); |
|||
}); |
|||
|
|||
connection.onUserPacket.subscribe((user) => { |
|||
store.dispatch(addUser(user)); |
|||
}); |
|||
|
|||
connection.onPositionPacket.subscribe((position) => { |
|||
store.dispatch(addPosition(position)); |
|||
}); |
|||
|
|||
connection.onNodeInfoPacket.subscribe( |
|||
(nodeInfoPacket): void | { payload: Protobuf.NodeInfo; type: string } => { |
|||
store.dispatch(addNode(nodeInfoPacket.data)); |
|||
store.dispatch(addChat(nodeInfoPacket.data.num)); |
|||
}, |
|||
); |
|||
|
|||
connection.onAdminPacket.subscribe((adminPacket) => { |
|||
console.log(adminPacket.data.variant.oneofKind); |
|||
|
|||
switch (adminPacket.data.variant.oneofKind) { |
|||
case 'getChannelResponse': |
|||
store.dispatch(addChannel(adminPacket.data.variant.getChannelResponse)); |
|||
store.dispatch( |
|||
addChat(adminPacket.data.variant.getChannelResponse.index), |
|||
); |
|||
break; |
|||
case 'getOwnerResponse': |
|||
store.dispatch( |
|||
addUser({ |
|||
data: adminPacket.data.variant.getOwnerResponse, |
|||
packet: adminPacket.packet, |
|||
}), |
|||
); |
|||
break; |
|||
case 'getConfigResponse': |
|||
store.dispatch(setConfig(adminPacket.data.variant.getConfigResponse)); |
|||
break; |
|||
case 'getModuleConfigResponse': |
|||
store.dispatch( |
|||
setModuleConfig(adminPacket.data.variant.getModuleConfigResponse), |
|||
); |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
connection.onMeshHeartbeat.subscribe( |
|||
(date): void | { payload: number; type: string } => |
|||
store.dispatch(setLastMeshInterraction(date.getTime())), |
|||
); |
|||
|
|||
connection.onRoutingPacket.subscribe((routingPacket) => { |
|||
console.log(routingPacket.data.variant.oneofKind); |
|||
|
|||
switch (routingPacket.data.variant.oneofKind) { |
|||
case 'errorReason': |
|||
console.log( |
|||
Protobuf.Routing_Error[routingPacket.data.variant.errorReason], |
|||
); |
|||
|
|||
break; |
|||
|
|||
default: |
|||
break; |
|||
} |
|||
|
|||
store.dispatch( |
|||
updateLastInteraction({ |
|||
id: routingPacket.packet.from, |
|||
time: new Date(routingPacket.packet.rxTime * 1000), |
|||
}), |
|||
); |
|||
}); |
|||
|
|||
connection.onTextPacket.subscribe((message) => { |
|||
const myNodeNum = store.getState().meshtastic.radio.hardware.myNodeNum; |
|||
|
|||
store.dispatch( |
|||
addMessage({ |
|||
message: message, |
|||
ack: message.packet.from !== myNodeNum, |
|||
received: message.packet.rxTime |
|||
? new Date(message.packet.rxTime * 1000) |
|||
: new Date(), |
|||
}), |
|||
); |
|||
}); |
|||
}; |
|||
@ -1,60 +0,0 @@ |
|||
import type { Style } from 'mapbox-gl'; |
|||
|
|||
export type MapStyleName = |
|||
| 'Streets' |
|||
| 'Outdoors' |
|||
| 'Light' |
|||
| 'Dark' |
|||
| 'Satellite'; |
|||
|
|||
export interface MapStyle { |
|||
title: MapStyleName; |
|||
data: Style | string; |
|||
} |
|||
|
|||
type MapStyleType = { |
|||
[mapStyleType in MapStyleName]: MapStyle; |
|||
}; |
|||
|
|||
export const MapStyles: MapStyleType = { |
|||
Streets: { |
|||
title: 'Streets', |
|||
data: 'mapbox://styles/mapbox/streets-v11?optimize=true', |
|||
} as MapStyle, |
|||
Outdoors: { |
|||
title: 'Outdoors', |
|||
data: 'mapbox://styles/mapbox/outdoors-v11?optimize=true', |
|||
} as MapStyle, |
|||
|
|||
Light: { |
|||
title: 'Light', |
|||
data: 'mapbox://styles/sachaw/cl03ij03g001414l20w0w0ivj?optimize=true', |
|||
} as MapStyle, |
|||
Dark: { |
|||
title: 'Dark', |
|||
data: 'mapbox://styles/sachaw/ckwzwm92e1oep14pjunjqlqbo?optimize=true', |
|||
} as MapStyle, |
|||
Satellite: { |
|||
title: 'Satellite', |
|||
|
|||
data: { |
|||
version: 8, |
|||
layers: [ |
|||
{ |
|||
id: 'esri', |
|||
type: 'raster', |
|||
source: 'esri', |
|||
}, |
|||
], |
|||
sources: { |
|||
esri: { |
|||
type: 'raster', |
|||
tiles: [ |
|||
'https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', |
|||
], |
|||
maxzoom: 17, |
|||
}, |
|||
}, |
|||
}, |
|||
} as MapStyle, |
|||
}; |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue