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 |
dist |
||||
node_modules |
node_modules |
||||
.env |
|
||||
stats.html |
stats.html |
||||
.vercel |
.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 { Pane } from "evergreen-ui"; |
||||
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'; |
|
||||
|
|
||||
export const App = (): JSX.Element => { |
import { AppLayout } from "@components/layout/AppLayout.js"; |
||||
const route = useRoute(); |
|
||||
const appState = useAppSelector((state) => state.app); |
import { PageRouter } from "./PageRouter.js"; |
||||
|
|
||||
|
export const App = (): JSX.Element => { |
||||
return ( |
return ( |
||||
<div className={`h-screen w-screen ${appState.darkMode ? 'dark' : ''}`}> |
<Pane display="flex"> |
||||
<Connection /> |
<AppLayout> |
||||
<div className="flex h-full flex-col"> |
<PageRouter /> |
||||
<div className="flex min-h-0 w-full flex-grow"> |
</AppLayout> |
||||
{route.name === 'messages' && <Messages />} |
</Pane> |
||||
{route.name === 'map' && <MapPage />} |
|
||||
{route.name === 'extensions' && <Extensions />} |
|
||||
{route.name === false && <NotFound />} |
|
||||
</div> |
|
||||
<BottomNav /> |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
); |
||||
}; |
}; |
||||
|
|||||
@ -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 { |
||||
import { useAppSelector } from '@hooks/useAppSelector'; |
ArrayIcon, |
||||
|
GlobeIcon, |
||||
|
IconComponent, |
||||
|
InboxIcon, |
||||
|
InfoSignIcon, |
||||
|
LabTestIcon, |
||||
|
LayersIcon, |
||||
|
majorScale, |
||||
|
Pane, |
||||
|
SettingsIcon, |
||||
|
Tab, |
||||
|
Tablist, |
||||
|
} from "evergreen-ui"; |
||||
|
|
||||
export interface SidebarProps { |
import { PeersDialog } from "@app/components/Dialog/PeersDialog.js"; |
||||
children: React.ReactNode; |
import { Page, useDevice } from "@app/core/stores/deviceStore.js"; |
||||
setSettingsOpen: (settingsOpen: boolean) => void; |
|
||||
settingsOpen: boolean; |
import { DeviceCard } from "./DeviceCard.js"; |
||||
|
|
||||
|
interface NavLink { |
||||
|
name: string; |
||||
|
icon: IconComponent; |
||||
|
page: Page; |
||||
|
disabled?: boolean; |
||||
} |
} |
||||
|
|
||||
export const Sidebar = ({ |
export const Sidebar = (): JSX.Element => { |
||||
settingsOpen, |
const { activePage, setActivePage } = useDevice(); |
||||
setSettingsOpen, |
const [PeersDialogOpen, setPeersDialogOpen] = useState(false); |
||||
children, |
|
||||
}: SidebarProps): JSX.Element => { |
const navLinks: NavLink[] = [ |
||||
const appState = useAppSelector((state) => state.app); |
{ |
||||
|
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 ( |
return ( |
||||
<div |
<Pane |
||||
className={`absolute z-20 h-full w-full flex-grow flex-col md:relative md:flex md:w-96 ${ |
display="flex" |
||||
appState.mobileNavOpen ? 'flex' : 'hidden' |
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"> |
<Tablist> |
||||
<div className="relative flex-grow gap-1"> |
{navLinks.map((Link) => ( |
||||
<div className="absolute h-full w-full">{children}</div> |
<Tab |
||||
<Settings open={settingsOpen} setOpen={setSettingsOpen} /> |
key={Link.name} |
||||
</div> |
gap={majorScale(2)} |
||||
</div> |
disabled={Link.disabled} |
||||
</div> |
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