Skip to content

Commit

Permalink
More friendly MQTT settings
Browse files Browse the repository at this point in the history
  • Loading branch information
sidoh committed Oct 20, 2024
1 parent d7f21d6 commit 0dc459c
Show file tree
Hide file tree
Showing 6 changed files with 186 additions and 58 deletions.
2 changes: 1 addition & 1 deletion web2/api/api-zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ const Settings = z
.describe(
"Topic pattern individual intercepted commands will be sent to. More detail on the format in README."
),
mqtt_update_state_pattern: z
mqtt_state_topic_pattern: z
.string()
.describe(
"Topic pattern device state will be sent to. More detail on the format in README."
Expand Down
7 changes: 7 additions & 0 deletions web2/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"esbuild-plugin-postcss": "^0.2.1",
"lodash": "^4.17.21",
"lucide-react": "^0.447.0",
"react-hook-form": "^7.53.0",
"react-router": "^6.26.2",
Expand Down
6 changes: 3 additions & 3 deletions web2/src/pages/settings/form-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,17 +258,17 @@ export const FormFields: React.FC<{
};

export const FieldSection: React.FC<{
title: string;
title?: string;
description?: string;
fields: (keyof typeof schemas.Settings.shape)[];
fieldNames?: Partial<Record<SettingsKey, string>>;
fieldTypes?: Partial<Record<SettingsKey, "text" | "number" | "password">>;
children?: React.ReactNode;
}> = ({ title, description, fields, fieldNames, fieldTypes, children }) => (
<div>
<h2 className="text-2xl font-bold">{title}</h2>
{title && <h2 className="text-2xl font-bold">{title}</h2>}
{description && <p className="text-sm text-gray-500">{description}</p>}
<hr className="my-4" />
{title && <hr className="my-4" />}
<FormFields
fields={fields}
fieldNames={fieldNames}
Expand Down
167 changes: 141 additions & 26 deletions web2/src/pages/settings/section-mqtt.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,160 @@
import * as React from "react";
import { NavChildProps } from "@/components/ui/sidebar-pill-nav";
import { FieldSection, FieldSections } from "./form-components";
import { FieldValues, useFormContext } from "react-hook-form";
import { useState, useEffect } from "react";
import {
NavChildProps
} from "@/components/ui/sidebar-pill-nav";
import {
FieldSection,
FieldSections
} from "./form-components";
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
} from "@/components/ui/form";
import SimpleSelect from "@/components/ui/select-box";
import { schemas } from "@/api";
import { z } from "zod";

type Settings = z.infer<typeof schemas.Settings>;
type SettingsKey = keyof typeof schemas.Settings.shape;

const TOPIC_PRESETS: Record<string, Partial<Settings>> = {
Default: {
mqtt_topic_pattern: "milight/commands/:device_id/:device_type/:group_id",
mqtt_update_topic_pattern:
"milight/updates/:device_id/:device_type/:group_id",
mqtt_state_topic_pattern: "milight/state/:device_id/:device_type/:group_id",
mqtt_client_status_topic: "milight/client_status",
simple_mqtt_client_status: true,
},
Custom: {},
};

const TopicFieldsSelector: React.FC<{}> = ({}) => {
const form = useFormContext();
const [selectedPreset, setSelectedPreset] = useState<string>("Custom");

useEffect(() => {
const currentFields = form.getValues();
for (const [preset, fields] of Object.entries(TOPIC_PRESETS)) {
if (isPresetEqual(currentFields, fields)) {
setSelectedPreset(preset);
break;
}
}
}, []);

const isPresetEqual = (a: FieldValues, b: Partial<Settings>) =>
Object.keys(b).every((key) => a[key] === b[key]);

const handlePresetChange = (value: string) => {
setSelectedPreset(value);
if (value !== "Custom") {
const topicFields = TOPIC_PRESETS[value];
for (const [key, value] of Object.entries(topicFields)) {
form.setValue(key as SettingsKey, value as string, {
shouldDirty: true,
shouldValidate: true,
shouldTouch: true,
});
}
form.handleSubmit((data) => {
console.log(data);
})();
}
};

return (
<div className="mt-4 flex flex-col gap-4">
<FormField
control={form.control}
name="topic_fields_preset"
render={() => (
<FormItem>
<FormLabel>Preset</FormLabel>
<FormControl>
<SimpleSelect
options={Object.keys(TOPIC_PRESETS).map((preset) => ({
label: preset,
value: preset,
}))}
value={{ label: selectedPreset, value: selectedPreset }}
onChange={(option) =>
handlePresetChange(option?.value as string)
}
/>
</FormControl>
<FormDescription>
Customize the MQTT topic patterns. Use the "Default" preset for
standard configurations.
</FormDescription>
</FormItem>
)}
/>

{selectedPreset !== "Custom" && (
<div className="preview-fields">
<h4 className="text-sm font-medium">Preview:</h4>
<ul>
{Object.entries(TOPIC_PRESETS[selectedPreset]).map(
([key, value]) => (
<li key={key} className="mt-2">
<div>
<strong className="text-sm font-medium">
{key
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())}
:
</strong>
</div>
<div>
<code className="bg-muted text-sm rounded">
{(value as any).toString()}
</code>
</div>
<div className="text-sm text-muted-foreground">
{schemas.Settings.shape[key as SettingsKey].description}
</div>
</li>
)
)}
</ul>
</div>
)}

{selectedPreset === "Custom" && (
<FieldSection
fields={[
"mqtt_topic_pattern",
"mqtt_update_topic_pattern",
"mqtt_state_topic_pattern",
"mqtt_client_status_topic",
"simple_mqtt_client_status",
]}
/>
)}
</div>
);
};

