1073 lines
51 KiB
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>
|