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
4 changes: 3 additions & 1 deletion apps/web/public/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
"unsetDefault": "Unset default",
"scanQr": "Scan QR Code",
"traceRoute": "Trace Route",
"submit": "Submit"
"submit": "Submit",
"saveAndExport": "Save & Export",
"importProfile": "Import Profile"
},
"app": {
"title": "Meshtastic",
Expand Down
8 changes: 8 additions & 0 deletions apps/web/public/i18n/locales/en/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@
"commandPalette": "Search commands..."
},
"toast": {
"profileImported": {
"title": "Profile Imported",
"description": "Settings staged. Click Save to commit."
},
"profileImportFailed": {
"title": "Import Failed",
"description": "Failed to parse the .cfg file."
},
"positionRequestSent": { "title": "Position request sent." },
"requestingPosition": { "title": "Requesting position, please wait..." },
"sendingTraceroute": { "title": "Sending Traceroute, please wait..." },
Expand Down
104 changes: 104 additions & 0 deletions apps/web/src/core/utils/profileExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { create, toBinary, fromBinary } from "@bufbuild/protobuf";
import { Protobuf, type ConfigEditor } from "@meshtastic/sdk";

export function exportProfile(editor: ConfigEditor) {
const radio = editor.radio.peek();
const modules = editor.modules.peek();
const owner = editor.owner.peek();

const profile = create(Protobuf.ClientOnly.DeviceProfileSchema, {
longName: owner?.longName,
shortName: owner?.shortName,
// @ts-expect-error fixedPosition is mapped dynamically in some contexts
fixedPosition: radio.fixedPosition,
config: {
device: radio.device,
position: radio.position,
power: radio.power,
network: radio.network,
display: radio.display,
lora: radio.lora,
bluetooth: radio.bluetooth,
security: radio.security,
sessionkey: radio.sessionkey,
},
moduleConfig: {
mqtt: modules.mqtt,
serial: modules.serial,
extNotification: modules.extNotification,
storeForward: modules.storeForward,
rangeTest: modules.rangeTest,
telemetry: modules.telemetry,
cannedMessage: modules.cannedMessage,
audio: modules.audio,
neighborInfo: modules.neighborInfo,
ambientLighting: modules.ambientLighting,
detectionSensor: modules.detectionSensor,
paxcounter: modules.paxcounter,
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const bytes = toBinary(Protobuf.ClientOnly.DeviceProfileSchema, profile);
const blob = new Blob([bytes], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "profile.cfg";
a.click();
URL.revokeObjectURL(url);
}

export function importProfile(bytes: Uint8Array, editor: ConfigEditor) {
const profile = fromBinary(Protobuf.ClientOnly.DeviceProfileSchema, bytes);

if (profile.longName || profile.shortName) {
editor.setOwner({
longName: profile.longName || editor.owner.peek()?.longName || "",
shortName: profile.shortName || editor.owner.peek()?.shortName || "",
macaddr: editor.owner.peek()?.macaddr || new Uint8Array(),
id: editor.owner.peek()?.id || "",
hwModel: editor.owner.peek()?.hwModel || 0,
isLicensed: editor.owner.peek()?.isLicensed || false,
role: editor.owner.peek()?.role || 0,
publicKey: editor.owner.peek()?.publicKey || new Uint8Array(),
});
}

const config = profile.config;
if (config) {
if (config.device) editor.setRadioSection("device", config.device);
if (config.position) editor.setRadioSection("position", config.position);
if (config.power) editor.setRadioSection("power", config.power);
if (config.network) editor.setRadioSection("network", config.network);
if (config.display) editor.setRadioSection("display", config.display);
if (config.lora) editor.setRadioSection("lora", config.lora);
if (config.bluetooth) editor.setRadioSection("bluetooth", config.bluetooth);
if (config.security) editor.setRadioSection("security", config.security);
if (config.sessionkey) editor.setRadioSection("sessionkey", config.sessionkey);
}

const moduleConfig = profile.moduleConfig;
if (moduleConfig) {
if (moduleConfig.mqtt) editor.setModuleSection("mqtt", moduleConfig.mqtt);
if (moduleConfig.serial) editor.setModuleSection("serial", moduleConfig.serial);
if (moduleConfig.extNotification) editor.setModuleSection("extNotification", moduleConfig.extNotification);
if (moduleConfig.storeForward) editor.setModuleSection("storeForward", moduleConfig.storeForward);
if (moduleConfig.rangeTest) editor.setModuleSection("rangeTest", moduleConfig.rangeTest);
if (moduleConfig.telemetry) editor.setModuleSection("telemetry", moduleConfig.telemetry);
if (moduleConfig.cannedMessage) editor.setModuleSection("cannedMessage", moduleConfig.cannedMessage);
if (moduleConfig.audio) editor.setModuleSection("audio", moduleConfig.audio);
if (moduleConfig.neighborInfo) editor.setModuleSection("neighborInfo", moduleConfig.neighborInfo);
if (moduleConfig.ambientLighting) editor.setModuleSection("ambientLighting", moduleConfig.ambientLighting);
if (moduleConfig.detectionSensor) editor.setModuleSection("detectionSensor", moduleConfig.detectionSensor);
if (moduleConfig.paxcounter) editor.setModuleSection("paxcounter", moduleConfig.paxcounter);
}

if (profile.fixedPosition) {
editor.queueAdminMessage({
payloadVariant: {
case: "setFixedPosition",
value: profile.fixedPosition
}
});
}
}
107 changes: 107 additions & 0 deletions apps/web/src/pages/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import {
DownloadIcon,
LayersIcon,
RadioTowerIcon,
RefreshCwIcon,
RouterIcon,
SaveIcon,
SaveOff,
UploadIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { exportProfile, importProfile } from "@core/utils/profileExport.ts";
import { RadioConfig } from "./RadioConfig.tsx";

const EMPTY_DIRTY_STRING_SIGNAL = {
Expand Down Expand Up @@ -129,6 +132,77 @@ const ConfigPage = () => {
return () => unsubRef.current?.();
}, []);

const fileInputRef = useRef<HTMLInputElement>(null);

const handleImportClick = useCallback(() => {
fileInputRef.current?.click();
}, []);

const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !editor) return;

try {
const buffer = await file.arrayBuffer();
importProfile(new Uint8Array(buffer), editor);
toast({
title: t("toast.profileImported.title", "Profile Imported"),
description: t(
"toast.profileImported.description",
"Settings staged. Click Save to commit.",
),
});
} catch {
toast({
title: t("toast.profileImportFailed.title", "Import Failed"),
description: t(
"toast.profileImportFailed.description",
"Failed to parse the .cfg file",
),
});
}

if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[editor, toast, t],
);

const handleSaveAndExport = useCallback(async () => {
if (!editor) return;

// Save unsaved changes if there are any
if (editorIsDirty || (formMethods && formMethods.formState.isDirty)) {
setIsSaving(true);
try {
const commitResult = await editor.commit();
if (commitResult.status === "error") {
throw commitResult.error;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (formMethods) {
formMethods.reset(undefined, {
keepDirty: false,
keepTouched: false,
keepValues: true,
});
formMethods.trigger();
}
} catch {
toast({
title: t("toast.configSaveError.title"),
description: t("toast.configSaveError.description"),
});
return; // Abort export if save failed
Comment thread
tylerpieper marked this conversation as resolved.
} finally {
setIsSaving(false);
}
}

exportProfile(editor);
}, [editor, editorIsDirty, formMethods, toast, t]);

const handleSave = useCallback(async () => {
if (!editor) return;
setIsSaving(true);
Expand Down Expand Up @@ -208,6 +282,30 @@ const ConfigPage = () => {
"transition-opacity",
]),
},
{
key: "import",
icon: DownloadIcon,
label: t("common:button.importProfile", "Import Profile"),
onClick: handleImportClick,
className: cn([
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer opacity-100",
]),
},
{
key: "saveAndExport",
icon: UploadIcon,
isLoading: isSaving && hasPending, // Only show loading if it's saving unsaved changes
disabled: isSaving,
onClick: handleSaveAndExport,
label: hasPending
? t("common:button.saveAndExport", "Save & Export")
: t("common:button.export"),
className: cn([
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer opacity-100",
]),
},
{
key: "reset",
icon: RefreshCwIcon,
Expand Down Expand Up @@ -245,6 +343,8 @@ const ConfigPage = () => {
buttonOpacity,
handleReset,
handleSave,
handleSaveAndExport,
handleImportClick,
t,
],
);
Expand All @@ -259,6 +359,13 @@ const ConfigPage = () => {
actions={actions}
>
{ActiveComponent && <ActiveComponent onFormInit={onFormInit} />}
<input
type="file"
accept=".cfg"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: "none" }}
/>
</PageLayout>
);
};
Expand Down