mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-03-28 17:43:06 +01:00
Add favicon and index.html for pyMC Repeater Dashboard
- Introduced a favicon.ico file for the application. - Created index.html with basic structure, including meta tags, links to Google Fonts, and references to JavaScript and CSS assets for the dashboard.
This commit is contained in:
@@ -666,40 +666,46 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to get adverts by contact_type '{contact_type}': {e}")
|
||||
return []
|
||||
|
||||
def generate_transport_key(self, key_length_bytes: int = 32) -> str:
|
||||
def generate_transport_key(self, name: str, key_length_bytes: int = 32) -> str:
|
||||
"""
|
||||
Generate a cryptographically secure transport key.
|
||||
Generate a transport key using the proper MeshCore key derivation.
|
||||
|
||||
Args:
|
||||
name: The key name to derive the key from
|
||||
key_length_bytes: Length of the key in bytes (default: 32 bytes = 256 bits)
|
||||
|
||||
Returns:
|
||||
A base64-encoded secure random key
|
||||
A base64-encoded transport key derived from the name
|
||||
"""
|
||||
try:
|
||||
# Generate cryptographically secure random bytes
|
||||
random_bytes = secrets.token_bytes(key_length_bytes)
|
||||
from pymc_core.protocol.transport_keys import get_auto_key_for
|
||||
|
||||
# Use the proper MeshCore key derivation function
|
||||
key_bytes = get_auto_key_for(name)
|
||||
|
||||
# Encode to base64 for safe storage and transmission
|
||||
key = base64.b64encode(random_bytes).decode('utf-8')
|
||||
key = base64.b64encode(key_bytes).decode('utf-8')
|
||||
|
||||
logger.debug(f"Generated transport key with {key_length_bytes} bytes ({len(key)} base64 chars)")
|
||||
logger.debug(f"Generated transport key for '{name}' with {len(key_bytes)} bytes ({len(key)} base64 chars)")
|
||||
return key
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate transport key: {e}")
|
||||
# Fallback to a simpler method if crypto fails
|
||||
import random
|
||||
import string
|
||||
fallback_key = ''.join(random.choices(string.ascii_letters + string.digits, k=key_length_bytes))
|
||||
logger.warning(f"Using fallback key generation method")
|
||||
return base64.b64encode(fallback_key.encode()).decode('utf-8')
|
||||
logger.error(f"Failed to generate transport key using get_auto_key_for: {e}")
|
||||
# Fallback to secure random if MeshCore function fails
|
||||
try:
|
||||
random_bytes = secrets.token_bytes(key_length_bytes)
|
||||
key = base64.b64encode(random_bytes).decode('utf-8')
|
||||
logger.warning(f"Using fallback random key generation for '{name}'")
|
||||
return key
|
||||
except Exception as fallback_e:
|
||||
logger.error(f"Fallback key generation also failed: {fallback_e}")
|
||||
raise
|
||||
|
||||
def create_transport_key(self, name: str, flood_policy: str, transport_key: Optional[str] = None, parent_id: Optional[int] = None, last_used: Optional[float] = None) -> Optional[int]:
|
||||
try:
|
||||
# Generate key if not provided
|
||||
if transport_key is None:
|
||||
transport_key = self.generate_transport_key()
|
||||
transport_key = self.generate_transport_key(name)
|
||||
|
||||
current_time = time.time()
|
||||
with sqlite3.connect(self.sqlite_path) as conn:
|
||||
|
||||
@@ -13,6 +13,9 @@ from pymc_core.protocol.constants import (
|
||||
PH_ROUTE_MASK,
|
||||
ROUTE_TYPE_DIRECT,
|
||||
ROUTE_TYPE_FLOOD,
|
||||
ROUTE_TYPE_TRANSPORT_FLOOD,
|
||||
ROUTE_TYPE_TRANSPORT_DIRECT,
|
||||
|
||||
)
|
||||
from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils
|
||||
|
||||
@@ -95,6 +98,9 @@ class RepeaterHandler(BaseHandler):
|
||||
self._transport_keys_cache_time = 0
|
||||
self._transport_keys_cache_ttl = 60 # Cache for 60 seconds
|
||||
|
||||
# Track last drop reason for better logging
|
||||
self._last_drop_reason = None
|
||||
|
||||
self._start_background_tasks()
|
||||
|
||||
async def __call__(self, packet: Packet, metadata: Optional[dict] = None) -> None:
|
||||
@@ -104,6 +110,9 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
self.rx_count += 1
|
||||
|
||||
# Reset drop reason for this packet processing
|
||||
self._last_drop_reason = None
|
||||
|
||||
# Check if we're in monitor mode (receive only, no forwarding)
|
||||
mode = self.config.get("repeater", {}).get("mode", "forward")
|
||||
monitor_mode = mode == "monitor"
|
||||
@@ -158,7 +167,7 @@ class RepeaterHandler(BaseHandler):
|
||||
if monitor_mode:
|
||||
drop_reason = "Monitor mode"
|
||||
else:
|
||||
drop_reason = self._get_drop_reason(packet)
|
||||
drop_reason = self._last_drop_reason or self._get_drop_reason(packet)
|
||||
logger.debug(f"Packet not forwarded: {drop_reason}")
|
||||
|
||||
# Extract packet type and route from header
|
||||
@@ -321,9 +330,7 @@ class RepeaterHandler(BaseHandler):
|
||||
# Check if global flood policy blocked it
|
||||
global_flood_allow = self.config.get("mesh", {}).get("global_flood_allow", True)
|
||||
if not global_flood_allow:
|
||||
allowed, reason = self._check_transport_codes(packet)
|
||||
if not allowed:
|
||||
return reason or "Not allowed by global flood policy"
|
||||
return "Global flood policy disabled"
|
||||
|
||||
if route_type == ROUTE_TYPE_DIRECT:
|
||||
if not packet.path or len(packet.path) == 0:
|
||||
@@ -413,7 +420,6 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
pkt_hash = packet.calculate_packet_hash().hex()
|
||||
if pkt_hash in self.seen_packets:
|
||||
logger.debug(f"Duplicate suppressed: {pkt_hash[:16]}")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -436,17 +442,7 @@ class RepeaterHandler(BaseHandler):
|
||||
return True, ""
|
||||
|
||||
def _check_transport_codes(self, packet: Packet) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if packet has valid transport codes for forwarding.
|
||||
Validates packet against all transport keys in database.
|
||||
Uses caching to avoid repeated database queries.
|
||||
|
||||
Args:
|
||||
packet: The packet to check
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, reason)
|
||||
"""
|
||||
|
||||
if not self.storage:
|
||||
logger.warning("Transport code check failed: no storage available")
|
||||
return False, "No storage available for transport key validation"
|
||||
@@ -461,51 +457,45 @@ class RepeaterHandler(BaseHandler):
|
||||
# Refresh cache
|
||||
self._transport_keys_cache = self.storage.get_transport_keys()
|
||||
self._transport_keys_cache_time = current_time
|
||||
logger.debug(f"Refreshed transport keys cache: {len(self._transport_keys_cache or [])} keys")
|
||||
|
||||
transport_keys = self._transport_keys_cache
|
||||
|
||||
if not transport_keys:
|
||||
logger.debug("No transport keys configured - denying packet")
|
||||
return False, "No matching transport code"
|
||||
return False, "No transport keys configured"
|
||||
|
||||
# Get payload once for efficiency
|
||||
# Check if packet has transport codes
|
||||
if not packet.has_transport_codes():
|
||||
return False, "No transport codes present"
|
||||
|
||||
|
||||
transport_code_0 = packet.transport_codes[0] # First transport code
|
||||
|
||||
|
||||
payload = packet.get_payload()
|
||||
if len(payload) < 2:
|
||||
logger.debug("Packet payload too short for transport code")
|
||||
return False, "No matching transport code"
|
||||
|
||||
# Extract packet's transport code (first 2 bytes)
|
||||
packet_code = struct.unpack('<H', payload[:2])[0]
|
||||
payload_type = packet.get_payload_type() if hasattr(packet, 'get_payload_type') else ((packet.header & 0x3C) >> 2)
|
||||
|
||||
# Check packet against each transport key
|
||||
for key_record in transport_keys:
|
||||
transport_key_hex = key_record.get("transport_key")
|
||||
transport_key_encoded = key_record.get("transport_key")
|
||||
key_name = key_record.get("name", "unknown")
|
||||
|
||||
if not transport_key_hex:
|
||||
if not transport_key_encoded:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Convert hex string to bytes and calculate expected code
|
||||
transport_key = bytes.fromhex(transport_key_hex)
|
||||
import base64
|
||||
transport_key = base64.b64decode(transport_key_encoded)
|
||||
expected_code = calc_transport_code(transport_key, packet)
|
||||
|
||||
# Check if codes match
|
||||
if packet_code == expected_code:
|
||||
key_name = key_record.get("name", "unknown")
|
||||
logger.debug(
|
||||
f"Transport code validated: key='{key_name}', "
|
||||
f"code=0x{packet_code:04X}"
|
||||
)
|
||||
if transport_code_0 == expected_code:
|
||||
logger.debug(f"Transport code validated for key '{key_name}'")
|
||||
return True, ""
|
||||
|
||||
except Exception as e:
|
||||
key_name = key_record.get("name", "unknown")
|
||||
logger.debug(f"Error checking transport key '{key_name}': {e}")
|
||||
logger.warning(f"Error checking transport key '{key_name}': {e}")
|
||||
continue
|
||||
|
||||
# No matching transport code found
|
||||
logger.debug(f"Packet denied: no matching transport code (checked {len(transport_keys)} keys)")
|
||||
logger.debug(f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)")
|
||||
return False, "No matching transport code"
|
||||
|
||||
except Exception as e:
|
||||
@@ -517,23 +507,26 @@ class RepeaterHandler(BaseHandler):
|
||||
# Validate
|
||||
valid, reason = self.validate_packet(packet)
|
||||
if not valid:
|
||||
logger.debug(f"Flood validation failed: {reason}")
|
||||
self._last_drop_reason = reason
|
||||
return None
|
||||
|
||||
# Check global flood policy
|
||||
global_flood_allow = self.config.get("mesh", {}).get("global_flood_allow", True)
|
||||
if not global_flood_allow:
|
||||
# Global flood disabled - check transport codes
|
||||
allowed, check_reason = self._check_transport_codes(packet)
|
||||
if not allowed:
|
||||
logger.debug(f"Flood denied: {check_reason or 'no matching transport code'}")
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
if route_type == ROUTE_TYPE_FLOOD or route_type == ROUTE_TYPE_TRANSPORT_FLOOD:
|
||||
|
||||
allowed, check_reason = self._check_transport_codes(packet)
|
||||
if not allowed:
|
||||
self._last_drop_reason = check_reason
|
||||
return None
|
||||
else:
|
||||
self._last_drop_reason = "Global flood policy disabled"
|
||||
return None
|
||||
logger.debug("Flood allowed by transport code validation")
|
||||
else:
|
||||
logger.debug("Global flood policy enabled - allowing packet")
|
||||
|
||||
# Suppress duplicates
|
||||
if self.is_duplicate(packet):
|
||||
self._last_drop_reason = "Duplicate"
|
||||
return None
|
||||
|
||||
if packet.path is None:
|
||||
@@ -545,7 +538,6 @@ class RepeaterHandler(BaseHandler):
|
||||
packet.path_len = len(packet.path)
|
||||
|
||||
self.mark_seen(packet)
|
||||
logger.debug(f"Flood: forwarding with path len {packet.path_len}")
|
||||
|
||||
return packet
|
||||
|
||||
@@ -553,22 +545,18 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
# Check if we're the next hop
|
||||
if not packet.path or len(packet.path) == 0:
|
||||
logger.debug("Direct: no path")
|
||||
self._last_drop_reason = "Direct: no path"
|
||||
return None
|
||||
|
||||
next_hop = packet.path[0]
|
||||
if next_hop != self.local_hash:
|
||||
logger.debug(f"Direct: not our hop (next={next_hop:02X}, local={self.local_hash:02X})")
|
||||
self._last_drop_reason = "Direct: not for us"
|
||||
return None
|
||||
|
||||
original_path = list(packet.path)
|
||||
packet.path = bytearray(packet.path[1:])
|
||||
packet.path_len = len(packet.path)
|
||||
|
||||
old_path = [f"{b:02X}" for b in original_path]
|
||||
new_path = [f"{b:02X}" for b in packet.path]
|
||||
logger.debug(f"Direct: forwarding, path {old_path} -> {new_path}")
|
||||
|
||||
return packet
|
||||
|
||||
@staticmethod
|
||||
@@ -649,14 +637,14 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
|
||||
if route_type == ROUTE_TYPE_FLOOD:
|
||||
if route_type == ROUTE_TYPE_FLOOD or route_type == ROUTE_TYPE_TRANSPORT_FLOOD:
|
||||
fwd_pkt = self.flood_forward(packet)
|
||||
if fwd_pkt is None:
|
||||
return None
|
||||
delay = self._calculate_tx_delay(fwd_pkt, snr)
|
||||
return fwd_pkt, delay
|
||||
|
||||
elif route_type == ROUTE_TYPE_DIRECT:
|
||||
elif route_type == ROUTE_TYPE_DIRECT or route_type == ROUTE_TYPE_TRANSPORT_DIRECT:
|
||||
fwd_pkt = self.direct_forward(packet)
|
||||
if fwd_pkt is None:
|
||||
return None
|
||||
@@ -664,7 +652,7 @@ class RepeaterHandler(BaseHandler):
|
||||
return fwd_pkt, delay
|
||||
|
||||
else:
|
||||
logger.debug(f"Unknown route type: {route_type}")
|
||||
self._last_drop_reason = f"Unknown route type: {route_type}"
|
||||
return None
|
||||
|
||||
async def schedule_retransmit(self, fwd_pkt: Packet, delay: float, airtime_ms: float = 0.0):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>pyMC Repeater - Configuration</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<!-- Navigation Component -->
|
||||
<!-- NAVIGATION_PLACEHOLDER -->
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="content">
|
||||
<header>
|
||||
<h1>Configuration</h1>
|
||||
<p>System configuration and settings</p>
|
||||
</header>
|
||||
|
||||
<div class="info-box">
|
||||
Configuration is read-only. To modify settings, edit the config file and restart the daemon.
|
||||
</div>
|
||||
|
||||
<!-- CAD Calibration Tool -->
|
||||
<div class="info-box" style="background: var(--accent-color); color: white; border: none;">
|
||||
<strong>CAD Calibration Tool Available</strong>
|
||||
<p style="margin: 8px 0 0 0;">
|
||||
Optimize your Channel Activity Detection settings.
|
||||
<a href="/cad-calibration" style="color: white; text-decoration: underline;">Launch CAD Calibration Tool →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Radio Configuration -->
|
||||
<h2>Radio Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<div class="config-label">Frequency</div>
|
||||
<div class="config-value" id="radio-freq">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Spreading Factor</div>
|
||||
<div class="config-value" id="radio-sf">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Bandwidth</div>
|
||||
<div class="config-value" id="radio-bw">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">TX Power</div>
|
||||
<div class="config-value" id="radio-tx">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Coding Rate</div>
|
||||
<div class="config-value" id="radio-cr">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Preamble Length</div>
|
||||
<div class="config-value" id="radio-preamble">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repeater Configuration -->
|
||||
<h2>Repeater Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<div class="config-label">Node Name</div>
|
||||
<div class="config-value" id="node-name">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Local Hash</div>
|
||||
<div class="config-value" id="local-hash">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Public Key</div>
|
||||
<div class="config-value" id="public-key" style="word-break: break-all; font-family: monospace; font-size: 0.9em;">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Latitude</div>
|
||||
<div class="config-value" id="latitude">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Longitude</div>
|
||||
<div class="config-value" id="longitude">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Mode</div>
|
||||
<div class="config-value" id="repeater-mode">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Periodic Advertisement Interval</div>
|
||||
<div class="config-value" id="send-advert-interval">Loading...</div>
|
||||
<div class="config-help">How often the repeater sends an advertisement packet (0 = disabled)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duty Cycle -->
|
||||
<h2>Duty Cycle</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<div class="config-label">Max Airtime %</div>
|
||||
<div class="config-value" id="duty-cycle">Loading...</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Enforcement</div>
|
||||
<div class="config-value" id="duty-enforcement">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TX Delays -->
|
||||
<h2>Transmission Delays</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<div class="config-label">Flood TX Delay Factor</div>
|
||||
<div class="config-value" id="tx-delay-factor">Loading...</div>
|
||||
<div class="config-help">Multiplier for flood packet transmission delays (collision avoidance)</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="config-label">Direct TX Delay Factor</div>
|
||||
<div class="config-value" id="direct-tx-delay-factor">Loading...</div>
|
||||
<div class="config-help">Base delay for direct-routed packet transmission (seconds)</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentConfig = {};
|
||||
|
||||
function loadConfiguration() {
|
||||
fetch('/api/stats')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
currentConfig = data;
|
||||
const config = data.config || {};
|
||||
const radio = config.radio || {};
|
||||
const dutyCycle = config.duty_cycle || {};
|
||||
const delays = config.delays || {};
|
||||
|
||||
// Update radio settings
|
||||
if (radio.frequency) {
|
||||
document.getElementById('radio-freq').textContent = (radio.frequency / 1000000).toFixed(3) + ' MHz';
|
||||
}
|
||||
if (radio.spreading_factor) {
|
||||
document.getElementById('radio-sf').textContent = radio.spreading_factor;
|
||||
}
|
||||
if (radio.bandwidth) {
|
||||
document.getElementById('radio-bw').textContent = (radio.bandwidth / 1000).toFixed(1) + ' kHz';
|
||||
}
|
||||
if (radio.tx_power !== undefined) {
|
||||
document.getElementById('radio-tx').textContent = radio.tx_power + ' dBm';
|
||||
}
|
||||
if (radio.coding_rate) {
|
||||
document.getElementById('radio-cr').textContent = '4/' + radio.coding_rate;
|
||||
}
|
||||
if (radio.preamble_length) {
|
||||
document.getElementById('radio-preamble').textContent = radio.preamble_length + ' symbols';
|
||||
}
|
||||
|
||||
// Update repeater settings
|
||||
if (config.node_name) {
|
||||
document.getElementById('node-name').textContent = config.node_name;
|
||||
}
|
||||
if (data.local_hash) {
|
||||
document.getElementById('local-hash').textContent = data.local_hash;
|
||||
}
|
||||
if (data.public_key) {
|
||||
document.getElementById('public-key').textContent = data.public_key;
|
||||
} else {
|
||||
document.getElementById('public-key').textContent = 'Not set';
|
||||
}
|
||||
if (config.repeater && config.repeater.latitude !== undefined) {
|
||||
const lat = config.repeater.latitude;
|
||||
document.getElementById('latitude').textContent = lat && lat !== 0 ? lat.toFixed(6) : 'Not set';
|
||||
}
|
||||
if (config.repeater && config.repeater.longitude !== undefined) {
|
||||
const lng = config.repeater.longitude;
|
||||
document.getElementById('longitude').textContent = lng && lng !== 0 ? lng.toFixed(6) : 'Not set';
|
||||
}
|
||||
if (config.repeater && config.repeater.mode) {
|
||||
const mode = config.repeater.mode;
|
||||
document.getElementById('repeater-mode').textContent =
|
||||
mode.charAt(0).toUpperCase() + mode.slice(1);
|
||||
}
|
||||
if (config.repeater && config.repeater.send_advert_interval_hours !== undefined) {
|
||||
const interval = config.repeater.send_advert_interval_hours;
|
||||
if (interval === 0) {
|
||||
document.getElementById('send-advert-interval').textContent = 'Disabled';
|
||||
} else {
|
||||
document.getElementById('send-advert-interval').textContent = interval + ' hour' + (interval !== 1 ? 's' : '');
|
||||
}
|
||||
}
|
||||
|
||||
// Update duty cycle
|
||||
if (dutyCycle.max_airtime_percent !== undefined) {
|
||||
document.getElementById('duty-cycle').textContent = dutyCycle.max_airtime_percent.toFixed(1) + '%';
|
||||
}
|
||||
document.getElementById('duty-enforcement').textContent =
|
||||
dutyCycle.enforcement_enabled ? 'Enabled' : 'Disabled';
|
||||
|
||||
// Update delays
|
||||
if (delays.tx_delay_factor !== undefined) {
|
||||
document.getElementById('tx-delay-factor').textContent = delays.tx_delay_factor.toFixed(2) + 'x';
|
||||
}
|
||||
if (delays.direct_tx_delay_factor !== undefined) {
|
||||
document.getElementById('direct-tx-delay-factor').textContent = delays.direct_tx_delay_factor.toFixed(2) + 's';
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('Error loading configuration:', e);
|
||||
// Show error in UI
|
||||
document.querySelectorAll('.config-value').forEach(el => {
|
||||
if (el.textContent === 'Loading...') {
|
||||
el.textContent = 'Error';
|
||||
el.style.color = '#f48771';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Load configuration on page load
|
||||
document.addEventListener('DOMContentLoaded', loadConfiguration);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,934 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>pyMC Repeater - Help</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<style>
|
||||
.help-container {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
max-width: 1400px;
|
||||
<div class="table-column">
|
||||
<h4>RSSI</h4>
|
||||
<div class="column-desc">Received Signal Strength Indicator (dBm)</div>
|
||||
<div class="column-detail">
|
||||
Measures signal power. <strong>More negative = weaker signal</strong><br>
|
||||
<strong>Excellent:</strong> -80 to -100 dBm (strong)<br>
|
||||
<strong>Good:</strong> -100 to -120 dBm (acceptable)<br>
|
||||
<strong>Poor:</strong> -120 to -140 dBm (weak, may be unreliable)<br>
|
||||
<strong>Note:</strong> RSSI is displayed for monitoring but does NOT directly affect packet score calculation. Score is based purely on SNR and packet length, matching the C++ MeshCore algorithm. However, RSSI typically correlates with SNR - better RSSI usually means better SNR.
|
||||
</div>
|
||||
</div>: 0 auto;
|
||||
}
|
||||
|
||||
.help-sidebar {
|
||||
width: 280px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.help-toc {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-toc h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.help-toc ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-toc li {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.help-toc a {
|
||||
color: #a8b1c3;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
|
||||
.help-toc a:hover {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.help-toc a.active {
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-bottom: 4rem;
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
.help-section h2 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #f1f3f5;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.help-section h3 {
|
||||
font-size: 1.2rem;
|
||||
margin: 2rem 0 1rem 0;
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-section p {
|
||||
color: #a8b1c3;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.help-section ul {
|
||||
color: #a8b1c3;
|
||||
margin: 1rem 0 1rem 2rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.help-section li {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.table-explanation {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-left: 3px solid #3b82f6;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.table-column {
|
||||
margin: 1.5rem 0;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.table-column h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #60a5fa;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.table-column .column-desc {
|
||||
color: #7d8599;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.table-column .column-detail {
|
||||
color: #a8b1c3;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.score-formula {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-left: 3px solid #10b981;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.95rem;
|
||||
color: #10b981;
|
||||
line-height: 1.8;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border-left: 3px solid #f59e0b;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
color: #a8b1c3;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-left: 3px solid #3b82f6;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
color: #a8b1c3;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.config-impact {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.config-item h5 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #60a5fa;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-item p {
|
||||
color: #a8b1c3;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: #60a5fa;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.back-to-top {
|
||||
display: inline-block;
|
||||
margin-top: 2rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: all 250ms ease;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.help-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.help-sidebar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
top: auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.config-impact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<!-- Navigation Component -->
|
||||
<!-- NAVIGATION_PLACEHOLDER -->
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="content">
|
||||
<header>
|
||||
<h1>Help & Documentation</h1>
|
||||
<p>Learn how to interpret packet data, understand scoring, and optimize your configuration</p>
|
||||
</header>
|
||||
|
||||
<div class="help-container">
|
||||
<!-- Table of Contents Sidebar -->
|
||||
<aside class="help-sidebar">
|
||||
<div class="help-toc">
|
||||
<h3>Contents</h3>
|
||||
<ul>
|
||||
<li><a href="#packet-table" class="toc-link">Packet Table</a></li>
|
||||
<li><a href="#column-details" class="toc-link">Column Details</a></li>
|
||||
<li><a href="#scoring-system" class="toc-link">Scoring System</a></li>
|
||||
<li><a href="#score-factors" class="toc-link">Score Factors</a></li>
|
||||
<li><a href="#reactive-scoring" class="toc-link">Reactive Scoring</a></li>
|
||||
<li><a href="#configuration" class="toc-link">Configuration Effects</a></li>
|
||||
<li><a href="#config-settings" class="toc-link">Config Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="help-content">
|
||||
<!-- Packet Table Section -->
|
||||
<section id="packet-table" class="help-section">
|
||||
<h2>Packet Table Overview</h2>
|
||||
|
||||
<p>The packet table displays real-time information about every packet your repeater receives and processes. Each row represents a single packet event, showing transmission details, signal quality metrics, and repeater processing information.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Purpose:</strong> The packet table helps you monitor network traffic, diagnose signal issues, and understand how your repeater is handling different types of packets.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Column Details Section -->
|
||||
<section id="column-details" class="help-section">
|
||||
<h2>Column Details</h2>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>Time</h4>
|
||||
<div class="column-desc">Format: HH:MM:SS</div>
|
||||
<div class="column-detail">
|
||||
The exact time the packet was received by the radio module. Displayed in 24-hour format. Useful for correlating events with logs and identifying traffic patterns throughout the day.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>Type</h4>
|
||||
<div class="column-desc">Packet payload type identifier</div>
|
||||
<div class="column-detail">
|
||||
<strong>ADVERT:</strong> Node advertisement/discovery packets (usually broadcasts)<br>
|
||||
<strong>ACK:</strong> Acknowledgment responses<br>
|
||||
<strong>TXT:</strong> Text messages<br>
|
||||
<strong>GRP:</strong> Group messages<br>
|
||||
<strong>PATH:</strong> Path information packets<br>
|
||||
<strong>RESP:</strong> Response packets<br>
|
||||
<strong>TRACE:</strong> Trace/debug packets<br>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>Route</h4>
|
||||
<div class="column-desc">Routing mode indicator</div>
|
||||
<div class="column-detail">
|
||||
<strong>DIRECT:</strong> Packet explicitly routed to this repeater (contains its address in the path)<br>
|
||||
<strong>FLOOD:</strong> Broadcast packet intended for all nodes in range<br>
|
||||
DIRECT packets have higher priority since they're specifically addressed to your repeater. FLOOD packets are retransmitted if bandwidth allows.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>Length</h4>
|
||||
<div class="column-desc">Payload size in bytes</div>
|
||||
<div class="column-detail">
|
||||
The actual payload data size (not including LoRa overhead). Affects airtime consumption and score calculation. Larger packets take longer to transmit, consuming more airtime budget. Typical range: 20-250 bytes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>RSSI</h4>
|
||||
<div class="column-desc">Received Signal Strength Indicator (dBm)</div>
|
||||
<div class="column-detail">
|
||||
Measures signal power. <strong>More negative = weaker signal</strong><br>
|
||||
<strong>Excellent:</strong> -80 to -100 dBm (strong)<br>
|
||||
<strong>Good:</strong> -100 to -120 dBm (acceptable)<br>
|
||||
<strong>Poor:</strong> -120 to -140 dBm (weak, may be unreliable)<br>
|
||||
Affects score calculation - better RSSI yields higher scores. Distance and obstacles reduce RSSI.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>SNR</h4>
|
||||
<div class="column-desc">Signal-to-Noise Ratio (dB)</div>
|
||||
<div class="column-detail">
|
||||
Measures signal clarity vs. background noise. <strong>Higher = cleaner signal</strong><br>
|
||||
<strong>Excellent:</strong> SNR > 10 dB (very clean)<br>
|
||||
<strong>Good:</strong> SNR 5-10 dB (normal operation)<br>
|
||||
<strong>Poor:</strong> SNR < 5 dB (noisy environment)<br>
|
||||
Even with weak RSSI, high SNR indicates reliable reception. Critical for score calculation.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>Score</h4>
|
||||
<div class="column-desc">Composite quality metric (0.0 - 1.0)</div>
|
||||
<div class="column-detail">
|
||||
A single number representing overall packet quality based on SNR and packet length. This matches the C++ MeshCore algorithm exactly. Higher scores (closer to 1.0) indicate better quality packets with good SNR relative to the spreading factor threshold. Used internally for optional reactive delay optimization (when use_score_for_tx is enabled). See Scoring System section for detailed calculation method.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>TX Delay</h4>
|
||||
<div class="column-desc">Time in milliseconds</div>
|
||||
<div class="column-detail">
|
||||
How long the repeater waited before retransmitting. Delay factors include:<br>
|
||||
• Airtime budget checking<br>
|
||||
• Random collision avoidance (0-5ms factor)<br>
|
||||
• Current channel utilization<br>
|
||||
• Optional quality-based prioritization (when enabled)<br>
|
||||
Longer delays may indicate congestion or airtime throttling to comply with duty cycle limits.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>Status</h4>
|
||||
<div class="column-desc">Packet processing outcome</div>
|
||||
<div class="column-detail">
|
||||
<strong>FORWARDED:</strong> Packet has been successfully retransmitted to other nodes. The repeater forwarded this packet over the air.<br>
|
||||
<strong>DROPPED:</strong> Packet was rejected and not forwarded.<br>
|
||||
<br>
|
||||
<strong>Drop Reasons:</strong>
|
||||
<ul style="margin: 8px 0; padding-left: 20px; font-size: 0.9em; line-height: 1.6;">
|
||||
<li><strong>Duplicate:</strong> Packet hash already in cache. Prevents redundant retransmission.</li>
|
||||
<li><strong>Empty payload:</strong> Packet has no payload data. Cannot be processed.</li>
|
||||
<li><strong>Path at max size:</strong> Path field has reached maximum length. Cannot add repeater identifier.</li>
|
||||
<li><strong>Duty cycle limit:</strong> Airtime budget exhausted. Cannot transmit (EU 1% duty cycle or configured limit).</li>
|
||||
<li><strong>Direct: no path:</strong> Direct-mode packet lacks routing path.</li>
|
||||
<li><strong>Direct: not our hop:</strong> Direct-mode packet is not addressed to this repeater node.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scoring System Section -->
|
||||
<section id="scoring-system" class="help-section">
|
||||
<h2>Scoring System</h2>
|
||||
|
||||
<p>The packet score is calculated using the exact same algorithm as the C++ MeshCore implementation. It combines SNR (relative to spreading factor threshold) and packet length to produce a single quality indicator (0.0 to 1.0). This score can optionally be used for reactive delay optimization when use_score_for_tx is enabled.</p>
|
||||
|
||||
<h3>The Scoring Formula</h3>
|
||||
|
||||
<div class="score-formula">
|
||||
<div style="background: var(--color-bg-secondary); border: 1px solid var(--color-border); padding: 20px; border-radius: 8px; margin-bottom: 20px;">
|
||||
<div style="font-size: 1.2em; font-weight: bold; margin-bottom: 15px; text-align: center; color: var(--color-text-primary);">
|
||||
Score = SNR Factor × Length Factor
|
||||
</div>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; background: var(--color-bg-tertiary); border-radius: 6px; overflow: hidden;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border-right: 1px solid var(--color-border); width: 50%;">
|
||||
<div style="font-size: 0.9em; color: var(--color-text-secondary); margin-bottom: 6px;">SNR Factor</div>
|
||||
<div style="font-size: 1.1em; font-family: monospace; color: var(--color-accent-primary);">
|
||||
(SNR - SF<sub>threshold</sub>) / 10
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 12px; width: 50%;">
|
||||
<div style="font-size: 0.9em; color: var(--color-text-secondary); margin-bottom: 6px;">Length Factor</div>
|
||||
<div style="font-size: 1.1em; font-family: monospace; color: var(--color-accent-primary);">
|
||||
(1 - length / 256)
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 style="margin-top: 20px; margin-bottom: 15px; color: var(--color-text-primary);">Spreading Factor Thresholds</h4>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 20px;">
|
||||
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
|
||||
<strong style="color: var(--color-text-primary);">SF7</strong> → -7.5 dB
|
||||
</div>
|
||||
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
|
||||
<strong style="color: var(--color-text-primary);">SF8</strong> → -10.0 dB
|
||||
</div>
|
||||
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
|
||||
<strong style="color: var(--color-text-primary);">SF9</strong> → -12.5 dB
|
||||
</div>
|
||||
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
|
||||
<strong style="color: var(--color-text-primary);">SF10</strong> → -15.0 dB
|
||||
</div>
|
||||
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
|
||||
<strong style="color: var(--color-text-primary);">SF11</strong> → -17.5 dB
|
||||
</div>
|
||||
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
|
||||
<strong style="color: var(--color-text-primary);">SF12</strong> → -20.0 dB
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin-bottom: 15px; color: var(--color-text-primary);">Real-World Example</h4>
|
||||
<div style="background: var(--color-bg-tertiary); border-left: 4px solid var(--color-info); padding: 15px; border-radius: 6px;">
|
||||
<p style="color: var(--color-text-primary);"><strong>Packet Details:</strong></p>
|
||||
<ul style="margin: 8px 0; padding-left: 20px; color: var(--color-text-secondary);">
|
||||
<li>SNR: <code style="background: var(--color-bg-secondary); padding: 2px 6px; border-radius: 3px; color: var(--color-accent-primary);">12 dB</code></li>
|
||||
<li>Spreading Factor: <code style="background: var(--color-bg-secondary); padding: 2px 6px; border-radius: 3px; color: var(--color-accent-primary);">SF8</code></li>
|
||||
<li>Payload Length: <code style="background: var(--color-bg-secondary); padding: 2px 6px; border-radius: 3px; color: var(--color-accent-primary);">100 bytes</code></li>
|
||||
</ul>
|
||||
<hr style="border: none; border-top: 1px solid var(--color-border); margin: 12px 0;">
|
||||
<p style="margin: 8px 0; color: var(--color-text-primary);"><strong>Calculation:</strong></p>
|
||||
<div style="background: var(--color-bg-secondary); padding: 12px; border-radius: 4px; font-family: monospace; font-size: 0.95em; color: var(--color-text-secondary);">
|
||||
SNR Factor = (12 - (-10)) / 10 = 22 / 10 = <strong style="color: var(--color-accent-primary);">2.2</strong> (clamped to 1.0)<br>
|
||||
Length Factor = (1 - 100/256) = 0.609<br>
|
||||
<strong style="color: var(--color-accent-primary);">Score = 1.0 × 0.609 = 0.61</strong> (FAIR quality)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>This formula ensures that:</p>
|
||||
<ul>
|
||||
<li><strong>Signal quality matters:</strong> Higher SNR produces higher scores, with SF-specific thresholds</li>
|
||||
<li><strong>Smaller packets score higher:</strong> They consume less airtime due to shorter transmission time</li>
|
||||
<li><strong>Poor SNR packets may score zero:</strong> If SNR falls below SF threshold, score = 0.0</li>
|
||||
</ul>
|
||||
|
||||
<h3>Score Interpretation</h3>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<!-- Visual score scale -->
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="font-weight: bold; margin-bottom: 8px; color: var(--color-text-primary);">Quality Scale</div>
|
||||
<div style="background: linear-gradient(90deg, #ef4444 0%, #f97316 25%, #eab308 50%, #84cc16 75%, #22c55e 100%); height: 30px; border-radius: 6px; margin-bottom: 8px; border: 2px solid var(--color-border);"></div>
|
||||
<div style="display: flex; justify-content: space-between; font-size: 0.85em; color: var(--color-text-secondary);">
|
||||
<span>0.0</span>
|
||||
<span>0.25</span>
|
||||
<span>0.5</span>
|
||||
<span>0.75</span>
|
||||
<span>1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score ratings -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
|
||||
<div style="background: linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05)); border-left: 4px solid #22c55e; padding: 15px; border-radius: 6px;">
|
||||
<div style="font-weight: bold; color: #22c55e; font-size: 1.1em; margin-bottom: 6px;">0.9 - 1.0 Excellent</div>
|
||||
<div style="color: #555; font-size: 0.9em;">Perfect conditions, high SNR, small payload</div>
|
||||
</div>
|
||||
|
||||
<div style="background: linear-gradient(135deg, rgba(132, 204, 22, 0.1), rgba(132, 204, 22, 0.05)); border-left: 4px solid #84cc16; padding: 15px; border-radius: 6px;">
|
||||
<div style="font-weight: bold; color: #84cc16; font-size: 1.1em; margin-bottom: 6px;">0.7 - 0.9 Good</div>
|
||||
<div style="color: #555; font-size: 0.9em;">Normal operation, acceptable signal</div>
|
||||
</div>
|
||||
|
||||
<div style="background: linear-gradient(135deg, rgba(234, 179, 8, 0.1), rgba(234, 179, 8, 0.05)); border-left: 4px solid #eab308; padding: 15px; border-radius: 6px;">
|
||||
<div style="font-weight: bold; color: #ca8a04; font-size: 1.1em; margin-bottom: 6px;">0.5 - 0.7 Fair</div>
|
||||
<div style="color: #555; font-size: 0.9em;">Degraded conditions, lower SNR</div>
|
||||
</div>
|
||||
|
||||
<div style="background: linear-gradient(135deg, rgba(249, 115, 22, 0.1), rgba(249, 115, 22, 0.05)); border-left: 4px solid #f97316; padding: 15px; border-radius: 6px;">
|
||||
<div style="font-weight: bold; color: #ea580c; font-size: 1.1em; margin-bottom: 6px;">0.3 - 0.5 Poor</div>
|
||||
<div style="color: #555; font-size: 0.9em;">Marginal conditions, weak signal</div>
|
||||
</div>
|
||||
|
||||
<div style="background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(239, 68, 68, 0.05)); border-left: 4px solid #ef4444; padding: 15px; border-radius: 6px;">
|
||||
<div style="font-weight: bold; color: #dc2626; font-size: 1.1em; margin-bottom: 6px;">< 0.3 Very Poor</div>
|
||||
<div style="color: #555; font-size: 0.9em;">Barely usable, may be dropped</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Score Factors Section -->
|
||||
<section id="score-factors" class="help-section">
|
||||
<h2>What Affects Your Score?</h2>
|
||||
|
||||
<h3>Primary Factors</h3>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>Signal-to-Noise Ratio (SNR)</h4>
|
||||
<div class="column-detail">
|
||||
<strong>Impact: HIGHEST</strong><br>
|
||||
Each 1 dB improvement in SNR can increase score by ~0.05. High interference environments significantly reduce scores. The repeater benefits from placement with clear LoS (line of sight) to minimize multipath and fading.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>Packet Payload Length</h4>
|
||||
<div class="column-detail">
|
||||
<strong>Impact: HIGH</strong><br>
|
||||
Larger packets consume more airtime due to longer transmission times. A 100-byte packet scores lower than a 50-byte packet with identical SNR.
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>RSSI (Signal Strength)</h4>
|
||||
<div class="column-detail">
|
||||
<strong>Impact: NOT USED IN SCORING</strong><br>
|
||||
RSSI is displayed for monitoring purposes but does NOT affect the score calculation. The C++ MeshCore algorithm uses only SNR and packet length. However, RSSI correlates with SNR - better RSSI typically means better SNR, which indirectly results in higher scores.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Environmental Factors</h3>
|
||||
|
||||
<ul>
|
||||
<li><strong>Weather:</strong> Rain and fog reduce signal strength and increase noise</li>
|
||||
<li><strong>Time of Day:</strong> Atmospheric conditions change, especially during dawn/dusk</li>
|
||||
<li><strong>Frequency Congestion:</strong> More devices on 869 MHz = higher noise floor</li>
|
||||
<li><strong>Physical Obstructions:</strong> Buildings and trees block signals, increase fading</li>
|
||||
<li><strong>Antenna Orientation:</strong> Poor antenna alignment reduces SNR significantly</li>
|
||||
</ul>
|
||||
|
||||
<div class="warning-box">
|
||||
<p><strong>Environmental Issues:</strong> If you see consistently low scores across many packets, check your antenna placement, orientation, and surroundings. Poor environmental conditions are often the limiting factor, not the repeater itself.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reactive Scoring Section -->
|
||||
<section id="reactive-scoring" class="help-section">
|
||||
<h2>Reactive Score-Based Delay Optimization</h2>
|
||||
|
||||
<p>The repeater includes an optional reactive scoring system that dynamically prioritizes packets based on signal quality during network congestion. This feature matches the C++ MeshCore behavior for intelligent packet prioritization.</p>
|
||||
|
||||
<h3>How It Works</h3>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Key Principle:</strong> When the repeater detects congestion (calculated TX delay ≥ 50ms), it automatically applies a quality-based delay multiplier to high-quality packets, giving them priority while gracefully backing off low-quality packets.</p>
|
||||
<p><strong>Default Behavior:</strong> This feature is <strong>disabled by default</strong> (use_score_for_tx: false). When disabled, all packets follow standard C++ MeshCore delay calculation with pure randomization.</p>
|
||||
</div>
|
||||
|
||||
<h3>Delay Multiplier Formula</h3>
|
||||
|
||||
<div class="score-formula">
|
||||
<div style="background: var(--color-bg-secondary); border: 1px solid var(--color-border); padding: 20px; border-radius: 8px;">
|
||||
<div style="font-size: 1.1em; font-weight: bold; margin-bottom: 15px; text-align: center; color: var(--color-text-primary);">
|
||||
Applied Only When: delay ≥ 50ms AND use_score_for_tx = true
|
||||
</div>
|
||||
|
||||
<div style="background: var(--color-bg-tertiary); padding: 15px; border-radius: 6px; font-family: monospace; color: var(--color-accent-primary); line-height: 1.8;">
|
||||
<strong style="color: var(--color-text-primary);">Delay Multiplier = max(0.2, 1.0 - score)</strong>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px; color: var(--color-text-secondary); font-size: 0.9em;">
|
||||
<p><strong>What this means:</strong></p>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li><strong>Perfect packet (score 1.0):</strong> Multiplier = max(0.2, 0.0) = 0.2 → Gets 20% of base delay (fast priority)</li>
|
||||
<li><strong>Good packet (score 0.7):</strong> Multiplier = max(0.2, 0.3) = 0.3 → Gets 30% of base delay</li>
|
||||
<li><strong>Fair packet (score 0.5):</strong> Multiplier = max(0.2, 0.5) = 0.5 → Gets 50% of base delay</li>
|
||||
<li><strong>Poor packet (score 0.2):</strong> Multiplier = max(0.2, 0.8) = 0.8 → Gets 80% of base delay (slower, backoff)</li>
|
||||
<li><strong>Minimum floor:</strong> No packet gets less than 20% multiplier (prevents starvation)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Example: Reactive Scoring in Action</h3>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Scenario:</strong> Two packets arrive during congestion (base delay 100ms), tx_delay_factor=1.0</p>
|
||||
<ul>
|
||||
<li><strong>Packet X:</strong> Excellent signal, score = 0.9</li>
|
||||
<li><strong>Packet Y:</strong> Weak signal, score = 0.4</li>
|
||||
</ul>
|
||||
<p><strong>Without Reactive Scoring (disabled):</strong></p>
|
||||
<ul>
|
||||
<li>Packet X: TX Delay = 0-500ms (pure random collision avoidance)</li>
|
||||
<li>Packet Y: TX Delay = 0-500ms (pure random collision avoidance)</li>
|
||||
<li>Result: Both may transmit at same time, causing collision</li>
|
||||
</ul>
|
||||
<p><strong>With Reactive Scoring (enabled, congestion detected):</strong></p>
|
||||
<ul>
|
||||
<li>Packet X: Multiplier = 0.1 → TX Delay = 0-50ms (high priority, transmits first)</li>
|
||||
<li>Packet Y: Multiplier = 0.6 → TX Delay = 0-300ms (lower priority, waits longer)</li>
|
||||
<li>Result: High-quality packets forward with minimal delay; marginal packets gracefully back off</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3>Configuration</h3>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>use_score_for_tx</h4>
|
||||
<div class="column-desc">Enable/disable reactive score-based delay optimization</div>
|
||||
<div class="column-detail">
|
||||
<strong>Default:</strong> false (disabled)<br>
|
||||
<strong>Options:</strong> true or false<br>
|
||||
<strong>When true:</strong> Activates quality-based delay multiplier when congestion detected (delay ≥ 50ms)<br>
|
||||
<strong>When false:</strong> Standard C++ MeshCore behavior, pure random delays, no score influence on timing<br>
|
||||
<strong>Location in config.yaml:</strong>
|
||||
<div style="background: var(--color-bg-secondary); padding: 12px; border-radius: 4px; margin-top: 8px; font-family: monospace; font-size: 0.9em;">
|
||||
repeater:<br>
|
||||
use_score_for_tx: false
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>score_threshold</h4>
|
||||
<div class="column-desc">Reserved for future enhancement / statistics monitoring</div>
|
||||
<div class="column-detail">
|
||||
<strong>Default:</strong> 0.3<br>
|
||||
<strong>Range:</strong> 0.0 - 1.0<br>
|
||||
<strong>Current Status:</strong> This value is read from config but <strong>not currently used</strong> in packet processing. It is reserved for future features.<br>
|
||||
<strong>Future Potential Uses:</strong>
|
||||
<ul style="margin: 8px 0; padding-left: 20px;">
|
||||
<li>Dashboard quality alerts when average packet score drops below threshold</li>
|
||||
<li>Proactive packet filtering - dropping very poor quality packets upfront (below threshold)</li>
|
||||
<li>Quality monitoring and trend statistics in web UI</li>
|
||||
<li>Logging alerts for poor signal conditions</li>
|
||||
</ul>
|
||||
<strong>Recommendation:</strong> Leave at default (0.3). Changing it currently has <strong>no effect on packet processing</strong>. This setting will become active once future quality monitoring features are implemented.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>When to Enable Reactive Scoring</h3>
|
||||
|
||||
<div class="config-impact">
|
||||
<div class="config-item">
|
||||
<h5>Enable (use_score_for_tx: true)</h5>
|
||||
<p>
|
||||
• High-traffic networks where collisions are frequent<br>
|
||||
• Noisy environments with poor average signal quality<br>
|
||||
• You want to prioritize high-quality packets during congestion<br>
|
||||
• Testing adaptive network behavior<br>
|
||||
• Duty-cycle constrained regions (EU) with limited bandwidth
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<h5>Disable (use_score_for_tx: false)</h5>
|
||||
<p>
|
||||
• Low-traffic networks where congestion is rare<br>
|
||||
• You want pure C++ MeshCore compatibility<br>
|
||||
• Consistent delay behavior is more important than efficiency<br>
|
||||
• New deployments - start simple and tune later<br>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<p><strong>Important:</strong> Reactive scoring only affects TX delay timing, not packet forwarding decisions. All packets still get forwarded (unless dropped for other reasons like duplicates or duty cycle). The system gracefully prioritizes quality during congestion without dropping packets, matching MeshCore's intelligent backpressure strategy.</p>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Configuration Impact on Scoring</h2>
|
||||
|
||||
<p>Your repeater's configuration settings directly affect packet scoring and processing behavior.</p>
|
||||
|
||||
<h3>Radio Configuration Parameters</h3>
|
||||
|
||||
<div class="config-impact">
|
||||
<div class="config-item">
|
||||
<h5>Spreading Factor (SF)</h5>
|
||||
<p><strong>Current setting:</strong> SF 8<br>
|
||||
<strong>Higher SF (9-12):</strong> Better range and SNR, but slower transmission, more airtime consumed<br>
|
||||
<strong>Lower SF (7):</strong> Faster transmission, less airtime, but worse sensitivity and range<br>
|
||||
<strong>Score impact:</strong> Higher SF generally improves SNR = higher scores, but increases payload duration penalty</p>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<h5>Bandwidth (BW)</h5>
|
||||
<p><strong>Current setting:</strong> 62.5 kHz<br>
|
||||
<strong>Wider BW (125 kHz):</strong> Faster data rate, less airtime per byte, but worse sensitivity<br>
|
||||
<strong>Narrower BW (31.25 kHz):</strong> Better sensitivity, but slower transmission<br>
|
||||
<strong>Score impact:</strong> BW affects SNR - narrower = potentially better SNR but longer TX times</p>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<h5>TX Power</h5>
|
||||
<p><strong>Current setting:</strong> 14 dBm<br>
|
||||
<strong>Higher power:</strong> Better outbound range, but may increase noise at nearby receivers<br>
|
||||
<strong>Lower power:</strong> Reduces interference, saves energy, but limits outbound range<br>
|
||||
<strong>Score impact:</strong> TX power only affects outgoing transmissions, not received score</p>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<h5>Coding Rate (CR)</h5>
|
||||
<p><strong>Current setting:</strong> 4/8<br>
|
||||
<strong>Higher CR (4/7):</strong> Less error correction, faster transmission, more airtime efficient<br>
|
||||
<strong>Lower CR (4/8):</strong> More error correction, better resilience to interference<br>
|
||||
<strong>Score impact:</strong> Higher CR can improve SNR in clean environments, reduce it in noisy ones</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Duty Cycle Configuration</h3>
|
||||
|
||||
<div class="table-explanation">
|
||||
<p><strong>Current Duty Cycle Limit:</strong> 6% max airtime per hour</p>
|
||||
<p>This means your repeater can spend at most 3.6 minutes (21.6 seconds per minute) transmitting per hour. How this affects packet handling:</p>
|
||||
<ul>
|
||||
<li><strong>When below limit:</strong> All packets retransmitted if they pass validation</li>
|
||||
<li><strong>When approaching limit:</strong> Incoming packets may be dropped if airtime budget is exhausted</li>
|
||||
<li><strong>When limit reached:</strong> All new transmissions are dropped until the duty cycle budget resets (each minute)</li>
|
||||
</ul>
|
||||
<p><strong>Important:</strong> The repeater does NOT queue packets for later transmission. When duty cycle limit is reached, packets are immediately dropped. This is by design - a repeater must forward immediately or drop the packet. Note: Packet score does not affect duty cycle enforcement - all packets are treated equally when duty cycle limit is reached.</p>
|
||||
</div>
|
||||
|
||||
<h3>Airtime Consumption Example</h3>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Scenario:</strong> 100-byte packet at SF8, BW 62.5 kHz, CR 4/8<br>
|
||||
<strong>Airtime:</strong> ~512 ms<br>
|
||||
<strong>At 6% duty cycle:</strong> Can transmit ~420 packets/hour maximum<br>
|
||||
<strong>Effect on score:</strong> High volume of large packets will consume budget quickly, causing lower-scored packets to be dropped
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Configuration Settings Section -->
|
||||
<section id="config-settings" class="help-section">
|
||||
<h2>Configuration Settings Reference</h2>
|
||||
|
||||
<p>The repeater is configured via <code>config.yaml</code>. This section explains key settings and how they affect packet performance.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Important:</strong> Packet <strong>Score</strong> (signal quality) and <strong>TX Delay</strong> (collision avoidance timing) are independent systems. Score is calculated from SNR and packet length. Delays are configured via tx_delay_factor and direct_tx_delay_factor and are based on airtime, not signal quality.</p>
|
||||
</div>
|
||||
|
||||
<h3>Delay Settings</h3>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>tx_delay_factor</h4>
|
||||
<div class="column-desc">Flood mode transmission delay multiplier</div>
|
||||
<div class="column-detail">
|
||||
<strong>Default:</strong> 1.0<br>
|
||||
<strong>Purpose:</strong> Scales the base collision-avoidance delay for flood packets.<br>
|
||||
<strong>Formula:</strong> delay = random(0-5) × (airtime × 52/50 ÷ 2) × tx_delay_factor<br>
|
||||
<strong>Effect:</strong> Higher values = longer delays between flood packet retransmissions, reducing collisions but increasing latency. Lower values speed up propagation in low-traffic areas.<br>
|
||||
<strong>Typical range:</strong> 0.5 - 2.0 (0.5 = faster, 2.0 = collision-resistant)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>direct_tx_delay_factor</h4>
|
||||
<div class="column-desc">Direct mode transmission delay (in seconds)</div>
|
||||
<div class="column-detail">
|
||||
<strong>Default:</strong> 0.5 seconds<br>
|
||||
<strong>Purpose:</strong> Fixed delay for direct-routed packets (packets specifically addressed to this repeater).<br>
|
||||
<strong>Effect:</strong> Direct packets wait this many seconds before retransmission. Direct packets bypass the collision-avoidance algorithm and use a fixed delay instead.<br>
|
||||
<strong>Note:</strong> Typically lower than flood delays to prioritize DIRECT packets. 0 = immediate forwarding.<br>
|
||||
<strong>Typical range:</strong> 0 - 2.0 seconds
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>How TX Delay is Calculated</h3>
|
||||
|
||||
<p>The TX Delay shown in the packet table follows the MeshCore C++ implementation for collision avoidance:</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>For FLOOD packets (broadcast):</strong><br>
|
||||
TX Delay = random(0 to 5) × (airtime_ms × 52/50 ÷ 2) × tx_delay_factor ÷ 1000<br><br>
|
||||
<strong>For DIRECT packets (addressed to this repeater):</strong><br>
|
||||
TX Delay = direct_tx_delay_factor (fixed, in seconds)<br><br>
|
||||
<strong>Optional Reactive Scoring:</strong><br>
|
||||
If use_score_for_tx is enabled AND delay ≥ 50ms:<br>
|
||||
TX Delay = base_delay × max(0.2, 1.0 - packet_score)<br>
|
||||
This applies a quality-based multiplier during congestion: high-score packets get shorter delays (priority), low-score packets get longer delays (backoff).<br><br>
|
||||
<strong>Example:</strong> FLOOD packet with 100ms airtime, tx_delay_factor=1.0, score=0.8:<br>
|
||||
• Base delay = (100 × 52/50 ÷ 2) = 52 ms<br>
|
||||
• With random(0-5) multiplier: 0-260 ms (before score adjustment)<br>
|
||||
• If ≥50ms AND score adjustment active: 0-260ms × max(0.2, 1.0-0.8) = 0-260ms × 0.2 = <strong>0-52ms</strong> (prioritized)<br><br>
|
||||
<strong>Tuning:</strong> Increase tx_delay_factor in high-traffic areas to reduce collisions. Decrease in low-traffic areas for faster propagation. Enable use_score_for_tx for intelligent priority during congestion. Direct packets bypass randomization and use fixed delays.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>Duty Cycle Constraints</h3>
|
||||
|
||||
<div class="table-column">
|
||||
<h4>max_airtime_per_minute</h4>
|
||||
<div class="column-desc">Maximum transmission time per minute in milliseconds</div>
|
||||
<div class="column-detail">
|
||||
<strong>Common values:</strong><br>
|
||||
• <code>3600 ms/min</code> = 100% duty cycle (US/AU FCC, no restriction)<br>
|
||||
• <code>36 ms/min</code> = 1% duty cycle (EU ETSI standard)<br>
|
||||
• <code>360 ms/min</code> = 10% duty cycle (compromise for EU testing)<br><br>
|
||||
<strong>Effect on packet handling:</strong> Duty cycle enforcement is <strong>independent of packet score</strong>. When duty cycle limit is reached, ALL packets are dropped equally - regardless of signal quality. The system does not prioritize high-score packets; it simply refuses to transmit until the budget resets.<br>
|
||||
<strong>TX Delay impact:</strong> TX Delay shown in the packet table is unaffected by duty cycle limits. However, packets may be completely blocked (dropped) when airtime budget is exhausted. There is no queuing or delay-until-later mechanism - dropped packets are lost immediately.<br>
|
||||
<strong>Packet distribution during high traffic:</strong> When approaching or exceeding duty cycle limits (>80%), incoming packets are dropped indiscriminately based on airtime availability. The mean packet score will fluctuate based on random traffic mix, not because the system prefers high-score packets. All packets have equal probability of being dropped when budget is exhausted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>How These Work Together</h3>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Example Scenario - Packet Forwarding with Delay:</strong></p>
|
||||
<p>You receive 3 packets with different routes and sizes (tx_delay_factor=1.0, direct_tx_delay_factor=0.5s):</p>
|
||||
<ul>
|
||||
<li><strong>Packet A:</strong> Route DIRECT, 50 bytes → TX Delay = 0.5 seconds (fixed)</li>
|
||||
<li><strong>Packet B:</strong> Route FLOOD, 100 bytes → TX Delay = random(0-5) × 52ms × 1.0 = 0-260 ms</li>
|
||||
<li><strong>Packet C:</strong> Route FLOOD, 150 bytes → TX Delay = random(0-5) × 78ms × 1.0 = 0-390 ms</li>
|
||||
</ul>
|
||||
<p><strong>Processing order (without duty cycle limits):</strong></p>
|
||||
<ul>
|
||||
<li>Packet A: Waits 0.5s, then forwards (direct packets get fixed priority)</li>
|
||||
<li>Packets B & C: Random delays prevent collision, lower packet transmitted first if random lucky</li>
|
||||
</ul>
|
||||
<p><strong>If duty cycle ~95% full:</strong> Still forwards all three, but with increased TX delays. If insufficient airtime remains for a packet, it is dropped immediately (not queued)</p>
|
||||
</div>
|
||||
|
||||
<h3>Optimization Tips</h3>
|
||||
|
||||
<ul>
|
||||
<li><strong>For high-traffic/interference:</strong> Increase <code>tx_delay_factor</code> to 1.5-2.0 to reduce collisions with more randomization</li>
|
||||
<li><strong>For low-traffic areas:</strong> Decrease <code>tx_delay_factor</code> to 0.5 for faster propagation</li>
|
||||
<li><strong>For priority direct packets:</strong> Lower <code>direct_tx_delay_factor</code> below 0.5s for faster handling</li>
|
||||
<li><strong>For duty-cycle constrained regions (EU):</strong> Keep default settings; airtime budget enforces fairness</li>
|
||||
<li><strong>Monitor TX Delay column:</strong> Increasing delays indicate network congestion or approaching duty cycle limits</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<a href="#packet-table" class="back-to-top">↑ Back to Top</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Table of contents active link highlighting
|
||||
const tocLinks = document.querySelectorAll('.toc-link');
|
||||
const sections = document.querySelectorAll('.help-section');
|
||||
|
||||
function updateActiveTocLink() {
|
||||
let current = '';
|
||||
|
||||
sections.forEach(section => {
|
||||
const sectionTop = section.offsetTop;
|
||||
const sectionHeight = section.clientHeight;
|
||||
const scrollPosition = window.scrollY + 150;
|
||||
|
||||
if (scrollPosition >= sectionTop && scrollPosition < sectionTop + sectionHeight) {
|
||||
current = section.getAttribute('id');
|
||||
}
|
||||
});
|
||||
|
||||
tocLinks.forEach(link => {
|
||||
link.classList.remove('active');
|
||||
if (link.getAttribute('href') === `#${current}`) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', updateActiveTocLink);
|
||||
updateActiveTocLink();
|
||||
|
||||
// Smooth scroll for anchor links
|
||||
tocLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const targetId = link.getAttribute('href');
|
||||
const targetSection = document.querySelector(targetId);
|
||||
if (targetSection) {
|
||||
targetSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,238 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>pyMC Repeater - Logs</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<!-- Navigation Component -->
|
||||
<!-- NAVIGATION_PLACEHOLDER -->
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="content">
|
||||
<header>
|
||||
<h1>System Logs</h1>
|
||||
<p>Real-time system events and diagnostics</p>
|
||||
</header>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div style="margin-bottom: 20px; padding: 15px; background: var(--color-bg-secondary); border-radius: 8px; border: 1px solid var(--color-border);">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 15px;">
|
||||
<label style="font-weight: bold; margin: 0; width: 100%;">Logger Filters:</label>
|
||||
<button id="selectAllBtn" style="flex: 1; min-width: 100px; padding: 8px 12px; background: var(--color-accent-primary); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em;">Select All</button>
|
||||
<button id="clearAllBtn" style="flex: 1; min-width: 100px; padding: 8px 12px; background: var(--color-accent-secondary); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em;">Clear All</button>
|
||||
</div>
|
||||
<div id="filterContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||
<!-- Filters will be added here dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mock log data - will be replaced with real logs via API -->
|
||||
<div class="log-container" id="logs">
|
||||
<div class="log-line">
|
||||
<span class="log-time">[Loading...]</span>
|
||||
<span class="log-level info">INFO</span>
|
||||
<span class="log-msg">Fetching system logs...</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Track filter state and all logs
|
||||
let allLogs = [];
|
||||
let enabledLoggers = new Set();
|
||||
let allLoggers = new Set();
|
||||
|
||||
// Fetch logs from API on page load
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const response = await fetch('/api/logs');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.logs && data.logs.length > 0) {
|
||||
allLogs = data.logs;
|
||||
|
||||
// Extract all unique logger names
|
||||
const newLoggers = new Set();
|
||||
allLogs.forEach(log => {
|
||||
const loggerName = extractLoggerName(log.message);
|
||||
newLoggers.add(loggerName);
|
||||
});
|
||||
|
||||
// On first load, enable all detected loggers
|
||||
if (enabledLoggers.size === 0) {
|
||||
enabledLoggers = new Set(newLoggers);
|
||||
}
|
||||
|
||||
// Check if logger set has changed
|
||||
const loggersChanged = !setsEqual(allLoggers, newLoggers);
|
||||
|
||||
// Update allLoggers with currently active loggers only
|
||||
allLoggers = newLoggers;
|
||||
|
||||
// Only update filter UI if the set of loggers changed
|
||||
if (loggersChanged) {
|
||||
updateFilterUI();
|
||||
}
|
||||
|
||||
// Always display filtered logs (but don't rebuild filters)
|
||||
displayLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading logs:', error);
|
||||
const logsContainer = document.getElementById('logs');
|
||||
logsContainer.innerHTML = `
|
||||
<div class="log-line">
|
||||
<span class="log-time">[Error]</span>
|
||||
<span class="log-level error">ERROR</span>
|
||||
<span class="log-msg">Failed to load logs: ${escapeHtml(error.message)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to compare two sets
|
||||
function setsEqual(set1, set2) {
|
||||
if (set1.size !== set2.size) return false;
|
||||
for (let item of set1) {
|
||||
if (!set2.has(item)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract logger name from log message (e.g., "RepeaterDaemon", "HTTPServer", etc.)
|
||||
function extractLoggerName(message) {
|
||||
// Format: "2025-10-22 12:47:30,270 - LoggerName - LEVEL - message"
|
||||
const match = message.match(/- (\w+) -/);
|
||||
return match ? match[1] : 'Unknown';
|
||||
}
|
||||
|
||||
// Update filter UI with detected loggers
|
||||
function updateFilterUI() {
|
||||
const filterContainer = document.getElementById('filterContainer');
|
||||
const sortedLoggers = Array.from(allLoggers).sort();
|
||||
|
||||
// Clear existing buttons
|
||||
filterContainer.innerHTML = '';
|
||||
|
||||
sortedLoggers.forEach(logger => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'filter-btn';
|
||||
button.dataset.logger = logger;
|
||||
button.textContent = logger;
|
||||
button.style.cssText = `
|
||||
padding: 8px 14px;
|
||||
border: 2px solid var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9em;
|
||||
`;
|
||||
|
||||
// Set active state based on enabledLoggers
|
||||
if (enabledLoggers.has(logger)) {
|
||||
button.style.background = 'var(--color-accent-primary)';
|
||||
button.style.color = 'white';
|
||||
button.style.borderColor = 'var(--color-accent-primary)';
|
||||
}
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
if (enabledLoggers.has(logger)) {
|
||||
enabledLoggers.delete(logger);
|
||||
} else {
|
||||
enabledLoggers.add(logger);
|
||||
}
|
||||
updateFilterUI();
|
||||
displayLogs();
|
||||
});
|
||||
|
||||
filterContainer.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
// Display logs filtered by enabled loggers
|
||||
function displayLogs() {
|
||||
const logsContainer = document.getElementById('logs');
|
||||
logsContainer.innerHTML = '';
|
||||
|
||||
allLogs.forEach(log => {
|
||||
const loggerName = extractLoggerName(log.message);
|
||||
|
||||
// Skip if logger is not enabled
|
||||
if (!enabledLoggers.has(loggerName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
|
||||
// Try to parse timestamp
|
||||
const timestamp = new Date(log.timestamp);
|
||||
const timeStr = timestamp.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
// Get log level from API response or parse from message
|
||||
let level = log.level || 'INFO';
|
||||
let levelClass = level.toLowerCase();
|
||||
|
||||
logLine.innerHTML = `
|
||||
<span class="log-time">[${timeStr}]</span>
|
||||
<span class="log-level ${levelClass}">${level}</span>
|
||||
<span class="log-msg">${escapeHtml(log.message || '')}</span>
|
||||
`;
|
||||
|
||||
logsContainer.appendChild(logLine);
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
|
||||
// Helper function to escape HTML
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
// Setup button event listeners
|
||||
function setupButtons() {
|
||||
document.getElementById('selectAllBtn').addEventListener('click', () => {
|
||||
enabledLoggers = new Set(allLoggers);
|
||||
updateFilterUI();
|
||||
displayLogs();
|
||||
});
|
||||
|
||||
document.getElementById('clearAllBtn').addEventListener('click', () => {
|
||||
enabledLoggers.clear();
|
||||
updateFilterUI();
|
||||
displayLogs();
|
||||
});
|
||||
}
|
||||
|
||||
// Load logs on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupButtons();
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
// Refresh logs every 5 seconds
|
||||
setInterval(loadLogs, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,796 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>pyMC Repeater - Neighbors</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<!-- Navigation Component -->
|
||||
<!-- NAVIGATION_PLACEHOLDER -->
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="content">
|
||||
<header>
|
||||
<h1>Neighbor Repeaters</h1>
|
||||
<div class="header-info">
|
||||
<span>Tracking: <strong id="neighbor-count">0</strong> repeaters</span>
|
||||
<span>Updated: <strong id="update-time">{{ last_updated }}</strong></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Neighbors Map -->
|
||||
<div class="map-card">
|
||||
<h2>Repeater Network Map</h2>
|
||||
<div id="map-container" class="map-container">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neighbors Table -->
|
||||
<div class="table-card">
|
||||
<h2>Discovered Repeaters</h2>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node Name</th>
|
||||
<th>Public Key</th>
|
||||
<th>Contact Type</th>
|
||||
<th>Location</th>
|
||||
<th>Distance</th>
|
||||
<th>RSSI</th>
|
||||
<th>SNR</th>
|
||||
<th>Last Seen</th>
|
||||
<th>First Seen</th>
|
||||
<th>Advert Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="neighbors-table">
|
||||
<tr>
|
||||
<td colspan="10" class="empty-message">
|
||||
No repeaters discovered yet - waiting for adverts...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let updateInterval;
|
||||
let map = null;
|
||||
let centerMarker = null;
|
||||
let neighborMarkers = [];
|
||||
let connectionLines = [];
|
||||
let configLat = null;
|
||||
let configLng = null;
|
||||
|
||||
// Haversine formula to calculate distance between two lat/lng points in kilometers
|
||||
function calculateDistance(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371; // Earth's radius in kilometers
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// Format distance with appropriate units
|
||||
function formatDistance(distanceKm) {
|
||||
if (distanceKm < 1) {
|
||||
return (distanceKm * 1000).toFixed(0) + ' m';
|
||||
} else if (distanceKm < 10) {
|
||||
return distanceKm.toFixed(2) + ' km';
|
||||
} else {
|
||||
return distanceKm.toFixed(1) + ' km';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
function initMap() {
|
||||
if (map || !configLat || !configLng) return; // Already initialized or no coords yet
|
||||
|
||||
map = L.map('map').setView([configLat, configLng], 10);
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
tileSize: 256,
|
||||
className: 'map-tiles'
|
||||
}).addTo(map);
|
||||
|
||||
// Add center marker (your repeater)
|
||||
centerMarker = L.circleMarker([configLat, configLng], {
|
||||
radius: 8,
|
||||
fillColor: '#6a9955',
|
||||
color: '#4ade80',
|
||||
weight: 3,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(map);
|
||||
|
||||
centerMarker.bindPopup('<strong>Your Repeater</strong><br>(' + configLat.toFixed(4) + ', ' + configLng.toFixed(4) + ')');
|
||||
|
||||
// Force map to recalculate size after container is visible
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateMapData(stats) {
|
||||
// Get lat/lng from API
|
||||
if (stats.config && stats.config.repeater) {
|
||||
configLat = stats.config.repeater.latitude;
|
||||
configLng = stats.config.repeater.longitude;
|
||||
}
|
||||
|
||||
// Initialize map if needed (after we have coords from API)
|
||||
if (!map) {
|
||||
initMap();
|
||||
}
|
||||
|
||||
if (!map) return; // Still no valid coords
|
||||
|
||||
// Update center marker position
|
||||
if (centerMarker) {
|
||||
centerMarker.setLatLng([configLat, configLng]);
|
||||
}
|
||||
|
||||
// Clear existing neighbor markers and lines
|
||||
neighborMarkers.forEach(m => map.removeLayer(m));
|
||||
connectionLines.forEach(l => map.removeLayer(l));
|
||||
neighborMarkers = [];
|
||||
connectionLines = [];
|
||||
|
||||
// Use real API data
|
||||
const neighbors = stats.neighbors || {};
|
||||
const neighborsArray = Object.entries(neighbors);
|
||||
|
||||
if (neighborsArray.length === 0) return;
|
||||
|
||||
neighborsArray.forEach(([pubkey, neighbor]) => {
|
||||
if (!neighbor.latitude || !neighbor.longitude) return;
|
||||
if (neighbor.latitude === 0.0 && neighbor.longitude === 0.0) return;
|
||||
|
||||
const lat = neighbor.latitude;
|
||||
const lng = neighbor.longitude;
|
||||
const name = neighbor.node_name || 'Unknown';
|
||||
|
||||
// Add neighbor marker
|
||||
const marker = L.circleMarker([lat, lng], {
|
||||
radius: 6,
|
||||
fillColor: '#4ec9b0',
|
||||
color: '#ce9178',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.7
|
||||
}).addTo(map);
|
||||
|
||||
const popupText = `<strong>${name}</strong><br>
|
||||
RSSI: ${neighbor.rssi || 'N/A'}<br>
|
||||
SNR: ${neighbor.snr ? neighbor.snr.toFixed(1) + ' dB' : 'N/A'}<br>
|
||||
Adverts: ${neighbor.advert_count || 0}<br>
|
||||
(${lat.toFixed(4)}, ${lng.toFixed(4)})`;
|
||||
|
||||
marker.bindPopup(popupText);
|
||||
neighborMarkers.push(marker);
|
||||
|
||||
// Draw connection line from center to neighbor
|
||||
const line = L.polyline([
|
||||
[configLat, configLng],
|
||||
[lat, lng]
|
||||
], {
|
||||
color: '#4ec9b0',
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
dashArray: '5, 5'
|
||||
}).addTo(map);
|
||||
|
||||
// Add SNR label to the middle of the line
|
||||
const midLat = (configLat + lat) / 2;
|
||||
const midLng = (configLng + lng) / 2;
|
||||
const snrLabel = L.marker([midLat, midLng], {
|
||||
icon: L.divIcon({
|
||||
className: 'snr-label',
|
||||
html: `<div class="snr-text">${neighbor.snr ? neighbor.snr.toFixed(1) + ' dB' : 'N/A'}</div>`,
|
||||
iconSize: [65, 24],
|
||||
iconAnchor: [32, 12]
|
||||
})
|
||||
}).addTo(map);
|
||||
|
||||
connectionLines.push(line);
|
||||
connectionLines.push(snrLabel);
|
||||
});
|
||||
|
||||
// Fit map to show all markers
|
||||
if (neighborsArray.length > 0) {
|
||||
const group = new L.featureGroup([centerMarker, ...neighborMarkers]);
|
||||
map.fitBounds(group.getBounds(), { padding: [50, 50] });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Send Advert button
|
||||
function sendAdvert() {
|
||||
const btn = document.getElementById('send-advert-btn');
|
||||
if (!btn) return;
|
||||
|
||||
const icon = btn.querySelector('.icon');
|
||||
const iconHTML = icon ? icon.outerHTML : '';
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = iconHTML + 'Sending...';
|
||||
|
||||
fetch('/api/send_advert', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btn.innerHTML = iconHTML + 'Sent!';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = iconHTML + 'Send Advert';
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
btn.innerHTML = iconHTML + 'Error';
|
||||
console.error('Failed to send advert:', data.error);
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = iconHTML + 'Send Advert';
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('Error sending advert:', e);
|
||||
btn.innerHTML = iconHTML + 'Error';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = iconHTML + 'Send Advert';
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function updateNeighbors() {
|
||||
fetch('/api/stats')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const neighbors = data.neighbors || {};
|
||||
const neighborCount = Object.keys(neighbors).length;
|
||||
|
||||
document.getElementById('neighbor-count').textContent = neighborCount;
|
||||
document.getElementById('update-time').textContent = new Date().toLocaleTimeString();
|
||||
|
||||
updateNeighborsTable(neighbors);
|
||||
updateMapData(data);
|
||||
})
|
||||
.catch(e => console.error('Error fetching neighbors:', e));
|
||||
}
|
||||
|
||||
// Mock test data function
|
||||
function updateNeighborsTable(neighbors) {
|
||||
const tbody = document.getElementById('neighbors-table');
|
||||
|
||||
if (!neighbors || Object.keys(neighbors).length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="10" class="empty-message">
|
||||
No repeaters discovered yet - waiting for adverts...
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by last_seen (most recent first)
|
||||
const sortedNeighbors = Object.entries(neighbors).sort((a, b) => {
|
||||
return b[1].last_seen - a[1].last_seen;
|
||||
});
|
||||
|
||||
tbody.innerHTML = sortedNeighbors.map(([pubkey, neighbor]) => {
|
||||
const name = neighbor.node_name || 'Unknown';
|
||||
// Format pubkey properly - it's a 64-char hex string
|
||||
const pubkeyShort = pubkey.length >= 16
|
||||
? `<${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}>`
|
||||
: `<${pubkey}>`;
|
||||
const contactType = neighbor.contact_type || 'Repeater';
|
||||
const location = neighbor.latitude && neighbor.longitude && (neighbor.latitude !== 0.0 || neighbor.longitude !== 0.0)
|
||||
? `${neighbor.latitude.toFixed(6)}, ${neighbor.longitude.toFixed(6)}`
|
||||
: 'N/A';
|
||||
|
||||
// Calculate distance if both local and neighbor have valid coordinates
|
||||
let distance = 'N/A';
|
||||
if (configLat && configLng &&
|
||||
neighbor.latitude && neighbor.longitude &&
|
||||
(neighbor.latitude !== 0.0 || neighbor.longitude !== 0.0)) {
|
||||
const distanceKm = calculateDistance(configLat, configLng, neighbor.latitude, neighbor.longitude);
|
||||
distance = formatDistance(distanceKm);
|
||||
}
|
||||
|
||||
const rssi = neighbor.rssi || 'N/A';
|
||||
const snr = neighbor.snr !== undefined ? neighbor.snr.toFixed(1) + ' dB' : 'N/A';
|
||||
const lastSeen = new Date(neighbor.last_seen * 1000).toLocaleString();
|
||||
const firstSeen = new Date(neighbor.first_seen * 1000).toLocaleString();
|
||||
const advertCount = neighbor.advert_count || 0;
|
||||
|
||||
// Color code RSSI
|
||||
let rssiClass = 'rssi-poor';
|
||||
if (rssi !== 'N/A') {
|
||||
if (rssi > -80) rssiClass = 'rssi-excellent';
|
||||
else if (rssi > -90) rssiClass = 'rssi-good';
|
||||
else if (rssi > -100) rssiClass = 'rssi-fair';
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td data-label="Node Name"><strong>${name}</strong></td>
|
||||
<td data-label="Public Key"><code class="pubkey">${pubkeyShort}</code></td>
|
||||
<td data-label="Contact Type"><span class="contact-type-badge">${contactType}</span></td>
|
||||
<td data-label="Location">${location}</td>
|
||||
<td data-label="Distance"><strong class="distance">${distance}</strong></td>
|
||||
<td data-label="RSSI"><span class="${rssiClass}">${rssi}</span></td>
|
||||
<td data-label="SNR">${snr}</td>
|
||||
<td data-label="Last Seen">${lastSeen}</td>
|
||||
<td data-label="First Seen">${firstSeen}</td>
|
||||
<td data-label="Advert Count">${advertCount}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ORIGINAL FUNCTION - COMMENTED OUT
|
||||
function updateNeighborsTable(neighbors) {
|
||||
const tbody = document.getElementById('neighbors-table');
|
||||
|
||||
if (!neighbors || Object.keys(neighbors).length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="9" class="empty-message">
|
||||
No repeaters discovered yet - waiting for adverts...
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by last_seen (most recent first)
|
||||
const sortedNeighbors = Object.entries(neighbors).sort((a, b) => {
|
||||
return b[1].last_seen - a[1].last_seen;
|
||||
});
|
||||
|
||||
tbody.innerHTML = sortedNeighbors.map(([pubkey, neighbor]) => {
|
||||
const name = neighbor.node_name || 'Unknown';
|
||||
// Format pubkey properly - it's a 64-char hex string
|
||||
const pubkeyShort = pubkey.length >= 16
|
||||
? `<${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}>`
|
||||
: `<${pubkey}>`;
|
||||
const contactType = neighbor.contact_type || 'Repeater';
|
||||
const location = neighbor.latitude && neighbor.longitude && (neighbor.latitude !== 0.0 || neighbor.longitude !== 0.0)
|
||||
? `${neighbor.latitude.toFixed(6)}, ${neighbor.longitude.toFixed(6)}`
|
||||
: 'N/A';
|
||||
const rssi = neighbor.rssi || 'N/A';
|
||||
const snr = neighbor.snr !== undefined ? neighbor.snr.toFixed(1) + ' dB' : 'N/A';
|
||||
const lastSeen = new Date(neighbor.last_seen * 1000).toLocaleString();
|
||||
const firstSeen = new Date(neighbor.first_seen * 1000).toLocaleString();
|
||||
const advertCount = neighbor.advert_count || 0;
|
||||
|
||||
// Color code RSSI
|
||||
let rssiClass = 'rssi-poor';
|
||||
if (rssi !== 'N/A') {
|
||||
if (rssi > -80) rssiClass = 'rssi-excellent';
|
||||
else if (rssi > -90) rssiClass = 'rssi-good';
|
||||
else if (rssi > -100) rssiClass = 'rssi-fair';
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td data-label="Node Name"><strong>${name}</strong></td>
|
||||
<td data-label="Public Key"><code class="pubkey">${pubkeyShort}</code></td>
|
||||
<td data-label="Contact Type"><span class="contact-type-badge">${contactType}</span></td>
|
||||
<td data-label="Location">${location}</td>
|
||||
<td data-label="RSSI"><span class="${rssiClass}">${rssi}</span></td>
|
||||
<td data-label="SNR">${snr}</td>
|
||||
<td data-label="Last Seen">${lastSeen}</td>
|
||||
<td data-label="First Seen">${firstSeen}</td>
|
||||
<td data-label="Advert Count">${advertCount}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
*/
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateNeighbors();
|
||||
|
||||
// Auto-update every 10 seconds
|
||||
updateInterval = setInterval(updateNeighbors, 10000);
|
||||
|
||||
// Attach send advert button handler
|
||||
const sendAdvertBtn = document.getElementById('send-advert-btn');
|
||||
if (sendAdvertBtn) {
|
||||
sendAdvertBtn.addEventListener('click', sendAdvert);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.map-card {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.map-card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Dark theme for leaflet */
|
||||
.map-tiles {
|
||||
filter: invert(0.93) hue-rotate(200deg) saturate(0.5);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
background: rgba(30, 30, 30, 0.7) !important;
|
||||
color: #999 !important;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.leaflet-control-attribution a {
|
||||
color: #4ec9b0 !important;
|
||||
}
|
||||
|
||||
.leaflet-control {
|
||||
background: var(--color-bg-secondary) !important;
|
||||
border: 1px solid var(--color-border) !important;
|
||||
color: var(--color-text-primary) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
color: var(--color-text-primary) !important;
|
||||
background: var(--color-bg-secondary) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom-in:hover,
|
||||
.leaflet-control-zoom-out:hover {
|
||||
background: var(--color-bg-tertiary) !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: var(--color-bg-secondary) !important;
|
||||
border: 1px solid var(--color-border) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
color: var(--color-text-primary) !important;
|
||||
margin: 0 !important;
|
||||
padding: 12px 16px !important;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.leaflet-popup-content strong {
|
||||
color: #4ec9b0 !important;
|
||||
font-size: 1.05rem;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.leaflet-popup-content br {
|
||||
content: '';
|
||||
display: block;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper a.leaflet-popup-close-button {
|
||||
color: var(--color-text-tertiary) !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper a.leaflet-popup-close-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background: var(--color-bg-secondary) !important;
|
||||
border: 1px solid var(--color-border) !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.snr-label {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.snr-text {
|
||||
background: rgba(30, 30, 30, 0.85);
|
||||
color: #4ec9b0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid #4ec9b0;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#map {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.map-card {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.map-card h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#map {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.map-card {
|
||||
padding: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.map-card h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pubkey {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85em;
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.contact-type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: #60a5fa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rssi-excellent {
|
||||
color: #4ade80;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rssi-good {
|
||||
color: #4ec9b0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rssi-fair {
|
||||
color: #dcdcaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rssi-poor {
|
||||
color: #f48771;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Mobile responsive table styling */
|
||||
@media (max-width: 768px) {
|
||||
.data-table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-table tbody {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
display: block;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-accent-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
margin-right: var(--spacing-lg);
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: fit-content;
|
||||
padding: 2px 6px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Node Name and Public Key get full width */
|
||||
.data-table td:nth-child(1),
|
||||
.data-table td:nth-child(2) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.data-table td:nth-child(1)::before { content: "Node Name"; }
|
||||
.data-table td:nth-child(2)::before { content: "Public Key"; }
|
||||
.data-table td:nth-child(3)::before { content: "Contact Type"; }
|
||||
.data-table td:nth-child(4)::before { content: "Location"; }
|
||||
.data-table td:nth-child(5)::before { content: "Distance"; }
|
||||
.data-table td:nth-child(6)::before { content: "RSSI"; }
|
||||
.data-table td:nth-child(7)::before { content: "SNR"; }
|
||||
.data-table td:nth-child(8)::before { content: "Last Seen"; }
|
||||
.data-table td:nth-child(9)::before { content: "First Seen"; }
|
||||
.data-table td:nth-child(10)::before { content: "Advert Count"; }
|
||||
|
||||
/* Location and timestamps wrap to next line */
|
||||
.data-table td:nth-child(4),
|
||||
.data-table td:nth-child(8),
|
||||
.data-table td:nth-child(9) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.data-table tbody tr {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
margin-right: var(--spacing-md);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.data-table td::before {
|
||||
font-size: 0.55rem;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* Full width items */
|
||||
.data-table td:nth-child(1),
|
||||
.data-table td:nth-child(2),
|
||||
.data-table td:nth-child(4),
|
||||
.data-table td:nth-child(8),
|
||||
.data-table td:nth-child(9) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.pubkey {
|
||||
font-size: 0.7rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.data-table tbody tr {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
margin-right: var(--spacing-sm);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.data-table td::before {
|
||||
font-size: 0.5rem;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
/* Full width items */
|
||||
.data-table td:nth-child(1),
|
||||
.data-table td:nth-child(2),
|
||||
.data-table td:nth-child(4),
|
||||
.data-table td:nth-child(8),
|
||||
.data-table td:nth-child(9) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.pubkey {
|
||||
font-size: 0.6rem;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.contact-type-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.distance {
|
||||
color: #dcdcaa;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,335 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>pyMC Repeater - Statistics</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<!-- Navigation Component -->
|
||||
<!-- NAVIGATION_PLACEHOLDER -->
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="content">
|
||||
<header>
|
||||
<h1>Statistics</h1>
|
||||
<p>Detailed performance analytics and metrics</p>
|
||||
</header>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<h2>Summary</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card success">
|
||||
<div class="stat-label">Total RX</div>
|
||||
<div class="stat-value" id="total-rx">0<span class="stat-unit">packets</span></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card success">
|
||||
<div class="stat-label">Total TX</div>
|
||||
<div class="stat-value" id="total-tx">0<span class="stat-unit">packets</span></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Repeats</div>
|
||||
<div class="stat-value" id="repeat-count">0<span class="stat-unit">packets</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<h2>Performance Charts</h2>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3>RX vs TX Over Time</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="rxtxChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3>Packet Type Distribution</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="packetTypeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3>Signal Metrics Over Time</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="signalMetricsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3>Route Type Distribution</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="routeTypeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let rxtxChart = null;
|
||||
let packetTypeChart = null;
|
||||
let signalMetricsChart = null;
|
||||
let routeTypeChart = null;
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#d4d4d4'
|
||||
}
|
||||
},
|
||||
filler: {
|
||||
propagate: true
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#999' },
|
||||
grid: { color: '#333' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#999' },
|
||||
grid: { color: '#333' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function initCharts() {
|
||||
// RX vs TX chart
|
||||
let rxtxCtx = document.getElementById('rxtxChart').getContext('2d');
|
||||
rxtxChart = new Chart(rxtxCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['00:00', '05:00', '10:00', '15:00', '20:00'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'RX',
|
||||
data: [0, 0, 0, 0, 0],
|
||||
borderColor: '#4ec9b0',
|
||||
backgroundColor: 'rgba(78, 201, 176, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'TX',
|
||||
data: [0, 0, 0, 0, 0],
|
||||
borderColor: '#6a9955',
|
||||
backgroundColor: 'rgba(106, 153, 85, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: chartOptions
|
||||
});
|
||||
|
||||
// Packet type chart
|
||||
let typeCtx = document.getElementById('packetTypeChart').getContext('2d');
|
||||
packetTypeChart = new Chart(typeCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['REQ', 'RESPONSE', 'TXT', 'ACK', 'ADVERT', 'GRP_TXT', 'GRP_DATA', 'PATH', 'OTHER'],
|
||||
datasets: [{
|
||||
data: [0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
backgroundColor: ['#ce9178', '#f48771', '#dcdcaa', '#6a9955', '#4ec9b0', '#c586c0', '#9cdcfe', '#569cd6', '#808080']
|
||||
}]
|
||||
},
|
||||
options: chartOptions
|
||||
});
|
||||
|
||||
// Signal metrics chart (RSSI, SNR, Noise Floor)
|
||||
let metricsCtx = document.getElementById('signalMetricsChart').getContext('2d');
|
||||
signalMetricsChart = new Chart(metricsCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['00:00', '05:00', '10:00', '15:00', '20:00'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'RSSI (dBm)',
|
||||
data: [0, 0, 0, 0, 0],
|
||||
borderColor: '#ce9178',
|
||||
backgroundColor: 'rgba(206, 145, 120, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1'
|
||||
},
|
||||
{
|
||||
label: 'SNR (dB)',
|
||||
data: [0, 0, 0, 0, 0],
|
||||
borderColor: '#4ec9b0',
|
||||
backgroundColor: 'rgba(78, 201, 176, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y2'
|
||||
},
|
||||
{
|
||||
label: 'Noise Floor (dBm)',
|
||||
data: [0, 0, 0, 0, 0],
|
||||
borderColor: '#f48771',
|
||||
backgroundColor: 'rgba(244, 135, 113, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
borderDash: [5, 5],
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...chartOptions,
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#999' },
|
||||
grid: { color: '#333' }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
ticks: { color: '#999' },
|
||||
grid: { color: '#333' },
|
||||
title: {
|
||||
display: true,
|
||||
text: 'RSSI / Noise (dBm)',
|
||||
color: '#ce9178'
|
||||
}
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
ticks: { color: '#999' },
|
||||
grid: { drawOnChartArea: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Route type chart
|
||||
let routeCtx = document.getElementById('routeTypeChart').getContext('2d');
|
||||
routeTypeChart = new Chart(routeCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['FLOOD', 'DIRECT'],
|
||||
datasets: [{
|
||||
data: [0, 0],
|
||||
backgroundColor: ['#dcdcaa', '#6a9955']
|
||||
}]
|
||||
},
|
||||
options: chartOptions
|
||||
});
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
fetch('/api/stats')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Update summary
|
||||
const rx = data.rx_count || 0;
|
||||
const tx = data.forwarded_count || 0;
|
||||
const repeats = tx - rx;
|
||||
|
||||
document.getElementById('total-rx').textContent = rx;
|
||||
document.getElementById('total-tx').textContent = tx;
|
||||
document.getElementById('repeat-count').textContent = repeats;
|
||||
|
||||
// Update charts with data trends
|
||||
const packets = data.recent_packets || [];
|
||||
|
||||
// Calculate packet type distribution
|
||||
// Types: 0x00=REQ, 0x01=RESPONSE, 0x02=TXT, 0x03=ACK, 0x04=ADVERT,
|
||||
// 0x05=GRP_TXT, 0x06=GRP_DATA, 0x08=PATH, other
|
||||
const types = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 8: 0, other: 0 };
|
||||
const routes = { flood: 0, direct: 0 };
|
||||
let rssiSum = 0, snrSum = 0, rssiMin = 0;
|
||||
let packetCount = 0;
|
||||
|
||||
packets.forEach(p => {
|
||||
// Count packet types
|
||||
if (p.type === 0 || p.type === 1 || p.type === 2 || p.type === 3 ||
|
||||
p.type === 4 || p.type === 5 || p.type === 6 || p.type === 8) {
|
||||
types[p.type] = (types[p.type] || 0) + 1;
|
||||
} else {
|
||||
types.other++;
|
||||
}
|
||||
if (p.route === 1) routes.flood++; else routes.direct++;
|
||||
rssiSum += p.rssi || 0;
|
||||
snrSum += p.snr || 0;
|
||||
if (rssiMin === 0 || p.rssi < rssiMin) rssiMin = p.rssi;
|
||||
packetCount++;
|
||||
});
|
||||
|
||||
// Update packet type chart
|
||||
packetTypeChart.data.datasets[0].data = [
|
||||
types[0] || 0, // REQ
|
||||
types[1] || 0, // RESPONSE
|
||||
types[2] || 0, // TXT
|
||||
types[3] || 0, // ACK
|
||||
types[4] || 0, // ADVERT
|
||||
types[5] || 0, // GRP_TXT (channel messages)
|
||||
types[6] || 0, // GRP_DATA
|
||||
types[8] || 0, // PATH
|
||||
types.other || 0 // OTHER
|
||||
];
|
||||
packetTypeChart.update();
|
||||
|
||||
// Update RX vs TX chart (add current counts to timeline)
|
||||
const now = new Date();
|
||||
const timeLabel = now.getHours().toString().padStart(2, '0') + ':' +
|
||||
now.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
rxtxChart.data.labels.push(timeLabel);
|
||||
rxtxChart.data.datasets[0].data.push(rx); // RX count
|
||||
rxtxChart.data.datasets[1].data.push(tx); // TX count
|
||||
|
||||
// Keep only last 20 data points
|
||||
if (rxtxChart.data.labels.length > 20) {
|
||||
rxtxChart.data.labels.shift();
|
||||
rxtxChart.data.datasets[0].data.shift();
|
||||
rxtxChart.data.datasets[1].data.shift();
|
||||
}
|
||||
rxtxChart.update();
|
||||
|
||||
// Update route type chart
|
||||
routeTypeChart.data.datasets[0].data = [routes.flood, routes.direct];
|
||||
routeTypeChart.update();
|
||||
|
||||
// Update signal metrics chart
|
||||
const avgRssi = packetCount > 0 ? Math.round(rssiSum / packetCount) : 0;
|
||||
const avgSnr = packetCount > 0 ? Math.round(snrSum / packetCount) : 0;
|
||||
const noiseFloor = avgRssi - avgSnr; // Noise Floor = RSSI - SNR
|
||||
|
||||
signalMetricsChart.data.datasets[0].data.push(avgRssi);
|
||||
signalMetricsChart.data.datasets[1].data.push(avgSnr);
|
||||
signalMetricsChart.data.datasets[2].data.push(noiseFloor);
|
||||
|
||||
if (signalMetricsChart.data.datasets[0].data.length > 5) {
|
||||
signalMetricsChart.data.datasets[0].data.shift();
|
||||
signalMetricsChart.data.datasets[1].data.shift();
|
||||
signalMetricsChart.data.datasets[2].data.shift();
|
||||
}
|
||||
signalMetricsChart.update();
|
||||
})
|
||||
.catch(e => console.error('Error fetching stats:', e));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initCharts();
|
||||
updateStats();
|
||||
setInterval(updateStats, 5000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
3950
repeater/web/html/assets/index-BJnLrjMq.js
Normal file
3950
repeater/web/html/assets/index-BJnLrjMq.js
Normal file
File diff suppressed because one or more lines are too long
1
repeater/web/html/assets/index-wgCsqUA2.css
Normal file
1
repeater/web/html/assets/index-wgCsqUA2.css
Normal file
File diff suppressed because one or more lines are too long
BIN
repeater/web/html/favicon.ico
Normal file
BIN
repeater/web/html/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
17
repeater/web/html/index.html
Normal file
17
repeater/web/html/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pyMC Repeater Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-BJnLrjMq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-wgCsqUA2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user