feat(rnode): add advanced tools, diagnostics, and device management UI components for RNode firmware flashing and configuration

This commit is contained in:
Ivan
2026-04-19 11:35:00 -05:00
parent 91d1d2e7d5
commit b451aaf29a
25 changed files with 3262 additions and 1736 deletions
@@ -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 }}&deg;</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}`;
}
}