Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
- "apps/web/**"
- "packages/**"
- "playwright.config.ts"
- "tsconfig.json"
- ".github/workflows/e2e.yml"

jobs:
Expand Down
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
94 changes: 94 additions & 0 deletions apps/web/src/core/hooks/useWaitForConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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, vi } 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");
});

it("reuses one store subscription across repeated pending renders", async () => {
const device = useDeviceStore.getState().addDevice(DEVICE_ID);
const subscribe = vi.spyOn(useDeviceStore, "subscribe");
const { result, rerender } = renderHook(
() => {
try {
useWaitForConfig({ configCase: "lora" });
return "ready" as const;
} catch (pending) {
return pending as Promise<void>;
}
},
{ wrapper },
);

const pending = result.current;
rerender();
rerender();

expect(result.current).toBe(pending);
expect(subscribe).toHaveBeenCalledTimes(1);

act(() => {
device.setConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: create(Protobuf.Config.Config_LoRaConfigSchema, {}),
},
}),
);
});
await pending;
});
});
37 changes: 35 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 @@ -8,17 +9,49 @@ type UseWaitForConfigProps =
| { configCase: ValidConfigType; moduleConfigCase?: never }
| { configCase?: never; moduleConfigCase: ValidModuleConfigType };

const pendingConfigWaiters = new Map<string, Promise<void>>();

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>(() => {});
const configKey = configCase ?? `module:${moduleConfigCase}`;
const waiterKey = `${device.id}:${configKey}`;
const existingWaiter = pendingConfigWaiters.get(waiterKey);
if (existingWaiter) {
throw existingWaiter;
}

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

let unsubscribe = (): void => {};
const check = (): void => {
if (isWaitComplete()) {
unsubscribe();
resolve();
}
};
unsubscribe = useDeviceStore.subscribe(check);
check();
});
pendingConfigWaiters.set(waiterKey, waiter);
void waiter.finally(() => pendingConfigWaiters.delete(waiterKey));
throw waiter;
}
}
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,
};
}
65 changes: 56 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,66 @@ 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));
const handleConfigComplete = vi.spyOn(client, "handleConfigComplete");

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

await vi.waitFor(() => {
expect(handleConfigComplete).toHaveBeenCalledTimes(2);
});
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 +133,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