mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-07-20 06:30:56 +00:00
feat(rnode): add advanced tools, diagnostics, and device management UI components for RNode firmware flashing and configuration
This commit is contained in:
@@ -984,7 +984,7 @@
|
||||
<div v-if="isVoicemailSettingsExpanded" class="px-4 pb-6 space-y-6">
|
||||
<!-- Status Banner -->
|
||||
<div
|
||||
v-if="!voicemailStatus.has_espeak || !voicemailStatus.has_ffmpeg"
|
||||
v-if="!voicemailStatus.has_espeak"
|
||||
class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg flex gap-3 items-start"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
@@ -993,17 +993,10 @@
|
||||
/>
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||
<p class="font-bold mb-1">Dependencies Missing</p>
|
||||
<p v-if="!voicemailStatus.has_espeak">
|
||||
<p>
|
||||
Voicemail requires `espeak-ng` to generate greetings. Please install it on your
|
||||
system.
|
||||
</p>
|
||||
<p
|
||||
v-if="!voicemailStatus.has_ffmpeg"
|
||||
:class="{ 'mt-1': !voicemailStatus.has_espeak }"
|
||||
>
|
||||
Voicemail requires `ffmpeg` to process audio files. Please install it on your
|
||||
system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1018,7 +1011,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:disabled="!voicemailStatus.has_espeak || !voicemailStatus.has_ffmpeg"
|
||||
:disabled="!voicemailStatus.has_espeak"
|
||||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="config.voicemail_enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-zinc-700'"
|
||||
@click="
|
||||
@@ -1112,11 +1105,7 @@
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
:disabled="
|
||||
!voicemailStatus.has_espeak ||
|
||||
!voicemailStatus.has_ffmpeg ||
|
||||
isGeneratingGreeting
|
||||
"
|
||||
:disabled="!voicemailStatus.has_espeak || isGeneratingGreeting"
|
||||
class="text-[10px] bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 px-3 py-1 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50"
|
||||
@click="
|
||||
updateConfig({ voicemail_greeting: config.voicemail_greeting });
|
||||
@@ -1144,11 +1133,7 @@
|
||||
@change="uploadGreeting"
|
||||
/>
|
||||
<button
|
||||
:disabled="
|
||||
!voicemailStatus.has_ffmpeg ||
|
||||
isUploadingGreeting ||
|
||||
voicemailStatus.is_greeting_recording
|
||||
"
|
||||
:disabled="isUploadingGreeting || voicemailStatus.is_greeting_recording"
|
||||
class="text-xs bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 px-4 py-2 rounded-lg font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
@click="$refs.greetingUpload.click()"
|
||||
>
|
||||
@@ -2176,7 +2161,6 @@ export default {
|
||||
unreadVoicemailsCount: 0,
|
||||
voicemailStatus: {
|
||||
has_espeak: false,
|
||||
has_ffmpeg: false,
|
||||
is_recording: false,
|
||||
is_greeting_recording: false,
|
||||
has_greeting: false,
|
||||
|
||||
@@ -213,35 +213,138 @@
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
id="tcp-kiss-framing"
|
||||
v-model="newInterfaceKISSFramingEnabled"
|
||||
/>
|
||||
<FormLabel for="tcp-kiss-framing" class="cursor-pointer !mb-0 text-sm"
|
||||
>Use KISS framing (legacy compatibility)</FormLabel
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
id="tcp-i2p-tunneled"
|
||||
v-model="newInterfaceI2PTunnelingEnabled"
|
||||
/>
|
||||
<FormLabel for="tcp-i2p-tunneled" class="cursor-pointer !mb-0 text-sm"
|
||||
>I2P Tunneled (target is an I2P b32)</FormLabel
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Connect Timeout (s)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceConnectTimeout"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="default"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Max Reconnect Tries</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceMaxReconnectTries"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="default"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Fixed MTU</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceFixedMTU"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="auto"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="newInterfaceType === 'BackboneInterface'" class="space-y-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Remote host</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceTargetHost"
|
||||
type="text"
|
||||
placeholder="e.g. example.com or 1.2.3.4"
|
||||
class="input-field"
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
id="backbone-listen-mode"
|
||||
v-model="newInterfaceBackboneListenMode"
|
||||
/>
|
||||
<FormLabel
|
||||
for="backbone-listen-mode"
|
||||
class="cursor-pointer !mb-0 text-sm"
|
||||
>Listener mode (host this backbone)</FormLabel
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Target port</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceTargetPort"
|
||||
type="number"
|
||||
placeholder="4242"
|
||||
class="input-field"
|
||||
/>
|
||||
<div v-if="!newInterfaceBackboneListenMode" class="space-y-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Remote host</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceTargetHost"
|
||||
type="text"
|
||||
placeholder="e.g. example.com or 1.2.3.4"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Target port</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceTargetPort"
|
||||
type="number"
|
||||
placeholder="4242"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Transport identity (hex)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceTransportIdentity"
|
||||
type="text"
|
||||
placeholder="32 hex chars from the directory"
|
||||
class="input-field font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Transport identity (hex)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceTransportIdentity"
|
||||
type="text"
|
||||
placeholder="32 hex chars from the directory"
|
||||
class="input-field font-mono text-xs"
|
||||
/>
|
||||
<div v-else class="space-y-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Listen IP (Optional)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceBackboneListenIp"
|
||||
type="text"
|
||||
placeholder="0.0.0.0"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Listen Port</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceBackboneListenPort"
|
||||
type="number"
|
||||
placeholder="4242"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Device (optional)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceBackboneListenDevice"
|
||||
type="text"
|
||||
placeholder="e.g. eth0"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
id="backbone-listen-ipv6"
|
||||
v-model="newInterfacePreferIPV6"
|
||||
/>
|
||||
<FormLabel
|
||||
for="backbone-listen-ipv6"
|
||||
class="cursor-pointer !mb-0 text-sm"
|
||||
>Prefer IPv6</FormLabel
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -268,6 +371,37 @@
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Device (optional)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceNetworkDevice"
|
||||
type="text"
|
||||
placeholder="e.g. eth0 (overrides Listen IP)"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="newInterfaceType === 'TCPServerInterface'"
|
||||
class="flex flex-wrap items-center gap-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle id="tcp-server-ipv6" v-model="newInterfacePreferIPV6" />
|
||||
<FormLabel
|
||||
for="tcp-server-ipv6"
|
||||
class="cursor-pointer !mb-0 text-sm"
|
||||
>Prefer IPv6</FormLabel
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
id="tcp-server-i2p"
|
||||
v-model="newInterfaceI2PTunnelingEnabled"
|
||||
/>
|
||||
<FormLabel for="tcp-server-i2p" class="cursor-pointer !mb-0 text-sm"
|
||||
>I2P Tunneled</FormLabel
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UDP Extras -->
|
||||
@@ -300,6 +434,12 @@
|
||||
ⓘ To use the I2P interface, you must have an I2P router running on your
|
||||
system.
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle id="i2p-connectable" v-model="newInterfaceConnectable" />
|
||||
<FormLabel for="i2p-connectable" class="cursor-pointer !mb-0 text-sm"
|
||||
>Allow incoming peers (connectable)</FormLabel
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Initial Peers (Optional)</FormLabel>
|
||||
<div class="space-y-2">
|
||||
@@ -487,6 +627,345 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Spreading Factor</FormLabel>
|
||||
<select v-model="newInterfaceSpreadingFactor" class="input-field">
|
||||
<option
|
||||
v-for="sf in RNodeInterfaceDefaults.spreadingfactors"
|
||||
:key="sf"
|
||||
:value="sf"
|
||||
>
|
||||
SF{{ sf }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Coding Rate</FormLabel>
|
||||
<select v-model="newInterfaceCodingRate" class="input-field">
|
||||
<option
|
||||
v-for="cr in RNodeInterfaceDefaults.codingrates"
|
||||
:key="cr"
|
||||
:value="cr"
|
||||
>
|
||||
4:{{ cr }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle id="rnode-flow-control" v-model="newInterfaceFlowControl" />
|
||||
<FormLabel for="rnode-flow-control" class="cursor-pointer !mb-0 text-sm"
|
||||
>Hardware flow control</FormLabel
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Station ID Callsign</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceIDCallsign"
|
||||
type="text"
|
||||
placeholder="e.g. NOCALL"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">ID Interval (s)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceIDInterval"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="600"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Airtime Limit Long (%)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceAirtimeLimitLong"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
placeholder="optional"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Airtime Limit Short (%)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceAirtimeLimitShort"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
placeholder="optional"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Serial port settings (Serial/KISS/AX25KISS) -->
|
||||
<div
|
||||
v-if="
|
||||
['SerialInterface', 'KISSInterface', 'AX25KISSInterface'].includes(
|
||||
newInterfaceType
|
||||
)
|
||||
"
|
||||
class="space-y-4 pt-4 border-t border-gray-100 dark:border-zinc-800"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Baud Rate</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceSpeed"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="9600"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Data Bits</FormLabel>
|
||||
<select v-model="newInterfaceDatabits" class="input-field">
|
||||
<option :value="null">Default (8)</option>
|
||||
<option :value="5">5</option>
|
||||
<option :value="6">6</option>
|
||||
<option :value="7">7</option>
|
||||
<option :value="8">8</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Parity</FormLabel>
|
||||
<select v-model="newInterfaceParity" class="input-field">
|
||||
<option :value="null">Default (none)</option>
|
||||
<option value="N">None</option>
|
||||
<option value="E">Even</option>
|
||||
<option value="O">Odd</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Stop Bits</FormLabel>
|
||||
<select v-model="newInterfaceStopbits" class="input-field">
|
||||
<option :value="null">Default (1)</option>
|
||||
<option :value="1">1</option>
|
||||
<option :value="2">2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KISS framing parameters (KISS/AX25KISS) -->
|
||||
<div
|
||||
v-if="['KISSInterface', 'AX25KISSInterface'].includes(newInterfaceType)"
|
||||
class="space-y-4 pt-4 border-t border-gray-100 dark:border-zinc-800"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Preamble (ms)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfacePreamble"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="350"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">TX Tail (ms)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceTXTail"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="20"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Persistence (0-255)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfacePersistence"
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
placeholder="64"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Slot Time (ms)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceSlotTime"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="20"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle id="kiss-flow-control" v-model="newInterfaceFlowControl" />
|
||||
<FormLabel for="kiss-flow-control" class="cursor-pointer !mb-0 text-sm"
|
||||
>Hardware flow control</FormLabel
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Beacon Callsign</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceIDCallsign"
|
||||
type="text"
|
||||
placeholder="optional"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Beacon Interval (s)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceIDInterval"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="optional"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AX.25 KISS extras -->
|
||||
<div
|
||||
v-if="newInterfaceType === 'AX25KISSInterface'"
|
||||
class="space-y-4 pt-4 border-t border-gray-100 dark:border-zinc-800"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">AX.25 Callsign</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceCallsign"
|
||||
type="text"
|
||||
placeholder="e.g. NOCALL"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">SSID (0-15)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceSSID"
|
||||
type="number"
|
||||
min="0"
|
||||
max="15"
|
||||
placeholder="0"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AutoInterface -->
|
||||
<div v-if="newInterfaceType === 'AutoInterface'" class="space-y-4">
|
||||
<div
|
||||
class="bg-pink-50/50 dark:bg-pink-900/10 p-3 rounded-2xl border border-pink-100 dark:border-pink-900/20 text-xs text-pink-800 dark:text-pink-300"
|
||||
>
|
||||
ⓘ Auto Interface auto-discovers peers on connected networks via IPv6
|
||||
multicast.
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Group ID (optional)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceGroupID"
|
||||
type="text"
|
||||
placeholder="reticulum"
|
||||
class="input-field"
|
||||
/>
|
||||
<p class="text-[10px] text-gray-400 mt-1">
|
||||
Only peers sharing the same group_id will discover each other.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Discovery Scope</FormLabel>
|
||||
<select v-model="newInterfaceDiscoveryScope" class="input-field">
|
||||
<option :value="null">Default (link)</option>
|
||||
<option value="link">Link (local segment)</option>
|
||||
<option value="admin">Administrative</option>
|
||||
<option value="site">Site</option>
|
||||
<option value="organisation">Organisation</option>
|
||||
<option value="global">Global</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Multicast Address Type</FormLabel>
|
||||
<select
|
||||
v-model="newInterfaceMulticastAddressType"
|
||||
class="input-field"
|
||||
>
|
||||
<option :value="null">Default (temporary)</option>
|
||||
<option value="temporary">Temporary</option>
|
||||
<option value="permanent">Permanent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel class="glass-label">Discovery Port</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceDiscoveryPort"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder="29716"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Data Port</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceDataPort"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder="42671"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label"
|
||||
>Allowed Devices (comma separated)</FormLabel
|
||||
>
|
||||
<input
|
||||
v-model="newInterfaceDevices"
|
||||
type="text"
|
||||
placeholder="e.g. eth0, wlan0"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label"
|
||||
>Ignored Devices (comma separated)</FormLabel
|
||||
>
|
||||
<input
|
||||
v-model="newInterfaceIgnoredDevices"
|
||||
type="text"
|
||||
placeholder="e.g. tun0, docker0"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Configured Bitrate (bps)</FormLabel>
|
||||
<input
|
||||
v-model="newInterfaceConfiguredBitrate"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="optional override"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipe Interface -->
|
||||
@@ -1066,9 +1545,18 @@ export default {
|
||||
newInterfaceListenIp: null,
|
||||
newInterfaceListenPort: null,
|
||||
newInterfaceNetworkDevice: null,
|
||||
newInterfacePreferIPV6: null,
|
||||
newInterfaceKISSFramingEnabled: null,
|
||||
newInterfaceI2PTunnelingEnabled: null,
|
||||
newInterfacePreferIPV6: false,
|
||||
newInterfaceKISSFramingEnabled: false,
|
||||
newInterfaceI2PTunnelingEnabled: false,
|
||||
newInterfaceConnectTimeout: null,
|
||||
newInterfaceMaxReconnectTries: null,
|
||||
newInterfaceFixedMTU: null,
|
||||
newInterfaceConfiguredBitrate: null,
|
||||
newInterfaceConnectable: true,
|
||||
newInterfaceBackboneListenMode: false,
|
||||
newInterfaceBackboneListenPort: null,
|
||||
newInterfaceBackboneListenIp: null,
|
||||
newInterfaceBackboneListenDevice: null,
|
||||
|
||||
sharedInterfaceSettings: {
|
||||
mode: null,
|
||||
@@ -1143,10 +1631,14 @@ export default {
|
||||
newInterfacePersistence: null,
|
||||
newInterfaceSlotTime: null,
|
||||
|
||||
// AX25 KISS only
|
||||
newInterfaceSSID: null,
|
||||
|
||||
// RNode and KISS
|
||||
newInterfaceCallsign: null,
|
||||
newInterfaceIDCallsign: null,
|
||||
newInterfaceIDInterval: null,
|
||||
newInterfaceFlowControl: null,
|
||||
newInterfaceFlowControl: false,
|
||||
newInterfaceAirtimeLimitLong: null,
|
||||
newInterfaceAirtimeLimitShort: null,
|
||||
|
||||
@@ -1343,6 +1835,50 @@ export default {
|
||||
}
|
||||
this.newInterfaceListenIp = iface.listen_ip;
|
||||
this.newInterfaceListenPort = iface.listen_port;
|
||||
this.newInterfaceForwardIp = iface.forward_ip;
|
||||
this.newInterfaceForwardPort = iface.forward_port;
|
||||
this.newInterfaceNetworkDevice = iface.device;
|
||||
this.newInterfacePreferIPV6 = this.parseBool(iface.prefer_ipv6);
|
||||
this.newInterfaceKISSFramingEnabled = this.parseBool(iface.kiss_framing);
|
||||
this.newInterfaceI2PTunnelingEnabled = this.parseBool(iface.i2p_tunneled);
|
||||
this.newInterfaceConnectTimeout = iface.connect_timeout ?? null;
|
||||
this.newInterfaceMaxReconnectTries = iface.max_reconnect_tries ?? null;
|
||||
this.newInterfaceFixedMTU = iface.fixed_mtu ?? null;
|
||||
this.newInterfaceConnectable =
|
||||
iface.connectable === undefined ? true : this.parseBool(iface.connectable);
|
||||
|
||||
if (iface.type === "BackboneInterface") {
|
||||
this.newInterfaceBackboneListenMode = iface.listen_port != null && iface.listen_port !== "";
|
||||
this.newInterfaceBackboneListenIp = iface.listen_ip ?? null;
|
||||
this.newInterfaceBackboneListenPort = iface.listen_port ?? null;
|
||||
this.newInterfaceBackboneListenDevice = iface.device ?? null;
|
||||
}
|
||||
|
||||
this.newInterfaceGroupID = iface.group_id ?? null;
|
||||
this.newInterfaceMulticastAddressType = iface.multicast_address_type ?? null;
|
||||
this.newInterfaceDevices = iface.devices ?? null;
|
||||
this.newInterfaceIgnoredDevices = iface.ignored_devices ?? null;
|
||||
this.newInterfaceDiscoveryScope = iface.discovery_scope ?? null;
|
||||
this.newInterfaceDiscoveryPort = iface.discovery_port ?? null;
|
||||
this.newInterfaceDataPort = iface.data_port ?? null;
|
||||
this.newInterfaceConfiguredBitrate = iface.configured_bitrate ?? null;
|
||||
|
||||
this.newInterfaceFlowControl = this.parseBool(iface.flow_control);
|
||||
this.newInterfaceCallsign = iface.callsign ?? null;
|
||||
this.newInterfaceIDCallsign = iface.id_callsign ?? null;
|
||||
this.newInterfaceIDInterval = iface.id_interval ?? null;
|
||||
this.newInterfaceAirtimeLimitLong = iface.airtime_limit_long ?? null;
|
||||
this.newInterfaceAirtimeLimitShort = iface.airtime_limit_short ?? null;
|
||||
this.newInterfaceSSID = iface.ssid ?? null;
|
||||
this.newInterfaceSpeed = iface.speed ?? null;
|
||||
this.newInterfaceDatabits = iface.databits ?? null;
|
||||
this.newInterfaceParity = iface.parity ?? null;
|
||||
this.newInterfaceStopbits = iface.stopbits ?? null;
|
||||
this.newInterfacePreamble = iface.preamble ?? null;
|
||||
this.newInterfaceTXTail = iface.txtail ?? null;
|
||||
this.newInterfacePersistence = iface.persistence ?? null;
|
||||
this.newInterfaceSlotTime = iface.slottime ?? null;
|
||||
|
||||
this.newInterfacePort = iface.port;
|
||||
this.newInterfaceRNodeUseIP = false;
|
||||
if (iface.port && String(iface.port).startsWith("tcp://")) {
|
||||
@@ -1490,6 +2026,43 @@ export default {
|
||||
// KISS/AX.25
|
||||
if (config.callsign) this.newInterfaceCallsign = config.callsign;
|
||||
if (config.ssid) this.newInterfaceSSID = config.ssid;
|
||||
if (config.id_callsign) this.newInterfaceIDCallsign = config.id_callsign;
|
||||
if (config.id_interval) this.newInterfaceIDInterval = Number(config.id_interval);
|
||||
if (config.flow_control !== undefined) this.newInterfaceFlowControl = this.parseBool(config.flow_control);
|
||||
if (config.preamble) this.newInterfacePreamble = Number(config.preamble);
|
||||
if (config.txtail) this.newInterfaceTXTail = Number(config.txtail);
|
||||
if (config.persistence) this.newInterfacePersistence = Number(config.persistence);
|
||||
if (config.slottime) this.newInterfaceSlotTime = Number(config.slottime);
|
||||
if (config.speed) this.newInterfaceSpeed = Number(config.speed);
|
||||
if (config.databits) this.newInterfaceDatabits = Number(config.databits);
|
||||
if (config.parity) this.newInterfaceParity = config.parity;
|
||||
if (config.stopbits) this.newInterfaceStopbits = Number(config.stopbits);
|
||||
|
||||
// AutoInterface
|
||||
if (config.group_id) this.newInterfaceGroupID = config.group_id;
|
||||
if (config.discovery_scope) this.newInterfaceDiscoveryScope = config.discovery_scope;
|
||||
if (config.discovery_port) this.newInterfaceDiscoveryPort = Number(config.discovery_port);
|
||||
if (config.data_port) this.newInterfaceDataPort = Number(config.data_port);
|
||||
if (config.multicast_address_type) this.newInterfaceMulticastAddressType = config.multicast_address_type;
|
||||
if (config.devices) this.newInterfaceDevices = config.devices;
|
||||
if (config.ignored_devices) this.newInterfaceIgnoredDevices = config.ignored_devices;
|
||||
if (config.configured_bitrate) this.newInterfaceConfiguredBitrate = Number(config.configured_bitrate);
|
||||
|
||||
// TCP / Network extras
|
||||
if (config.kiss_framing !== undefined)
|
||||
this.newInterfaceKISSFramingEnabled = this.parseBool(config.kiss_framing);
|
||||
if (config.i2p_tunneled !== undefined)
|
||||
this.newInterfaceI2PTunnelingEnabled = this.parseBool(config.i2p_tunneled);
|
||||
if (config.connect_timeout) this.newInterfaceConnectTimeout = Number(config.connect_timeout);
|
||||
if (config.max_reconnect_tries) this.newInterfaceMaxReconnectTries = Number(config.max_reconnect_tries);
|
||||
if (config.fixed_mtu) this.newInterfaceFixedMTU = Number(config.fixed_mtu);
|
||||
if (config.device) this.newInterfaceNetworkDevice = config.device;
|
||||
if (config.prefer_ipv6 !== undefined) this.newInterfacePreferIPV6 = this.parseBool(config.prefer_ipv6);
|
||||
if (config.connectable !== undefined) this.newInterfaceConnectable = this.parseBool(config.connectable);
|
||||
|
||||
// RNode airtime / id callsign
|
||||
if (config.airtime_limit_long) this.newInterfaceAirtimeLimitLong = Number(config.airtime_limit_long);
|
||||
if (config.airtime_limit_short) this.newInterfaceAirtimeLimitShort = Number(config.airtime_limit_short);
|
||||
|
||||
// Advanced
|
||||
if (config.mode) this.sharedInterfaceSettings.mode = config.mode;
|
||||
@@ -1570,10 +2143,60 @@ export default {
|
||||
discovery_frequency: discoveryEnabled ? this.numOrNull(config.discovery_frequency) : null,
|
||||
discovery_bandwidth: discoveryEnabled ? this.numOrNull(config.discovery_bandwidth) : null,
|
||||
discovery_modulation: discoveryEnabled ? this.numOrNull(config.discovery_modulation) : null,
|
||||
mode: config.mode || "full",
|
||||
mode: config.mode || null,
|
||||
bitrate: this.numOrNull(config.bitrate),
|
||||
network_name: config.network_name || null,
|
||||
passphrase: config.passphrase || null,
|
||||
forward_ip: config.forward_ip || null,
|
||||
forward_port: this.numOrNull(config.forward_port),
|
||||
device: config.device || null,
|
||||
prefer_ipv6:
|
||||
config.prefer_ipv6 !== undefined && config.prefer_ipv6 !== null && config.prefer_ipv6 !== ""
|
||||
? this.parseBool(config.prefer_ipv6)
|
||||
: null,
|
||||
kiss_framing:
|
||||
config.kiss_framing !== undefined && config.kiss_framing !== null && config.kiss_framing !== ""
|
||||
? this.parseBool(config.kiss_framing)
|
||||
: null,
|
||||
i2p_tunneled:
|
||||
config.i2p_tunneled !== undefined && config.i2p_tunneled !== null && config.i2p_tunneled !== ""
|
||||
? this.parseBool(config.i2p_tunneled)
|
||||
: null,
|
||||
connect_timeout: this.numOrNull(config.connect_timeout),
|
||||
max_reconnect_tries: this.numOrNull(config.max_reconnect_tries),
|
||||
fixed_mtu: this.numOrNull(config.fixed_mtu),
|
||||
connectable:
|
||||
config.type === "I2PInterface"
|
||||
? config.connectable !== undefined && config.connectable !== null && config.connectable !== ""
|
||||
? this.parseBool(config.connectable)
|
||||
: true
|
||||
: null,
|
||||
group_id: config.group_id || null,
|
||||
multicast_address_type: config.multicast_address_type || null,
|
||||
devices: config.devices || null,
|
||||
ignored_devices: config.ignored_devices || null,
|
||||
discovery_scope: config.discovery_scope || null,
|
||||
discovery_port: this.numOrNull(config.discovery_port),
|
||||
data_port: this.numOrNull(config.data_port),
|
||||
configured_bitrate: this.numOrNull(config.configured_bitrate),
|
||||
callsign: config.callsign || null,
|
||||
id_callsign: config.id_callsign || null,
|
||||
id_interval: this.numOrNull(config.id_interval),
|
||||
ssid: this.numOrNull(config.ssid),
|
||||
airtime_limit_long: this.numOrNull(config.airtime_limit_long),
|
||||
airtime_limit_short: this.numOrNull(config.airtime_limit_short),
|
||||
speed: this.numOrNull(config.speed),
|
||||
databits: this.numOrNull(config.databits),
|
||||
parity: config.parity || null,
|
||||
stopbits: this.numOrNull(config.stopbits),
|
||||
preamble: this.numOrNull(config.preamble),
|
||||
txtail: this.numOrNull(config.txtail),
|
||||
persistence: this.numOrNull(config.persistence),
|
||||
slottime: this.numOrNull(config.slottime),
|
||||
flow_control:
|
||||
config.flow_control !== undefined && config.flow_control !== null && config.flow_control !== ""
|
||||
? this.parseBool(config.flow_control)
|
||||
: null,
|
||||
};
|
||||
},
|
||||
applyDiscoveredInterfacePrefill() {
|
||||
@@ -1652,16 +2275,36 @@ export default {
|
||||
? (this.I2PSettings.newInterfacePeers || []).map((p) => String(p).trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const response = await window.api.post(`/api/v1/reticulum/interfaces/add`, {
|
||||
const isBackboneListener =
|
||||
this.newInterfaceType === "BackboneInterface" && this.newInterfaceBackboneListenMode === true;
|
||||
|
||||
const payload = {
|
||||
allow_overwriting_interface: this.isEditingInterface,
|
||||
name: this.newInterfaceName,
|
||||
type: this.newInterfaceType,
|
||||
target_host: this.newInterfaceTargetHost,
|
||||
target_port: this.newInterfaceTargetPort,
|
||||
transport_identity: this.newInterfaceTransportIdentity,
|
||||
target_host: isBackboneListener ? null : this.newInterfaceTargetHost,
|
||||
target_port: isBackboneListener ? null : this.newInterfaceTargetPort,
|
||||
transport_identity: isBackboneListener ? null : this.newInterfaceTransportIdentity,
|
||||
peers: i2pPeers,
|
||||
listen_ip: this.newInterfaceListenIp,
|
||||
listen_port: this.newInterfaceListenPort,
|
||||
listen_ip: isBackboneListener
|
||||
? this.newInterfaceBackboneListenIp || null
|
||||
: this.newInterfaceListenIp,
|
||||
listen_port: isBackboneListener
|
||||
? this.newInterfaceBackboneListenPort || null
|
||||
: this.newInterfaceListenPort,
|
||||
forward_ip: this.newInterfaceForwardIp,
|
||||
forward_port: this.newInterfaceForwardPort,
|
||||
device: isBackboneListener
|
||||
? this.newInterfaceBackboneListenDevice || null
|
||||
: this.newInterfaceNetworkDevice,
|
||||
prefer_ipv6: this.newInterfacePreferIPV6 === true,
|
||||
kiss_framing: this.newInterfaceKISSFramingEnabled === true,
|
||||
i2p_tunneled: this.newInterfaceI2PTunnelingEnabled === true,
|
||||
connect_timeout: this.numOrNull(this.newInterfaceConnectTimeout),
|
||||
max_reconnect_tries: this.numOrNull(this.newInterfaceMaxReconnectTries),
|
||||
fixed_mtu: this.numOrNull(this.newInterfaceFixedMTU),
|
||||
connectable:
|
||||
this.newInterfaceType === "I2PInterface" ? this.newInterfaceConnectable === true : null,
|
||||
port: this.newInterfaceRNodeUseIP
|
||||
? `tcp://${this.newInterfaceRNodeIPHost}:${this.newInterfaceRNodeIPPort}`
|
||||
: this.newInterfacePort,
|
||||
@@ -1670,6 +2313,29 @@ export default {
|
||||
txpower: this.newInterfaceTxpower,
|
||||
spreadingfactor: this.newInterfaceSpreadingFactor,
|
||||
codingrate: this.newInterfaceCodingRate,
|
||||
flow_control: this.newInterfaceFlowControl === true,
|
||||
callsign: this.newInterfaceCallsign,
|
||||
id_callsign: this.newInterfaceIDCallsign,
|
||||
id_interval: this.numOrNull(this.newInterfaceIDInterval),
|
||||
airtime_limit_long: this.numOrNull(this.newInterfaceAirtimeLimitLong),
|
||||
airtime_limit_short: this.numOrNull(this.newInterfaceAirtimeLimitShort),
|
||||
ssid: this.numOrNull(this.newInterfaceSSID),
|
||||
speed: this.numOrNull(this.newInterfaceSpeed),
|
||||
databits: this.numOrNull(this.newInterfaceDatabits),
|
||||
parity: this.newInterfaceParity,
|
||||
stopbits: this.numOrNull(this.newInterfaceStopbits),
|
||||
preamble: this.numOrNull(this.newInterfacePreamble),
|
||||
txtail: this.numOrNull(this.newInterfaceTXTail),
|
||||
persistence: this.numOrNull(this.newInterfacePersistence),
|
||||
slottime: this.numOrNull(this.newInterfaceSlotTime),
|
||||
group_id: this.newInterfaceGroupID,
|
||||
multicast_address_type: this.newInterfaceMulticastAddressType,
|
||||
devices: this.newInterfaceDevices,
|
||||
ignored_devices: this.newInterfaceIgnoredDevices,
|
||||
discovery_scope: this.newInterfaceDiscoveryScope,
|
||||
discovery_port: this.numOrNull(this.newInterfaceDiscoveryPort),
|
||||
data_port: this.numOrNull(this.newInterfaceDataPort),
|
||||
configured_bitrate: this.numOrNull(this.newInterfaceConfiguredBitrate),
|
||||
command: this.newInterfaceCommand,
|
||||
respawn_delay: this.newInterfaceRespawnDelay,
|
||||
discoverable: discoveryEnabled ? "yes" : null,
|
||||
@@ -1689,11 +2355,13 @@ export default {
|
||||
discovery_frequency: discoveryEnabled ? this.numOrNull(this.discovery.discovery_frequency) : null,
|
||||
discovery_bandwidth: discoveryEnabled ? this.numOrNull(this.discovery.discovery_bandwidth) : null,
|
||||
discovery_modulation: discoveryEnabled ? this.numOrNull(this.discovery.discovery_modulation) : null,
|
||||
mode: this.sharedInterfaceSettings.mode || "full",
|
||||
mode: this.sharedInterfaceSettings.mode || null,
|
||||
bitrate: this.sharedInterfaceSettings.bitrate,
|
||||
network_name: this.sharedInterfaceSettings.network_name,
|
||||
passphrase: this.sharedInterfaceSettings.passphrase,
|
||||
});
|
||||
};
|
||||
|
||||
const response = await window.api.post(`/api/v1/reticulum/interfaces/add`, payload);
|
||||
|
||||
if (response.data.message) ToastUtils.success(response.data.message);
|
||||
GlobalState.hasPendingInterfaceChanges = true;
|
||||
|
||||
@@ -807,35 +807,25 @@
|
||||
>
|
||||
{{ $t("stickers.empty_library") }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-gray-300 dark:border-zinc-600 px-2 py-2 text-xs flex items-center justify-center gap-1 hover:border-emerald-500"
|
||||
@click="openStickerEditor()"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="image-edit-outline" class="size-4" />
|
||||
{{ $t("sticker_editor.create_button") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border-2 border-dashed border-gray-300 dark:border-zinc-600 px-2 py-2 text-xs hover:border-blue-400"
|
||||
:class="
|
||||
stickerDropActive
|
||||
? 'border-blue-500 bg-blue-50/70 dark:bg-blue-950/40'
|
||||
: ''
|
||||
"
|
||||
:disabled="isStickerUploading"
|
||||
@click="triggerStickerUploadInput"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<MaterialDesignIcon
|
||||
icon-name="upload"
|
||||
class="size-4 text-blue-500"
|
||||
/>
|
||||
{{ $t("stickers.upload_short") }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl border-2 border-dashed border-gray-300 dark:border-zinc-600 px-2 py-2 text-xs hover:border-blue-400"
|
||||
:class="
|
||||
stickerDropActive
|
||||
? 'border-blue-500 bg-blue-50/70 dark:bg-blue-950/40'
|
||||
: ''
|
||||
"
|
||||
:disabled="isStickerUploading"
|
||||
@click="triggerStickerUploadInput"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<MaterialDesignIcon
|
||||
icon-name="upload"
|
||||
class="size-4 text-blue-500"
|
||||
/>
|
||||
{{ $t("stickers.upload_short") }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="emojiStickerTab === 'gifs'"
|
||||
@@ -1415,13 +1405,6 @@
|
||||
@close="isPaperMessageModalOpen = false"
|
||||
/>
|
||||
|
||||
<StickerEditor
|
||||
:visible="isStickerEditorOpen"
|
||||
:default-pack-id="activeStickerPackId"
|
||||
@close="closeStickerEditor"
|
||||
@saved="onStickerEditorSaved"
|
||||
/>
|
||||
|
||||
<PaperMessageModal
|
||||
v-if="isPaperMessageResultModalOpen"
|
||||
:initial-uri="generatedPaperMessageUri"
|
||||
@@ -1667,7 +1650,6 @@ import { createOutboundQueue } from "../../js/outboundSendQueue";
|
||||
import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url";
|
||||
import "emoji-picker-element";
|
||||
import StickerView from "../stickers/StickerView.vue";
|
||||
import StickerEditor from "../stickers/StickerEditor.vue";
|
||||
import InViewAnimatedImg from "./InViewAnimatedImg.vue";
|
||||
|
||||
export default {
|
||||
@@ -1687,7 +1669,6 @@ export default {
|
||||
ConversationMessageEntry,
|
||||
ConversationMessageListVirtual,
|
||||
StickerView,
|
||||
StickerEditor,
|
||||
InViewAnimatedImg,
|
||||
},
|
||||
props: {
|
||||
@@ -1798,7 +1779,6 @@ export default {
|
||||
userStickers: [],
|
||||
userStickerPacks: [],
|
||||
activeStickerPackId: null,
|
||||
isStickerEditorOpen: false,
|
||||
isStickerPickerOpen: false,
|
||||
emojiStickerTab: "emoji",
|
||||
emojiPickerDataUrl: emojiPickerEnDataUrl,
|
||||
@@ -4712,16 +4692,6 @@ export default {
|
||||
stickerImageUrl(stickerId) {
|
||||
return `/api/v1/stickers/${stickerId}/image`;
|
||||
},
|
||||
openStickerEditor() {
|
||||
this.isStickerEditorOpen = true;
|
||||
this.isStickerPickerOpen = false;
|
||||
},
|
||||
closeStickerEditor() {
|
||||
this.isStickerEditorOpen = false;
|
||||
},
|
||||
async onStickerEditorSaved() {
|
||||
await this.loadUserStickers();
|
||||
},
|
||||
onStickerPanelDragOver(event) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="text-xs font-bold text-gray-400 dark:text-zinc-600 uppercase tracking-widest">
|
||||
{{ $t("tools.rnode_flasher.advanced_tools") }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
<button
|
||||
v-for="action in availableActions"
|
||||
:key="action.id"
|
||||
type="button"
|
||||
class="rnf-action-btn"
|
||||
:class="{ 'rnf-action-btn--danger': action.danger }"
|
||||
@click="$emit('action', action.id)"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="action.icon" class="size-4" />
|
||||
<span>{{ $t(action.labelKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
const ALL_ACTIONS = [
|
||||
{ id: "detect", icon: "magnify", labelKey: "tools.rnode_flasher.detect_rnode" },
|
||||
{ id: "diagnose", icon: "stethoscope", labelKey: "tools.rnode_flasher.run_diagnostics" },
|
||||
{ id: "reboot", icon: "restart", labelKey: "tools.rnode_flasher.reboot_rnode" },
|
||||
{ id: "read-display", icon: "monitor", labelKey: "tools.rnode_flasher.read_display" },
|
||||
{ id: "dump-eeprom", icon: "database-export", labelKey: "tools.rnode_flasher.dump_eeprom" },
|
||||
{
|
||||
id: "wipe-eeprom",
|
||||
icon: "eraser",
|
||||
labelKey: "tools.rnode_flasher.wipe_eeprom",
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
name: "RNodeAdvancedTools",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
disabledActions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ["action"],
|
||||
computed: {
|
||||
availableActions() {
|
||||
return ALL_ACTIONS.filter((a) => !this.disabledActions.includes(a.id));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rnf-action-btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-xl bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 px-3 py-2.5 text-[11px] font-bold text-gray-700 dark:text-zinc-300 border border-gray-200 dark:border-zinc-700 transition-all active:scale-95;
|
||||
}
|
||||
.rnf-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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<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-4 sm: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-4 sm:p-6 space-y-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<button class="rnf-action-btn" @click="$emit('action', 'enable-bluetooth')">
|
||||
<MaterialDesignIcon icon-name="bluetooth-connect" class="size-4" />
|
||||
{{ $t("tools.rnode_flasher.enable") }}
|
||||
</button>
|
||||
<button class="rnf-action-btn" @click="$emit('action', 'disable-bluetooth')">
|
||||
<MaterialDesignIcon icon-name="bluetooth-off" class="size-4" />
|
||||
{{ $t("tools.rnode_flasher.disable") }}
|
||||
</button>
|
||||
<button
|
||||
class="rnf-action-btn col-span-2 sm:col-span-1 bg-blue-500 !text-white !border-none hover:bg-blue-600"
|
||||
@click="$emit('action', 'pair-bluetooth')"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="key-link" class="size-4" />
|
||||
{{ $t("tools.rnode_flasher.start_pairing") }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-400 dark:text-zinc-500 leading-relaxed uppercase tracking-wider">
|
||||
{{ $t("tools.rnode_flasher.bluetooth_restart_warning") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "RNodeBluetoothPanel",
|
||||
components: { MaterialDesignIcon },
|
||||
emits: ["action"],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rnf-action-btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-xl bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 px-3 py-2.5 text-[11px] font-bold text-gray-700 dark:text-zinc-300 border border-gray-200 dark:border-zinc-700 transition-all active:scale-95;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="space-y-2">
|
||||
<div
|
||||
v-for="warning in warnings"
|
||||
:key="warning.id"
|
||||
class="flex items-start gap-3 rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50 dark:bg-amber-900/10 px-4 py-3"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="warning.icon"
|
||||
class="size-5 mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
<div class="text-sm font-semibold text-amber-800 dark:text-amber-200">
|
||||
{{ $t(warning.titleKey) }}
|
||||
</div>
|
||||
<ul class="text-xs text-amber-700 dark:text-amber-300 list-disc pl-4 space-y-0.5">
|
||||
<li v-for="key in warning.suggestionKeys" :key="key">{{ $t(key) }}</li>
|
||||
</ul>
|
||||
<div v-if="warning.actions?.length" class="pt-1 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="action in warning.actions"
|
||||
:key="action.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 dark:border-amber-700 bg-white dark:bg-amber-900/30 px-2.5 py-1 text-xs font-bold text-amber-700 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors"
|
||||
@click="$emit('action', action.id)"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="action.icon" class="size-3.5" />
|
||||
<span>{{ $t(action.labelKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import { transportSuggestionKeys, TRANSPORT_SERIAL, TRANSPORT_BLUETOOTH } from "../../js/rnode/Capabilities.js";
|
||||
|
||||
export default {
|
||||
name: "RNodeCapabilitiesBanner",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
capabilities: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
androidAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["action"],
|
||||
computed: {
|
||||
warnings() {
|
||||
const warnings = [];
|
||||
const serial = this.capabilities?.transports?.[TRANSPORT_SERIAL];
|
||||
const bluetooth = this.capabilities?.transports?.[TRANSPORT_BLUETOOTH];
|
||||
|
||||
if (serial && !serial.available) {
|
||||
warnings.push({
|
||||
id: "serial",
|
||||
icon: "usb-port",
|
||||
titleKey: "tools.rnode_flasher.support.serial.title",
|
||||
suggestionKeys: transportSuggestionKeys(this.capabilities, TRANSPORT_SERIAL),
|
||||
actions: this._serialActions(serial),
|
||||
});
|
||||
}
|
||||
if (bluetooth && !bluetooth.available) {
|
||||
warnings.push({
|
||||
id: "bluetooth",
|
||||
icon: "bluetooth",
|
||||
titleKey: "tools.rnode_flasher.support.bluetooth.title",
|
||||
suggestionKeys: transportSuggestionKeys(this.capabilities, TRANSPORT_BLUETOOTH),
|
||||
actions: this._bluetoothActions(),
|
||||
});
|
||||
}
|
||||
return warnings;
|
||||
},
|
||||
visible() {
|
||||
return this.warnings.length > 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
_serialActions(serial) {
|
||||
const actions = [];
|
||||
if (serial?.reason === "polyfill_not_loaded") {
|
||||
actions.push({
|
||||
id: "load-polyfill",
|
||||
icon: "download",
|
||||
labelKey: "tools.rnode_flasher.support.actions.load_polyfill",
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
},
|
||||
_bluetoothActions() {
|
||||
const actions = [];
|
||||
if (this.androidAvailable) {
|
||||
actions.push({
|
||||
id: "request-bluetooth",
|
||||
icon: "bluetooth-settings",
|
||||
labelKey: "tools.rnode_flasher.support.actions.request_bluetooth",
|
||||
});
|
||||
actions.push({
|
||||
id: "open-bluetooth-settings",
|
||||
icon: "cog",
|
||||
labelKey: "tools.rnode_flasher.support.actions.open_settings",
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div v-if="image" class="p-4 rounded-2xl bg-zinc-950 flex flex-col items-center gap-2 border border-zinc-800">
|
||||
<img :src="image" alt="RNode display" class="h-24 sm:h-28 pixelated" />
|
||||
<button
|
||||
type="button"
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-zinc-400 hover:text-zinc-200"
|
||||
@click="$emit('clear')"
|
||||
>
|
||||
{{ $t("common.clear") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "RNodeDeviceDisplay",
|
||||
props: {
|
||||
image: { type: String, default: null },
|
||||
},
|
||||
emits: ["clear"],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg text-blue-600 dark:text-blue-400 shrink-0">
|
||||
<MaterialDesignIcon icon-name="usb-port" class="size-5" />
|
||||
</div>
|
||||
<h2 class="font-bold text-gray-900 dark:text-zinc-100">
|
||||
{{ stepNumber }}. {{ $t("tools.rnode_flasher.select_device") }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="rnf-label">{{ $t("tools.rnode_flasher.connection_method") }}</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="option in connectionOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
:data-testid="`rnode-transport-${option.id}`"
|
||||
:disabled="!option.available"
|
||||
class="flex flex-col items-center justify-center gap-1 py-2.5 px-2 rounded-xl border text-xs sm:text-sm font-bold transition-all"
|
||||
:class="
|
||||
connectionMethod === option.id
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: option.available
|
||||
? 'bg-gray-50 dark:bg-zinc-800/50 border-gray-200 dark:border-zinc-800 text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800'
|
||||
: 'bg-gray-100 dark:bg-zinc-900 border-gray-200 dark:border-zinc-800 text-gray-400 dark:text-zinc-600 cursor-not-allowed'
|
||||
"
|
||||
@click="option.available && $emit('update:connectionMethod', option.id)"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="option.icon" class="size-4 sm:size-5" />
|
||||
<span>{{ $t(option.labelKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="connectionMethod === 'wifi'" class="space-y-1">
|
||||
<label class="rnf-label">{{ $t("tools.rnode_flasher.ip_address") }}</label>
|
||||
<input
|
||||
:value="wifiHost"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="rnf-input"
|
||||
:placeholder="$t('tools.rnode_flasher.ip_address_placeholder')"
|
||||
@input="$emit('update:wifiHost', $event.target.value)"
|
||||
/>
|
||||
<p class="text-[10px] text-gray-500 dark:text-zinc-500">
|
||||
{{ $t("tools.rnode_flasher.wifi_help") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="rnf-label">{{ $t("tools.rnode_flasher.product") }}</label>
|
||||
<select :value="selectedProduct?.id ?? ''" class="rnf-input" @change="onProductChange">
|
||||
<option value="" disabled>{{ $t("tools.rnode_flasher.select_product") }}</option>
|
||||
<option v-for="product in products" :key="product.id" :value="product.id">
|
||||
{{ product.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="rnf-label">{{ $t("tools.rnode_flasher.model") }}</label>
|
||||
<select
|
||||
:value="selectedModel?.id ?? ''"
|
||||
:disabled="!selectedProduct"
|
||||
class="rnf-input disabled:opacity-50"
|
||||
@change="onModelChange"
|
||||
>
|
||||
<option value="" disabled>{{ $t("tools.rnode_flasher.select_model") }}</option>
|
||||
<template v-if="selectedProduct">
|
||||
<option v-for="model in selectedProduct.models" :key="model.id" :value="model.id">
|
||||
{{ model.name }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="selectedProduct?.platform === 0x70 && connectionMethod === 'serial'"
|
||||
: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="$emit('enter-dfu')"
|
||||
>
|
||||
<v-progress-circular v-if="isEnteringDfuMode" indeterminate size="16" width="2" />
|
||||
<MaterialDesignIcon v-else icon-name="restart-alert" class="size-4" />
|
||||
<span>
|
||||
{{
|
||||
isEnteringDfuMode
|
||||
? $t("tools.rnode_flasher.entering_dfu_mode")
|
||||
: $t("tools.rnode_flasher.enter_dfu_mode")
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "RNodeDeviceSelector",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
stepNumber: { type: Number, default: 1 },
|
||||
connectionMethod: { type: String, required: true },
|
||||
wifiHost: { type: String, default: "" },
|
||||
selectedProduct: { type: Object, default: null },
|
||||
selectedModel: { type: Object, default: null },
|
||||
products: { type: Array, required: true },
|
||||
capabilities: { type: Object, required: true },
|
||||
isEnteringDfuMode: { type: Boolean, default: false },
|
||||
},
|
||||
emits: [
|
||||
"update:connectionMethod",
|
||||
"update:wifiHost",
|
||||
"update:selectedProduct",
|
||||
"update:selectedModel",
|
||||
"enter-dfu",
|
||||
],
|
||||
computed: {
|
||||
connectionOptions() {
|
||||
const t = this.capabilities?.transports || {};
|
||||
return [
|
||||
{
|
||||
id: "serial",
|
||||
labelKey: "tools.rnode_flasher.serial",
|
||||
icon: "usb-port",
|
||||
available: Boolean(t.serial?.available),
|
||||
},
|
||||
{
|
||||
id: "bluetooth",
|
||||
labelKey: "tools.rnode_flasher.bluetooth",
|
||||
icon: "bluetooth",
|
||||
available: Boolean(t.bluetooth?.available),
|
||||
},
|
||||
{
|
||||
id: "wifi",
|
||||
labelKey: "tools.rnode_flasher.wifi",
|
||||
icon: "wifi",
|
||||
available: Boolean(t.wifi?.available),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onProductChange(event) {
|
||||
const id = event.target.value;
|
||||
const product = this.products.find((p) => String(p.id) === String(id)) || null;
|
||||
this.$emit("update:selectedProduct", product);
|
||||
this.$emit("update:selectedModel", null);
|
||||
},
|
||||
onModelChange(event) {
|
||||
const id = event.target.value;
|
||||
const model = this.selectedProduct?.models?.find((m) => String(m.id) === String(id)) || null;
|
||||
this.$emit("update:selectedModel", model);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rnf-label {
|
||||
@apply text-xs font-semibold text-gray-500 dark:text-zinc-500 uppercase tracking-wider;
|
||||
}
|
||||
.rnf-input {
|
||||
@apply 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;
|
||||
}
|
||||
select.rnf-input {
|
||||
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;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="diagnostics"
|
||||
class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div class="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="stethoscope" class="size-5 text-emerald-500" />
|
||||
<h3 class="font-bold text-gray-900 dark:text-zinc-100">
|
||||
{{ $t("tools.rnode_flasher.diagnostics.title") }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="hasIssues"
|
||||
class="ml-auto px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
{{ $t("tools.rnode_flasher.diagnostics.needs_attention") }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="ml-auto px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
{{ $t("tools.rnode_flasher.diagnostics.healthy") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-3 p-4 sm:p-6 text-xs">
|
||||
<div v-for="row in summaryRows" :key="row.key" class="space-y-0.5 min-w-0">
|
||||
<dt class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500">
|
||||
{{ $t(row.labelKey) }}
|
||||
</dt>
|
||||
<dd class="font-mono text-gray-800 dark:text-zinc-200 break-all">
|
||||
{{ row.value || "—" }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
v-if="hasIssues"
|
||||
class="border-t border-gray-100 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-900/10 px-4 sm:px-6 py-4 space-y-2"
|
||||
>
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-300">
|
||||
{{ $t("tools.rnode_flasher.diagnostics.issues_detected") }}
|
||||
</div>
|
||||
<ul class="list-disc pl-4 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
||||
<li v-for="key in diagnostics.suggestionKeys" :key="key">{{ $t(key) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "RNodeDiagnosticsPanel",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
diagnostics: { type: Object, default: null },
|
||||
},
|
||||
computed: {
|
||||
hasIssues() {
|
||||
return Array.isArray(this.diagnostics?.issues) && this.diagnostics.issues.length > 0;
|
||||
},
|
||||
summaryRows() {
|
||||
const s = this.diagnostics?.summary || {};
|
||||
return [
|
||||
{
|
||||
key: "firmware_version",
|
||||
labelKey: "tools.rnode_flasher.diagnostics.firmware_version",
|
||||
value: s.firmware_version,
|
||||
},
|
||||
{
|
||||
key: "platform",
|
||||
labelKey: "tools.rnode_flasher.diagnostics.platform",
|
||||
value: s.platform != null ? `0x${s.platform.toString(16).padStart(2, "0")}` : null,
|
||||
},
|
||||
{
|
||||
key: "board",
|
||||
labelKey: "tools.rnode_flasher.diagnostics.board",
|
||||
value: s.board != null ? `0x${s.board.toString(16).padStart(2, "0")}` : null,
|
||||
},
|
||||
{
|
||||
key: "is_provisioned",
|
||||
labelKey: "tools.rnode_flasher.diagnostics.provisioned",
|
||||
value: s.is_provisioned ? "yes" : "no",
|
||||
},
|
||||
{
|
||||
key: "product",
|
||||
labelKey: "tools.rnode_flasher.diagnostics.product",
|
||||
value: s.product != null ? `0x${s.product.toString(16).padStart(2, "0")}` : null,
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
labelKey: "tools.rnode_flasher.diagnostics.model",
|
||||
value: s.model != null ? `0x${s.model.toString(16).padStart(2, "0")}` : null,
|
||||
},
|
||||
{
|
||||
key: "fw_hash",
|
||||
labelKey: "tools.rnode_flasher.diagnostics.firmware_hash",
|
||||
value: s.firmware_hash ? s.firmware_hash.slice(0, 16) : null,
|
||||
},
|
||||
{
|
||||
key: "target_hash",
|
||||
labelKey: "tools.rnode_flasher.diagnostics.target_hash",
|
||||
value: s.target_firmware_hash ? s.target_firmware_hash.slice(0, 16) : null,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg text-purple-600 dark:text-purple-400 shrink-0"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="file-download" class="size-5" />
|
||||
</div>
|
||||
<h2 class="font-bold text-gray-900 dark:text-zinc-100">
|
||||
{{ stepNumber }}. {{ $t("tools.rnode_flasher.select_firmware") }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="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="flex items-center justify-between gap-2">
|
||||
<div class="text-xs font-bold text-blue-700 dark:text-blue-400 uppercase">
|
||||
{{ $t("tools.rnode_flasher.download_recommended") }}
|
||||
</div>
|
||||
<span
|
||||
v-if="latestRelease?.tag_name"
|
||||
class="text-[10px] font-mono text-blue-600 dark:text-blue-300 bg-blue-100/60 dark:bg-blue-900/30 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{{ latestRelease.tag_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-400 break-all font-mono">
|
||||
{{ recommendedFirmwareFilename }}
|
||||
</div>
|
||||
<button
|
||||
:disabled="isDownloadingFirmware || !recommendedFirmwareFilename"
|
||||
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="$emit('download-recommended')"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="rnf-label">{{ $t("tools.rnode_flasher.select_firmware_file") }}</label>
|
||||
<input
|
||||
ref="file"
|
||||
type="file"
|
||||
accept=".zip"
|
||||
data-testid="rnode-firmware-file"
|
||||
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"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="firmwareFile"
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-xl border border-emerald-200 dark:border-emerald-900/40 bg-emerald-50 dark:bg-emerald-900/10"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<MaterialDesignIcon
|
||||
icon-name="check-circle"
|
||||
class="size-4 text-emerald-600 dark:text-emerald-400 shrink-0"
|
||||
/>
|
||||
<span class="text-xs font-mono truncate text-emerald-800 dark:text-emerald-200">
|
||||
{{ firmwareFile.name }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[10px] font-bold uppercase text-emerald-700 dark:text-emerald-300 hover:underline"
|
||||
@click="clearFile"
|
||||
>
|
||||
{{ $t("common.clear") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "RNodeFirmwareSelector",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
stepNumber: { type: Number, default: 2 },
|
||||
recommendedFirmwareFilename: { type: String, default: null },
|
||||
latestRelease: { type: Object, default: null },
|
||||
isDownloadingFirmware: { type: Boolean, default: false },
|
||||
firmwareFile: { type: Object, default: null },
|
||||
},
|
||||
emits: ["download-recommended", "update:firmwareFile"],
|
||||
methods: {
|
||||
onFileChange(event) {
|
||||
const file = event.target.files?.[0] || null;
|
||||
this.$emit("update:firmwareFile", file);
|
||||
},
|
||||
clearFile() {
|
||||
if (this.$refs.file) {
|
||||
this.$refs.file.value = "";
|
||||
}
|
||||
this.$emit("update:firmwareFile", null);
|
||||
},
|
||||
setFile(file) {
|
||||
if (!this.$refs.file) return;
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
this.$refs.file.files = dataTransfer.files;
|
||||
this.$emit("update:firmwareFile", file);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rnf-label {
|
||||
@apply text-xs font-semibold text-gray-500 dark:text-zinc-500 uppercase tracking-wider;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="flex items-start gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||
role="alert"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="alert-circle"
|
||||
class="size-4 mt-0.5 text-red-600 dark:text-red-400 shrink-0"
|
||||
/>
|
||||
<span class="text-xs text-red-600 dark:text-red-400 break-words">{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="!canFlash || isFlashing"
|
||||
data-testid="rnode-flash-btn"
|
||||
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 disabled:cursor-not-allowed"
|
||||
@click="$emit('flash')"
|
||||
>
|
||||
<v-progress-circular v-if="isFlashing" indeterminate size="16" width="2" />
|
||||
<MaterialDesignIcon v-else icon-name="flash" class="size-5" />
|
||||
<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-1" role="status" aria-live="polite">
|
||||
<v-progress-linear :model-value="flashingProgress" color="green" height="8" rounded />
|
||||
<div class="flex items-center justify-between text-[10px] font-mono">
|
||||
<span class="text-gray-500 dark:text-zinc-500 truncate">{{ flashingStatus }}</span>
|
||||
<span class="text-gray-700 dark:text-zinc-300 font-bold">{{ flashingProgress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "RNodeFlashAction",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
canFlash: { type: Boolean, default: false },
|
||||
isFlashing: { type: Boolean, default: false },
|
||||
flashingProgress: { type: Number, default: 0 },
|
||||
flashingStatus: { type: String, default: "" },
|
||||
errorMessage: { type: String, default: null },
|
||||
},
|
||||
emits: ["flash"],
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<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">
|
||||
{{ provisionStepNumber }}. {{ $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="!canProvision"
|
||||
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="$emit('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">
|
||||
{{ provisionStepNumber + 1 }}. {{ $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="$emit('set-hash')"
|
||||
>
|
||||
{{ $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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "RNodeProvisionPanel",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
provisionStepNumber: { type: Number, default: 3 },
|
||||
canProvision: { type: Boolean, default: false },
|
||||
isProvisioning: { type: Boolean, default: false },
|
||||
isSettingFirmwareHash: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["provision", "set-hash"],
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<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-4 sm: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-4 sm:p-6 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<label class="rnf-input-label">{{ $t("tools.rnode_flasher.frequency") }}</label>
|
||||
<input
|
||||
:value="frequency"
|
||||
type="number"
|
||||
class="rnf-config-input"
|
||||
@input="$emit('update:frequency', Number($event.target.value))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="rnf-input-label">{{ $t("tools.rnode_flasher.tx_power") }}</label>
|
||||
<input
|
||||
:value="txPower"
|
||||
type="number"
|
||||
class="rnf-config-input"
|
||||
@input="$emit('update:txPower', Number($event.target.value))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="rnf-input-label">{{ $t("tools.rnode_flasher.bandwidth") }}</label>
|
||||
<select
|
||||
:value="bandwidth"
|
||||
class="rnf-config-input"
|
||||
@change="$emit('update:bandwidth', Number($event.target.value))"
|
||||
>
|
||||
<option v-for="bw in bandwidths" :key="bw" :value="bw">{{ bw / 1000 }} KHz</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="rnf-input-label">{{ $t("tools.rnode_flasher.spreading_factor") }}</label>
|
||||
<select
|
||||
:value="spreadingFactor"
|
||||
class="rnf-config-input"
|
||||
@change="$emit('update:spreadingFactor', Number($event.target.value))"
|
||||
>
|
||||
<option v-for="sf in spreadingFactors" :key="sf" :value="sf">
|
||||
{{ sf }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
class="rnf-action-btn bg-green-600 !text-white !border-none hover:bg-green-700"
|
||||
@click="$emit('action', 'enable-tnc')"
|
||||
>
|
||||
{{ $t("tools.rnode_flasher.enable") }}
|
||||
</button>
|
||||
<button class="rnf-action-btn" @click="$emit('action', 'disable-tnc')">
|
||||
{{ $t("tools.rnode_flasher.disable") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
const BANDWIDTHS = [7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000];
|
||||
const SPREADING_FACTORS = [7, 8, 9, 10, 11, 12];
|
||||
|
||||
export default {
|
||||
name: "RNodeTncPanel",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
frequency: { type: Number, default: 917375000 },
|
||||
bandwidth: { type: Number, default: 250000 },
|
||||
txPower: { type: Number, default: 22 },
|
||||
spreadingFactor: { type: Number, default: 11 },
|
||||
},
|
||||
emits: ["update:frequency", "update:bandwidth", "update:txPower", "update:spreadingFactor", "action"],
|
||||
data() {
|
||||
return {
|
||||
bandwidths: BANDWIDTHS,
|
||||
spreadingFactors: SPREADING_FACTORS,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rnf-input-label {
|
||||
@apply text-[10px] font-bold text-zinc-400 uppercase tracking-widest;
|
||||
}
|
||||
.rnf-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-[12px] rounded-lg focus:ring-1 focus:ring-blue-500/50 focus:border-blue-500 px-3 py-2 transition-all;
|
||||
}
|
||||
.rnf-action-btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-xl bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 px-3 py-2.5 text-[11px] font-bold text-gray-700 dark:text-zinc-300 border border-gray-200 dark:border-zinc-700 transition-all active:scale-95;
|
||||
}
|
||||
</style>
|
||||
@@ -1,625 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/70 p-4"
|
||||
@click.self="onCancel"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl max-h-[92vh] overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl flex flex-col border border-gray-200 dark:border-zinc-700"
|
||||
>
|
||||
<header class="flex items-center justify-between border-b border-gray-200 dark:border-zinc-700 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="sticker-emoji" class="size-5 text-blue-500" />
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-zinc-100">
|
||||
{{ $t("sticker_editor.title") }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800"
|
||||
@click="onCancel"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="size-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="relative w-full max-w-[512px] aspect-square rounded-xl bg-checkerboard border border-gray-200 dark:border-zinc-700 overflow-hidden"
|
||||
>
|
||||
<canvas ref="canvas" class="w-full h-full" :width="canvasSize" :height="canvasSize" />
|
||||
<div
|
||||
v-if="busy"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/40 text-white text-sm"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="size-8 border-4 border-white/50 border-t-white rounded-full animate-spin" />
|
||||
<span>{{ busyMessage || $t("sticker_editor.processing") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400 text-center max-w-md px-2">
|
||||
{{ $t("sticker_editor.canvas_hint") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 text-sm">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
|
||||
{{ $t("sticker_editor.section_source") }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-gray-300 dark:border-zinc-600 px-3 py-2 hover:border-blue-500 text-gray-700 dark:text-zinc-200 flex items-center gap-2"
|
||||
@click="triggerSourceInput"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="image-plus" class="size-4" />
|
||||
{{ $t("sticker_editor.choose_image") }}
|
||||
</button>
|
||||
<input
|
||||
ref="sourceInput"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/bmp,image/gif"
|
||||
class="hidden"
|
||||
@change="onSourceFile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="sourceLoaded" class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
|
||||
{{ $t("sticker_editor.section_transform") }}
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-2">
|
||||
<span>{{ $t("sticker_editor.scale") }}</span>
|
||||
<input
|
||||
v-model.number="scale"
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="3"
|
||||
step="0.01"
|
||||
class="flex-1 mx-2"
|
||||
@input="redraw"
|
||||
/>
|
||||
<span class="w-10 text-right tabular-nums">{{ scale.toFixed(2) }}</span>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-2">
|
||||
<span>{{ $t("sticker_editor.rotation") }}</span>
|
||||
<input
|
||||
v-model.number="rotation"
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
step="1"
|
||||
class="flex-1 mx-2"
|
||||
@input="redraw"
|
||||
/>
|
||||
<span class="w-10 text-right tabular-nums">{{ rotation }}°</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="flipH" type="checkbox" @change="redraw" />
|
||||
{{ $t("sticker_editor.flip_h") }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="flipV" type="checkbox" @change="redraw" />
|
||||
{{ $t("sticker_editor.flip_v") }}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 text-xs"
|
||||
@click="resetTransform"
|
||||
>
|
||||
{{ $t("sticker_editor.reset_transform") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
|
||||
{{ $t("sticker_editor.section_effects") }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 hover:border-emerald-500"
|
||||
:disabled="busy"
|
||||
@click="removeBackground"
|
||||
>
|
||||
{{
|
||||
bgRemoved ? $t("sticker_editor.bg_removed") : $t("sticker_editor.remove_background")
|
||||
}}
|
||||
</button>
|
||||
<label class="flex items-center justify-between gap-2">
|
||||
<span>{{ $t("sticker_editor.white_stroke") }}</span>
|
||||
<input
|
||||
v-model.number="strokeWidth"
|
||||
type="range"
|
||||
min="0"
|
||||
max="24"
|
||||
step="1"
|
||||
class="flex-1 mx-2"
|
||||
@input="redraw"
|
||||
/>
|
||||
<span class="w-10 text-right tabular-nums">{{ strokeWidth }}px</span>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-2">
|
||||
<span>{{ $t("sticker_editor.shadow") }}</span>
|
||||
<input
|
||||
v-model.number="shadowBlur"
|
||||
type="range"
|
||||
min="0"
|
||||
max="48"
|
||||
step="1"
|
||||
class="flex-1 mx-2"
|
||||
@input="redraw"
|
||||
/>
|
||||
<span class="w-10 text-right tabular-nums">{{ shadowBlur }}px</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
|
||||
{{ $t("sticker_editor.section_overlay") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="overlayText"
|
||||
type="text"
|
||||
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 bg-white dark:bg-zinc-800"
|
||||
:placeholder="$t('sticker_editor.overlay_placeholder')"
|
||||
@input="redraw"
|
||||
/>
|
||||
<label class="flex items-center justify-between gap-2">
|
||||
<span>{{ $t("sticker_editor.font_size") }}</span>
|
||||
<input
|
||||
v-model.number="overlayFontSize"
|
||||
type="range"
|
||||
min="16"
|
||||
max="160"
|
||||
step="2"
|
||||
class="flex-1 mx-2"
|
||||
@input="redraw"
|
||||
/>
|
||||
<span class="w-10 text-right tabular-nums">{{ overlayFontSize }}</span>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-2">
|
||||
<span>{{ $t("sticker_editor.overlay_y") }}</span>
|
||||
<input
|
||||
v-model.number="overlayY"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
class="flex-1 mx-2"
|
||||
@input="redraw"
|
||||
/>
|
||||
<span class="w-10 text-right tabular-nums">{{ overlayY }}%</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-1">
|
||||
<input v-model="overlayColor" type="color" class="w-7 h-7 cursor-pointer" />
|
||||
{{ $t("sticker_editor.text_color") }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<input v-model="overlayStrokeColor" type="color" class="w-7 h-7 cursor-pointer" />
|
||||
{{ $t("sticker_editor.stroke_color") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
|
||||
{{ $t("sticker_editor.section_meta") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="stickerName"
|
||||
type="text"
|
||||
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 bg-white dark:bg-zinc-800"
|
||||
:placeholder="$t('sticker_editor.name_placeholder')"
|
||||
maxlength="64"
|
||||
/>
|
||||
<input
|
||||
v-model="stickerEmoji"
|
||||
type="text"
|
||||
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 bg-white dark:bg-zinc-800"
|
||||
:placeholder="$t('sticker_editor.emoji_placeholder')"
|
||||
maxlength="8"
|
||||
/>
|
||||
<label class="flex items-center justify-between gap-2">
|
||||
<span>{{ $t("sticker_editor.format") }}</span>
|
||||
<select
|
||||
v-model="exportFormat"
|
||||
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 bg-white dark:bg-zinc-800"
|
||||
>
|
||||
<option value="webp">WebP</option>
|
||||
<option value="png">PNG</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-2">
|
||||
<span>{{ $t("sticker_editor.quality") }}</span>
|
||||
<input
|
||||
v-model.number="exportQuality"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="flex-1 mx-2"
|
||||
/>
|
||||
<span class="w-10 text-right tabular-nums">{{ Math.round(exportQuality * 100) }}%</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="flex items-center justify-between gap-2 border-t border-gray-200 dark:border-zinc-700 px-4 py-3 bg-gray-50 dark:bg-zinc-900/50"
|
||||
>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400">
|
||||
{{
|
||||
$t("sticker_editor.size_label", {
|
||||
size: formattedSize,
|
||||
limit: "512 KB",
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-zinc-800"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ $t("sticker_editor.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-1.5 text-sm font-medium"
|
||||
:disabled="!sourceLoaded || busy || !canSave"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ $t("sticker_editor.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils.js";
|
||||
|
||||
const CANVAS_SIZE = 512;
|
||||
const TELEGRAM_STATIC_LIMIT = 512 * 1024;
|
||||
|
||||
export default {
|
||||
name: "StickerEditor",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
defaultPackId: { type: [Number, String, null], default: null },
|
||||
initialFile: { type: [File, Blob, null], default: null },
|
||||
},
|
||||
emits: ["close", "saved"],
|
||||
data() {
|
||||
return {
|
||||
canvasSize: CANVAS_SIZE,
|
||||
sourceImage: null,
|
||||
sourceLoaded: false,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
strokeWidth: 0,
|
||||
shadowBlur: 0,
|
||||
overlayText: "",
|
||||
overlayFontSize: 64,
|
||||
overlayY: 90,
|
||||
overlayColor: "#ffffff",
|
||||
overlayStrokeColor: "#000000",
|
||||
stickerName: "",
|
||||
stickerEmoji: "",
|
||||
exportFormat: "webp",
|
||||
exportQuality: 0.92,
|
||||
busy: false,
|
||||
busyMessage: "",
|
||||
bgRemoved: false,
|
||||
lastBlob: null,
|
||||
bgRemovalModule: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
formattedSize() {
|
||||
const size = this.lastBlob ? this.lastBlob.size : 0;
|
||||
if (size <= 0) return "0 KB";
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
},
|
||||
canSave() {
|
||||
return this.lastBlob && this.lastBlob.size > 0 && this.lastBlob.size <= TELEGRAM_STATIC_LIMIT;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
visible(v) {
|
||||
if (v) {
|
||||
this.resetState();
|
||||
this.$nextTick(() => {
|
||||
if (this.initialFile) {
|
||||
this.loadSourceFromBlob(this.initialFile);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
exportFormat() {
|
||||
this.redraw();
|
||||
},
|
||||
exportQuality() {
|
||||
this.redraw();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
resetState() {
|
||||
this.sourceImage = null;
|
||||
this.sourceLoaded = false;
|
||||
this.scale = 1;
|
||||
this.rotation = 0;
|
||||
this.flipH = false;
|
||||
this.flipV = false;
|
||||
this.strokeWidth = 0;
|
||||
this.shadowBlur = 0;
|
||||
this.overlayText = "";
|
||||
this.stickerName = "";
|
||||
this.stickerEmoji = "";
|
||||
this.bgRemoved = false;
|
||||
this.lastBlob = null;
|
||||
const c = this.$refs.canvas;
|
||||
if (c) {
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
}
|
||||
},
|
||||
resetTransform() {
|
||||
this.scale = 1;
|
||||
this.rotation = 0;
|
||||
this.flipH = false;
|
||||
this.flipV = false;
|
||||
this.redraw();
|
||||
},
|
||||
triggerSourceInput() {
|
||||
this.$refs.sourceInput?.click();
|
||||
},
|
||||
onSourceFile(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) this.loadSourceFromBlob(file);
|
||||
event.target.value = "";
|
||||
},
|
||||
async loadSourceFromBlob(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = await this.loadImage(url);
|
||||
this.sourceImage = img;
|
||||
this.bgRemoved = false;
|
||||
const longest = Math.max(img.naturalWidth, img.naturalHeight) || 1;
|
||||
this.scale = Math.min(1, CANVAS_SIZE / longest);
|
||||
this.sourceLoaded = true;
|
||||
this.redraw();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("sticker_editor.image_load_failed"));
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
},
|
||||
async redraw() {
|
||||
if (!this.sourceImage) return;
|
||||
const canvas = this.$refs.canvas;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const img = this.sourceImage;
|
||||
const drawW = img.naturalWidth * this.scale;
|
||||
const drawH = img.naturalHeight * this.scale;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
ctx.rotate((this.rotation * Math.PI) / 180);
|
||||
ctx.scale(this.flipH ? -1 : 1, this.flipV ? -1 : 1);
|
||||
|
||||
if (this.shadowBlur > 0) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.55)";
|
||||
ctx.shadowBlur = this.shadowBlur;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = Math.max(2, this.shadowBlur / 4);
|
||||
}
|
||||
|
||||
if (this.strokeWidth > 0 && this.bgRemoved) {
|
||||
this.drawStrokedImage(ctx, img, drawW, drawH, this.strokeWidth);
|
||||
} else {
|
||||
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (this.overlayText) {
|
||||
this.drawOverlayText(ctx);
|
||||
}
|
||||
|
||||
await this.recomputeBlob();
|
||||
},
|
||||
drawStrokedImage(ctx, img, w, h, stroke) {
|
||||
const off = document.createElement("canvas");
|
||||
off.width = w + stroke * 2;
|
||||
off.height = h + stroke * 2;
|
||||
const oc = off.getContext("2d");
|
||||
oc.drawImage(img, stroke, stroke, w, h);
|
||||
const stamp = document.createElement("canvas");
|
||||
stamp.width = off.width;
|
||||
stamp.height = off.height;
|
||||
const sc = stamp.getContext("2d");
|
||||
for (let dx = -stroke; dx <= stroke; dx += 1) {
|
||||
for (let dy = -stroke; dy <= stroke; dy += 1) {
|
||||
if (dx * dx + dy * dy > stroke * stroke) continue;
|
||||
sc.drawImage(off, dx, dy);
|
||||
}
|
||||
}
|
||||
sc.globalCompositeOperation = "source-in";
|
||||
sc.fillStyle = "#ffffff";
|
||||
sc.fillRect(0, 0, stamp.width, stamp.height);
|
||||
ctx.drawImage(stamp, -stamp.width / 2, -stamp.height / 2);
|
||||
ctx.drawImage(img, -w / 2, -h / 2, w, h);
|
||||
},
|
||||
drawOverlayText(ctx) {
|
||||
const text = this.overlayText;
|
||||
const px = Math.max(8, this.overlayFontSize);
|
||||
ctx.save();
|
||||
ctx.font = `900 ${px}px "Inter", "Helvetica Neue", sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
const cy = (this.overlayY / 100) * this.canvasSize;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.miterLimit = 2;
|
||||
ctx.lineWidth = Math.max(2, px / 8);
|
||||
ctx.strokeStyle = this.overlayStrokeColor;
|
||||
ctx.strokeText(text, this.canvasSize / 2, cy);
|
||||
ctx.fillStyle = this.overlayColor;
|
||||
ctx.fillText(text, this.canvasSize / 2, cy);
|
||||
ctx.restore();
|
||||
},
|
||||
async recomputeBlob() {
|
||||
const canvas = this.$refs.canvas;
|
||||
if (!canvas) {
|
||||
this.lastBlob = null;
|
||||
return;
|
||||
}
|
||||
const mime = this.exportFormat === "png" ? "image/png" : "image/webp";
|
||||
const quality = this.exportFormat === "png" ? undefined : this.exportQuality;
|
||||
const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b), mime, quality));
|
||||
this.lastBlob = blob;
|
||||
},
|
||||
async removeBackground() {
|
||||
if (!this.sourceImage) return;
|
||||
this.busy = true;
|
||||
this.busyMessage = this.$t("sticker_editor.removing_background");
|
||||
try {
|
||||
if (!this.bgRemovalModule) {
|
||||
this.bgRemovalModule = await import("@imgly/background-removal");
|
||||
}
|
||||
const removeFn = this.bgRemovalModule.removeBackground || this.bgRemovalModule.default;
|
||||
if (!removeFn) {
|
||||
throw new Error("background removal entrypoint not found");
|
||||
}
|
||||
const blob = await this.blobFromSourceImage();
|
||||
const cleaned = await removeFn(blob, {
|
||||
output: { format: "image/png", quality: 0.95 },
|
||||
});
|
||||
const url = URL.createObjectURL(cleaned);
|
||||
try {
|
||||
const img = await this.loadImage(url);
|
||||
this.sourceImage = img;
|
||||
this.bgRemoved = true;
|
||||
if (this.strokeWidth === 0) {
|
||||
this.strokeWidth = 8;
|
||||
}
|
||||
if (this.shadowBlur === 0) {
|
||||
this.shadowBlur = 16;
|
||||
}
|
||||
await this.redraw();
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("sticker_editor.bg_removal_failed"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.busyMessage = "";
|
||||
}
|
||||
},
|
||||
async blobFromSourceImage() {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = this.sourceImage.naturalWidth;
|
||||
c.height = this.sourceImage.naturalHeight;
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.drawImage(this.sourceImage, 0, 0);
|
||||
return new Promise((resolve) => c.toBlob((b) => resolve(b), "image/png"));
|
||||
},
|
||||
arrayBufferToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
},
|
||||
async onSave() {
|
||||
if (!this.lastBlob) return;
|
||||
if (this.lastBlob.size > TELEGRAM_STATIC_LIMIT) {
|
||||
ToastUtils.error(this.$t("sticker_editor.too_large"));
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.busyMessage = this.$t("sticker_editor.saving");
|
||||
try {
|
||||
const buf = await this.lastBlob.arrayBuffer();
|
||||
const b64 = this.arrayBufferToBase64(buf);
|
||||
const payload = {
|
||||
image_bytes: b64,
|
||||
image_type: this.exportFormat,
|
||||
name: this.stickerName || null,
|
||||
emoji: this.stickerEmoji || null,
|
||||
strict: true,
|
||||
};
|
||||
if (this.defaultPackId != null) {
|
||||
payload.pack_id = Number(this.defaultPackId);
|
||||
}
|
||||
const r = await window.api.post("/api/v1/stickers", payload);
|
||||
ToastUtils.success(this.$t("sticker_editor.saved"));
|
||||
this.$emit("saved", r.data?.sticker || null);
|
||||
this.$emit("close");
|
||||
} catch (e) {
|
||||
const err = e?.response?.data?.error || "save_failed";
|
||||
if (err === "duplicate_sticker") {
|
||||
ToastUtils.info(this.$t("stickers.duplicate"));
|
||||
} else {
|
||||
ToastUtils.error(`${this.$t("sticker_editor.save_failed")}: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.busyMessage = "";
|
||||
}
|
||||
},
|
||||
onCancel() {
|
||||
this.$emit("close");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bg-checkerboard {
|
||||
background-image:
|
||||
linear-gradient(45deg, #d1d5db 25%, transparent 25%), linear-gradient(-45deg, #d1d5db 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #d1d5db 75%), linear-gradient(-45deg, transparent 75%, #d1d5db 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 10px,
|
||||
10px -10px,
|
||||
-10px 0;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .bg-checkerboard {
|
||||
background-image:
|
||||
linear-gradient(45deg, #374151 25%, transparent 25%), linear-gradient(-45deg, #374151 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #374151 75%), linear-gradient(-45deg, transparent 75%, #374151 75%);
|
||||
background-color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
@@ -9,14 +9,6 @@
|
||||
<MaterialDesignIcon icon-name="folder-plus-outline" class="size-4" />
|
||||
{{ $t("sticker_packs.create") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-gray-300 dark:border-zinc-600 px-3 py-1.5 text-sm hover:border-emerald-500 flex items-center gap-1"
|
||||
@click="openEditor()"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="image-edit-outline" class="size-4" />
|
||||
{{ $t("sticker_packs.open_editor") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-gray-300 dark:border-zinc-600 px-3 py-1.5 text-sm hover:border-teal-500 flex items-center gap-1"
|
||||
@@ -60,14 +52,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-1.5 hover:bg-gray-100 dark:hover:bg-zinc-700 text-gray-600 dark:text-zinc-300"
|
||||
:title="$t('sticker_packs.add_sticker')"
|
||||
@click="openEditor(pack.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="plus" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-1.5 hover:bg-gray-100 dark:hover:bg-zinc-700 text-gray-600 dark:text-zinc-300"
|
||||
@@ -157,7 +141,7 @@
|
||||
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-3 py-1.5 text-sm"
|
||||
@click="createOpen = false"
|
||||
>
|
||||
{{ $t("sticker_editor.cancel") }}
|
||||
{{ $t("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -171,25 +155,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StickerEditor
|
||||
:visible="editorOpen"
|
||||
:default-pack-id="editorPackId"
|
||||
@close="onEditorClose"
|
||||
@saved="onEditorSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import StickerEditor from "./StickerEditor.vue";
|
||||
import StickerView from "./StickerView.vue";
|
||||
import ToastUtils from "../../js/ToastUtils.js";
|
||||
import DialogUtils from "../../js/DialogUtils.js";
|
||||
|
||||
export default {
|
||||
name: "StickerPacksManager",
|
||||
components: { MaterialDesignIcon, StickerEditor, StickerView },
|
||||
components: { MaterialDesignIcon, StickerView },
|
||||
data() {
|
||||
return {
|
||||
packs: [],
|
||||
@@ -199,8 +176,6 @@ export default {
|
||||
newPackDescription: "",
|
||||
newPackType: "mixed",
|
||||
newPackStrict: true,
|
||||
editorOpen: false,
|
||||
editorPackId: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -244,17 +219,6 @@ export default {
|
||||
ToastUtils.error(`${this.$t("sticker_packs.create_failed")}: ${err}`);
|
||||
}
|
||||
},
|
||||
openEditor(packId = null) {
|
||||
this.editorPackId = packId;
|
||||
this.editorOpen = true;
|
||||
},
|
||||
onEditorClose() {
|
||||
this.editorOpen = false;
|
||||
this.editorPackId = null;
|
||||
},
|
||||
async onEditorSaved() {
|
||||
await this.loadPacks();
|
||||
},
|
||||
async exportPack(pack) {
|
||||
try {
|
||||
const r = await window.api.get(`/api/v1/sticker-packs/${pack.id}/export`);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<div ref="stickerRoot" :class="['sticker-view', sizeClass]">
|
||||
<video v-if="isVideo" ref="videoEl" :src="src" class="sticker-media" loop muted playsinline @error="onError" />
|
||||
<div v-else-if="isAnimated" ref="lottieMount" class="sticker-media" />
|
||||
<div
|
||||
v-else-if="isAnimated"
|
||||
class="w-full h-full flex items-center justify-center bg-gray-200/80 dark:bg-zinc-700/50 text-gray-500 dark:text-zinc-400"
|
||||
:title="alt || 'TGS'"
|
||||
:aria-label="alt || 'Animated sticker'"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="animation-outline" class="w-[42%] h-[42%] opacity-70" />
|
||||
</div>
|
||||
<img
|
||||
v-else
|
||||
:src="src"
|
||||
@@ -16,10 +23,11 @@
|
||||
|
||||
<script>
|
||||
import { attachInView } from "../../js/inViewObserver.js";
|
||||
import { decodeTgsBuffer } from "../../js/tgsDecode.js";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "StickerView",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
src: { type: String, required: true },
|
||||
imageType: { type: String, default: "" },
|
||||
@@ -29,7 +37,6 @@ export default {
|
||||
emits: ["error"],
|
||||
data() {
|
||||
return {
|
||||
lottieAnim: null,
|
||||
destroyed: false,
|
||||
inView: false,
|
||||
ioCleanup: null,
|
||||
@@ -51,25 +58,13 @@ export default {
|
||||
this.onInViewChanged();
|
||||
},
|
||||
src() {
|
||||
if (this.isAnimated) {
|
||||
this.teardownLottie();
|
||||
this.$nextTick(() => {
|
||||
if (this.inView) {
|
||||
this.mountLottie();
|
||||
}
|
||||
});
|
||||
} else if (this.isVideo) {
|
||||
if (this.isVideo) {
|
||||
this.$nextTick(() => this.syncVideoPlayback());
|
||||
}
|
||||
},
|
||||
imageType() {
|
||||
this.teardownLottie();
|
||||
if (this.isAnimated) {
|
||||
this.$nextTick(() => {
|
||||
if (this.inView) {
|
||||
this.mountLottie();
|
||||
}
|
||||
});
|
||||
if (this.isVideo) {
|
||||
this.$nextTick(() => this.syncVideoPlayback());
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -82,7 +77,6 @@ export default {
|
||||
this.ioCleanup();
|
||||
this.ioCleanup = null;
|
||||
}
|
||||
this.teardownLottie();
|
||||
},
|
||||
methods: {
|
||||
setupInView() {
|
||||
@@ -98,13 +92,6 @@ export default {
|
||||
if (this.isVideo) {
|
||||
this.syncVideoPlayback();
|
||||
}
|
||||
if (this.isAnimated) {
|
||||
if (this.inView && !this.lottieAnim && !this.destroyed) {
|
||||
this.$nextTick(() => this.mountLottie());
|
||||
} else {
|
||||
this.syncLottiePlayback();
|
||||
}
|
||||
}
|
||||
},
|
||||
syncVideoPlayback() {
|
||||
const v = this.$refs.videoEl;
|
||||
@@ -117,57 +104,6 @@ export default {
|
||||
v.pause?.();
|
||||
}
|
||||
},
|
||||
syncLottiePlayback() {
|
||||
if (!this.lottieAnim || !this.isAnimated) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (this.inView) {
|
||||
this.lottieAnim.play();
|
||||
} else {
|
||||
this.lottieAnim.pause();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
},
|
||||
async mountLottie() {
|
||||
if (!this.$refs.lottieMount || !this.src || !this.inView) {
|
||||
return;
|
||||
}
|
||||
this.teardownLottie();
|
||||
try {
|
||||
const lottie = await import("lottie-web/build/player/lottie_light.js");
|
||||
const lib = lottie.default || lottie;
|
||||
const response = await fetch(this.src);
|
||||
const buf = await response.arrayBuffer();
|
||||
const data = await decodeTgsBuffer(buf);
|
||||
if (this.destroyed || !this.inView) {
|
||||
return;
|
||||
}
|
||||
this.lottieAnim = lib.loadAnimation({
|
||||
container: this.$refs.lottieMount,
|
||||
renderer: "svg",
|
||||
loop: true,
|
||||
autoplay: false,
|
||||
animationData: data,
|
||||
});
|
||||
this.syncLottiePlayback();
|
||||
} catch (e) {
|
||||
console.error("Failed to render TGS sticker", e);
|
||||
this.$emit("error", e);
|
||||
}
|
||||
},
|
||||
teardownLottie() {
|
||||
if (this.lottieAnim) {
|
||||
try {
|
||||
this.lottieAnim.destroy();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
this.lottieAnim = null;
|
||||
}
|
||||
},
|
||||
onError(e) {
|
||||
this.$emit("error", e);
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Frontend module map: primary entry points, large feature surfaces, and shared layers.
|
||||
*
|
||||
* Entry: meshchatx/src/frontend/main.js
|
||||
*
|
||||
* Application shell: components/App.vue (routing, WebSocket shell, sidebar, global modals)
|
||||
*
|
||||
* Feature surfaces (orchestration-heavy .vue files):
|
||||
* - components/messages/ConversationViewer.vue
|
||||
* - components/settings/SettingsPage.vue
|
||||
* - components/call/CallPage.vue
|
||||
* - components/map/MapPage.vue
|
||||
*
|
||||
* Shared state and events:
|
||||
* - js/GlobalState.js, js/GlobalEmitter.js
|
||||
* - js/KeyboardShortcuts.js, js/WebSocketConnection.js
|
||||
*
|
||||
* Extracted domain helpers (non-UI):
|
||||
* - js/settings/settingsConfigService.js
|
||||
* - js/settings/settingsTransportService.js
|
||||
* - js/settings/settingsMaintenanceClient.js
|
||||
* - js/settings/settingsVisualiserPrefs.js
|
||||
* - components/messages/conversationMessageHelpers.js
|
||||
* - components/messages/conversationScroll.js
|
||||
*/
|
||||
|
||||
export const FRONTEND_MODULE_MAP_VERSION = 1;
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Thin JS wrapper around the native MeshChatXAndroid bridge.
|
||||
*
|
||||
* The bridge methods are added by the Android WebView side
|
||||
* (see android/app/src/main/java/com/meshchatx/MainActivity.java).
|
||||
* Each method may be missing on older builds, so all helpers degrade
|
||||
* gracefully and return safe defaults.
|
||||
*
|
||||
* The wrapper is also fully testable: a custom bridge object can be
|
||||
* injected via the constructor.
|
||||
*/
|
||||
|
||||
const PERM_BLUETOOTH = "bluetooth";
|
||||
const PERM_USB = "usb";
|
||||
|
||||
function pickEnv() {
|
||||
if (typeof window !== "undefined") {
|
||||
return window;
|
||||
}
|
||||
if (typeof globalThis !== "undefined") {
|
||||
return globalThis;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function safeCall(fn, fallback) {
|
||||
try {
|
||||
const result = fn();
|
||||
return result === undefined ? fallback : result;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default class AndroidBridge {
|
||||
constructor(bridge = null, env = null) {
|
||||
this.env = env || pickEnv();
|
||||
this.bridge = bridge || this.env.MeshChatXAndroid || null;
|
||||
}
|
||||
|
||||
isAvailable() {
|
||||
return Boolean(this.bridge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a runtime permission group is currently granted on the
|
||||
* Android host. Returns true on non-android (no-op) so calling code
|
||||
* can chain checks without branching.
|
||||
*/
|
||||
hasPermission(permissionGroup) {
|
||||
if (!this.bridge) {
|
||||
return true;
|
||||
}
|
||||
if (permissionGroup === PERM_BLUETOOTH && typeof this.bridge.hasBluetoothPermissions === "function") {
|
||||
return safeCall(() => Boolean(this.bridge.hasBluetoothPermissions()), false);
|
||||
}
|
||||
if (permissionGroup === PERM_USB && typeof this.bridge.hasUsbPermissions === "function") {
|
||||
return safeCall(() => Boolean(this.bridge.hasUsbPermissions()), false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a runtime permission group from Android. Resolves to true if
|
||||
* the permission was already granted (or the call was made on a non-
|
||||
* android build). The actual grant result is delivered asynchronously
|
||||
* by the OS, so callers should re-check via hasPermission afterwards.
|
||||
*/
|
||||
async requestPermission(permissionGroup) {
|
||||
if (!this.bridge) {
|
||||
return true;
|
||||
}
|
||||
if (permissionGroup === PERM_BLUETOOTH && typeof this.bridge.requestBluetoothPermissions === "function") {
|
||||
return safeCall(() => {
|
||||
this.bridge.requestBluetoothPermissions();
|
||||
return true;
|
||||
}, false);
|
||||
}
|
||||
if (permissionGroup === PERM_USB && typeof this.bridge.requestUsbPermissions === "function") {
|
||||
return safeCall(() => {
|
||||
this.bridge.requestUsbPermissions();
|
||||
return true;
|
||||
}, false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
openBluetoothSettings() {
|
||||
if (!this.bridge || typeof this.bridge.openBluetoothSettings !== "function") {
|
||||
return false;
|
||||
}
|
||||
return safeCall(() => {
|
||||
this.bridge.openBluetoothSettings();
|
||||
return true;
|
||||
}, false);
|
||||
}
|
||||
|
||||
openUsbSettings() {
|
||||
if (!this.bridge || typeof this.bridge.openUsbSettings !== "function") {
|
||||
return false;
|
||||
}
|
||||
return safeCall(() => {
|
||||
this.bridge.openUsbSettings();
|
||||
return true;
|
||||
}, false);
|
||||
}
|
||||
|
||||
getPlatform() {
|
||||
if (!this.bridge || typeof this.bridge.getPlatform !== "function") {
|
||||
return null;
|
||||
}
|
||||
return safeCall(() => this.bridge.getPlatform(), null);
|
||||
}
|
||||
}
|
||||
|
||||
AndroidBridge.PERM_BLUETOOTH = PERM_BLUETOOTH;
|
||||
AndroidBridge.PERM_USB = PERM_USB;
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Runtime capability detection for RNode flasher.
|
||||
*
|
||||
* Determines which connection transports are available in the current
|
||||
* environment (Web Serial, Web Bluetooth, WebUSB polyfill, WiFi/HTTP)
|
||||
* and exposes structured reasons when something is unsupported so the UI
|
||||
* can render actionable guidance.
|
||||
*
|
||||
* Pure functions, no DOM mutation, safe to import in tests.
|
||||
*/
|
||||
|
||||
export const TRANSPORT_SERIAL = "serial";
|
||||
export const TRANSPORT_BLUETOOTH = "bluetooth";
|
||||
export const TRANSPORT_WIFI = "wifi";
|
||||
|
||||
const ANDROID_RE = /android/i;
|
||||
const ELECTRON_RE = /electron/i;
|
||||
|
||||
function pickGlobal(provided) {
|
||||
if (provided) {
|
||||
return provided;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
return window;
|
||||
}
|
||||
if (typeof globalThis !== "undefined") {
|
||||
return globalThis;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function detectPlatform(env) {
|
||||
const ua = env.navigator?.userAgent ?? "";
|
||||
const isAndroid = ANDROID_RE.test(ua);
|
||||
const isElectron = ELECTRON_RE.test(ua) || Boolean(env.electron);
|
||||
const hasMeshChatXAndroid = Boolean(env.MeshChatXAndroid);
|
||||
return {
|
||||
isAndroid,
|
||||
isElectron,
|
||||
hasMeshChatXAndroid,
|
||||
isSecureContext: Boolean(env.isSecureContext),
|
||||
userAgent: ua,
|
||||
};
|
||||
}
|
||||
|
||||
function detectSerial(env, platform) {
|
||||
const hasNative = Boolean(env.navigator?.serial);
|
||||
const hasUsbPolyfillTarget = Boolean(env.navigator?.usb);
|
||||
const hasPolyfillModule = Boolean(env.serial);
|
||||
|
||||
if (hasNative) {
|
||||
return {
|
||||
available: true,
|
||||
kind: "native",
|
||||
polyfilled: false,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
if (hasUsbPolyfillTarget && hasPolyfillModule) {
|
||||
return {
|
||||
available: true,
|
||||
kind: "polyfill",
|
||||
polyfilled: true,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
if (hasUsbPolyfillTarget && !hasPolyfillModule) {
|
||||
return {
|
||||
available: false,
|
||||
kind: "polyfill-pending",
|
||||
polyfilled: false,
|
||||
reason: "polyfill_not_loaded",
|
||||
};
|
||||
}
|
||||
if (platform.isAndroid) {
|
||||
return {
|
||||
available: false,
|
||||
kind: "none",
|
||||
polyfilled: false,
|
||||
reason: "android_webview_no_serial",
|
||||
};
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
kind: "none",
|
||||
polyfilled: false,
|
||||
reason: "browser_unsupported",
|
||||
};
|
||||
}
|
||||
|
||||
function detectBluetooth(env, platform) {
|
||||
const hasNative = Boolean(env.navigator?.bluetooth);
|
||||
if (hasNative) {
|
||||
return {
|
||||
available: true,
|
||||
kind: "web-bluetooth",
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
if (platform.hasMeshChatXAndroid) {
|
||||
return {
|
||||
available: false,
|
||||
kind: "android-bridge",
|
||||
reason: "android_bridge_not_implemented",
|
||||
};
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
kind: "none",
|
||||
reason: platform.isSecureContext ? "browser_unsupported" : "insecure_context",
|
||||
};
|
||||
}
|
||||
|
||||
function detectWifi() {
|
||||
return {
|
||||
available: true,
|
||||
kind: "http",
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect the environment and return a capabilities snapshot.
|
||||
*
|
||||
* @param {object} [overrides]
|
||||
* @param {object} [overrides.env] alternative global object (window-like) for tests
|
||||
* @returns {{
|
||||
* platform: object,
|
||||
* transports: { serial: object, bluetooth: object, wifi: object },
|
||||
* anyAvailable: boolean,
|
||||
* }}
|
||||
*/
|
||||
export function detectCapabilities(overrides = {}) {
|
||||
const env = pickGlobal(overrides.env);
|
||||
const platform = detectPlatform(env);
|
||||
const transports = {
|
||||
[TRANSPORT_SERIAL]: detectSerial(env, platform),
|
||||
[TRANSPORT_BLUETOOTH]: detectBluetooth(env, platform),
|
||||
[TRANSPORT_WIFI]: detectWifi(),
|
||||
};
|
||||
const anyAvailable =
|
||||
transports[TRANSPORT_SERIAL].available ||
|
||||
transports[TRANSPORT_BLUETOOTH].available ||
|
||||
transports[TRANSPORT_WIFI].available;
|
||||
return { platform, transports, anyAvailable };
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the most appropriate default transport given current capabilities.
|
||||
*
|
||||
* Order of preference: native serial, polyfill serial, web bluetooth, wifi.
|
||||
*/
|
||||
export function pickDefaultTransport(capabilities) {
|
||||
const t = capabilities?.transports ?? {};
|
||||
if (t[TRANSPORT_SERIAL]?.available) {
|
||||
return TRANSPORT_SERIAL;
|
||||
}
|
||||
if (t[TRANSPORT_BLUETOOTH]?.available) {
|
||||
return TRANSPORT_BLUETOOTH;
|
||||
}
|
||||
return TRANSPORT_WIFI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of human-readable, translation-aware suggestions for a
|
||||
* transport that is unavailable. The caller maps these keys to i18n strings.
|
||||
*/
|
||||
export function transportSuggestionKeys(capabilities, transportName) {
|
||||
const transport = capabilities?.transports?.[transportName];
|
||||
if (!transport || transport.available) {
|
||||
return [];
|
||||
}
|
||||
const platform = capabilities.platform ?? {};
|
||||
const reason = transport.reason ?? "unknown";
|
||||
const suggestions = [`tools.rnode_flasher.support.${transportName}.${reason}`];
|
||||
if (transportName === TRANSPORT_SERIAL && platform.isAndroid) {
|
||||
suggestions.push("tools.rnode_flasher.support.serial.android_use_chrome");
|
||||
}
|
||||
if (transportName === TRANSPORT_BLUETOOTH && !platform.isSecureContext) {
|
||||
suggestions.push("tools.rnode_flasher.support.bluetooth.requires_https");
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export default {
|
||||
detectCapabilities,
|
||||
pickDefaultTransport,
|
||||
transportSuggestionKeys,
|
||||
TRANSPORT_SERIAL,
|
||||
TRANSPORT_BLUETOOTH,
|
||||
TRANSPORT_WIFI,
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import RNodeUtils from "./RNodeUtils.js";
|
||||
|
||||
/**
|
||||
* Smart diagnostics for connected RNode devices.
|
||||
*
|
||||
* Given an open RNode handle, inspect()/inspectQuiet() will try to read the
|
||||
* essential identification & state from the device and return a structured
|
||||
* report including i18n suggestion keys for any issues detected.
|
||||
*
|
||||
* The suggestion keys map directly to entries under
|
||||
* tools.rnode_flasher.diagnostics.* in the locale files, so the UI does not
|
||||
* need any logic to translate raw issue identifiers.
|
||||
*/
|
||||
|
||||
export const ISSUE_NOT_PROVISIONED = "not_provisioned";
|
||||
export const ISSUE_FIRMWARE_HASH_MISSING = "firmware_hash_missing";
|
||||
export const ISSUE_FIRMWARE_HASH_MISMATCH = "firmware_hash_mismatch";
|
||||
export const ISSUE_PRODUCT_MISMATCH = "product_mismatch";
|
||||
export const ISSUE_NO_FIRMWARE_VERSION = "no_firmware_version";
|
||||
export const ISSUE_DETECT_FAILED = "detect_failed";
|
||||
export const ISSUE_READ_TIMEOUT = "read_timeout";
|
||||
|
||||
const ALL_ISSUE_KEYS = new Set([
|
||||
ISSUE_NOT_PROVISIONED,
|
||||
ISSUE_FIRMWARE_HASH_MISSING,
|
||||
ISSUE_FIRMWARE_HASH_MISMATCH,
|
||||
ISSUE_PRODUCT_MISMATCH,
|
||||
ISSUE_NO_FIRMWARE_VERSION,
|
||||
ISSUE_DETECT_FAILED,
|
||||
ISSUE_READ_TIMEOUT,
|
||||
]);
|
||||
|
||||
function suggestionKeysFor(issue) {
|
||||
return [`tools.rnode_flasher.diagnostics.suggestions.${issue}`];
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, code) {
|
||||
let timer = null;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const err = new Error(code || "timeout");
|
||||
err.code = code || "TIMEOUT";
|
||||
reject(err);
|
||||
}, ms);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function safeArrayEqual(a, b) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) {
|
||||
return false;
|
||||
}
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if ((a[i] & 0xff) !== (b[i] & 0xff)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a diagnostics sweep against a connected RNode.
|
||||
*
|
||||
* @param {object} rnode RNode instance (already detected/open)
|
||||
* @param {{ expectedProductId?: number, expectedModelId?: number, timeoutMs?: number }} [options]
|
||||
* @returns {Promise<{
|
||||
* firmwareVersion: string|null,
|
||||
* platform: number|null,
|
||||
* board: number|null,
|
||||
* mcu: number|null,
|
||||
* eepromBytes: number[]|null,
|
||||
* romDetails: object|null,
|
||||
* firmwareHash: number[]|null,
|
||||
* targetFirmwareHash: number[]|null,
|
||||
* isProvisioned: boolean,
|
||||
* issues: string[],
|
||||
* suggestionKeys: string[],
|
||||
* summary: object,
|
||||
* }>}
|
||||
*/
|
||||
export async function diagnose(rnode, options = {}) {
|
||||
const timeoutMs = options.timeoutMs || 4000;
|
||||
const result = {
|
||||
firmwareVersion: null,
|
||||
platform: null,
|
||||
board: null,
|
||||
mcu: null,
|
||||
eepromBytes: null,
|
||||
romDetails: null,
|
||||
firmwareHash: null,
|
||||
targetFirmwareHash: null,
|
||||
isProvisioned: false,
|
||||
issues: [],
|
||||
suggestionKeys: [],
|
||||
summary: {},
|
||||
};
|
||||
|
||||
const addIssue = (issue) => {
|
||||
if (!ALL_ISSUE_KEYS.has(issue)) {
|
||||
return;
|
||||
}
|
||||
if (!result.issues.includes(issue)) {
|
||||
result.issues.push(issue);
|
||||
for (const key of suggestionKeysFor(issue)) {
|
||||
if (!result.suggestionKeys.includes(key)) {
|
||||
result.suggestionKeys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!rnode || typeof rnode.getFirmwareVersion !== "function") {
|
||||
addIssue(ISSUE_DETECT_FAILED);
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
result.firmwareVersion = await withTimeout(rnode.getFirmwareVersion(), timeoutMs, "READ_TIMEOUT");
|
||||
} catch (err) {
|
||||
if (err?.code === "READ_TIMEOUT") {
|
||||
addIssue(ISSUE_READ_TIMEOUT);
|
||||
} else {
|
||||
addIssue(ISSUE_NO_FIRMWARE_VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rnode.getPlatform === "function") {
|
||||
try {
|
||||
result.platform = await withTimeout(rnode.getPlatform(), timeoutMs, "READ_TIMEOUT");
|
||||
} catch {
|
||||
// platform isn't critical for diagnostics
|
||||
}
|
||||
}
|
||||
if (typeof rnode.getBoard === "function") {
|
||||
try {
|
||||
result.board = await withTimeout(rnode.getBoard(), timeoutMs, "READ_TIMEOUT");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (typeof rnode.getMcu === "function") {
|
||||
try {
|
||||
result.mcu = await withTimeout(rnode.getMcu(), timeoutMs, "READ_TIMEOUT");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let romDetails = null;
|
||||
if (typeof rnode.getRomAsObject === "function") {
|
||||
try {
|
||||
const rom = await withTimeout(rnode.getRomAsObject(), timeoutMs, "READ_TIMEOUT");
|
||||
result.eepromBytes = Array.isArray(rom?.eeprom) ? Array.from(rom.eeprom) : null;
|
||||
try {
|
||||
romDetails = typeof rom?.parse === "function" ? rom.parse() : null;
|
||||
} catch {
|
||||
romDetails = null;
|
||||
}
|
||||
result.romDetails = romDetails;
|
||||
result.isProvisioned = Boolean(romDetails?.is_provisioned);
|
||||
} catch {
|
||||
// ignore: handled by isProvisioned=false / not_provisioned issue below
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.isProvisioned) {
|
||||
addIssue(ISSUE_NOT_PROVISIONED);
|
||||
}
|
||||
|
||||
if (
|
||||
result.isProvisioned &&
|
||||
options.expectedProductId !== undefined &&
|
||||
romDetails &&
|
||||
romDetails.product !== options.expectedProductId
|
||||
) {
|
||||
addIssue(ISSUE_PRODUCT_MISMATCH);
|
||||
}
|
||||
|
||||
if (typeof rnode.getFirmwareHash === "function") {
|
||||
try {
|
||||
result.firmwareHash = await withTimeout(rnode.getFirmwareHash(), timeoutMs, "READ_TIMEOUT");
|
||||
} catch {
|
||||
// ignore: hash retrieval can fail on un-provisioned units
|
||||
}
|
||||
}
|
||||
if (typeof rnode.getTargetFirmwareHash === "function") {
|
||||
try {
|
||||
result.targetFirmwareHash = await withTimeout(rnode.getTargetFirmwareHash(), timeoutMs, "READ_TIMEOUT");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isProvisioned) {
|
||||
if (
|
||||
!Array.isArray(result.targetFirmwareHash) ||
|
||||
result.targetFirmwareHash.length === 0 ||
|
||||
result.targetFirmwareHash.every((b) => (b & 0xff) === 0)
|
||||
) {
|
||||
addIssue(ISSUE_FIRMWARE_HASH_MISSING);
|
||||
} else if (
|
||||
Array.isArray(result.firmwareHash) &&
|
||||
result.firmwareHash.length > 0 &&
|
||||
!safeArrayEqual(result.firmwareHash, result.targetFirmwareHash)
|
||||
) {
|
||||
addIssue(ISSUE_FIRMWARE_HASH_MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
result.summary = {
|
||||
firmware_version: result.firmwareVersion,
|
||||
platform: result.platform,
|
||||
board: result.board,
|
||||
mcu: result.mcu,
|
||||
is_provisioned: result.isProvisioned,
|
||||
product: romDetails?.product ?? null,
|
||||
model: romDetails?.model ?? null,
|
||||
hardware_revision: romDetails?.hardware_revision ?? null,
|
||||
serial_number: romDetails?.serial_number ?? null,
|
||||
firmware_hash: result.firmwareHash ? RNodeUtils.bytesToHex(result.firmwareHash) : null,
|
||||
target_firmware_hash: result.targetFirmwareHash ? RNodeUtils.bytesToHex(result.targetFirmwareHash) : null,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a model id from products.js against the EEPROM to see whether the
|
||||
* selected product/model in the UI matches the device the user just plugged
|
||||
* in. Useful as a guardrail before flashing.
|
||||
*/
|
||||
export function evaluateProductMatch(romDetails, expected) {
|
||||
if (!romDetails || !expected) {
|
||||
return { matches: false, reason: "missing_data" };
|
||||
}
|
||||
if (!romDetails.is_provisioned) {
|
||||
return { matches: false, reason: "not_provisioned" };
|
||||
}
|
||||
if (expected.productId !== undefined && romDetails.product !== expected.productId) {
|
||||
return { matches: false, reason: "product_mismatch" };
|
||||
}
|
||||
if (expected.modelId !== undefined && romDetails.model !== expected.modelId) {
|
||||
return { matches: false, reason: "model_mismatch" };
|
||||
}
|
||||
return { matches: true, reason: null };
|
||||
}
|
||||
|
||||
export default { diagnose, evaluateProductMatch };
|
||||
@@ -0,0 +1,218 @@
|
||||
import Transport from "./Transport.js";
|
||||
|
||||
/**
|
||||
* Bluetooth LE transport for RNode using the Nordic UART Service (NUS).
|
||||
*
|
||||
* RNode firmware exposes a serial-over-BLE channel using the standard NUS
|
||||
* UUIDs (https://infocenter.nordicsemi.com/topic/sdk_nrf5_v17.0.2/ble_sdk_app_nus_eval.html).
|
||||
* The transport adapts that GATT contract to the Web Serial-style readable/
|
||||
* writable streams expected by RNode.js.
|
||||
*
|
||||
* Notes:
|
||||
* - BLE is suitable for management commands, EEPROM operations, TNC
|
||||
* configuration and bluetooth control. It is NOT usable for ESP32 or
|
||||
* nRF52 bootloader flashing because those bootloaders speak UART only.
|
||||
* - Web Bluetooth requires a secure context (https or localhost).
|
||||
* - The MTU is small (~20 bytes by default), so writes are chunked.
|
||||
*/
|
||||
|
||||
export const NUS_SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
export const NUS_RX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
export const NUS_TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
const DEFAULT_WRITE_CHUNK_SIZE = 20;
|
||||
|
||||
export default class BluetoothTransport extends Transport {
|
||||
/**
|
||||
* @param {BluetoothDevice} device
|
||||
* @param {{ writeChunkSize?: number, env?: object }} [options]
|
||||
*/
|
||||
constructor(device, options = {}) {
|
||||
super("bluetooth");
|
||||
if (!device) {
|
||||
throw new Error("BluetoothTransport requires a BluetoothDevice");
|
||||
}
|
||||
this.device = device;
|
||||
this.writeChunkSize = options.writeChunkSize || DEFAULT_WRITE_CHUNK_SIZE;
|
||||
this.env = options.env || (typeof window !== "undefined" ? window : globalThis);
|
||||
this.gattServer = null;
|
||||
this.rxCharacteristic = null;
|
||||
this.txCharacteristic = null;
|
||||
this._notifyHandler = null;
|
||||
this._readableController = null;
|
||||
this._disconnectedHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a chooser dialog for a NUS-capable RNode.
|
||||
* Returns a connected BluetoothTransport (with .open() still required to
|
||||
* attach streams).
|
||||
*/
|
||||
static async request(options = {}) {
|
||||
const env = options.env || (typeof window !== "undefined" ? window : globalThis);
|
||||
if (!env.navigator?.bluetooth) {
|
||||
const err = new Error("web_bluetooth_unavailable");
|
||||
err.code = "WEB_BLUETOOTH_UNAVAILABLE";
|
||||
throw err;
|
||||
}
|
||||
let device;
|
||||
try {
|
||||
device = await env.navigator.bluetooth.requestDevice({
|
||||
filters: options.filters || [{ services: [NUS_SERVICE_UUID] }],
|
||||
optionalServices: [NUS_SERVICE_UUID, ...(options.optionalServices || [])],
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause?.message || String(cause);
|
||||
if (cause?.name === "NotFoundError" || /User cancelled/i.test(message)) {
|
||||
const err = new Error("no_device_selected");
|
||||
err.code = "NO_DEVICE_SELECTED";
|
||||
err.cause = cause;
|
||||
throw err;
|
||||
}
|
||||
const err = new Error(message);
|
||||
err.code = "DEVICE_REQUEST_FAILED";
|
||||
err.cause = cause;
|
||||
throw err;
|
||||
}
|
||||
return new BluetoothTransport(device, { env });
|
||||
}
|
||||
|
||||
async open(_opts = {}) {
|
||||
if (!this.device.gatt) {
|
||||
const err = new Error("bluetooth_gatt_unavailable");
|
||||
err.code = "GATT_UNAVAILABLE";
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.gattServer = await this.device.gatt.connect();
|
||||
|
||||
let service;
|
||||
try {
|
||||
service = await this.gattServer.getPrimaryService(NUS_SERVICE_UUID);
|
||||
} catch (cause) {
|
||||
const err = new Error("nus_service_not_found");
|
||||
err.code = "NUS_SERVICE_NOT_FOUND";
|
||||
err.cause = cause;
|
||||
await this._safeDisconnect();
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
this.rxCharacteristic = await service.getCharacteristic(NUS_RX_CHARACTERISTIC_UUID);
|
||||
this.txCharacteristic = await service.getCharacteristic(NUS_TX_CHARACTERISTIC_UUID);
|
||||
} catch (cause) {
|
||||
const err = new Error("nus_characteristics_missing");
|
||||
err.code = "NUS_CHARACTERISTICS_MISSING";
|
||||
err.cause = cause;
|
||||
await this._safeDisconnect();
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.txCharacteristic.startNotifications();
|
||||
|
||||
this._notifyHandler = (event) => {
|
||||
const target = event.target;
|
||||
const value = target?.value;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice();
|
||||
if (this._readableController) {
|
||||
try {
|
||||
this._readableController.enqueue(bytes);
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
}
|
||||
};
|
||||
this.txCharacteristic.addEventListener("characteristicvaluechanged", this._notifyHandler);
|
||||
|
||||
this._disconnectedHandler = () => {
|
||||
if (this._readableController) {
|
||||
try {
|
||||
this._readableController.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
this.device.addEventListener("gattserverdisconnected", this._disconnectedHandler);
|
||||
|
||||
const transport = this;
|
||||
this.readable = new ReadableStream({
|
||||
start(controller) {
|
||||
transport._readableController = controller;
|
||||
},
|
||||
cancel() {
|
||||
transport._readableController = null;
|
||||
},
|
||||
});
|
||||
|
||||
const chunkSize = this.writeChunkSize;
|
||||
const writeChar = this.rxCharacteristic;
|
||||
this.writable = new WritableStream({
|
||||
async write(chunk) {
|
||||
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const slice = bytes.subarray(offset, offset + chunkSize);
|
||||
if (typeof writeChar.writeValueWithoutResponse === "function") {
|
||||
await writeChar.writeValueWithoutResponse(slice);
|
||||
} else {
|
||||
await writeChar.writeValue(slice);
|
||||
}
|
||||
offset += chunkSize;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.txCharacteristic && this._notifyHandler) {
|
||||
try {
|
||||
this.txCharacteristic.removeEventListener("characteristicvaluechanged", this._notifyHandler);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await this.txCharacteristic.stopNotifications();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (this.device && this._disconnectedHandler) {
|
||||
try {
|
||||
this.device.removeEventListener("gattserverdisconnected", this._disconnectedHandler);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await this._safeDisconnect();
|
||||
this.readable = null;
|
||||
this.writable = null;
|
||||
this.rxCharacteristic = null;
|
||||
this.txCharacteristic = null;
|
||||
this._notifyHandler = null;
|
||||
this._disconnectedHandler = null;
|
||||
this._readableController = null;
|
||||
}
|
||||
|
||||
async _safeDisconnect() {
|
||||
try {
|
||||
if (this.gattServer && this.gattServer.connected) {
|
||||
this.gattServer.disconnect();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.gattServer = null;
|
||||
}
|
||||
|
||||
canManageDevice() {
|
||||
return true;
|
||||
}
|
||||
|
||||
description() {
|
||||
return "bluetooth-le";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Transport from "./Transport.js";
|
||||
|
||||
/**
|
||||
* Web Serial transport.
|
||||
*
|
||||
* Wraps an underlying SerialPort (either the native navigator.serial port or
|
||||
* the web-serial-polyfill port that bridges WebUSB devices). Most of the
|
||||
* real work is delegated to the wrapped port; the wrapper is mainly here so
|
||||
* the rest of the codebase has a single uniform API and so error reporting
|
||||
* surface area is centralised.
|
||||
*/
|
||||
export default class SerialTransport extends Transport {
|
||||
/**
|
||||
* @param {SerialPort} port wrapped serial port
|
||||
* @param {{ polyfilled?: boolean, env?: object }} [options]
|
||||
*/
|
||||
constructor(port, options = {}) {
|
||||
super("serial");
|
||||
if (!port) {
|
||||
throw new Error("SerialTransport requires a SerialPort instance");
|
||||
}
|
||||
this.port = port;
|
||||
this.polyfilled = Boolean(options.polyfilled);
|
||||
this.env = options.env || (typeof window !== "undefined" ? window : globalThis);
|
||||
this.opened = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a port from the user via navigator.serial.requestPort. Returns
|
||||
* a SerialTransport wrapper or throws a descriptive error.
|
||||
*
|
||||
* @param {{ filters?: Array, env?: object }} [options]
|
||||
*/
|
||||
static async request(options = {}) {
|
||||
const env = options.env || (typeof window !== "undefined" ? window : globalThis);
|
||||
if (!env.navigator?.serial) {
|
||||
const err = new Error("web_serial_unavailable");
|
||||
err.code = "WEB_SERIAL_UNAVAILABLE";
|
||||
throw err;
|
||||
}
|
||||
let port;
|
||||
try {
|
||||
port = await env.navigator.serial.requestPort({
|
||||
filters: options.filters || [],
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause?.message || String(cause);
|
||||
if (cause?.name === "NotFoundError" || /No port selected/i.test(message)) {
|
||||
const err = new Error("no_port_selected");
|
||||
err.code = "NO_PORT_SELECTED";
|
||||
err.cause = cause;
|
||||
throw err;
|
||||
}
|
||||
const err = new Error(message);
|
||||
err.code = "PORT_REQUEST_FAILED";
|
||||
err.cause = cause;
|
||||
throw err;
|
||||
}
|
||||
const polyfilled = Boolean(env.serial && env.navigator.serial === env.serial);
|
||||
return new SerialTransport(port, { polyfilled, env });
|
||||
}
|
||||
|
||||
async open(opts = {}) {
|
||||
const baudRate = typeof opts.baudRate === "number" ? opts.baudRate : 115200;
|
||||
await this.port.open({ baudRate });
|
||||
this.readable = this.port.readable;
|
||||
this.writable = this.port.writable;
|
||||
this.opened = true;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.opened = false;
|
||||
try {
|
||||
if (typeof this.port.close === "function") {
|
||||
await this.port.close();
|
||||
}
|
||||
} finally {
|
||||
this.readable = null;
|
||||
this.writable = null;
|
||||
}
|
||||
}
|
||||
|
||||
async setSignals(signals) {
|
||||
if (typeof this.port.setSignals === "function") {
|
||||
await this.port.setSignals(signals);
|
||||
}
|
||||
}
|
||||
|
||||
canFlashEsp32() {
|
||||
return true;
|
||||
}
|
||||
|
||||
canFlashNrf52() {
|
||||
return true;
|
||||
}
|
||||
|
||||
canManageDevice() {
|
||||
return true;
|
||||
}
|
||||
|
||||
description() {
|
||||
return this.polyfilled ? "serial-polyfill" : "serial";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Abstract transport contract used by RNode flasher.
|
||||
*
|
||||
* Concrete transports (Web Serial, Web Bluetooth NUS, WiFi OTA, ...) expose
|
||||
* the same shape as a Web Serial port:
|
||||
*
|
||||
* - readable: ReadableStream<Uint8Array>
|
||||
* - writable: WritableStream<Uint8Array>
|
||||
* - open(opts): Promise<void>
|
||||
* - close(): Promise<void>
|
||||
*
|
||||
* This lets RNode.js and Nrf52DfuFlasher.js stay transport-agnostic.
|
||||
*
|
||||
* Concrete classes should also expose:
|
||||
* - kind: short string identifier ("serial", "bluetooth", "wifi")
|
||||
* - canFlashEsp32(): boolean
|
||||
* - canFlashNrf52(): boolean
|
||||
* - canManageDevice(): boolean
|
||||
* - description(): short user-facing label
|
||||
*/
|
||||
|
||||
export default class Transport {
|
||||
constructor(kind) {
|
||||
this.kind = kind;
|
||||
this.readable = null;
|
||||
this.writable = null;
|
||||
}
|
||||
|
||||
async open(_opts = {}) {
|
||||
throw new Error(`${this.kind} transport: open() not implemented`);
|
||||
}
|
||||
|
||||
async close() {
|
||||
throw new Error(`${this.kind} transport: close() not implemented`);
|
||||
}
|
||||
|
||||
canFlashEsp32() {
|
||||
return false;
|
||||
}
|
||||
|
||||
canFlashNrf52() {
|
||||
return false;
|
||||
}
|
||||
|
||||
canManageDevice() {
|
||||
return false;
|
||||
}
|
||||
|
||||
canOtaFlash() {
|
||||
return false;
|
||||
}
|
||||
|
||||
description() {
|
||||
return this.kind;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Transport from "./Transport.js";
|
||||
|
||||
/**
|
||||
* WiFi/OTA transport for RNode firmware over the device HTTP update endpoint.
|
||||
*
|
||||
* Most RNode-derived ESP32 firmwares ship a small HTTP server that exposes
|
||||
* /update for in-place flashing of the main application image. This transport
|
||||
* does not provide a serial-style readable/writable pair; instead it exposes
|
||||
* upload(blob, onProgress) which performs an XMLHttpRequest multipart POST
|
||||
* with progress events and timeout handling.
|
||||
*/
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120000;
|
||||
const IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)$/;
|
||||
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
||||
|
||||
export default class WifiTransport extends Transport {
|
||||
/**
|
||||
* @param {string} ipAddressOrHost
|
||||
* @param {{ timeoutMs?: number, env?: object, scheme?: string }} [options]
|
||||
*/
|
||||
constructor(ipAddressOrHost, options = {}) {
|
||||
super("wifi");
|
||||
if (!WifiTransport.isValidHost(ipAddressOrHost)) {
|
||||
const err = new Error("invalid_host");
|
||||
err.code = "INVALID_HOST";
|
||||
throw err;
|
||||
}
|
||||
this.host = ipAddressOrHost;
|
||||
this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
|
||||
this.env = options.env || (typeof window !== "undefined" ? window : globalThis);
|
||||
this.scheme = options.scheme || "http";
|
||||
}
|
||||
|
||||
static isValidHost(value) {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 253) {
|
||||
return false;
|
||||
}
|
||||
return IPV4_RE.test(trimmed) || HOSTNAME_RE.test(trimmed);
|
||||
}
|
||||
|
||||
async open() {
|
||||
// No persistent stream; OTA upload is one-shot. Kept to keep API symmetric.
|
||||
this.opened = true;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.opened = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a firmware blob to /update on the configured device.
|
||||
*
|
||||
* @param {Blob} blob raw firmware payload
|
||||
* @param {(percentage: number) => void} [onProgress]
|
||||
*/
|
||||
async upload(blob, onProgress) {
|
||||
if (!blob) {
|
||||
const err = new Error("no_payload");
|
||||
err.code = "NO_PAYLOAD";
|
||||
throw err;
|
||||
}
|
||||
const Xhr = this.env.XMLHttpRequest;
|
||||
if (!Xhr) {
|
||||
const err = new Error("xhr_unavailable");
|
||||
err.code = "XHR_UNAVAILABLE";
|
||||
throw err;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new Xhr();
|
||||
const url = `${this.scheme}://${this.host}/update`;
|
||||
xhr.open("POST", url, true);
|
||||
xhr.timeout = this.timeoutMs;
|
||||
|
||||
if (xhr.upload && typeof onProgress === "function") {
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
const percentage = Math.floor((event.loaded / event.total) * 100);
|
||||
onProgress(percentage);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
const err = new Error("upload_timeout");
|
||||
err.code = "UPLOAD_TIMEOUT";
|
||||
reject(err);
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
const err = new Error("network_error");
|
||||
err.code = "NETWORK_ERROR";
|
||||
reject(err);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve({ status: xhr.status, body: xhr.responseText });
|
||||
return;
|
||||
}
|
||||
const err = new Error(`http_${xhr.status}`);
|
||||
err.code = "HTTP_ERROR";
|
||||
err.status = xhr.status;
|
||||
err.body = xhr.responseText;
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const formData = new this.env.FormData();
|
||||
formData.append("update", blob, "firmware.bin");
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
canOtaFlash() {
|
||||
return true;
|
||||
}
|
||||
|
||||
description() {
|
||||
return `wifi://${this.host}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user