Initial 1.3.x rework

This commit is contained in:
Sacha Weatherstone
2022-07-26 08:52:23 +10:00
parent ff6019098b
commit a2280ab96e
163 changed files with 6383 additions and 14397 deletions
-150
View File
@@ -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>
);
};
+141
View File
@@ -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>
);
};
+112
View File
@@ -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>
);
};
-16
View File
@@ -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>
);
};
-137
View File
@@ -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>
);
};
-13
View File
@@ -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>
);
};
+88
View File
@@ -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>
);
};
+113
View File
@@ -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>
);
};
-60
View File
@@ -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>
);
};
-35
View File
@@ -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>
);
};
+85
View File
@@ -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>
);
};
+60
View File
@@ -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>
);
};
+102
View File
@@ -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>
);
};
-79
View File
@@ -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>
);
};
-93
View File
@@ -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>
);
};
-95
View File
@@ -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>
);
};
+49
View File
@@ -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>
);
};
-48
View File
@@ -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>
);
};
-23
View File
@@ -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>
);
};
-57
View File
@@ -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>
);
};
-9
View File
@@ -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>
);
};
-72
View File
@@ -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"
>
&#8203;
</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>
);
};
-17
View File
@@ -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>
);
};
-79
View File
@@ -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>
);
};
-49
View File
@@ -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>
);
},
);
-36
View File
@@ -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>
);
};
-41
View File
@@ -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>
);
-14
View File
@@ -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>
);
-69
View File
@@ -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>
);
},
);
+74
View File
@@ -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>
);
};
+147
View File
@@ -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>
);
};
+111 -24
View File
@@ -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>
);
};
-91
View File
@@ -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>
);
};
-203
View File
@@ -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>
);
};
-32
View File
@@ -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}
/>
);
};
+18
View File
@@ -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>
);
};
-110
View File
@@ -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>
);
};
-29
View File
@@ -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;
}
-60
View File
@@ -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>
);
};