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