Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/web/src/components/PageComponents/Settings/LoRa.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type LoRaValidation,
LoRaValidationSchema,
withLoRaDefaults,
} from "@app/validation/config/lora.ts";
import {
DynamicForm,
Expand Down Expand Up @@ -29,11 +30,15 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
const editor = useConfigEditor();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);

const effectiveLora =
const effectiveLoraConfig =
radio.lora ??
(getEffectiveConfig("lora") as
| Protobuf.Config.Config_LoRaConfig
| undefined);
const effectiveLora = effectiveLoraConfig
? withLoRaDefaults(effectiveLoraConfig)
: undefined;
const defaultLora = config.lora ? withLoRaDefaults(config.lora) : undefined;

const { t } = useTranslation("config");

Expand All @@ -50,7 +55,7 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={LoRaValidationSchema}
defaultValues={config.lora}
defaultValues={defaultLora}
values={effectiveLora}
fieldGroups={[
{
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/core/hooks/useWaitForConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { create } from "@bufbuild/protobuf";
import { CurrentDeviceContext } from "@core/hooks/useDeviceContext.ts";
import { useDeviceStore } from "@core/stores/deviceStore/index.ts";
import { Protobuf } from "@meshtastic/sdk";
import { act, renderHook } from "@testing-library/react";
import type { PropsWithChildren } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { useWaitForConfig } from "./useWaitForConfig.ts";

const DEVICE_ID = 4242;

const wrapper = ({ children }: PropsWithChildren) => (
<CurrentDeviceContext.Provider value={{ deviceId: DEVICE_ID }}>
{children}
</CurrentDeviceContext.Provider>
);

describe("useWaitForConfig", () => {
beforeEach(() => {
useDeviceStore.getState().removeDevice(DEVICE_ID);
});

it("resolves its suspense promise when the requested config arrives", async () => {
const device = useDeviceStore.getState().addDevice(DEVICE_ID);
const { result } = renderHook(
() => {
try {
useWaitForConfig({ configCase: "lora" });
return "ready" as const;
} catch (pending) {
return pending as Promise<void>;
}
},
{ wrapper },
);

expect(result.current).toBeInstanceOf(Promise);
const pending = result.current as Promise<void>;

act(() => {
device.setConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: create(Protobuf.Config.Config_LoRaConfigSchema, {}),
},
}),
);
});

const outcome = await Promise.race([
pending.then(() => "resolved"),
new Promise<string>((resolve) =>
setTimeout(() => resolve("timed-out"), 50),
),
]);
expect(outcome).toBe("resolved");
});
});
25 changes: 23 additions & 2 deletions apps/web/src/core/hooks/useWaitForConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
useDevice,
useDeviceStore,
type ValidConfigType,
type ValidModuleConfigType,
} from "@core/stores";
Expand All @@ -12,13 +13,33 @@ export function useWaitForConfig({
configCase,
moduleConfigCase,
}: UseWaitForConfigProps): void {
const { config, moduleConfig } = useDevice();
const device = useDevice();
const { config, moduleConfig } = device;

const isDataDefined = configCase
? config[configCase] !== undefined
: moduleConfig[moduleConfigCase as ValidModuleConfigType] !== undefined;

if (!isDataDefined) {
throw new Promise<void>(() => {});
throw new Promise<void>((resolve) => {
const hasRequestedConfig = (): boolean => {
const current = useDeviceStore.getState().getDevice(device.id);
if (!current) return false;
return configCase
? current.config[configCase] !== undefined
: current.moduleConfig[moduleConfigCase as ValidModuleConfigType] !==
undefined;
};

let unsubscribe = (): void => {};
const check = (): void => {
if (hasRequestedConfig()) {
unsubscribe();
resolve();
}
};
unsubscribe = useDeviceStore.subscribe(check);
check();
Comment thread
RCGV1 marked this conversation as resolved.
Outdated
});
}
}
17 changes: 14 additions & 3 deletions apps/web/src/pages/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const ConfigPage = () => {
);

const [isSaving, setIsSaving] = useState(false);
const [formResetKey, setFormResetKey] = useState(0);
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
const unsubRef = useRef<(() => void) | null>(null);
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
Expand Down Expand Up @@ -163,10 +164,15 @@ const ConfigPage = () => {
}, [toast, t, formMethods, editor]);

