This commit is contained in:
Sacha Weatherstone
2022-02-07 00:00:55 +11:00
parent e43b04990b
commit 8d184578f0
109 changed files with 3495 additions and 3632 deletions
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import useSWR from 'swr';
import fetcher from '@app/core/utils/fetcher';
import { useAppSelector } from '@app/hooks/useAppSelector';
export interface File {
nameModified: string;
name: string;
size: number;
}
export interface Files {
data: {
files: File[];
fileSystem: {
total: number;
used: number;
free: number;
};
};
status: string;
}
export const FileBrowser = (): JSX.Element => {
const connectionParams = useAppSelector(
(state) => state.app.connectionParams,
);
const { data } = useSWR<Files>(
`${connectionParams.HTTP.tls ? 'https' : 'http'}://${
connectionParams.HTTP.address
}/json/spiffs/browse/static`,
fetcher,
);
return (
<div className="flex h-full w-full select-none flex-col gap-4 p-4">
<div className="w-full flex-grow rounded-md bg-gray-200 dark:bg-primaryDark">
<div className="flex h-10 w-full rounded-t-md bg-gray-300 px-4 text-lg font-semibold dark:bg-zinc-700 dark:text-white">
<div className="my-auto w-1/3">FileName</div>
<div className="my-auto w-1/3">Actions</div>
</div>
<div className="px-4">
{data?.data.files.map((file) => (
<div
key={file.name}
className="flex h-10 w-full border-b border-gray-500 px-4 font-medium dark:text-white"
>
<div className="my-auto w-1/3">
<a
target="_blank"
rel="noopener noreferrer"
href={`${connectionParams.HTTP.tls ? 'https' : 'http'}://${
connectionParams.HTTP.address
}/${file.name.replace('static/', '')}`}
>
{file.name.replace('static/', '').replace('.gz', '')}
</a>
</div>
<div className="my-auto w-1/3"></div>
</div>
))}
</div>
</div>
</div>
);
};
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { FiFile, FiInfo } from 'react-icons/fi';
import { RiPinDistanceFill } from 'react-icons/ri';
import { VscExtensions } from 'react-icons/vsc';
import { Layout } from '@app/components/layout';
import { ExternalSection } from '@app/components/layout/Sidebar/sections/ExternalSection';
import { FileBrowser } from './FileBrowser';
import { Info } from './Info';
export const Extensions = (): JSX.Element => {
const [selectedExtension, setSelectedExtension] = React.useState<
'info' | 'fileBrowser' | 'rangeTest'
>('info');
return (
<Layout
title="Extensions"
icon={<VscExtensions />}
sidebarContents={
<div className="absolute flex h-full w-full flex-col dark:bg-primaryDark">
<ExternalSection
onClick={(): void => {
setSelectedExtension('info');
}}
icon={<FiInfo />}
title="Node Info"
/>
<ExternalSection
onClick={(): void => {
setSelectedExtension('fileBrowser');
}}
icon={<FiFile />}
title="File Browser"
/>
<ExternalSection
onClick={(): void => {
setSelectedExtension('rangeTest');
}}
icon={<RiPinDistanceFill />}
title="Range Test"
/>
</div>
}
>
<div className="w-full">
{selectedExtension === 'info' && <Info />}
{selectedExtension === 'fileBrowser' && <FileBrowser />}
</div>
</Layout>
);
};
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { m } from 'framer-motion';
import JSONPretty from 'react-json-pretty';
import { useAppSelector } from '@app/hooks/useAppSelector.js';
// eslint-disable-next-line import/no-unresolved
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
const Hashicon = skypack_hashicon.Hashicon;
// import { Hashicon } from '@emeraldpay/hashicon-react';
export const Info = (): JSX.Element => {
const hardwareInfo = useAppSelector(
(state) => state.meshtastic.radio.hardware,
);
const node = useAppSelector((state) =>
state.meshtastic.nodes.find(
(node) => node.number === hardwareInfo.myNodeNum,
),
);
return (
<div className="flex w-full select-none flex-col gap-4 p-4">
<m.div
whileHover={{ scale: 1.01 }}
className="flex w-full flex-col gap-4 rounded-md p-8 shadow-md dark:bg-primaryDark"
>
<div className="m-auto">
<Hashicon value={hardwareInfo.myNodeNum.toString()} size={180} />
</div>
<div className="text-center text-lg font-medium dark:text-white">
{node?.user?.longName || 'Unknown'}
</div>
</m.div>
<div className="rounded-md p-8 shadow-md dark:bg-primaryDark">
<JSONPretty data={hardwareInfo} />
</div>
</div>
);
};
+90
View File
@@ -0,0 +1,90 @@
import React from 'react';
import { FaDirections, FaGlobeAfrica, FaMountain } from 'react-icons/fa';
import { MdFullscreen, MdRadar, MdWbShade } from 'react-icons/md';
import {
setExaggeration,
setHillShade,
setMapStyle,
} from '@core/slices/mapSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { useMapbox } from '@hooks/useMapbox';
import { IconButton } from '@meshtastic/components';
import type { MapStyle } from './styles';
import { MapStyles } from './styles';
export const MapContainer = (): JSX.Element => {
const dispatch = useAppDispatch();
const darkMode = useAppSelector((state) => state.app.darkMode);
const mapState = useAppSelector((state) => state.map);
const { ref } = useMapbox();
const ChangeMapStyle = React.useCallback(
(styleName: string, style: MapStyle) => {
dispatch(
setMapStyle(
mapState.style.title === styleName
? darkMode
? MapStyles.Dark
: MapStyles.Light
: style,
),
);
},
[dispatch, darkMode, mapState.style.title],
);
return (
<div className="relative flex h-full w-full">
<div className="absolute right-0 z-20 m-4 space-y-2 rounded-md border border-gray-300 bg-white p-2 shadow-md dark:border-gray-600 dark:bg-primaryDark">
<IconButton
active={mapState.style.title === 'Satellite'}
onClick={(): void => {
ChangeMapStyle('Satellite', MapStyles.Satellite);
}}
icon={<FaGlobeAfrica />}
/>
<div
className={`-m-1 space-y-2 rounded-md border-gray-300 p-1 dark:border-gray-600 ${
mapState.style.title === 'Outdoors' ? 'border' : ''
}`}
>
<IconButton
active={mapState.style.title === 'Outdoors'}
onClick={(): void => {
ChangeMapStyle('Outdoors', MapStyles.Outdoors);
}}
icon={<FaDirections />}
/>
{mapState.style.title === 'Outdoors' && (
<IconButton
active={mapState.hillShade}
onClick={(): void => {
dispatch(setHillShade(!mapState.hillShade));
}}
icon={<MdWbShade />}
/>
)}
</div>
<hr className="text-gray-400 dark:text-gray-200" />
<IconButton
active={mapState.exaggeration}
onClick={(): void => {
dispatch(setExaggeration(!mapState.exaggeration));
}}
icon={<FaMountain />}
/>
<IconButton icon={<MdFullscreen />} />
<IconButton icon={<MdRadar />} />
</div>
<div className="h-full w-full flex-grow" ref={ref} />
</div>
);
};
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import ReactDOM from 'react-dom';
import mapbox from 'mapbox-gl';
import { useMapbox } from '@hooks/useMapbox';
export interface MarkerProps extends Omit<mapbox.MarkerOptions, 'element'> {
children?: React.ReactNode;
center: mapbox.LngLatLike;
}
export const Marker = ({
children,
center,
...props
}: MarkerProps): JSX.Element => {
const { map } = useMapbox();
const ref = React.useRef<HTMLDivElement>(document.createElement('div'));
const addMarker = React.useCallback((): void => {
if (map) {
const marker = new mapbox.Marker(ref.current, props).setLngLat(center);
marker.addTo(map);
}
}, [map, center, props]);
React.useEffect(() => {
map?.on('load', () => {
addMarker();
});
}, [addMarker, map]);
React.useEffect(() => {
if (map?.loaded()) {
addMarker();
}
}, [addMarker, map]);
<div ref={ref}>{children}</div>;
return ReactDOM.createPortal(children, ref.current);
};
+85
View File
@@ -0,0 +1,85 @@
import React from 'react';
import mapboxgl from 'mapbox-gl';
import { FiMapPin } from 'react-icons/fi';
import { RiRoadMapLine } from 'react-icons/ri';
import { Layout } from '@app/components/layout';
import { MapboxProvider } from '@app/components/MapBox/MapboxProvider';
import type { Node } from '@app/core/slices/meshtasticSlice';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { NodeCard } from '../Nodes/NodeCard';
import { MapContainer } from './MapContainer';
import { Marker } from './Marker';
export const Map = (): JSX.Element => {
const [selectedNode, setSelectedNode] = React.useState<Node>();
const nodes = useAppSelector((state) => state.meshtastic.nodes);
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware.myNodeNum,
);
return (
<MapboxProvider>
{nodes.map((node) => {
return (
node.currentPosition && (
<Marker
key={node.number}
center={
new mapboxgl.LngLat(
node.currentPosition.longitudeI / 1e7,
node.currentPosition.latitudeI / 1e7,
)
}
>
<div
onClick={(): void => {
setSelectedNode(node);
}}
className={`z-50 rounded-full border-2 bg-opacity-30 ${
node.number === selectedNode?.number
? 'border-green-500 bg-green-500'
: 'border-blue-500 bg-blue-500'
}`}
>
<div className="m-4 ">
<FiMapPin className="h-5 w-5" />
</div>
</div>
</Marker>
)
);
})}
<Layout
title="Nodes"
icon={<RiRoadMapLine />}
sidebarContents={
<div className="flex flex-col gap-2">
{!nodes.length && (
<span className="p-4 text-sm text-gray-400 dark:text-gray-600">
No nodes found.
</span>
)}
{nodes.map((node) => (
<NodeCard
key={node.number}
node={node}
isMyNode={node.number === myNodeNum}
selected={selectedNode?.number === node.number}
setSelected={(): void => {
setSelectedNode(node);
}}
/>
))}
</div>
}
>
<MapContainer />
</Layout>
</MapboxProvider>
);
};
+28
View File
@@ -0,0 +1,28 @@
export interface MapStyle {
title: string;
url: string;
}
export const MapStyles = {
Streets: {
title: 'Streets',
url: 'mapbox://styles/mapbox/streets-v11?optimize=true',
} as MapStyle,
Outdoors: {
title: 'Outdoors',
url: 'mapbox://styles/mapbox/outdoors-v11?optimize=true',
} as MapStyle,
Light: {
title: 'Light',
url: 'mapbox://styles/mapbox/light-v10?optimize=true',
} as MapStyle,
Dark: {
title: 'Dark',
url: 'mapbox://styles/sachaw/ckwzwm92e1oep14pjunjqlqbo?optimize=true',
} as MapStyle,
Satellite: {
title: 'Satellite',
url: 'mapbox://styles/mapbox/satellite-v9?optimize=true',
} as MapStyle,
};
-63
View File
@@ -1,63 +0,0 @@
import React from 'react';
import { FiHash } from 'react-icons/fi';
import { Message } from '@components/chat/Message';
import { MessageBar } from '@components/chat/MessageBar';
import { useAppSelector } from '@hooks/useAppSelector';
import { Select } from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const Messages = (): JSX.Element => {
const nodes = useAppSelector((state) => state.meshtastic.nodes);
const channels = useAppSelector((state) => state.meshtastic.radio.channels);
const [channelIndex, setChannelIndex] = React.useState(0);
return (
<div className="flex flex-col w-full">
<div className="flex justify-between w-full px-2 border-b border-gray-300 dark:border-gray-600 dark:text-gray-300">
<div className="flex py-2 my-auto text-sm">
<FiHash className="w-4 h-4 my-auto mr-1" />
<Select
options={channels
.filter(
(channel) =>
channel.channel.role !== Protobuf.Channel_Role.DISABLED &&
channel.channel.settings?.name !== 'admin',
)
.map((channel) => {
return {
name: channel.channel.settings?.name.length
? channel.channel.settings.name
: channel.channel.role === Protobuf.Channel_Role.PRIMARY
? 'Primary'
: `CH: ${channel.channel.index}`,
value: channel.channel.index,
};
})}
onChange={(e): void => {
setChannelIndex(parseInt(e.target.value));
}}
small
/>
</div>
</div>
<div className="flex flex-col flex-grow p-6 space-y-2 overflow-y-auto bg-white border-b border-gray-300 md:py-8 md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
{channels[channelIndex]?.messages.map((message, index) => (
<Message
key={index}
isSender={message.isSender}
message={message.message.data}
ack={message.ack}
rxTime={message.received}
senderName={
nodes.find((node) => node.number === message.message.packet.from)
?.user?.longName ?? 'UNK'
}
/>
))}
</div>
<MessageBar channelIndex={channelIndex} />
</div>
);
};
+81
View File
@@ -0,0 +1,81 @@
import type React from 'react';
import type { Node } from '@app/core/slices/meshtasticSlice';
import { Tooltip } from '@meshtastic/components';
// eslint-disable-next-line import/no-unresolved
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
const Hashicon = skypack_hashicon.Hashicon;
export interface MessageProps {
lastMsgSameUser: boolean;
message: string;
ack: boolean;
rxTime: Date;
sender?: Node;
}
export const Message = ({
lastMsgSameUser,
message,
ack,
rxTime,
sender,
}: MessageProps): JSX.Element => {
return (
<div className="group mb-3 hover:bg-gray-200 dark:hover:bg-primaryDark">
{lastMsgSameUser ? (
<div
className={`mx-6 -mt-3 flex gap-2 ${lastMsgSameUser ? '' : 'py-1'}`}
>
<div className="flex">
<Tooltip content={rxTime.toString()}>
<div className="my-auto ml-auto w-8 pt-1 text-xs text-transparent dark:group-hover:text-gray-400">
{rxTime
.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
})
.replace('AM', '')
.replace('PM', '')}
</div>
</Tooltip>
</div>
<div
className={`my-auto dark:text-gray-300 ${
ack ? '' : 'animate-pulse dark:text-gray-500'
}`}
>
{message}
</div>
</div>
) : (
<div className="mx-6 flex gap-2">
<div className="my-auto w-8">
<Hashicon value={(sender?.number ?? 0).toString()} size={32} />
</div>
<div>
<div className="flex gap-2">
<div className="cursor-default text-sm font-semibold hover:underline dark:text-white">
{sender?.user?.longName ?? 'UNK'}
</div>
<div className="my-auto text-xs dark:text-gray-400">
{rxTime.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
<div
className={`dark:text-gray-300 ${
ack ? '' : 'animate-pulse dark:text-gray-400'
}`}
>
{message}
</div>
</div>
</div>
)}
</div>
);
};
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
import { connection } from '@core/connection';
import { ackMessage } from '@core/slices/meshtasticSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { Input } from '@meshtastic/components';
export interface MessageBarProps {
channelIndex: number;
}
export const MessageBar = ({ channelIndex }: MessageBarProps): JSX.Element => {
const dispatch = useAppDispatch();
const ready = useAppSelector((state) => state.meshtastic.ready);
const [currentMessage, setCurrentMessage] = React.useState('');
const sendMessage = (): void => {
if (ready) {
void connection.sendText(
currentMessage,
undefined,
true,
channelIndex--,
(id) => {
dispatch(ackMessage({ channel: channelIndex--, messageId: id }));
return Promise.resolve();
},
);
setCurrentMessage('');
}
};
return (
<div className="mx-auto flex w-full space-x-2 bg-gray-50 p-4 text-gray-500 dark:bg-transparent dark:text-gray-400">
<div className="mx-auto flex w-full max-w-4xl">
<form
className="flex w-full space-x-2"
onSubmit={(e): void => {
e.preventDefault();
sendMessage();
}}
>
<Input
type="text"
minLength={2}
placeholder="Enter Message"
disabled={!ready}
value={currentMessage}
onChange={(e): void => {
setCurrentMessage(e.target.value);
}}
/>
</form>
</div>
</div>
);
};
+152
View File
@@ -0,0 +1,152 @@
import React from 'react';
import { m } from 'framer-motion';
import { FiHash, FiMessageCircle, FiSettings } from 'react-icons/fi';
import { MdPublic } from 'react-icons/md';
import TimeAgo from 'timeago-react';
import { Layout } from '@app/components/layout';
import { SidebarItem } from '@components/layout/Sidebar/SidebarItem';
import { useAppSelector } from '@hooks/useAppSelector';
import { IconButton, Tooltip } from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
// eslint-disable-next-line import/no-unresolved
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
import { Message } from './Message';
import { MessageBar } from './MessageBar';
const Hashicon = skypack_hashicon.Hashicon;
export const Messages = (): JSX.Element => {
const nodes = useAppSelector((state) => state.meshtastic.nodes);
const channels = useAppSelector(
(state) => state.meshtastic.radio.channels,
).filter((ch) => ch.channel.role !== Protobuf.Channel_Role.DISABLED);
const [channelIndex, setChannelIndex] = React.useState(0);
const chatRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (chatRef.current) {
chatRef.current.scrollTop = chatRef.current.scrollHeight;
}
}, [channels]);
return (
<Layout
title="Message Groups"
icon={<FiMessageCircle />}
sidebarContents={
<div className="flex flex-col gap-2">
{channels.map((channel) => (
<SidebarItem
key={channel.channel.index}
selected={channelIndex === channel.channel.index}
setSelected={(): void => {
setChannelIndex(channel.channel.index);
}}
actions={<IconButton icon={<FiSettings />} />}
>
<div className="flex h-8 w-8 rounded-full bg-gray-200 dark:bg-primaryDark dark:text-white">
<div className="m-auto">
{channel.channel.role === Protobuf.Channel_Role.PRIMARY ? (
<MdPublic />
) : (
<p>
{channel.channel.settings?.name.length
? channel.channel.settings.name
.substring(0, 3)
.toUpperCase()
: `CH: ${channel.channel.index}`}
</p>
)}
</div>
</div>
{channel.messages.length ? (
<>
<div className="mx-2 flex h-8">
{[
...new Set(
channel.messages.flatMap(({ message }) => [
message.packet.from,
]),
),
]
.sort()
.map((nodeId) => {
return (
<Tooltip
key={nodeId}
content={
nodes.find((node) => node.number === nodeId)?.user
?.longName ?? 'UNK'
}
>
<div className="flex h-full">
<m.div
whileHover={{ scale: 1.1 }}
className="my-auto -ml-2"
>
<Hashicon value={nodeId.toString()} size={20} />
</m.div>
</div>
</Tooltip>
);
})}
</div>
<TimeAgo
className="my-auto ml-auto text-xs font-semibold dark:text-gray-400"
datetime={channel.lastChatInterraction}
/>
</>
) : (
<div className="my-auto dark:text-white">No messages</div>
)}
</SidebarItem>
))}
</div>
}
>
<div className="flex w-full flex-col">
<div className="flex w-full justify-between border-b border-gray-300 px-2 dark:border-gray-600 dark:text-gray-300">
<div className="my-auto flex py-2 text-sm">
<FiHash className="my-auto mr-1 h-4 w-4" />
<div>
{channels[channelIndex]?.channel.settings?.name.length
? channels[channelIndex]?.channel.settings?.name
: channels[channelIndex]?.channel.role ===
Protobuf.Channel_Role.PRIMARY
? 'Primary'
: `Channel: ${channels[channelIndex]?.channel.index}`}
</div>
</div>
</div>
<div
ref={chatRef}
className="flex flex-grow flex-col space-y-2 overflow-y-auto border-b border-gray-300 bg-white pb-6 dark:border-gray-600 dark:bg-secondaryDark"
>
<div className="mt-auto">
{channels[channelIndex]?.messages.map((message, index) => (
<Message
key={index}
message={message.message.data}
ack={message.ack}
rxTime={message.received}
lastMsgSameUser={
index === 0
? false
: channels[channelIndex]?.messages[index - 1].message.packet
.from === message.message.packet.from
}
sender={nodes.find(
(node) => node.number === message.message.packet.from,
)}
/>
))}
</div>
</div>
<MessageBar channelIndex={channelIndex} />
</div>
</Layout>
);
};
-129
View File
@@ -1,129 +0,0 @@
import React from 'react';
import mapbox from 'mapbox-gl';
import { FiMapPin, FiXCircle } from 'react-icons/fi';
import { Marker } from '@app/components/Map/Marker';
import type { Node } from '@app/core/slices/meshtasticSlice';
import { Drawer } from '@components/generic/Drawer';
import { Map } from '@components/Map';
import { NodeSidebar } from '@components/pages/nodes/NodeSidebar';
import { useAppSelector } from '@hooks/useAppSelector';
import { useBreakpoint } from '@hooks/useBreakpoint';
import { IconButton } from '@meshtastic/components';
import { NodeCard } from '../components/pages/nodes/NodeCard';
export const Nodes = (): JSX.Element => {
const myNodeInfo = useAppSelector((state) => state.meshtastic.radio.hardware);
const nodes = useAppSelector((state) => state.meshtastic.nodes)
.slice()
.sort((a, b) =>
a.number === myNodeInfo.myNodeNum
? 1
: b?.lastHeard.getTime() - a?.lastHeard.getTime(),
);
const myNode = nodes.find((node) => node.number === myNodeInfo.myNodeNum);
const { breakpoint } = useBreakpoint();
const [navOpen, setNavOpen] = React.useState(false);
const [sidebarOpen, setSidebarOpen] = React.useState(false);
const [selectedNode, setSelectedNode] = React.useState<Node | undefined>();
return (
<div className="relative flex w-full dark:text-white">
<Drawer
open={breakpoint === 'sm' ? navOpen : true}
permenant={breakpoint !== 'sm'}
onClose={(): void => {
setNavOpen(!navOpen);
}}
>
<div className="flex items-center justify-between m-6 mr-6">
<div className="text-3xl font-extrabold leading-none tracking-tight">
Nodes
</div>
<div className="md:hidden">
<IconButton
icon={<FiXCircle className="w-5 h-5" />}
onClick={(): void => {
setNavOpen(false);
}}
/>
</div>
</div>
{!nodes.length && (
<span className="p-4 text-sm text-gray-400 dark:text-gray-600">
No nodes found.
</span>
)}
{myNode && (
<NodeCard
node={myNode}
isMyNode={true}
setSelected={(): void => {
setSelectedNode(myNode);
setSidebarOpen(true);
}}
/>
)}
{nodes
.filter((node) => node.number !== myNodeInfo.myNodeNum)
.map((node) => (
<NodeCard
key={node.number}
node={node}
setSelected={(): void => {
setSelectedNode(node);
setSidebarOpen(true);
}}
/>
))}
</Drawer>
{nodes.map((node) => {
return (
node.currentPosition && (
<Marker
center={
new mapbox.LngLat(
node.currentPosition.longitudeI / 1e7,
node.currentPosition.latitudeI / 1e7,
)
}
>
<div
onClick={(): void => {
setSelectedNode(node);
setSidebarOpen(true);
}}
className={`z-50 border-2 rounded-full bg-opacity-30 ${
node.number === selectedNode?.number
? 'bg-green-500 border-green-500'
: 'bg-blue-500 border-blue-500'
}`}
>
<div className="m-4 ">
<FiMapPin className="w-5 h-5" />
</div>
</div>
</Marker>
)
);
})}
<Map />
{sidebarOpen && selectedNode && (
<NodeSidebar
closeSidebar={(): void => {
setSidebarOpen(false);
}}
node={selectedNode}
/>
)}
</div>
);
};
+141
View File
@@ -0,0 +1,141 @@
import React from 'react';
import mapbox from 'mapbox-gl';
import {
FiAlignLeft,
FiCode,
FiMapPin,
FiSliders,
FiUser,
} from 'react-icons/fi';
import { IoTelescope } from 'react-icons/io5';
import { MdGpsFixed, MdGpsNotFixed, MdGpsOff } from 'react-icons/md';
import JSONPretty from 'react-json-pretty';
import { CollapsibleSection } from '@app/components/layout/Sidebar/sections/CollapsibleSection';
import { SidebarOverlay } from '@app/components/layout/Sidebar/sections/SidebarOverlay';
import { SidebarItem } from '@app/components/layout/Sidebar/SidebarItem';
import { CopyButton } from '@app/components/menu/buttons/CopyButton';
import type { Node } from '@core/slices/meshtasticSlice';
import { useMapbox } from '@hooks/useMapbox';
import { IconButton } from '@meshtastic/components';
// eslint-disable-next-line import/no-unresolved
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
const Hashicon = skypack_hashicon.Hashicon;
type PositionConfidence = 'high' | 'low' | 'none';
export interface NodeCardProps {
node: Node;
isMyNode: boolean;
selected: boolean;
setSelected: () => void;
}
export const NodeCard = ({
node,
isMyNode,
selected,
setSelected,
}: NodeCardProps): JSX.Element => {
const { map } = useMapbox();
const [infoOpen, setInfoOpen] = React.useState(false);
const [PositionConfidence, setPositionConfidence] =
React.useState<PositionConfidence>('none');
React.useEffect(() => {
setPositionConfidence(
node.currentPosition
? new Date(node.currentPosition.posTimestamp * 1000) >
new Date(new Date().getTime() - 1000 * 60 * 30)
? 'high'
: 'low'
: 'none',
);
}, [node.currentPosition]);
return (
<SidebarItem
selected={selected}
setSelected={setSelected}
actions={
<>
<IconButton
disabled={PositionConfidence === 'none'}
onClick={(e): void => {
e.stopPropagation();
setSelected();
if (PositionConfidence !== 'none' && node.currentPosition) {
map?.flyTo({
center: new mapbox.LngLat(
node.currentPosition.longitudeI / 1e7,
node.currentPosition.latitudeI / 1e7,
),
zoom: 16,
});
}
}}
icon={
PositionConfidence === 'high' ? (
<MdGpsFixed />
) : PositionConfidence === 'low' ? (
<MdGpsNotFixed />
) : (
<MdGpsOff />
)
}
/>
<IconButton
onClick={(e): void => {
e.stopPropagation();
setInfoOpen(true);
}}
icon={<FiAlignLeft />}
/>
</>
}
>
<div className="flex dark:text-white">
<div className="m-auto">
<Hashicon value={node.number.toString()} size={32} />
</div>
</div>
<div className="my-auto mr-auto text-xs font-semibold dark:text-gray-400">
{node.lastHeard.getTime()
? node.lastHeard.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
})
: 'Never'}
</div>
<SidebarOverlay
title={`Node ${node.user?.longName ?? 'UNK'} `}
open={infoOpen}
close={(): void => {
setInfoOpen(false);
}}
>
<CollapsibleSection title="User" icon={<FiUser />}>
<div>Info</div>
</CollapsibleSection>
<CollapsibleSection title="Location" icon={<FiMapPin />}>
<div>Info</div>
</CollapsibleSection>
<CollapsibleSection title="Line of Sight" icon={<IoTelescope />}>
<div>Info</div>
</CollapsibleSection>
<CollapsibleSection title="Administration" icon={<FiSliders />}>
<div>Info</div>
</CollapsibleSection>
<CollapsibleSection title="Debug" icon={<FiCode />}>
<>
<div className="fixed right-0 mr-6">
<CopyButton data={JSON.stringify(node)} />
</div>
<JSONPretty className="max-w-sm" data={node} />
</>
</CollapsibleSection>
</SidebarOverlay>
</SidebarItem>
);
};
+129
View File
@@ -0,0 +1,129 @@
import React from 'react';
import type { Edge, Node } from 'react-flow-renderer';
import ReactFlow, { Background, Controls, MiniMap } from 'react-flow-renderer';
import { FiSettings } from 'react-icons/fi';
import { RiMindMap } from 'react-icons/ri';
import { Layout } from '@app/components/layout';
import { SidebarItem } from '@app/components/layout/Sidebar/SidebarItem';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { IconButton } from '@meshtastic/components';
// eslint-disable-next-line import/no-unresolved
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
const Hashicon = skypack_hashicon.Hashicon;
export const Nodes = (): JSX.Element => {
const [graphNodes, setGraphNodes] = React.useState<Node[]>([]);
const [graphEdges, setGraphEdges] = React.useState<Edge[]>([]);
const [selected, setSelected] = React.useState<number>(0);
const nodes = useAppSelector((state) => state.meshtastic.nodes);
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware.myNodeNum,
);
React.useEffect(() => {
const tmpNodes: Node[] = [];
// User Terminal
tmpNodes.push({
id: '1',
type: 'input',
data: { label: 'User Terminal' },
position: { x: 160 + 500, y: 0 + 500 },
});
nodes.map((node, index) => {
tmpNodes.push({
id: node.number.toString(),
data: { label: node.user?.longName ?? `Unknown ${node.number}` },
position: { x: index * 160 + 500, y: 100 + 500 },
});
});
setGraphNodes(tmpNodes);
}, [nodes, myNodeNum]);
React.useEffect(() => {
const tmpEdges: Edge[] = [];
nodes.map((node, index) => {
if (node.number === myNodeNum) {
tmpEdges.push({
id: `e${1}-${myNodeNum}`,
source: '1',
target: myNodeNum.toString(),
type: 'smoothstep',
style: {
stroke: 'yellow',
strokeWidth: 2,
},
});
}
node.routes.map((route) => {
tmpEdges.push({
id: `e${route.from}-${route.to}`,
source: node.number.toString(),
target: route.to.toString(),
type: 'smoothstep',
animated: true,
});
});
});
setGraphEdges(tmpEdges);
}, [nodes, myNodeNum]);
return (
<Layout
title="Nodes"
icon={<RiMindMap />}
sidebarContents={
<>
{nodes.map((node) => (
<SidebarItem
key={node.number}
selected={node.number === selected}
setSelected={(): void => {
setSelected(node.number);
}}
actions={
<IconButton
onClick={(e): void => {
e.stopPropagation();
setSelected(node.number);
}}
icon={<FiSettings />}
/>
}
>
<div className="flex dark:text-white">
<div className="m-auto">
<Hashicon value={node.number.toString()} size={32} />
</div>
</div>
<div className="my-auto mr-auto text-xs font-semibold dark:text-gray-400">
{node.lastHeard.getTime()
? node.lastHeard.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
})
: 'Never'}
</div>
</SidebarItem>
))}
</>
}
>
<div className="relative flex h-full w-full">
<ReactFlow nodes={graphNodes} edges={graphEdges}>
<MiniMap />
<Controls />
<Background />
</ReactFlow>
</div>
</Layout>
);
};
+21
View File
@@ -0,0 +1,21 @@
import type React from 'react';
import JSONPretty from 'react-json-pretty';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import type { Node } from '@core/slices/meshtasticSlice';
export interface DebugPanelProps {
node: Node;
}
export const DebugPanel = ({ node }: DebugPanelProps): JSX.Element => {
return (
<div className="relative">
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(node)} />
</div>
<JSONPretty className="max-w-sm" data={node} />
</div>
);
};
+32
View File
@@ -0,0 +1,32 @@
import type React from 'react';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import type { Node } from '@core/slices/meshtasticSlice';
export interface PositionPanelProps {
node: Node;
}
export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
return (
<div className="p-2">
{node.currentPosition && (
<div className="flex h-10 select-none justify-between rounded-md border border-gray-300 bg-transparent bg-gray-200 px-1 text-gray-500 dark:border-gray-600 dark:bg-secondaryDark dark:text-gray-400 ">
<div className="my-auto px-1">
{(node.currentPosition.latitudeI / 1e7).toPrecision(6)},&nbsp;
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
</div>
<CopyButton
data={
node.currentPosition
? `${node.currentPosition.latitudeI / 1e7},${
node.currentPosition.longitudeI / 1e7
}`
: ''
}
/>
</div>
)}
</div>
);
};
+7 -10
View File
@@ -1,18 +1,15 @@
import type React from 'react';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import { Card } from '@meshtastic/components';
export const NotFound = (): JSX.Element => {
return (
<PrimaryTemplate title="Page not found" tagline="404">
<Card
title="The requested file or directory could not be found"
description="Better luck next time"
>
<br />
<br />
</Card>
</PrimaryTemplate>
<Card
title="The requested file or directory could not be found"
description="Better luck next time"
>
<br />
<br />
</Card>
);
};
-230
View File
@@ -1,230 +0,0 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { FiExternalLink, FiMenu, FiX } from 'react-icons/fi';
import {
RiArrowDownLine,
RiArrowUpDownLine,
RiArrowUpLine,
} from 'react-icons/ri';
import { Tooltip } from '@app/components/generic/Tooltip';
import type { ChannelData } from '@app/core/slices/meshtasticSlice';
import { FormFooter } from '@components/FormFooter';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { useBreakpoint } from '@hooks/useBreakpoint';
import {
Card,
Checkbox,
IconButton,
Input,
Loading,
Select,
} from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
import { ChannelsSidebar } from '../../components/pages/settings/radio/channels/ChannelsSidebar';
export interface ChannelsProps {
navOpen?: boolean;
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
export const Channels = ({
navOpen,
setNavOpen,
}: ChannelsProps): JSX.Element => {
const channels = useAppSelector((state) => state.meshtastic.radio.channels);
const adminChannel =
channels.find(
(channel) => channel.channel.role === Protobuf.Channel_Role.PRIMARY,
) ?? channels[0];
const { breakpoint } = useBreakpoint();
const [usePreset, setUsePreset] = React.useState(true);
const [loading, setLoading] = React.useState(false);
const [sidebarOpen, setSidebarOpen] = React.useState(breakpoint !== 'sm');
const [selectedChannel, setSelectedChannel] = React.useState<
ChannelData | undefined
>();
const { register, handleSubmit, reset, formState } = useForm<
DeepOmit<Protobuf.Channel, 'psk'>
>({
defaultValues: {
...adminChannel.channel,
},
});
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
const channelData = Protobuf.Channel.create({
...data,
settings: {
...data.settings,
psk: adminChannel.channel.settings?.psk,
},
});
await connection.setChannel(channelData, (): Promise<void> => {
reset({ ...data });
setLoading(false);
return Promise.resolve();
});
});
return (
<>
<PrimaryTemplate
title="Channels"
tagline="Settings"
leftButton={
<IconButton
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
footer={
<FormFooter
dirty={formState.isDirty}
saveAction={onSubmit}
clearAction={reset}
/>
}
>
<div className="space-y-4">
{adminChannel && (
<Card>
{loading && <Loading />}
<div className="w-full max-w-3xl p-10 md:max-w-xl">
{/* TODO: get gap working */}
<Checkbox
checked={usePreset}
label="Use Presets"
onChange={(e): void => setUsePreset(e.target.checked)}
/>
<form onSubmit={onSubmit}>
{usePreset ? (
<Select
label="Preset"
optionsEnum={Protobuf.ChannelSettings_ModemConfig}
{...register('settings.modemConfig', {
valueAsNumber: true,
})}
/>
) : (
<>
<Input
label="Bandwidth"
type="number"
suffix="MHz"
{...register('settings.bandwidth', {
valueAsNumber: true,
})}
/>
<Input
label="Spread Factor"
type="number"
suffix="CPS"
min={7}
max={12}
{...register('settings.spreadFactor', {
valueAsNumber: true,
})}
/>
<Input
label="Coding Rate"
type="number"
{...register('settings.codingRate', {
valueAsNumber: true,
})}
/>
</>
)}
<Input
label="Transmit Power"
type="number"
suffix="dBm"
{...register('settings.txPower', { valueAsNumber: true })}
/>
</form>
</div>
</Card>
)}
<Card>
<div className="w-full p-4 space-y-2 md:p-10">
{channels.map((channel) => (
<div
key={channel.channel.index}
onClick={(): void => {
setSelectedChannel(channel);
setSidebarOpen(true);
}}
className={`flex justify-between p-2 border border-gray-300 dark:border-gray-600 bg-gray-100 rounded-md dark:bg-secondaryDark shadow-md ${
selectedChannel?.channel.index === channel.channel.index
? 'border-primary dark:border-primary'
: ''
}`}
>
<div className="flex my-auto space-x-2">
<div
className={`h-3 my-auto w-3 rounded-full ${
[
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel.channel.role)
? 'bg-green-500'
: 'bg-gray-400'
}`}
/>
<div>
{channel.channel.settings?.name.length
? channel.channel.settings.name
: channel.channel.role === Protobuf.Channel_Role.PRIMARY
? 'Primary'
: `Channel: ${channel.channel.index}`}
</div>
</div>
<div className="flex gap-2">
<Tooltip contents={`MQTT Status`}>
<div className="p-2 rounded-md">
{channel.channel.settings?.uplinkEnabled &&
channel.channel.settings?.downlinkEnabled ? (
<RiArrowUpDownLine className="p-0.5 group-active:scale-90" />
) : channel.channel.settings?.uplinkEnabled ? (
<RiArrowUpLine className="p-0.5 group-active:scale-90" />
) : channel.channel.settings?.downlinkEnabled ? (
<RiArrowDownLine className="p-0.5 group-active:scale-90" />
) : (
<FiX className="p-0.5" />
)}
</div>
</Tooltip>
<IconButton
active={
selectedChannel?.channel.index === channel.channel.index
}
icon={<FiExternalLink />}
/>
</div>
</div>
))}
</div>
</Card>
</div>
</PrimaryTemplate>
{sidebarOpen && (
<ChannelsSidebar
closeSidebar={(): void => {
setSidebarOpen(false);
}}
channel={selectedChannel?.channel}
/>
)}
</>
);
};
-98
View File
@@ -1,98 +0,0 @@
import React from 'react';
import {
FiLayers,
FiLayout,
FiMapPin,
FiPackage,
FiRadio,
FiUser,
FiWifi,
FiZap,
} from 'react-icons/fi';
import type { SidebarItemProps } from '@app/components/generic/SidebarItem';
import { PageLayout } from '@components/templates/PageLayout';
import { Channels } from './Channels';
import { Interface } from './Interface';
import { Plugins } from './Plugins';
import { Position } from './Position';
import { Power } from './Power';
import { Radio } from './Radio';
import { User } from './User';
import { WiFi } from './WiFi';
export const Settings = (): JSX.Element => {
// const { hasGps, hasWifi } = useAppSelector((state) => state.meshtastic.radio.hardware);
const hasGps = true;
const hasWifi = true;
const panels: JSX.Element[] = [
<User key={4} />,
<Power key={5} />,
<Radio key={6} />,
<Channels key={7} />,
<Plugins key={8} />,
<Interface key={9} />,
];
const sidebarItems: SidebarItemProps[] = [
{
title: 'User',
description: 'Device name and details',
icon: <FiUser className="flex-shrink-0 w-6 h-6" />,
},
{
title: 'Power',
description: 'Power and sleep settings',
icon: <FiZap className="flex-shrink-0 w-6 h-6" />,
},
{
title: 'Radio',
description: 'LoRa settings',
icon: <FiRadio className="flex-shrink-0 w-6 h-6" />,
},
{
title: 'Channels',
description: 'Manage channels',
icon: <FiLayers className="flex-shrink-0 w-6 h-6" />,
},
{
title: 'Plugins',
description: 'Plugins',
icon: <FiPackage className="flex-shrink-0 w-6 h-6" />,
},
{
title: 'Interface',
description: 'Language and UI settings',
icon: <FiLayout className="flex-shrink-0 w-6 h-6" />,
},
];
React.useEffect(() => {
if (hasGps) {
panels.unshift(<Position key={3} />);
sidebarItems.unshift({
title: 'Position',
description: 'Position settings and flags',
icon: <FiMapPin className="flex-shrink-0 w-6 h-6" />,
});
}
if (hasWifi) {
panels.unshift(<WiFi key={2} />);
sidebarItems.unshift({
title: 'WiFi & MQTT',
description: 'WiFi & MQTT settings',
icon: <FiWifi className="flex-shrink-0 w-6 h-6" />,
});
}
console.log(panels);
}, [hasGps, hasWifi]);
return (
<PageLayout title="Settings" sidebarItems={sidebarItems} panels={panels} />
);
};
-73
View File
@@ -1,73 +0,0 @@
import type React from 'react';
import { useTranslation } from 'react-i18next';
import { FiMenu, FiSave } from 'react-icons/fi';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import i18n from '@core/translation';
import { Button, Card, Select } from '@meshtastic/components';
export interface InterfaceProps {
navOpen?: boolean;
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
export const Interface = ({
navOpen,
setNavOpen,
}: InterfaceProps): JSX.Element => {
const { t } = useTranslation();
return (
<PrimaryTemplate
title="Interface"
tagline="Settings"
leftButton={
<Button
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
footer={
<Button
className="px-10 ml-auto"
icon={<FiSave className="w-5 h-5" />}
active
border
>
{t('strings.save_changes')}
</Button>
}
>
<Card
title="Basic settings"
description="Device name and user parameters"
>
<div className="w-full max-w-3xl p-10 space-y-2 md:max-w-xl">
<Select
label="Language"
options={[
{
name: 'English',
value: 'en',
},
{
name: '日本',
value: 'jp',
},
{
name: 'Português',
value: 'pt',
},
]}
onChange={(e): void => {
void i18n.changeLanguage(e.target.value);
}}
/>
</div>
</Card>
</PrimaryTemplate>
);
};
-133
View File
@@ -1,133 +0,0 @@
import React from 'react';
import {
FiAlignLeft,
FiBell,
FiExternalLink,
FiFastForward,
FiMenu,
FiRss,
} from 'react-icons/fi';
import { PluginsSidebar } from '@app/components/pages/settings/plugins/PluginsSidebar';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import { Button, Card, IconButton } from '@meshtastic/components';
export type Plugin =
| 'Range Test'
| 'External Notifications'
| 'Serial'
| 'Store & Forward';
export interface PluginsProps {
navOpen?: boolean;
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
export const Plugins = ({ navOpen, setNavOpen }: PluginsProps): JSX.Element => {
const [sidebarOpen, setSidebarOpen] = React.useState(false);
const [selectedPlugin, setSelectedPlugin] = React.useState<
Plugin | undefined
>();
const preferences = useAppSelector(
(state) => state.meshtastic.radio.preferences,
);
const plugins: {
name: Plugin;
description: string;
enabled: boolean;
icon: JSX.Element;
}[] = [
{
name: 'Range Test',
description: 'Test the range of your Meshtastic node',
enabled: preferences.rangeTestPluginEnabled,
icon: <FiRss />,
},
{
name: 'External Notifications',
description: 'External hardware alerts',
enabled: preferences.extNotificationPluginEnabled,
icon: <FiBell />,
},
{
name: 'Serial',
description: 'Send serial data over the mesh',
enabled: preferences.serialpluginEnabled,
icon: <FiAlignLeft />,
},
{
name: 'Store & Forward',
description: 'Retrive message history',
enabled: preferences.storeForwardPluginEnabled,
icon: <FiFastForward />,
},
];
return (
<>
<PrimaryTemplate
title="Plugins"
tagline="Settings"
leftButton={
<Button
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
>
<Card
title="Basic settings"
description="Device name and user parameters"
>
<div className="w-full max-w-3xl p-10 space-y-2 md:max-w-xl">
{plugins.map((plugin, index) => (
<div
key={index}
onClick={(): void => {
setSelectedPlugin(plugin.name);
setSidebarOpen(true);
}}
className={`flex justify-between p-2 border border-gray-300 dark:border-gray-600 bg-gray-100 rounded-md dark:bg-secondaryDark shadow-md ${
selectedPlugin === plugin.name
? 'border-primary dark:border-primary'
: ''
}`}
>
<div className="flex my-auto space-x-2">
<div
className={`h-3 my-auto w-3 rounded-full ${
plugin.enabled ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
<div className="flex gap-2">
<div className="my-auto">{plugin.icon}</div>
{plugin.name}
</div>
</div>
<div className="flex gap-2">
<IconButton
active={plugin.name === selectedPlugin}
icon={<FiExternalLink />}
/>
</div>
</div>
))}
</div>
</Card>
</PrimaryTemplate>
{sidebarOpen && (
<PluginsSidebar
closeSidebar={(): void => {
setSidebarOpen(false);
}}
plugin={selectedPlugin}
/>
)}
</>
);
};
-236
View File
@@ -1,236 +0,0 @@
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { FiCode, FiMenu } from 'react-icons/fi';
import JSONPretty from 'react-json-pretty';
import type { Theme } from 'react-select';
import ReactSelect from 'react-select';
import { FormFooter } from '@components/FormFooter';
import { Cover } from '@components/generic/Cover';
import { Label } from '@components/generic/form/Label';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import {
Card,
Checkbox,
IconButton,
Input,
Select,
} from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
export interface PositionProps {
navOpen?: boolean;
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
export const Position = ({
navOpen,
setNavOpen,
}: PositionProps): JSX.Element => {
const preferences = useAppSelector(
(state) => state.meshtastic.radio.preferences,
);
const darkMode = useAppSelector((state) => state.app.darkMode);
const [debug, setDebug] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.RadioConfig_UserPreferences>({
defaultValues: {
...preferences,
positionBroadcastSecs:
preferences.positionBroadcastSecs === 0
? preferences.isRouter
? 43200
: 900
: preferences.positionBroadcastSecs,
},
});
// const watchPsk = useWatch({
// control,
// name: 'positionFlags',
// defaultValue: 0,
// });
React.useEffect(() => {
reset(preferences);
}, [reset, preferences]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setPreferences(data, async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
});
});
const encode = (enums: Protobuf.PositionFlags[]): number => {
return enums.reduce((acc, curr) => acc | curr, 0);
};
const decode = (value: number): Protobuf.PositionFlags[] => {
const enumValues = Object.keys(Protobuf.PositionFlags)
.map(Number)
.filter(Boolean);
return enumValues.map((b) => value & b).filter(Boolean);
};
return (
<PrimaryTemplate
title="Position"
tagline="Settings"
leftButton={
<IconButton
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
rightButton={
<IconButton
icon={<FiCode className="w-5 h-5" />}
active={debug}
onClick={(): void => {
setDebug(!debug);
}}
/>
}
footer={
<FormFooter
dirty={formState.isDirty}
saveAction={onSubmit}
clearAction={reset}
/>
}
>
<Card loading={loading}>
<Cover enabled={debug} content={<JSONPretty data={preferences} />} />
<div className="w-full max-w-3xl p-10 md:max-w-xl">
<form className="space-y-2" onSubmit={onSubmit}>
<Input
label="Broadcast Interval"
type="number"
suffix="Seconds"
{...register('positionBroadcastSecs', { 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} />}
<ReactSelect
{...rest}
isMulti
theme={(theme): Theme => ({
...theme,
borderRadius: 7,
colors: {
...theme.colors,
primary: '#67ea94', //focus border color
// primary75: 'red',
// primary50: 'red',
// primary25: 'red',
// danger: 'red',
// dangerLight: 'red',
neutral0: darkMode ? 'rgb(30 41 59)' : 'white', //bg color
// neutral5: 'red',
neutral10: darkMode
? 'rgb(75 85 99)'
: 'rgb(229 231 235)', //tag bg color
neutral20: darkMode
? 'rgb(229 231 235)'
: 'rgb(156 163 175)', //border color
neutral30: '#67ea94', //border hover
// neutral40: 'red',
// neutral50: 'red',
// neutral60: 'red',
// neutral70: 'red',
neutral80: darkMode ? 'white' : 'black', //tag text color
// neutral90: 'red',
},
})}
value={decode(value).map((flag) => {
return {
value: flag,
label: Protobuf.PositionFlags[flag].replace(
'POS_',
'',
),
};
})}
options={Object.entries(Protobuf.PositionFlags)
.filter((value) => typeof value[1] !== 'number')
.filter(
(value) =>
parseInt(value[0]) !==
Protobuf.PositionFlags.POS_UNDEFINED,
)
.map((value) => {
return {
value: parseInt(value[0]),
label: value[1].toString().replace('POS_', ''),
};
})}
onChange={(e): void =>
onChange(encode(e.map((v) => v.value)))
}
/>
</div>
);
}}
/>
<Input
label="Position Type (DEBUG)"
type="number"
disabled
{...register('positionFlags', { valueAsNumber: true })}
/>
<Checkbox
label="Use Fixed Position"
{...register('fixedPosition')}
/>
<Select
label="Location Sharing"
optionsEnum={Protobuf.LocationSharing}
{...register('locationShare', { valueAsNumber: true })}
/>
<Select
label="GPS Mode"
optionsEnum={Protobuf.GpsOperation}
{...register('gpsOperation', { valueAsNumber: true })}
/>
<Select
label="Display Format"
optionsEnum={Protobuf.GpsCoordinateFormat}
{...register('gpsFormat', { valueAsNumber: true })}
/>
<Checkbox label="Accept 2D Fix" {...register('gpsAccept2D')} />
<Input
label="Max DOP"
type="number"
{...register('gpsMaxDop', { valueAsNumber: true })}
/>
<Input
label="Last GPS Attempt"
disabled
{...register('gpsAttemptTime', { valueAsNumber: true })}
/>
</form>
</div>
</Card>
</PrimaryTemplate>
);
};
-98
View File
@@ -1,98 +0,0 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { FiCode, FiMenu } from 'react-icons/fi';
import JSONPretty from 'react-json-pretty';
import { FormFooter } from '@components/FormFooter';
import { Cover } from '@components/generic/Cover';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Card, Checkbox, IconButton, Select } from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
export interface PowerProps {
navOpen?: boolean;
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
export const Power = ({ navOpen, setNavOpen }: PowerProps): JSX.Element => {
const preferences = useAppSelector(
(state) => state.meshtastic.radio.preferences,
);
const [debug, setDebug] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const { register, handleSubmit, formState, reset } =
useForm<Protobuf.RadioConfig_UserPreferences>({
defaultValues: {
...preferences,
isLowPower: preferences.isRouter ? true : preferences.isLowPower,
},
});
React.useEffect(() => {
reset(preferences);
}, [reset, preferences]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setPreferences(data, async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
});
});
return (
<PrimaryTemplate
title="Power"
tagline="Settings"
leftButton={
<IconButton
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
rightButton={
<IconButton
icon={<FiCode className="w-5 h-5" />}
active={debug}
onClick={(): void => {
setDebug(!debug);
}}
/>
}
footer={
<FormFooter
dirty={formState.isDirty}
saveAction={onSubmit}
clearAction={reset}
/>
}
>
<Card loading={loading}>
<Cover enabled={debug} content={<JSONPretty data={preferences} />} />
<div className="w-full max-w-3xl p-10 md:max-w-xl">
<form className="space-y-2" onSubmit={onSubmit}>
<Select
label="Charge current"
optionsEnum={Protobuf.ChargeCurrent}
{...register('chargeCurrent', { valueAsNumber: true })}
/>
<Checkbox label="Always powered" {...register('isAlwaysPowered')} />
<Checkbox
label="Powered by low power source (solar)"
disabled={preferences.isRouter}
validationMessage={
preferences.isRouter ? 'Enabled by default in router mode' : ''
}
{...register('isLowPower')}
/>
</form>
</div>
</Card>
</PrimaryTemplate>
);
};
-89
View File
@@ -1,89 +0,0 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { FiCode, FiMenu } from 'react-icons/fi';
import JSONPretty from 'react-json-pretty';
import { FormFooter } from '@components/FormFooter';
import { Cover } from '@components/generic/Cover';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Card, Checkbox, IconButton, Select } from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
export interface RadioProps {
navOpen?: boolean;
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
export const Radio = ({ navOpen, setNavOpen }: RadioProps): JSX.Element => {
const preferences = useAppSelector(
(state) => state.meshtastic.radio.preferences,
);
const [debug, setDebug] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const { register, handleSubmit, formState, reset } =
useForm<Protobuf.RadioConfig_UserPreferences>({
defaultValues: preferences,
});
React.useEffect(() => {
reset(preferences);
}, [reset, preferences]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setPreferences(data, async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
});
});
return (
<PrimaryTemplate
title="Radio"
tagline="Settings"
leftButton={
<IconButton
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
rightButton={
<IconButton
icon={<FiCode className="w-5 h-5" />}
active={debug}
onClick={(): void => {
setDebug(!debug);
}}
/>
}
footer={
<FormFooter
dirty={formState.isDirty}
saveAction={onSubmit}
clearAction={reset}
/>
}
>
<Card loading={loading}>
<Cover enabled={debug} content={<JSONPretty data={preferences} />} />
<div className="w-full max-w-3xl p-10 md:max-w-xl">
<form className="space-y-2" onSubmit={onSubmit}>
<Checkbox label="Is Router" {...register('isRouter')} />
<Select
label="Region"
optionsEnum={Protobuf.RegionCode}
{...register('region', { valueAsNumber: true })}
/>
<Checkbox label="Debug Log" {...register('debugLogEnabled')} />
<Checkbox label="Serial Disabled" {...register('serialDisabled')} />
</form>
</div>
</Card>
</PrimaryTemplate>
);
};
-169
View File
@@ -1,169 +0,0 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { FiCode, FiMenu } from 'react-icons/fi';
import JSONPretty from 'react-json-pretty';
import { base16 } from 'rfc4648';
import { FormFooter } from '@components/FormFooter';
import { Cover } from '@components/generic/Cover';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import {
Card,
Checkbox,
IconButton,
Input,
Select,
} from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
export interface UserProps {
navOpen?: boolean;
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
export const User = ({ navOpen, setNavOpen }: UserProps): JSX.Element => {
const [debug, setDebug] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware,
).myNodeNum;
const node = useAppSelector((state) => state.meshtastic.nodes).find(
(node) => node.number === myNodeNum,
);
const { register, handleSubmit, formState, reset } = useForm<{
longName: string;
shortName: string;
isLicensed: boolean;
team: Protobuf.Team;
antAzimuth: number;
antGainDbi: number;
txPowerDbm: number;
}>({
defaultValues: {
longName: node?.user?.longName,
shortName: node?.user?.shortName,
isLicensed: node?.user?.isLicensed,
team: node?.user?.team,
antAzimuth: node?.user?.antAzimuth,
antGainDbi: node?.user?.antGainDbi,
txPowerDbm: node?.user?.txPowerDbm,
},
});
React.useEffect(() => {
reset({
longName: node?.user?.longName,
shortName: node?.user?.shortName,
isLicensed: node?.user?.isLicensed,
team: node?.user?.team,
});
}, [reset, node]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
if (node?.user) {
void connection.setOwner({ ...node.user, ...data }, async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
});
// TODO: can be removed once getUser is implemented
// dispatch(
// addUser({ ...node.user, ...{ data: { ...node.user.data, ...data } } }),
// );
}
});
return (
<PrimaryTemplate
title="User"
tagline="Settings"
leftButton={
<IconButton
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
rightButton={
<IconButton
icon={<FiCode className="w-5 h-5" />}
active={debug}
onClick={(): void => {
setDebug(!debug);
}}
/>
}
footer={
<FormFooter
dirty={formState.isDirty}
saveAction={onSubmit}
clearAction={reset}
/>
}
>
<Card loading={loading}>
<Cover enabled={debug} content={<JSONPretty data={node?.user} />} />
<div className="w-full max-w-3xl p-10 md:max-w-xl">
<form className="space-y-2" onSubmit={onSubmit}>
<Input label="Device ID" value={node?.user?.id} disabled />
<Input
label="Hardware"
value={
Protobuf.HardwareModel[
node?.user?.hwModel ?? Protobuf.HardwareModel.UNSET
]
}
disabled
/>
<Input
label="Mac Address"
defaultValue={
base16
.stringify(node?.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(':') ?? ''
}
disabled
/>
<Input label="Device Name" {...register('longName')} />
<Input
label="Short Name"
maxLength={3}
{...register('shortName')}
/>
<Checkbox label="Licenced Operator?" {...register('isLicensed')} />
<Select
label="Team"
optionsEnum={Protobuf.Team}
{...register('team', { valueAsNumber: true })}
/>
<Input
label="Antenna Azimuth"
suffix="°"
type="number"
{...register('antAzimuth', { valueAsNumber: true })}
/>
<Input
label="Antenna Gain"
suffix="dBi"
type="number"
{...register('antGainDbi', { valueAsNumber: true })}
/>
<Input
label="Transmit Power"
suffix="dBm"
type="number"
{...register('txPowerDbm', { valueAsNumber: true })}
/>
</form>
</div>
</Card>
</PrimaryTemplate>
);
};
-126
View File
@@ -1,126 +0,0 @@
import React from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { FiCode, FiMenu } from 'react-icons/fi';
import JSONPretty from 'react-json-pretty';
import { FormFooter } from '@components/FormFooter';
import { Cover } from '@components/generic/Cover';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Card, Checkbox, IconButton, Input } from '@meshtastic/components';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export interface WiFiProps {
navOpen?: boolean;
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
export const WiFi = ({ navOpen, setNavOpen }: WiFiProps): JSX.Element => {
const { t } = useTranslation();
const preferences = useAppSelector(
(state) => state.meshtastic.radio.preferences,
);
const [debug, setDebug] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.RadioConfig_UserPreferences>({
defaultValues: preferences,
});
const watchWifiApMode = useWatch({
control,
name: 'wifiApMode',
defaultValue: false,
});
const watchMQTTDisabled = useWatch({
control,
name: 'mqttDisabled',
defaultValue: false,
});
React.useEffect(() => {
reset(preferences);
}, [reset, preferences]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setPreferences(data, async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
});
});
return (
<PrimaryTemplate
title="WiFi & MQTT"
tagline="Settings"
leftButton={
<IconButton
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
rightButton={
<IconButton
icon={<FiCode className="w-5 h-5" />}
active={debug}
onClick={(): void => {
setDebug(!debug);
}}
/>
}
footer={
<FormFooter
dirty={formState.isDirty}
saveAction={onSubmit}
clearAction={reset}
/>
}
>
<Card loading={loading}>
<Cover enabled={debug} content={<JSONPretty data={preferences} />} />
<div className="w-full max-w-3xl p-10 md:max-w-xl">
<form className="space-y-2" onSubmit={onSubmit}>
<Checkbox label="Enable WiFi AP" {...register('wifiApMode')} />
<Input
label={t('strings.wifi_ssid')}
disabled={watchWifiApMode}
{...register('wifiSsid')}
/>
<Input
type="password"
autoComplete="off"
label={t('strings.wifi_psk')}
disabled={watchWifiApMode}
{...register('wifiPassword')}
/>
<Checkbox label="Disable MQTT" {...register('mqttDisabled')} />
<Input
label="MQTT Server Address"
disabled={watchMQTTDisabled}
{...register('mqttServer')}
/>
<Input
label="MQTT Username"
disabled={watchMQTTDisabled}
{...register('mqttUsername')}
/>
<Input
label="MQTT Password"
type="password"
autoComplete="off"
disabled={watchMQTTDisabled}
{...register('mqttPassword')}
/>
</form>
</div>
</Card>
</PrimaryTemplate>
);
};