Files
MeshChatX/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue

1073 lines
51 KiB
Vue

<template>
<div class="flex flex-col h-full w-full bg-white dark:bg-zinc-950 overflow-hidden">
<!-- header -->
<div
class="flex items-center px-4 py-2 border-b border-gray-200 dark:border-zinc-800 bg-white/60 dark:bg-zinc-900/50 backdrop-blur-lg z-10 relative"
>
<div class="flex items-center space-x-2">
<v-icon icon="mdi-chip" color="purple" size="24"></v-icon>
<h1 class="text-xl font-black text-gray-900 dark:text-white">{{ $t("tools.rnode_flasher.title") }}</h1>
</div>
<div class="ml-auto flex items-center space-x-2">
<button
class="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
@click="showAdvanced = !showAdvanced"
>
<MaterialDesignIcon :icon-name="showAdvanced ? 'cog' : 'cog-outline'" class="size-5" />
<span class="hidden sm:inline">{{ showAdvanced ? "Simple" : "Advanced" }}</span>
</button>
<a
href="/rnode-flasher/index.html"
target="_blank"
class="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
title="Open original flasher in new tab"
>
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
<span class="hidden sm:inline">Original</span>
</a>
<button
class="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
@click="$router.push({ name: 'tools' })"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
</div>
<!-- content -->
<div class="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 space-y-6">
<!-- setup card: Select device & Firmware -->
<div
class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-xl overflow-hidden"
>
<div class="grid grid-cols-1 md:grid-cols-2">
<!-- Left: Device Selection -->
<div class="p-6 border-b md:border-b-0 md:border-r border-gray-100 dark:border-zinc-800 space-y-4">
<div class="flex items-center gap-2 mb-2">
<div
class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg text-blue-600 dark:text-blue-400"
>
<MaterialDesignIcon icon-name="usb-port" class="size-5" />
</div>
<h2 class="font-bold text-gray-900 dark:text-zinc-100">
1. {{ $t("tools.rnode_flasher.select_device") }}
</h2>
</div>
<div class="space-y-3">
<div class="space-y-1">
<label
class="text-xs font-semibold text-gray-500 dark:text-zinc-500 uppercase tracking-wider"
>{{ $t("tools.rnode_flasher.product") }}</label
>
<select
v-model="selectedProduct"
class="w-full bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 transition-all"
>
<option :value="null" disabled>
{{ $t("tools.rnode_flasher.select_product") }}
</option>
<option v-for="product of products" :key="product.id" :value="product">
{{ product.name }}
</option>
</select>
</div>
<div class="space-y-1">
<label
class="text-xs font-semibold text-gray-500 dark:text-zinc-500 uppercase tracking-wider"
>{{ $t("tools.rnode_flasher.model") }}</label
>
<select
v-model="selectedModel"
:disabled="!selectedProduct"
class="w-full bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 transition-all disabled:opacity-50"
>
<option :value="null" disabled>{{ $t("tools.rnode_flasher.select_model") }}</option>
<template v-if="selectedProduct">
<option v-for="model of selectedProduct.models" :key="model.id" :value="model">
{{ model.name }}
</option>
</template>
</select>
</div>
<button
v-if="selectedProduct?.platform === 0x70"
:disabled="isEnteringDfuMode"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-amber-100 dark:bg-amber-900/30 hover:bg-amber-200 dark:hover:bg-amber-900/40 px-4 py-2.5 text-sm font-bold text-amber-700 dark:text-amber-400 transition-colors disabled:opacity-50"
@click="enterDfuMode"
>
<v-progress-circular v-if="isEnteringDfuMode" indeterminate size="16" width="2" />
<span>{{
isEnteringDfuMode
? $t("tools.rnode_flasher.entering_dfu_mode")
: $t("tools.rnode_flasher.enter_dfu_mode")
}}</span>
</button>
</div>
</div>
<!-- Right: Firmware Selection -->
<div class="p-6 space-y-4 bg-gray-50/50 dark:bg-zinc-900/50">
<div class="flex items-center gap-2 mb-2">
<div
class="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg text-purple-600 dark:text-purple-400"
>
<MaterialDesignIcon icon-name="file-download" class="size-5" />
</div>
<h2 class="font-bold text-gray-900 dark:text-zinc-100">
2. {{ $t("tools.rnode_flasher.select_firmware") }}
</h2>
</div>
<div class="space-y-4">
<!-- Auto-download section -->
<div
v-if="selectedProduct && selectedModel && recommendedFirmwareFilename"
class="p-4 rounded-xl border border-blue-100 dark:border-blue-900/30 bg-blue-50/50 dark:bg-blue-900/10 space-y-3"
>
<div class="text-xs font-bold text-blue-700 dark:text-blue-400 uppercase">
{{ $t("tools.rnode_flasher.download_recommended") }}
</div>
<div class="text-sm text-gray-600 dark:text-zinc-400 break-all font-mono">
{{ recommendedFirmwareFilename }}
</div>
<button
:disabled="isDownloadingFirmware || !latestRelease"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-blue-600 hover:bg-blue-700 px-4 py-2.5 text-sm font-bold text-white transition-colors disabled:opacity-50"
@click="downloadRecommendedFirmware"
>
<v-progress-circular
v-if="isDownloadingFirmware"
indeterminate
size="16"
width="2"
/>
<MaterialDesignIcon v-else icon-name="cloud-download" class="size-4" />
<span>{{
isDownloadingFirmware
? $t("tools.rnode_flasher.downloading")
: $t("tools.rnode_flasher.download_recommended")
}}</span>
</button>
</div>
<!-- Manual file pick -->
<div class="space-y-1">
<label
class="text-xs font-semibold text-gray-500 dark:text-zinc-500 uppercase tracking-wider"
>{{ $t("tools.rnode_flasher.select_firmware") }}</label
>
<input
ref="file"
type="file"
accept=".zip"
class="block w-full text-sm text-gray-900 dark:text-zinc-100 border border-gray-200 dark:border-zinc-800 rounded-xl cursor-pointer bg-white dark:bg-zinc-900 focus:outline-none file:mr-4 file:py-2.5 file:px-4 file:border-0 file:text-sm file:font-bold file:bg-zinc-200 dark:file:bg-zinc-700 file:text-zinc-700 dark:file:text-zinc-200 hover:file:bg-zinc-300 dark:hover:file:bg-zinc-600"
/>
</div>
<div
v-if="flashError"
class="p-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-red-600 dark:text-red-400"
>
{{ flashError }}
</div>
<button
:disabled="!selectedProduct || !selectedModel || isFlashing"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-green-600 hover:bg-green-700 px-4 py-3 text-sm font-bold text-white shadow-lg shadow-green-600/20 transition-all active:scale-[0.98] disabled:opacity-50"
@click="flash"
>
<MaterialDesignIcon v-if="!isFlashing" icon-name="flash" class="size-5" />
<v-progress-circular v-else indeterminate size="16" width="2" />
<span>{{
isFlashing
? $t("tools.rnode_flasher.flashing", { percentage: flashingProgress })
: $t("tools.rnode_flasher.flash_now")
}}</span>
</button>
<div v-if="isFlashing" class="space-y-1.5 pt-2">
<v-progress-linear v-model="flashingProgress" color="green" height="8" rounded />
<div class="text-[10px] text-center text-gray-500 font-mono">{{ flashingStatus }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- actions & tools card -->
<div
v-if="showAdvanced || isProvisioning || isSettingFirmwareHash"
class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-xl p-6 space-y-6"
>
<!-- Provision & Finalize -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<div class="flex items-center gap-2">
<h3 class="font-bold text-gray-900 dark:text-zinc-100">
3. {{ $t("tools.rnode_flasher.step_provision") }}
</h3>
<MaterialDesignIcon icon-name="key-variant" class="size-4 text-zinc-400" />
</div>
<p class="text-xs text-gray-500 dark:text-zinc-500">
{{ $t("tools.rnode_flasher.provision_description") }}
</p>
<button
v-if="!isProvisioning"
:disabled="!selectedProduct || !selectedModel"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/40 px-4 py-2.5 text-sm font-bold text-blue-700 dark:text-blue-400 transition-colors disabled:opacity-50"
@click="provision"
>
{{ $t("tools.rnode_flasher.provision") }}
</button>
<div v-else class="flex items-center justify-center gap-2 text-sm text-blue-600 p-2">
<v-progress-circular indeterminate size="18" width="2" />
<span class="font-bold">{{ $t("tools.rnode_flasher.provisioning_wait") }}</span>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center gap-2">
<h3 class="font-bold text-gray-900 dark:text-zinc-100">
4. {{ $t("tools.rnode_flasher.step_set_hash") }}
</h3>
<MaterialDesignIcon icon-name="shield-check" class="size-4 text-zinc-400" />
</div>
<p class="text-xs text-gray-500 dark:text-zinc-500">
{{ $t("tools.rnode_flasher.set_hash_description") }}
</p>
<button
v-if="!isSettingFirmwareHash"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/40 px-4 py-2.5 text-sm font-bold text-blue-700 dark:text-blue-400 transition-colors"
@click="setFirmwareHash"
>
{{ $t("tools.rnode_flasher.set_firmware_hash") }}
</button>
<div v-else class="flex items-center justify-center gap-2 text-sm text-blue-600 p-2">
<v-progress-circular indeterminate size="18" width="2" />
<span class="font-bold">{{ $t("tools.rnode_flasher.setting_hash_wait") }}</span>
</div>
</div>
</div>
<!-- Advanced Actions Grid -->
<div v-if="showAdvanced" class="pt-6 border-t border-gray-100 dark:border-zinc-800">
<div class="text-xs font-bold text-gray-400 dark:text-zinc-600 uppercase mb-4 tracking-widest">
{{ $t("tools.rnode_flasher.advanced_tools") }}
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-3">
<button class="action-btn" @click="detect">
<MaterialDesignIcon icon-name="magnify" class="size-4" />
<span>{{ $t("tools.rnode_flasher.detect_rnode") }}</span>
</button>
<button class="action-btn" @click="reboot">
<MaterialDesignIcon icon-name="restart" class="size-4" />
<span>{{ $t("tools.rnode_flasher.reboot_rnode") }}</span>
</button>
<button class="action-btn" @click="readDisplay">
<MaterialDesignIcon icon-name="monitor" class="size-4" />
<span>{{ $t("tools.rnode_flasher.read_display") }}</span>
</button>
<button class="action-btn" @click="dumpEeprom">
<MaterialDesignIcon icon-name="database-export" class="size-4" />
<span>{{ $t("tools.rnode_flasher.dump_eeprom") }}</span>
</button>
<button class="action-btn danger" @click="wipeEeprom">
<MaterialDesignIcon icon-name="eraser" class="size-4" />
<span>{{ $t("tools.rnode_flasher.wipe_eeprom") }}</span>
</button>
</div>
<div
v-if="rnodeDisplayImage"
class="mt-4 p-4 rounded-2xl bg-zinc-950 flex justify-center border border-zinc-800"
>
<img :src="rnodeDisplayImage" class="h-28 pixelated" />
</div>
</div>
</div>
<!-- config cards -->
<div v-if="showAdvanced" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Bluetooth -->
<div
class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-xl overflow-hidden"
>
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center gap-2">
<MaterialDesignIcon icon-name="bluetooth" class="size-5 text-blue-500" />
<h3 class="font-bold text-gray-900 dark:text-zinc-100">
{{ $t("tools.rnode_flasher.configure_bluetooth") }}
</h3>
</div>
<div class="p-6 space-y-4">
<div class="flex flex-wrap gap-2">
<button class="action-btn flex-1" @click="enableBluetooth">
{{ $t("tools.rnode_flasher.enable") }}
</button>
<button class="action-btn flex-1" @click="disableBluetooth">
{{ $t("tools.rnode_flasher.disable") }}
</button>
<button
class="action-btn flex-[2] bg-blue-500 !text-white !border-none"
@click="startBluetoothPairing"
>
{{ $t("tools.rnode_flasher.start_pairing") }}
</button>
</div>
<div
class="text-[10px] text-gray-400 dark:text-zinc-500 leading-relaxed uppercase tracking-wider"
>
{{ $t("tools.rnode_flasher.bluetooth_restart_warning") }}
</div>
</div>
</div>
<!-- TNC -->
<div
class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-xl overflow-hidden"
>
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center gap-2">
<MaterialDesignIcon icon-name="radio-tower" class="size-5 text-green-500" />
<h3 class="font-bold text-gray-900 dark:text-zinc-100">
{{ $t("tools.rnode_flasher.configure_tnc") }}
</h3>
</div>
<div class="p-6 space-y-4">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{{
$t("tools.rnode_flasher.frequency")
}}</label>
<input v-model="configFrequency" type="number" class="config-input" />
</div>
<div class="space-y-1">
<label class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{{
$t("tools.rnode_flasher.tx_power")
}}</label>
<input v-model="configTxPower" type="number" class="config-input" />
</div>
<div class="space-y-1">
<label class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{{
$t("tools.rnode_flasher.bandwidth")
}}</label>
<select v-model="configBandwidth" class="config-input">
<option v-for="bw in RNodeInterfaceDefaults.bandwidths" :key="bw" :value="bw">
{{ bw / 1000 }} KHz
</option>
</select>
</div>
<div class="space-y-1">
<label class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{{
$t("tools.rnode_flasher.spreading_factor")
}}</label>
<select v-model="configSpreadingFactor" class="config-input">
<option v-for="sf in RNodeInterfaceDefaults.spreadingfactors" :key="sf" :value="sf">
{{ sf }}
</option>
</select>
</div>
</div>
<div class="flex gap-2">
<button
class="action-btn flex-1 bg-green-600 !text-white !border-none"
@click="enableTncMode"
>
{{ $t("tools.rnode_flasher.enable") }}
</button>
<button class="action-btn flex-1" @click="disableTncMode">
{{ $t("tools.rnode_flasher.disable") }}
</button>
</div>
</div>
</div>
</div>
<!-- help footer -->
<div
class="flex flex-col sm:flex-row items-center justify-between gap-4 p-4 border border-zinc-200 dark:border-zinc-800 rounded-2xl bg-zinc-50 dark:bg-zinc-900/30"
>
<div class="flex items-center gap-3 text-sm text-zinc-500">
<MaterialDesignIcon icon-name="help-circle-outline" class="size-5" />
<span>{{ $t("tools.rnode_flasher.find_device_issue") }}</span>
</div>
<div class="flex items-center gap-4">
<a
target="_blank"
href="https://github.com/liamcottle/rnode-flasher"
class="text-blue-500 hover:underline text-sm font-bold"
>RNode Flasher GH</a
>
<a
target="_blank"
href="https://github.com/markqvist/RNode_Firmware"
class="text-blue-500 hover:underline text-sm font-bold"
>RNode Firmware GH</a
>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import RNode from "../../js/rnode/RNode.js";
import ROM from "../../js/rnode/ROM.js";
import Nrf52DfuFlasher from "../../js/rnode/Nrf52DfuFlasher.js";
import RNodeUtils from "../../js/rnode/RNodeUtils.js";
import products from "../../js/rnode/products.js";
import ToastUtils from "../../js/ToastUtils.js";
export default {
name: "RNodeFlasherPage",
components: {
MaterialDesignIcon,
},
data() {
return {
rnode: null,
isFlashing: false,
flashingProgress: 0,
flashingStatus: "",
flashError: null,
isProvisioning: false,
isSettingFirmwareHash: false,
isEnteringDfuMode: false,
rnodeDisplayImage: null,
showAdvanced: false,
selectedProduct: null,
selectedModel: null,
products: products,
configFrequency: 917375000,
configBandwidth: 250000,
configTxPower: 22,
configSpreadingFactor: 11,
configCodingRate: 5,
latestRelease: null,
isDownloadingFirmware: false,
RNodeInterfaceDefaults: {
bandwidths: [7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000],
codingrates: [5, 6, 7, 8],
spreadingfactors: [7, 8, 9, 10, 11, 12],
},
};
},
computed: {
recommendedFirmwareFilename() {
return this.selectedModel?.firmware_filename ?? this.selectedProduct?.firmware_filename;
},
},
watch: {
selectedProduct() {
this.selectedModel = null;
},
},
mounted() {
this.loadVendorLibraries();
this.fetchLatestRelease();
},
methods: {
async fetchLatestRelease() {
try {
const response = await fetch("https://api.github.com/repos/markqvist/RNode_Firmware/releases/latest");
if (response.ok) {
this.latestRelease = await response.json();
}
} catch {
// ignore
}
},
async downloadRecommendedFirmware() {
if (!this.recommendedFirmwareFilename || !this.latestRelease) return;
const asset = this.latestRelease.assets.find((a) => a.name === this.recommendedFirmwareFilename);
if (!asset) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.firmware_not_found_in_release"));
return;
}
this.isDownloadingFirmware = true;
try {
const downloadUrl = `/api/v1/tools/rnode/download_firmware?url=${encodeURIComponent(asset.browser_download_url)}`;
const response = await fetch(downloadUrl);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(errorData.error || `Download failed with status ${response.status}`);
}
const blob = await response.blob();
const file = new File([blob], asset.name, { type: "application/zip" });
// Manually set the file to the input ref
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
this.$refs["file"].files = dataTransfer.files;
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.firmware_downloaded"));
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_download", { error: e.message || e }));
} finally {
this.isDownloadingFirmware = false;
}
},
async loadVendorLibraries() {
// Check if libraries are already loaded
if (window.zip && window.CryptoJS && window.ESPLoader) return;
const libs = [
"/rnode-flasher/js/zip.min.js",
"/rnode-flasher/js/crypto-js@3.9.1-1/core.js",
"/rnode-flasher/js/crypto-js@3.9.1-1/md5.js",
];
for (const lib of libs) {
await this.loadScript(lib);
}
try {
// Load ES Modules
const esptoolPath = "/rnode-flasher/js/esptool-js@0.4.5/bundle.js";
const esptool = await import(/* @vite-ignore */ esptoolPath);
window.ESPLoader = esptool.ESPLoader;
window.Transport = esptool.Transport;
const serialPolyfillPath = "/rnode-flasher/js/web-serial-polyfill@1.0.15/dist/serial.js";
const serialPolyfill = await import(/* @vite-ignore */ serialPolyfillPath);
if (serialPolyfill.serial) {
window.serial = serialPolyfill.serial;
}
} catch (e) {
console.error("Failed to load ES module vendor libraries:", e);
}
// Setup polyfill
if (!navigator.serial && navigator.usb && window.serial) {
navigator.serial = window.serial;
}
},
loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
},
async askForSerialPort() {
if (!navigator.serial) {
this.flashError = this.$t("tools.rnode_flasher.errors.web_serial_not_supported");
ToastUtils.error(this.flashError);
return null;
}
if (this.rnode) {
try {
await this.rnode.close();
} catch {
// ignore
}
this.rnode = null;
}
try {
return await navigator.serial.requestPort({ filters: [] });
} catch (e) {
if (e.name === "NotFoundError" || e.message?.includes("No port selected")) {
this.flashError = this.$t("tools.rnode_flasher.errors.no_device_selected");
} else {
this.flashError = this.$t("tools.rnode_flasher.errors.failed_connect", { error: e.message || e });
}
throw e;
}
},
async askForRNode() {
try {
const serialPort = await this.askForSerialPort();
if (!serialPort) return false;
this.flashingStatus = this.$t("tools.rnode_flasher.connecting_device");
this.rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await this.rnode.detect();
if (!isRNode) {
await this.rnode.close();
this.flashError = this.$t("tools.rnode_flasher.errors.not_an_rnode");
ToastUtils.error(this.flashError);
this.flashingStatus = "";
return false;
}
this.flashingStatus = "";
return this.rnode;
} catch (e) {
this.flashingStatus = "";
throw e;
}
},
async enterDfuMode() {
this.isEnteringDfuMode = true;
this.flashError = null;
try {
const serialPort = await this.askForSerialPort();
if (!serialPort) return;
const flasher = new Nrf52DfuFlasher(serialPort);
await flasher.enterDfuMode();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.dfu_ready"));
} catch (e) {
this.flashError = this.$t("tools.rnode_flasher.errors.failed_dfu", { error: e.message || e });
ToastUtils.error(this.flashError);
} finally {
this.isEnteringDfuMode = false;
}
},
async flash() {
switch (this.selectedProduct?.platform) {
case ROM.PLATFORM_ESP32:
await this.flashEsp32();
break;
case ROM.PLATFORM_NRF52:
await this.flashNrf52();
break;
default:
ToastUtils.error(this.$t("tools.rnode_flasher.errors.select_product_first"));
break;
}
},
async flashNrf52() {
this.flashError = null;
const file = this.$refs["file"].files[0];
if (!file) {
this.flashError = this.$t("tools.rnode_flasher.errors.select_firmware_first");
ToastUtils.error(this.flashError);
return;
}
let serialPort = null;
try {
serialPort = await this.askForSerialPort();
if (!serialPort) return;
this.isFlashing = true;
this.flashingProgress = 0;
this.flashingStatus = this.$t("tools.rnode_flasher.connecting_device");
const flasher = new Nrf52DfuFlasher(serialPort);
await flasher.flash(file, (percentage, message) => {
this.flashingProgress = percentage;
this.flashingStatus = message || this.$t("tools.rnode_flasher.flashing", { percentage });
});
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.flash_success"));
} catch (e) {
this.flashError = this.$t("tools.rnode_flasher.errors.failed_flash", { error: e.message || e });
ToastUtils.error(this.flashError);
} finally {
this.isFlashing = false;
this.flashingStatus = "";
if (serialPort) await serialPort.close().catch(() => {});
}
},
async flashEsp32() {
this.flashError = null;
if (!window.ESPLoader) {
this.flashError = this.$t("tools.rnode_flasher.errors.esptool_not_loaded");
ToastUtils.error(this.flashError);
return;
}
const flashConfig = this.selectedModel?.flash_config ?? this.selectedProduct?.flash_config;
if (!flashConfig) {
this.flashError = this.$t("tools.rnode_flasher.errors.no_flash_config");
ToastUtils.error(this.flashError);
return;
}
const file = this.$refs["file"].files[0];
if (!file) {
this.flashError = this.$t("tools.rnode_flasher.errors.select_firmware_first");
ToastUtils.error(this.flashError);
return;
}
let serialPort = null;
try {
serialPort = await this.askForSerialPort();
if (!serialPort) return;
this.isFlashing = true;
this.flashingProgress = 0;
this.flashingStatus = this.$t("tools.rnode_flasher.connecting_device");
const blobReader = new window.zip.BlobReader(file);
const zipReader = new window.zip.ZipReader(blobReader);
const zipEntries = await zipReader.getEntries();
const filesToFlash = [];
for (const [address, filename] of Object.entries(flashConfig.flash_files)) {
const entry = zipEntries.find((e) => e.filename === filename);
if (!entry)
throw new Error(this.$t("tools.rnode_flasher.errors.failed_extract", { file: filename }));
const blob = await entry.getData(new window.zip.BlobWriter());
filesToFlash.push({
address: parseInt(address),
data: await this.readAsBinaryString(blob),
});
}
const transport = new window.Transport(serialPort, true);
const esploader = new window.ESPLoader({
transport,
baudrate: 921600,
terminal: {
writeLine: console.log,
write: console.log,
},
});
await esploader.main();
await esploader.writeFlash({
fileArray: filesToFlash,
flashSize: flashConfig.flash_size,
flashMode: "DIO",
flashFreq: "80MHz",
calculateMD5Hash: (img) => window.CryptoJS.MD5(window.CryptoJS.enc.Latin1.parse(img)),
reportProgress: (idx, written, total) => {
this.flashingProgress = Math.floor((written / total) * 100);
this.flashingStatus = `File ${idx + 1}/${filesToFlash.length}: ${this.flashingProgress}%`;
},
});
// Reboot
await transport.setDTR(false);
await new Promise((r) => setTimeout(r, 100));
await transport.setDTR(true);
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.flash_success"));
} catch (e) {
this.flashError = this.$t("tools.rnode_flasher.errors.failed_flash", { error: e.message || e });
ToastUtils.error(this.flashError);
} finally {
this.isFlashing = false;
this.flashingStatus = "";
if (serialPort) await serialPort.close().catch(() => {});
}
},
async detect() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
const ver = await rnode.getFirmwareVersion();
ToastUtils.success(`RNode v${ver} detected`);
await rnode.close();
} catch {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_detect"));
}
},
async reboot() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.rebooting"));
} catch {
// ignore
}
},
async readDisplay() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
const buffer = await rnode.readDisplay();
await rnode.close();
this.rnodeDisplayImage = this.rnodeDisplayBufferToPng(buffer);
} catch {
// ignore
}
},
rnodeDisplayBufferToPng(displayBuffer) {
const displayArea = displayBuffer.slice(0, 512);
const statArea = displayBuffer.slice(512, 1024);
const displayCanvas = this.frameBufferToCanvas(displayArea, 64, 64, "#000000", "#FFFFFF");
const statCanvas = this.frameBufferToCanvas(statArea, 64, 64, "#000000", "#FFFFFF");
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 64;
const ctx = canvas.getContext("2d");
ctx.drawImage(displayCanvas, 0, 0);
ctx.drawImage(statCanvas, 64, 0);
const scaledCanvas = document.createElement("canvas");
scaledCanvas.width = 512;
scaledCanvas.height = 256;
const sCtx = scaledCanvas.getContext("2d");
sCtx.imageSmoothingEnabled = false;
sCtx.drawImage(canvas, 0, 0, 512, 256);
return scaledCanvas.toDataURL("image/png");
},
frameBufferToCanvas(fb, w, h, bg, fg) {
const c = document.createElement("canvas");
c.width = w;
c.height = h;
const ctx = c.getContext("2d");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = Math.floor((y * w + x) / 8);
const bit = (fb[idx] >> (7 - (x % 8))) & 1;
if (bit) ctx.fillRect(x, y, 1, 1);
}
}
return c;
},
async dumpEeprom() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
const eeprom = await rnode.getRom();
console.log(RNodeUtils.bytesToHex(eeprom));
await rnode.close();
ToastUtils.success("EEPROM dumped to console");
} catch {
// ignore
}
},
async wipeEeprom() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
if (!confirm(this.$t("tools.rnode_flasher.alerts.eeprom_wipe_confirm"))) {
await rnode.close();
return;
}
await rnode.wipeRom();
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.eeprom_wiped"));
} catch {
// ignore
}
},
async provision() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
const rom = await rnode.getRomAsObject();
if (rom.parse()) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.provisioned_already"));
await rnode.close();
return;
}
if (!this.selectedProduct || !this.selectedModel) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.select_product_first"));
await rnode.close();
return;
}
this.isProvisioning = true;
const product = this.selectedProduct.id;
const model = this.selectedModel.mapped_id ?? this.selectedModel.id;
const hwRev = 0x1;
const serial = 1;
const now = Math.floor(Date.now() / 1000);
const sBytes = RNodeUtils.packUInt32BE(serial);
const tBytes = RNodeUtils.packUInt32BE(now);
const checksum = RNodeUtils.md5([product, model, hwRev, ...sBytes, ...tBytes]);
await rnode.writeRom(ROM.ADDR_PRODUCT, product);
await rnode.writeRom(ROM.ADDR_MODEL, model);
await rnode.writeRom(ROM.ADDR_HW_REV, hwRev);
await rnode.writeRom(ROM.ADDR_SERIAL, sBytes[0]);
await rnode.writeRom(ROM.ADDR_SERIAL + 1, sBytes[1]);
await rnode.writeRom(ROM.ADDR_SERIAL + 2, sBytes[2]);
await rnode.writeRom(ROM.ADDR_SERIAL + 3, sBytes[3]);
await rnode.writeRom(ROM.ADDR_MADE, tBytes[0]);
await rnode.writeRom(ROM.ADDR_MADE + 1, tBytes[1]);
await rnode.writeRom(ROM.ADDR_MADE + 2, tBytes[2]);
await rnode.writeRom(ROM.ADDR_MADE + 3, tBytes[3]);
for (let i = 0; i < 16; i++) await rnode.writeRom(ROM.ADDR_CHKSUM + i, checksum[i]);
for (let i = 0; i < 128; i++) await rnode.writeRom(ROM.ADDR_SIGNATURE + i, 0x00);
await rnode.writeRom(ROM.ADDR_INFO_LOCK, ROM.INFO_LOCK_BYTE);
await RNodeUtils.sleepMillis(5000);
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.provision_success"));
} catch {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_provision"));
} finally {
this.isProvisioning = false;
}
},
async setFirmwareHash() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
const rom = await rnode.getRomAsObject();
if (!rom.parse()) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.not_provisioned"));
await rnode.close();
return;
}
this.isSettingFirmwareHash = true;
const hash = await rnode.getFirmwareHash();
await rnode.setFirmwareHash(hash);
await RNodeUtils.sleepMillis(5000);
await rnode.reset().catch(() => {
// ignore
});
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.hash_success"));
} catch {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_set_hash"));
} finally {
this.isSettingFirmwareHash = false;
}
},
async enableTncMode() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
await rnode.setFrequency(this.configFrequency);
await rnode.setBandwidth(this.configBandwidth);
await rnode.setTxPower(this.configTxPower);
await rnode.setSpreadingFactor(this.configSpreadingFactor);
await rnode.setCodingRate(this.configCodingRate);
await rnode.setRadioStateOn();
await RNodeUtils.sleepMillis(500);
await rnode.saveConfig();
await rnode.saveConfig();
await RNodeUtils.sleepMillis(5000);
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.tnc_enabled"));
} catch {
// ignore
}
},
async disableTncMode() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
await rnode.deleteConfig();
await RNodeUtils.sleepMillis(5000);
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.tnc_disabled"));
} catch {
// ignore
}
},
async enableBluetooth() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
await rnode.enableBluetooth();
await RNodeUtils.sleepMillis(1000);
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_enabled"));
} catch {
// ignore
}
},
async disableBluetooth() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
await rnode.disableBluetooth();
await RNodeUtils.sleepMillis(1000);
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_disabled"));
} catch {
// ignore
}
},
async startBluetoothPairing() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
await rnode.startBluetoothPairing((pin) => {
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_pairing_pin", { pin }));
});
ToastUtils.success("Pairing mode started (30s)");
} catch {
// ignore
}
},
async setDisplayRotation(rot) {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
await rnode.setDisplayRotation(rot);
await rnode.close();
ToastUtils.success("Rotation updated");
} catch {
// ignore
}
},
async startDisplayReconditioning() {
try {
const rnode = await this.askForRNode();
if (!rnode) return;
await rnode.startDisplayReconditioning();
await rnode.close();
ToastUtils.success("Reconditioning started");
} catch {
// ignore
}
},
async readAsBinaryString(blob) {
return new Promise((resolve) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.readAsBinaryString(blob);
});
},
},
};
</script>
<style scoped>
.action-btn {
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 px-3 py-2 text-[11px] font-bold text-gray-700 dark:text-zinc-300 border border-gray-200 dark:border-zinc-700 transition-all active:scale-95;
}
.action-btn.danger {
@apply bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border-red-100 dark:border-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/40;
}
.config-input {
@apply w-full bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-[11px] rounded-lg focus:ring-1 focus:ring-blue-500/50 focus:border-blue-500 px-3 py-2 transition-all;
}
.pixelated {
image-rendering: pixelated;
}
select {
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>