export const MQTTSettings: React.FC<NavChildProps<"mqtt">> = () => (
<FieldSections>
<FieldSection
title="Connection"
title="MQTT Connection"
fields={["mqtt_server", "mqtt_username", "mqtt_password"]}
fieldTypes={{
mqtt_password: "password",
}}
/>
<FieldSection title="MQTT Topics" fields={[]}>
<TopicFieldsSelector />
</FieldSection>
<FieldSection
title="Topics"
fields={[
"mqtt_topic_pattern",
"mqtt_update_topic_pattern",
"mqtt_update_state_pattern",
"mqtt_client_status_topic",
"mqtt_retain",
]}
fieldTypes={{
mqtt_password: "password",
}}
/>
<FieldSection
title="Home Assistant"
title="Home Assistant MQTT Discovery"
fields={["home_assistant_discovery_prefix"]}
/>
<FieldSection
title="Advanced"
fields={[
"mqtt_state_rate_limit",
"mqtt_debounce_delay",
"simple_mqtt_client_status",
]}
fields={["mqtt_state_rate_limit", "mqtt_debounce_delay", "mqtt_retain"]}
/>
</FieldSections>
);
61 changes: 33 additions & 28 deletions web2/src/pages/settings/settings-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { SystemSettings } from "./section-system";
import { RadioSettings } from "./section-radio";
import { StateSettings } from "./section-state";
import { UDPSettings } from "./section-udp";
import { debounce } from "lodash";

type Settings = z.infer<typeof schemas.Settings>;

Expand All @@ -40,26 +41,27 @@ export default function SettingsPage() {
mode: "onBlur",
});

const onSubmit = () => {
const update: Partial<Settings> = {};
const debouncedOnSubmit = useCallback(
debounce(() => {
const update: Partial<Settings> = {};

for (const field in form.formState.dirtyFields) {
update[field as keyof Settings] = form.getValues(field);
}
for (const field in form.formState.dirtyFields) {
update[field as keyof Settings] = form.getValues(field);
}

if (Object.keys(update).length > 0) {
api.putSettings(update).then(() => {
form.reset(form.getValues());
});
}
};
if (Object.keys(update).length > 0) {
api.putSettings(update).then(() => {
form.reset(form.getValues());
});
}
}, 300), // Adjust the debounce delay as needed
[form]
);

const handleFieldChange = useCallback((name: keyof Settings, value: any) => {
console.log(`Field ${name} changed to:`, value);
// Here you would typically send the individual field update to your API
// api.updateSetting(name, value);
onSubmit();
}, []);
debouncedOnSubmit();
}, [debouncedOnSubmit]);

useEffect(() => {
const subscription = form.watch((value, { name, type }) => {
Expand All @@ -70,18 +72,21 @@ export default function SettingsPage() {
const fieldKey: SettingsKey = name as SettingsKey;
const fieldType = extractSchemaType(schemas.Settings.shape[fieldKey]);

console.log("watch update", type, fieldKey, fieldType, value[fieldKey]);
handleFieldChange(fieldKey, value[fieldKey]);

if (
name &&
((type === "change" &&
(fieldType instanceof z.ZodEnum ||
fieldType instanceof z.ZodBoolean ||
fieldType instanceof z.ZodArray)) ||
name == "gateway_configs") // this is a hack but I CBA to figure out why type is undefined
) {
handleFieldChange(fieldKey, value[fieldKey]);
}
// console.log("watch update", type, fieldKey, fieldType, value[fieldKey]);

// if (
// name &&
// ((type === "change" &&
// (fieldType instanceof z.ZodEnum ||
// fieldType instanceof z.ZodBoolean ||
// fieldType instanceof z.ZodArray)) ||
// name == "gateway_configs" ||
// name == "group_state_fields") // this is a hack but I CBA to figure out why type is undefined for controlled fields
// ) {
// handleFieldChange(fieldKey, value[fieldKey]);
// }
});
return () => subscription.unsubscribe();
}, [form, handleFieldChange]);
Expand All @@ -107,10 +112,10 @@ export default function SettingsPage() {
) : (
<FormProvider {...form}>
<form
onBlur={onSubmit}
onBlur={debouncedOnSubmit}
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit(onSubmit)();
form.handleSubmit(debouncedOnSubmit)();
}}
>
<SidebarPillNav items={settingsNavItems}>
Expand Down

0 comments on commit 0dc459c

Please sign in to comment.