Merge pull request #439 from danditomaso/fix/validate-ble-pin

fix: Add 6-digit BLE PIN validation and error management
This commit is contained in:
Dan Ditomaso
2025-02-19 22:45:10 -05:00
committed by GitHub
9 changed files with 303 additions and 44 deletions

View File

@@ -13,6 +13,7 @@ import { Controller, type FieldValues } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select";
selectChange?: (e: string) => void;
properties: BaseFormBuilderProps<T>["properties"] & {
enumValue: {
[s: string]: string | number;
@@ -40,7 +41,10 @@ export function SelectInput<T extends FieldValues>({
: [];
return (
<Select
onValueChange={(e) => onChange(Number.parseInt(e))}
onValueChange={(e) => {
if (field.selectChange) field.selectChange(e);
onChange(Number.parseInt(e));
}}
disabled={disabled}
value={value?.toString()}
{...remainingProperties}

View File

@@ -1,23 +1,57 @@
import { useAppStore } from "@app/core/stores/appStore";
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
import { useState } from "react";
export const Bluetooth = (): JSX.Element => {
export const Bluetooth = () => {
const { config, setWorkingConfig } = useDevice();
const [bluetoothValidationText, setBluetoothValidationText] =
useState<string>();
const {
hasErrors,
getErrorMessage,
hasFieldError,
addError,
removeError,
clearErrors,
} = useAppStore();
const [bluetoothPin, setBluetoothPin] = useState(
config?.bluetooth?.fixedPin.toString() ?? "",
);
const validateBluetoothPin = (pin: string) => {
// if empty show error they need a pin set
if (pin === "") {
return addError("fixedPin", "Bluetooth Pin is required");
}
// clear any existing errors
clearErrors();
// if it starts with 0 show error
if (pin[0] === "0") {
return addError("fixedPin", "Bluetooth Pin cannot start with 0");
}
// if it's not 6 digits show error
if (pin.length < 6) {
return addError("fixedPin", "Pin must be 6 digits");
}
removeError("fixedPin");
};
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value[0] === "0") {
setBluetoothValidationText("Bluetooth Pin cannot start with 0.");
} else {
setBluetoothValidationText("");
}
const numericValue = e.target.value.replace(/\D/g, "").slice(0, 6);
setBluetoothPin(numericValue);
validateBluetoothPin(numericValue);
};
const onSubmit = (data: BluetoothValidation) => {
if (hasErrors()) {
return;
}
setWorkingConfig(
new Protobuf.Config.Config({
payloadVariant: {
@@ -48,6 +82,12 @@ export const Bluetooth = (): JSX.Element => {
name: "mode",
label: "Pairing mode",
description: "Pin selection behaviour.",
selectChange: (e) => {
if (e !== "1") {
setBluetoothPin("");
removeError("fixedPin");
}
},
disabledBy: [
{
fieldName: "enabled",
@@ -63,7 +103,9 @@ export const Bluetooth = (): JSX.Element => {
name: "fixedPin",
label: "Pin",
description: "Pin to use when pairing",
validationText: bluetoothValidationText,
validationText: hasFieldError("fixedPin")
? getErrorMessage("fixedPin")
: "",
inputChange: bluetoothPinChangeEvent,
disabledBy: [
{
@@ -77,7 +119,9 @@ export const Bluetooth = (): JSX.Element => {
fieldName: "enabled",
},
],
properties: {},
properties: {
value: bluetoothPin,
},
},
],
},

View File

@@ -70,7 +70,6 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
onCheckedChange={(checked) => {
checked ? setHTTPS(true) : setHTTPS(false);
}}
// label="Use TLS"
// description="Description"
disabled={

View File

@@ -1,6 +1,7 @@
import { cn } from "@app/core/utils/cn.ts";
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import Footer from "./UI/Footer";
import { Spinner } from "./UI/Spinner";
export interface PageLayoutProps {
label: string;
@@ -10,6 +11,8 @@ export interface PageLayoutProps {
icon: LucideIcon;
iconClasses?: string;
onClick: () => void;
disabled?: boolean;
isLoading?: boolean;
}[];
}
@@ -18,7 +21,7 @@ export const PageLayout = ({
noPadding,
actions,
children,
}: PageLayoutProps): JSX.Element => {
}: PageLayoutProps) => {
return (
<>
<div className="relative flex h-full w-full flex-col">
@@ -33,14 +36,22 @@ export const PageLayout = ({
<div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span>
<div className="flex justify-end space-x-4">
{actions?.map((action, index) => (
{actions?.map((action) => (
<button
key={action.icon.displayName}
type="button"
disabled={action?.disabled}
className="transition-all hover:text-accent"
onClick={action.onClick}
>
<action.icon className={action.iconClasses} />
{action?.isLoading ? (
<Spinner />
) : (
<action.icon
className={action.iconClasses}
aria-disabled={action.disabled}
/>
)}
</button>
))}
</div>

View File

@@ -0,0 +1,104 @@
import { cn } from "@app/core/utils/cn";
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "h-4 w-4",
md: "h-8 w-8",
lg: "h-12 w-12",
};
export function Spinner({ className, size = "md", ...props }: SpinnerProps) {
return (
<div
aria-label="Loading..."
className={cn(
"flex items-center justify-center fade-in-50 fade-out-50",
className,
)}
{...props}
>
<svg
className={cn("animate-spin-slow stroke-current", sizeClasses[size])}
role="img"
aria-label="Loading spinner"
viewBox="0 0 256 256"
>
<line
x1="128"
y1="32"
x2="128"
y2="64"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="195.9"
y1="60.1"
x2="173.3"
y2="82.7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="224"
y1="128"
x2="192"
y2="128"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="195.9"
y1="195.9"
x2="173.3"
y2="173.3"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="128"
y1="224"
x2="128"
y2="192"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="60.1"
y1="195.9"
x2="82.7"
y2="173.3"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="32"
y1="128"
x2="64"
y2="128"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="60.1"
y1="60.1"
x2="82.7"
y2="82.7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
</svg>
</div>
);
}

View File

@@ -18,6 +18,11 @@ export type AccentColor =
| "purple"
| "pink";
interface ErrorState {
field: string;
message: string;
}
interface AppState {
selectedDevice: number;
devices: {
@@ -33,11 +38,11 @@ interface AppState {
nodeNumDetails: number;
activeChat: number;
chatType: "broadcast" | "direct";
errors: ErrorState[];
setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void;
removeRasterSource: (index: number) => void;
setSelectedDevice: (deviceId: number) => void;
addDevice: (device: { id: number; num: number }) => void;
removeDevice: (deviceId: number) => void;
@@ -49,9 +54,18 @@ interface AppState {
setNodeNumDetails: (nodeNum: number) => void;
setActiveChat: (chat: number) => void;
setChatType: (type: "broadcast" | "direct") => void;
// Error management
hasErrors: () => boolean;
getErrorMessage: (field: string) => string | undefined;
hasFieldError: (field: string) => boolean;
addError: (field: string, message: string) => void;
removeError: (field: string) => void;
clearErrors: () => void;
setNewErrors: (newErrors: ErrorState[]) => void;
}
export const useAppStore = create<AppState>()((set) => ({
export const useAppStore = create<AppState>()((set, get) => ({
selectedDevice: 0,
devices: [],
currentPage: "messages",
@@ -67,6 +81,7 @@ export const useAppStore = create<AppState>()((set) => ({
nodeNumDetails: 0,
activeChat: Types.ChannelNumber.Primary,
chatType: "broadcast",
errors: [],
setRasterSources: (sources: RasterSource[]) => {
set(
@@ -146,4 +161,47 @@ export const useAppStore = create<AppState>()((set) => ({
set(() => ({
chatType: type,
})),
hasErrors: () => {
const state = get();
return state.errors.length > 0;
},
getErrorMessage: (field: string) => {
const state = get();
return state.errors.find((err) => err.field === field)?.message;
},
hasFieldError: (field: string) => {
const state = get();
return state.errors.some((err) => err.field === field);
},
addError: (field: string, message: string) => {
set(
produce<AppState>((draft) => {
draft.errors = [
...draft.errors.filter((e) => e.field !== field),
{ field, message },
];
}),
);
},
removeError: (field: string) => {
set(
produce<AppState>((draft) => {
draft.errors = draft.errors.filter((e) => e.field !== field);
}),
);
},
clearErrors: () => {
set(
produce<AppState>((draft) => {
draft.errors = [];
}),
);
},
setNewErrors: (newErrors: ErrorState[]) => {
set(
produce<AppState>((draft) => {
draft.errors = newErrors;
}),
);
},
}));

View File

@@ -99,3 +99,13 @@
img {
-webkit-user-drag: none;
}
@keyframes spin-slower {
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slower 2s linear infinite;
}

View File

@@ -14,7 +14,7 @@ import {
} from "@components/UI/Tabs.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
export const DeviceConfig = (): JSX.Element => {
export const DeviceConfig = () => {
const { metadata } = useDevice();
const tabs = [

View File

@@ -1,3 +1,4 @@
import { useAppStore } from "@app/core/stores/appStore";
import { useDevice } from "@app/core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
@@ -6,15 +7,62 @@ import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import { BoxesIcon, SaveIcon, SettingsIcon } from "lucide-react";
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
import { useState } from "react";
const ConfigPage = (): JSX.Element => {
const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice();
const { hasErrors } = useAppStore();
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module"
>("device");
const [isSaving, setIsSaving] = useState(false);
const { toast } = useToast();
const isError = hasErrors();
const handleSave = async () => {
if (hasErrors()) {
return toast({
title: "Config Errors Exist",
description: "Please fix the configuration errors before saving.",
});
}
setIsSaving(true);
try {
if (activeConfigSection === "device") {
await Promise.all(
workingConfig.map((config) =>
connection?.setConfig(config).then(() =>
toast({
title: "Saving Config",
description: `The configuration change ${config.payloadVariant.case} has been saved.`,
}),
),
),
);
} else {
await Promise.all(
workingModuleConfig.map((moduleConfig) =>
connection?.setModuleConfig(moduleConfig).then(() =>
toast({
title: "Saving Config",
description: `The configuration change ${moduleConfig.payloadVariant.case} has been saved.`,
}),
),
),
);
}
await connection?.commitEditSettings();
} catch (error) {
toast({
title: "Error Saving Config",
description: "An error occurred while saving the configuration.",
});
} finally {
setIsSaving(false);
}
};
return (
<>
@@ -40,30 +88,11 @@ const ConfigPage = (): JSX.Element => {
}
actions={[
{
icon: SaveIcon,
async onClick() {
if (activeConfigSection === "device") {
workingConfig.map(
async (config) =>
await connection?.setConfig(config).then(() =>
toast({
title: `Config ${config.payloadVariant.case} saved`,
}),
),
);
} else {
workingModuleConfig.map(
async (moduleConfig) =>
await connection?.setModuleConfig(moduleConfig).then(() =>
toast({
title: `Config ${moduleConfig.payloadVariant.case} saved`,
}),
),
);
}
await connection?.commitEditSettings();
},
icon: isError ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled: isSaving,
iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
onClick: handleSave,
},
]}
>