const handleReset = useCallback(() => {
if (formMethods) {
if (editor) {
// The editor is the source of the controlled `values` passed to each
// form. Remount the active form after discarding drafts so a controlled
// value update cannot emit a late change and recreate the draft.
editor.reset();
setFormResetKey((key) => key + 1);
} else if (formMethods) {
formMethods.reset();
}
editor?.reset();
}, [formMethods, editor]);

const leftSidebar = useMemo(
Expand Down Expand Up @@ -258,7 +264,12 @@ const ConfigPage = () => {
label={activeSection?.label ?? ""}
actions={actions}
>
{ActiveComponent && <ActiveComponent onFormInit={onFormInit} />}
{ActiveComponent && (
<ActiveComponent
key={`${activeSection.key}:${formResetKey}`}
onFormInit={onFormInit}
/>
)}
</PageLayout>
);
};
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/validation/config/lora.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/sdk";
import { describe, expect, it } from "vitest";
import { withLoRaDefaults } from "./lora.ts";

describe("LoRa modem presets", () => {
it("includes LONG_TURBO modem preset", () => {
Expand All @@ -11,3 +13,13 @@ describe("LoRa modem presets", () => {
).toBe(true);
});
});

describe("LoRa validation", () => {
it("defaults an omitted serialHalOnly form value to false", () => {
const { serialHalOnly: _, ...legacyConfig } = create(
Protobuf.Config.Config_LoRaConfigSchema,
);

expect(withLoRaDefaults(legacyConfig).serialHalOnly).toBe(false);
});
});
9 changes: 9 additions & 0 deletions apps/web/src/validation/config/lora.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,12 @@ export const LoRaValidationSchema = z.object({
});

export type LoRaValidation = z.infer<typeof LoRaValidationSchema>;

export function withLoRaDefaults<T extends { serialHalOnly?: boolean }>(
config: T,
): T & { serialHalOnly: boolean } {
return {
...config,
serialHalOnly: config.serialHalOnly ?? false,
};
}
62 changes: 53 additions & 9 deletions packages/sdk/src/core/client/MeshClient.progress.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { create } from "@bufbuild/protobuf";
import { create, fromBinary } from "@bufbuild/protobuf";
import * as Protobuf from "@meshtastic/protobufs";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createFakeTransport } from "../testing/createFakeTransport.ts";
import { MeshClient } from "./MeshClient.ts";

describe("MeshClient.progress", () => {
const CONFIG_ONLY_NONCE = 69420;
const NODES_ONLY_NONCE = 69421;

it("starts in the idle phase before configure() is called", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
Expand Down Expand Up @@ -60,21 +63,63 @@ describe("MeshClient.progress", () => {
expect(cur.received.modules).toBe(0);
});

it("flips to configured when onConfigComplete fires", () => {
const { transport } = createFakeTransport();
it("requests config and nodes in separate firmware-compatible stages", async () => {
const { transport, respond, sent } = createFakeTransport();
const client = new MeshClient({ transport });
void client.configure();

await client.configure();

const initialPackets = sent.map(
(packet) =>
fromBinary(Protobuf.Mesh.ToRadioSchema, packet).payloadVariant,
);
expect(initialPackets[0]?.case).toBe("heartbeat");
expect(initialPackets[1]).toEqual({
case: "wantConfigId",
value: CONFIG_ONLY_NONCE,
});

client.events.onConfigPacket.dispatch(
create(Protobuf.Config.ConfigSchema, {}),
);
client.events.onConfigComplete.dispatch(0);
respond.withConfigCompleteId(CONFIG_ONLY_NONCE);

await vi.waitFor(() => {
expect(sent).toHaveLength(3);
});
expect(
fromBinary(Protobuf.Mesh.ToRadioSchema, sent[2]!).payloadVariant,
).toEqual({ case: "wantConfigId", value: NODES_ONLY_NONCE });
expect(client.progress.value.phase).toBe("configuring");

respond.withConfigCompleteId(NODES_ONLY_NONCE);

await vi.waitFor(() => {
expect(client.progress.value.phase).toBe("configured");
});

const cur = client.progress.value;
expect(cur.phase).toBe("configured");
if (cur.phase !== "configured") throw new Error("unreachable");
expect(cur.received.config).toBe(1);
});

it("ignores stale and out-of-order config completion nonces", async () => {
const { transport, respond, sent } = createFakeTransport();
const client = new MeshClient({ transport });
const completed: number[] = [];
client.events.onConfigComplete.subscribe((id) => completed.push(id));

await client.configure();
respond.withConfigCompleteId(NODES_ONLY_NONCE);
respond.withConfigCompleteId(12345);

await new Promise((resolve) => setTimeout(resolve, 20));
expect(sent).toHaveLength(2);
expect(client.progress.value.phase).toBe("configuring");
expect(completed).toEqual([]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("ignores packets that arrive while idle (post-completion)", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
Expand All @@ -85,14 +130,13 @@ describe("MeshClient.progress", () => {
expect(client.progress.value.phase).toBe("idle");
});

it("resets counters when configure() runs again", () => {
it("resets counters when configure() runs again", async () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
void client.configure();
await client.configure();
client.events.onConfigPacket.dispatch(
create(Protobuf.Config.ConfigSchema, {}),
);
client.events.onConfigComplete.dispatch(0);
void client.configure();
expect(client.progress.value).toEqual({
phase: "configuring",
Expand Down
Loading
Loading