feat: Enhance web viewer functionality and configuration options

- Updated `config.ini.example` to include a new option for additional hashtag channels to decode in the packet stream.
- Modified `BotDataViewer` to retrieve and display additional decode-only channels from the configuration.
- Improved packet handling in `message_handler.py` to capture full packet data for web viewer integration.
- Enhanced the web viewer's JavaScript to support detailed packet analysis and display, including color-coded hex breakdowns and improved user interface elements.
- Added new styles and scripts to the web viewer templates for better visual representation of packet data and improved user experience.
This commit is contained in:
agessaman committed 2026-01-10 16:39:07 -08:00
1 parent 760bf7ad1f
commit ca80924e38
10 files changed
+11481 -125

No files matched your search

+1
View File
@@ -332,3 +332,4 @@ This project is licensed under the MIT License.
- [MeshCore Project](https://github.com/meshcore-dev/MeshCore) for the mesh networking protocol
- Some commands adapted from MeshingAround bot by K7MHI Kelly Keeton 2024
- Packet capture service based on [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) by agessaman
- [meshcore-decoder](https://github.com/michaelhart/meshcore-decoder) by Michael Hart for client-side packet decoding and decryption in the web viewer
+14 -1
View File
@@ -262,7 +262,7 @@ ping = "Pong!"
pong = "Ping!"
help = "Bot Help: test (or t), ping, help, hello, cmd, advert, @string, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats | More: 'help <command>'"
Override 'cmd' command output
# Override 'cmd' command output
# cmd = "Available commands: test (or t), ping, help, hello, cmd, advert, @string, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats"
[Channels]
@@ -806,6 +806,19 @@ auto_start = false
# Default: bot_data.db
db_path = bot_data.db
# Additional hashtag channels to decode in the packet stream
# The web viewer can decrypt GroupText messages from hashtag channels
# without adding them to the radio. Enter channel names (with or without #)
# as a comma-separated list. The Public channel is always included.
#
# Example: #CQ,#ARES,#EmComm
# This will allow decryption of messages on #CQ, #ARES, and #EmComm channels
# in addition to any channels configured on your radio.
#
# Note: Only hashtag channels work here - custom channels with private keys
# must be added to the radio itself.
decode_hashtag_channels =
####################################################################################################
# #
# Service Plugins Config #
+4
View File
@@ -668,6 +668,10 @@ class MessageHandler:
if (hasattr(self.bot, 'web_viewer_integration') and
self.bot.web_viewer_integration and
self.bot.web_viewer_integration.bot_integration):
# Use extracted_payload which is the full MeshCore packet
# (header + path_len + path + payload, without RF wrapper)
decoded_packet['raw_packet_hex'] = extracted_payload if extracted_payload else raw_hex
decoded_packet['packet_hash'] = packet_hash
self.bot.web_viewer_integration.bot_integration.capture_full_packet_data(decoded_packet)
# Process ADVERT packets for contact tracking (regardless of path length)
+70 -7
View File
@@ -37,7 +37,12 @@ class BotDataViewer:
# This is the directory containing the modules folder
self.bot_root = Path(os.path.join(os.path.dirname(__file__), '..', '..')).resolve()
self.app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), 'templates'))
self.app = Flask(
__name__,
template_folder=os.path.join(os.path.dirname(__file__), 'templates'),
static_folder=os.path.join(os.path.dirname(__file__), 'static'),
static_url_path='/static'
)
self.app.config['SECRET_KEY'] = 'meshcore_bot_viewer_secret'
# Flask-SocketIO configuration following 5.x best practices
@@ -2933,33 +2938,57 @@ class BotDataViewer:
return []
def _get_channels(self):
"""Get all configured channels from database"""
"""Get all configured channels from database plus additional decode-only channels"""
import sqlite3
conn = None
try:
conn = self._get_db_connection()
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT channel_idx, channel_name, channel_type, channel_key_hex, last_updated
FROM channels
ORDER BY channel_idx
''')
rows = cursor.fetchall()
channels = []
existing_names = set()
for row in rows:
name = row['channel_name']
channels.append({
'channel_idx': row['channel_idx'],
'index': row['channel_idx'], # Alias for compatibility
'name': row['channel_name'],
'channel_name': row['channel_name'], # Alias for compatibility
'name': name,
'channel_name': name, # Alias for compatibility
'type': row['channel_type'] or 'hashtag',
'key_hex': row['channel_key_hex'],
'last_updated': row['last_updated']
})
# Track names for deduplication (normalize to lowercase with #)
normalized = name.lower() if name.startswith('#') else f'#{name.lower()}'
existing_names.add(normalized)
# Add additional decode-only hashtag channels from config
additional_channels = self._get_additional_decode_channels()
for channel_name in additional_channels:
# Normalize name
normalized = channel_name.lower() if channel_name.startswith('#') else f'#{channel_name.lower()}'
if normalized not in existing_names:
channels.append({
'channel_idx': None, # Not a real radio channel
'index': None,
'name': normalized,
'channel_name': normalized,
'type': 'hashtag',
'key_hex': None, # Key will be derived client-side
'last_updated': None,
'decode_only': True # Flag to indicate this is decode-only
})
existing_names.add(normalized)
return channels
except Exception as e:
self.logger.error(f"Error getting channels: {e}")
@@ -2968,6 +2997,40 @@ class BotDataViewer:
if conn:
conn.close()
def _get_additional_decode_channels(self):
"""Get additional hashtag channels to decode from config"""
channels = set() # Use set for automatic deduplication
try:
# 1. Get channels from decode_hashtag_channels in [Web_Viewer]
if self.config and self.config.has_option('Web_Viewer', 'decode_hashtag_channels'):
channels_str = self.config.get('Web_Viewer', 'decode_hashtag_channels', fallback='')
if channels_str:
for c in channels_str.split(','):
c = c.strip().lower()
if c:
# Remove # prefix if present for normalization
if c.startswith('#'):
c = c[1:]
channels.add(c)
# 2. Import channels from [Channels_List] section
if self.config and self.config.has_section('Channels_List'):
for key in self.config.options('Channels_List'):
# Handle categorized channels like "sports.sounders" -> "sounders"
if '.' in key:
channel_name = key.split('.')[-1] # Get part after last dot
else:
channel_name = key
channel_name = channel_name.strip().lower()
if channel_name:
channels.add(channel_name)
except Exception as e:
self.logger.error(f"Error reading decode channels config: {e}")
return list(channels)
def _get_channel_number(self, channel_name):
"""Get channel number from channel name"""
# This would use channel_manager
+5
View File
@@ -84,6 +84,7 @@ class BotIntegration:
import sqlite3
import json
import time
from datetime import datetime
# Ensure packet_data is a dict (might be passed as dict already)
if not isinstance(packet_data, dict):
@@ -100,6 +101,10 @@ class BotIntegration:
# If no path_len either, default to 0 hops
packet_data['hops'] = 0
# Add datetime for frontend display
if 'datetime' not in packet_data:
packet_data['datetime'] = datetime.now().isoformat()
# Convert non-serializable objects to strings
serializable_data = self._make_json_serializable(packet_data)
@@ -0,0 +1,31 @@
var Nn=Object.create;var Rt=Object.defineProperty;var Kn=Object.getOwnPropertyDescriptor;var Ln=Object.getOwnPropertyNames;var Wn=Object.getPrototypeOf,Mn=Object.prototype.hasOwnProperty;var ee=(s=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(s,{get:(e,t)=>(typeof require<"u"?require:e)[t]}):s)(function(s){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+s+'" is not supported')});var M=(s,e)=>()=>(e||s((e={exports:{}}).exports,e),e.exports),In=(s,e)=>{for(var t in e)Rt(s,t,{get:e[t],enumerable:!0})},$n=(s,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let a of Ln(e))!Mn.call(s,a)&&a!==t&&Rt(s,a,{get:()=>e[a],enumerable:!(n=Kn(e,a))||n.enumerable});return s};var tr=(s,e,t)=>(t=s!=null?Nn(Wn(s)):{},$n(e||!s||!s.__esModule?Rt(t,"default",{value:s,enumerable:!0}):t,s));var Se=M(()=>{});var $=M((_e,Er)=>{(function(s,e){typeof _e=="object"?Er.exports=_e=e():typeof define=="function"&&define.amd?define([],e):s.CryptoJS=e()})(_e,function(){var s=s||(function(e,t){var n;if(typeof window<"u"&&window.crypto&&(n=window.crypto),typeof self<"u"&&self.crypto&&(n=self.crypto),typeof globalThis<"u"&&globalThis.crypto&&(n=globalThis.crypto),!n&&typeof window<"u"&&window.msCrypto&&(n=window.msCrypto),!n&&typeof global<"u"&&global.crypto&&(n=global.crypto),!n&&typeof ee=="function")try{n=Se()}catch{}var a=function(){if(n){if(typeof n.getRandomValues=="function")try{return n.getRandomValues(new Uint32Array(1))[0]}catch{}if(typeof n.randomBytes=="function")try{return n.randomBytes(4).readInt32LE()}catch{}}throw new Error("Native crypto module could not be used to get secure random number.")},r=Object.create||(function(){function l(){}return function(v){var C;return l.prototype=v,C=new l,l.prototype=null,C}})(),c={},i=c.lib={},f=i.Base=(function(){return{extend:function(l){var v=r(this);return l&&v.mixIn(l),(!v.hasOwnProperty("init")||this.init===v.init)&&(v.init=function(){v.$super.init.apply(this,arguments)}),v.init.prototype=v,v.$super=this,v},create:function(){var l=this.extend();return l.init.apply(l,arguments),l},init:function(){},mixIn:function(l){for(var v in l)l.hasOwnProperty(v)&&(this[v]=l[v]);l.hasOwnProperty("toString")&&(this.toString=l.toString)},clone:function(){return this.init.prototype.extend(this)}}})(),h=i.WordArray=f.extend({init:function(l,v){l=this.words=l||[],v!=t?this.sigBytes=v:this.sigBytes=l.length*4},toString:function(l){return(l||d).stringify(this)},concat:function(l){var v=this.words,C=l.words,B=this.sigBytes,A=l.sigBytes;if(this.clamp(),B%4)for(var y=0;y<A;y++){var w=C[y>>>2]>>>24-y%4*8&255;v[B+y>>>2]|=w<<24-(B+y)%4*8}else for(var S=0;S<A;S+=4)v[B+S>>>2]=C[S>>>2];return this.sigBytes+=A,this},clamp:function(){var l=this.words,v=this.sigBytes;l[v>>>2]&=4294967295<<32-v%4*8,l.length=e.ceil(v/4)},clone:function(){var l=f.clone.call(this);return l.words=this.words.slice(0),l},random:function(l){for(var v=[],C=0;C<l;C+=4)v.push(a());return new h.init(v,l)}}),o=c.enc={},d=o.Hex={stringify:function(l){for(var v=l.words,C=l.sigBytes,B=[],A=0;A<C;A++){var y=v[A>>>2]>>>24-A%4*8&255;B.push((y>>>4).toString(16)),B.push((y&15).toString(16))}return B.join("")},parse:function(l){for(var v=l.length,C=[],B=0;B<v;B+=2)C[B>>>3]|=parseInt(l.substr(B,2),16)<<24-B%8*4;return new h.init(C,v/2)}},x=o.Latin1={stringify:function(l){for(var v=l.words,C=l.sigBytes,B=[],A=0;A<C;A++){var y=v[A>>>2]>>>24-A%4*8&255;B.push(String.fromCharCode(y))}return B.join("")},parse:function(l){for(var v=l.length,C=[],B=0;B<v;B++)C[B>>>2]|=(l.charCodeAt(B)&255)<<24-B%4*8;return new h.init(C,v)}},u=o.Utf8={stringify:function(l){try{return decodeURIComponent(escape(x.stringify(l)))}catch{throw new Error("Malformed UTF-8 data")}},parse:function(l){return x.parse(unescape(encodeURIComponent(l)))}},p=i.BufferedBlockAlgorithm=f.extend({reset:function(){this._data=new h.init,this._nDataBytes=0},_append:function(l){typeof l=="string"&&(l=u.parse(l)),this._data.concat(l),this._nDataBytes+=l.sigBytes},_process:function(l){var v,C=this._data,B=C.words,A=C.sigBytes,y=this.blockSize,w=y*4,S=A/w;l?S=e.ceil(S):S=e.max((S|0)-this._minBufferSize,0);var T=S*y,q=e.min(T*4,A);if(T){for(var E=0;E<T;E+=y)this._doProcessBlock(B,E);v=B.splice(0,T),C.sigBytes-=q}return new h.init(v,q)},clone:function(){var l=f.clone.call(this);return l._data=this._data.clone(),l},_minBufferSize:0}),g=i.Hasher=p.extend({cfg:f.extend(),init:function(l){this.cfg=this.cfg.extend(l),this.reset()},reset:function(){p.reset.call(this),this._doReset()},update:function(l){return this._append(l),this._process(),this},finalize:function(l){l&&this._append(l);var v=this._doFinalize();return v},blockSize:512/32,_createHelper:function(l){return function(v,C){return new l.init(C).finalize(v)}},_createHmacHelper:function(l){return function(v,C){return new m.HMAC.init(l,C).finalize(v)}}}),m=c.algo={};return c})(Math);return s})});var se=M((we,Dr)=>{(function(s,e){typeof we=="object"?Dr.exports=we=e($()):typeof define=="function"&&defiLine truncated
\u2022 Timestamp (4 bytes) - send time as unix timestamp
\u2022 Request Type (1 byte) - type of request (GetStats, GetTelemetryData, etc.)
\u2022 Request Data (remaining bytes) - additional request-specific data`,startByte:a+r,endByte:a+e.length-1,value:h});let o={type:0,version:0,isValid:!0,timestamp:0,requestType:1,requestData:"",destinationHash:c,sourceHash:i,cipherMac:f,ciphertext:h};return t?.includeSegments&&(o.segments=n),o}catch(n){return{type:0,version:0,isValid:!1,errors:[n instanceof Error?n.message:"Failed to decode request payload"],timestamp:0,requestType:1,requestData:"",destinationHash:"",sourceHash:"",cipherMac:"",ciphertext:""}}}};var Ct=class{static decode(e,t){try{if(e.length<4){let d={type:1,version:0,isValid:!1,errors:["Response payload too short (minimum 4 bytes: dest + source + MAC)"],destinationHash:"",sourceHash:"",cipherMac:"",ciphertext:"",ciphertextLength:0};return t?.includeSegments&&(d.segments=[{name:"Invalid Response Data",description:"Response payload too short (minimum 4 bytes required)",startByte:t.segmentOffset||0,endByte:(t.segmentOffset||0)+e.length-1,value:V(e)}]),d}let n=[],a=t?.segmentOffset||0,r=0,c=n0(e[r]);t?.includeSegments&&n.push({name:"Destination Hash",description:"First byte of destination node public key",startByte:a+r,endByte:a+r,value:c}),r+=1;let i=n0(e[r]);t?.includeSegments&&n.push({name:"Source Hash",description:"First byte of source node public key",startByte:a+r,endByte:a+r,value:i}),r+=1;let f=V(e.subarray(r,r+2));t?.includeSegments&&n.push({name:"Cipher MAC",description:"MAC for encrypted data in next field",startByte:a+r,endByte:a+r+1,value:f}),r+=2;let h=V(e.subarray(r));t?.includeSegments&&e.length>r&&n.push({name:"Ciphertext",description:"Encrypted response data (tag + content)",startByte:a+r,endByte:a+e.length-1,value:h});let o={type:1,version:0,isValid:!0,destinationHash:c,sourceHash:i,cipherMac:f,ciphertext:h,ciphertextLength:e.length-4};return t?.includeSegments&&(o.segments=n),o}catch(n){return{type:1,version:0,isValid:!1,errors:[n instanceof Error?n.message:"Failed to decode response payload"],destinationHash:"",sourceHash:"",cipherMac:"",ciphertext:"",ciphertextLength:0}}}};var At=class{static decode(e,t){try{if(e.length<35){let d={type:7,version:0,isValid:!1,errors:["AnonRequest payload too short (minimum 35 bytes: dest + public key + MAC)"],destinationHash:"",senderPublicKey:"",cipherMac:"",ciphertext:"",ciphertextLength:0};return t?.includeSegments&&(d.segments=[{name:"Invalid AnonRequest Data",description:"AnonRequest payload too short (minimum 35 bytes required: 1 for dest hash + 32 for public key + 2 for MAC)",startByte:t.segmentOffset||0,endByte:(t.segmentOffset||0)+e.length-1,value:V(e)}]),d}let n=[],a=t?.segmentOffset||0,r=0,c=n0(e[0]);t?.includeSegments&&n.push({name:"Destination Hash",description:`First byte of destination node public key: 0x${c}`,startByte:a+r,endByte:a+r,value:c}),r+=1;let i=V(e.subarray(1,33));t?.includeSegments&&n.push({name:"Sender Public Key",description:"Ed25519 public key of the sender (32 bytes)",startByte:a+r,endByte:a+r+31,value:i}),r+=32;let f=V(e.subarray(33,35));t?.includeSegments&&n.push({name:"Cipher MAC",description:"MAC for encrypted data verification (2 bytes)",startByte:a+r,endByte:a+r+1,value:f}),r+=2;let h=V(e.subarray(35));t?.includeSegments&&e.length>35&&n.push({name:"Ciphertext",description:`Encrypted message data (${e.length-35} bytes). Contains encrypted plaintext with this structure:
\u2022 Timestamp (4 bytes) - send time as unix timestamp
\u2022 Sync Timestamp (4 bytes) - room server only, sender's "sync messages SINCE x" timestamp
\u2022 Password (remaining bytes) - password for repeater/room`,startByte:a+r,endByte:a+e.length-1,value:h});let o={type:7,version:0,isValid:!0,destinationHash:c,senderPublicKey:i,cipherMac:f,ciphertext:h,ciphertextLength:e.length-35};return t?.includeSegments&&(o.segments=n),o}catch(n){return{type:7,version:0,isValid:!1,errors:[n instanceof Error?n.message:"Failed to decode AnonRequest payload"],destinationHash:"",senderPublicKey:"",cipherMac:"",ciphertext:"",ciphertextLength:0}}}};var Et=class{static decode(e,t){try{if(e.length<4){let i={type:3,version:0,isValid:!1,errors:["Ack payload too short (minimum 4 bytes for checksum)"],checksum:""};return t?.includeSegments&&(i.segments=[{name:"Invalid Ack Data",description:"Ack payload too short (minimum 4 bytes required for checksum)",startByte:t.segmentOffset||0,endByte:(t.segmentOffset||0)+e.length-1,value:V(e)}]),i}let n=[],a=t?.segmentOffset||0,r=V(e.subarray(0,4));t?.includeSegments&&n.push({name:"Checksum",description:`CRC checksum of message timestamp, text, and sender pubkey: 0x${r}`,startByte:a,endByte:a+3,value:r}),t?.includeSegments&&e.length>4&&n.push({name:"Additional Data",description:"Extra data in Ack payload",startByte:a+4,endByte:a+e.length-1,value:V(e.subarray(4))});let c={type:3,version:0,isValid:!0,checksum:r};return t?.includeSegments&&(c.segments=n),c}catch(n){return{type:3,version:0,isValid:!1,errors:[n instanceof Error?n.message:"Failed to decode Ack payload"],checksum:""}}}};var Dt=class{static decode(e){try{if(e.length<2)return{type:8,version:0,isValid:!1,errors:["Path payload too short (minimum 2 bytes: path length + extra type)"],pathLength:0,pathHashes:[],extraType:0,extraData:""};let t=e[0];if(e.length<1+t+1)return{type:8,version:0,isValid:!1,errors:[`Path payload too short (need ${1+t+1} bytes for path length + path + extra type)`],pathLength:t,pathHashes:[],extraType:0,extraData:""};let n=[];for(let c=0;c<t;c++)n.push(n0(e[1+c]));let a=e[1+t],r="";return e.length>1+t+1&&(r=V(e.subarray(1+t+1))),{type:8,version:0,isValid:!0,pathLength:t,pathHashes:n,extraType:a,extraData:r}}catch(t){return{type:8,version:0,isValid:!1,errors:[t instanceof Error?t.message:"Failed to decode Path payload"],pathLength:0,pathHashes:[],extraType:0,extraData:""}}}};var bt=class{static decode(e,t){try{if(e.length<4){let d={type:2,version:0,isValid:!1,errors:["TextMessage payload too short (minimum 4 bytes: dest + source + MAC)"],destinationHash:"",sourceHash:"",cipherMac:"",ciphertext:"",ciphertextLength:0};return t?.includeSegments&&(d.segments=[{name:"Invalid TextMessage Data",description:"TextMessage payload too short (minimum 4 bytes required)",startByte:t.segmentOffset||0,endByte:(t.segmentOffset||0)+e.length-1,value:V(e)}]),d}let n=[],a=t?.segmentOffset||0,r=0,c=n0(e[r]);t?.includeSegments&&n.push({name:"Destination Hash",description:"First byte of destination node public key",startByte:a+r,endByte:a+r,value:c}),r+=1;let i=n0(e[r]);t?.includeSegments&&n.push({name:"Source Hash",description:"First byte of source node public key",startByte:a+r,endByte:a+r,value:i}),r+=1;let f=V(e.subarray(r,r+2));t?.includeSegments&&n.push({name:"Cipher MAC",description:"MAC for encrypted data in next field",startByte:a+r,endByte:a+r+1,value:f}),r+=2;let h=V(e.subarray(r));t?.includeSegments&&e.length>r&&n.push({name:"Ciphertext",description:"Encrypted message data (timestamp + message text)",startByte:a+r,endByte:a+e.length-1,value:h});let o={type:2,version:0,isValid:!0,destinationHash:c,sourceHash:i,cipherMac:f,ciphertext:h,ciphertextLength:e.length-4};return t?.includeSegments&&(o.segments=n),o}catch(n){return{type:2,version:0,isValid:!1,errors:[n instanceof Error?n.message:"Failed to decode TextMessage payload"],destinationHash:"",sourceHash:"",cipherMac:"",ciphertext:"",ciphertextLength:0}}}};var Ft=class{static decode(e,t){try{if(e.length<1)return this.createErrorPayload("Control payload too short (minimum 1 byte required)",e,t);let a=e[0]&240;switch(a){case 128:return this.decodeDiscoverReq(e,t);case 144:return this.decodeDiscoverResp(e,t);default:return this.createErrorPayload(`Unknown control sub-type: 0x${a.toString(16).padStart(2,"0")}`,e,t)}}catch(n){return this.createErrorPayload(n instanceof Error?n.message:"Failed to decode control payload",e,t)}}static decodeDiscoverReq(e,t){let n=[],a=t?.segmentOffset??0;if(e.length<6){let u={type:11,version:0,isValid:!1,errors:["DISCOVER_REQ payload too short (minimum 6 bytes required)"],subType:128,rawFlags:e[0],prefixOnly:!1,typeFilter:0,typeFilterNames:[],tag:0,since:0};return t?.includeSegments&&(u.segments=[{name:"Invalid DISCOVER_REQ Data",description:"DISCOVER_REQ payload too short (minimum 6 bytes required)",startByte:a,endByte:a+e.length-1,value:V(e)}]),u}let r=0,c=e[r],i=(c&1)!==0;t?.includeSegments&&n.push({name:"Flags",description:`Sub-type: DISCOVER_REQ (0x8) | Prefix Only: ${i}`,startByte:a+r,endByte:a+r,value:c.toString(16).padStart(2,"0").toUpperCase()}),r+=1;let f=e[r],h=this.parseTypeFilter(f);t?.i Line truncated
/*! Bundled license information:
crypto-js/ripemd160.js:
(** @preserve
(c) 2012 by Cédric Mesnil. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*)
crypto-js/mode-ctr-gladman.js:
(** @preserve
* Counter block mode compatible with Dr Brian Gladman fileenc.c
* derived from CryptoJS.mode.CTR
* Jan Hruby jhruby.web@gmail.com
*)
@noble/ed25519/index.js:
(*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) *)
*/
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -444,7 +444,10 @@
<!-- Chart.js for graphs -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- SHA-256 for channel key derivation (works in non-HTTPS contexts) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script>
<!-- Base JavaScript -->
<script>
// Global connection management
+2 -1
View File
@@ -164,7 +164,8 @@ class RadioManager {
try {
const response = await fetch('/api/channels');
const data = await response.json();
this.channels = data.channels || [];
// Filter out decode-only channels (they're for packet decoding, not radio management)
this.channels = (data.channels || []).filter(c => !c.decode_only);
this.renderChannels();
this.updateChannelIndexDisplay();
} catch (error) {
File diff suppressed because it is too large. Load diff