Core Improvements & Feature Enhancements

Update Summary

Refactored packet handling to replace a custom hash function with the built-in implementation.
Updated the statistics template — renamed success rate to repeats and adjusted calculations.
Added neighbours connection map with  SNR labels.
Added distance calculation and display to the neighbours table.
Added noise floor measurement and improved dashboard visualisation.
Added an automated upgrade script for the PyMC Repeater, including service management.
This commit is contained in:
Lloyd
2025-10-27 15:41:36 -07:00
committed by GitHub
14 changed files with 1057 additions and 79 deletions
+153
View File
@@ -0,0 +1,153 @@
# Noise Floor Measurement - Usage Guide
## Overview
The noise floor measurement capability has been added to provide real-time RF environment monitoring.
## Implementation
### 1. Radio Wrapper (pyMC_core)
Added `get_noise_floor()` method to `SX1262Radio` class:
```python
def get_noise_floor(self) -> Optional[float]:
"""
Get current noise floor (instantaneous RSSI) in dBm.
Returns None if radio is not initialized or if reading fails.
"""
```
### 2. Repeater Engine (pyMC_Repeater)
Added `get_noise_floor()` method to `RepeaterHandler` class:
```python
def get_noise_floor(self) -> Optional[float]:
"""
Get the current noise floor (instantaneous RSSI) from the radio in dBm.
Returns None if radio is not available or reading fails.
"""
```
The noise floor is automatically included in the stats dictionary returned by `get_stats()`:
```python
stats = handler.get_stats()
noise_floor = stats.get('noise_floor_dbm') # Returns float or None
```
## Usage Examples
### Example 1: Get Noise Floor Directly
```python
# From the repeater engine
handler = RepeaterHandler(config, dispatcher, local_hash)
noise_floor_dbm = handler.get_noise_floor()
if noise_floor_dbm is not None:
print(f"Current noise floor: {noise_floor_dbm:.1f} dBm")
else:
print("Noise floor not available")
```
### Example 2: Access via Stats
```python
# Get all stats including noise floor
stats = handler.get_stats()
noise_floor = stats.get('noise_floor_dbm')
if noise_floor is not None:
print(f"RF Environment: {noise_floor:.1f} dBm")
```
### Example 3: Monitor RF Environment
```python
import asyncio
async def monitor_rf_environment(handler, interval=5.0):
"""Monitor noise floor every N seconds"""
while True:
noise_floor = handler.get_noise_floor()
if noise_floor is not None:
if noise_floor > -100:
print(f"⚠️ High RF noise: {noise_floor:.1f} dBm")
else:
print(f"✓ Normal RF environment: {noise_floor:.1f} dBm")
await asyncio.sleep(interval)
```
### Example 4: Channel Assessment Before TX
```python
async def should_transmit(handler, threshold_dbm=-110):
"""
Check if channel is clear before transmitting.
Returns True if noise floor is below threshold (channel clear).
"""
noise_floor = handler.get_noise_floor()
if noise_floor is None:
# Can't determine, allow transmission
return True
if noise_floor > threshold_dbm:
# Channel busy - high noise
print(f"Channel busy: {noise_floor:.1f} dBm > {threshold_dbm} dBm")
return False
# Channel clear
return True
```
## Integration with Web Dashboard
The noise floor is automatically available in the `/api/stats` endpoint:
```javascript
// JavaScript example for web dashboard
fetch('/api/stats')
.then(response => response.json())
.then(data => {
const noiseFloor = data.noise_floor_dbm;
if (noiseFloor !== null) {
updateNoiseFloorDisplay(noiseFloor);
}
});
```
## Interpretation
### Typical Values
- **-120 to -110 dBm**: Very quiet RF environment (rural, low interference)
- **-110 to -100 dBm**: Normal RF environment (typical conditions)
- **-100 to -90 dBm**: Moderate RF noise (urban, some interference)
- **-90 dBm and above**: High RF noise (congested environment, potential issues)
### Use Cases
1. **Collision Avoidance**: Check noise floor before transmitting to detect if another station is already transmitting
2. **RF Environment Monitoring**: Track RF noise levels over time for site assessment
3. **Adaptive Transmission**: Adjust TX timing or power based on channel conditions
4. **Debugging**: Identify sources of interference or poor reception
## Technical Details
### Calculation
The noise floor is calculated from the SX1262's instantaneous RSSI register:
```python
raw_rssi = self.lora.getRssiInst()
noise_floor_dbm = -(float(raw_rssi) / 2)
```
### Update Rate
The noise floor is read on-demand when `get_noise_floor()` is called. There is no caching - each call queries the radio hardware directly.
### Error Handling
- Returns `None` if radio is not initialized
- Returns `None` if read fails (hardware error)
- Logs debug message on error (doesn't raise exceptions)
## Future Enhancements
Potential future improvements:
1. **Averaging**: Average noise floor over multiple samples for stability
2. **History**: Track noise floor history for trend analysis
3. **Thresholds**: Configurable thresholds for channel busy detection
4. **Carrier Sense**: Automatic carrier sense before each transmission
5. **Spectral Analysis**: Extended to include RSSI across multiple channels
+66
View File
@@ -26,6 +26,47 @@ My hope is that **pyMC_repeater** serves as a solid, approachable foundation tha
The repeater daemon runs continuously as a background process, forwarding LoRa packets using `pymc_core`'s Dispatcher and packet routing.
Supported Hardware (Out of the Box)
The following hardware is currently supported out-of-the-box:
Waveshare LoRaWAN/GNSS HAT
Hardware: Waveshare SX1262 LoRa HAT
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16
HackerGadgets uConsole
Hardware: uConsole RTL-SDR/LoRa/GPS/RTC/USB Hub
Platform: Clockwork uConsole (Raspberry Pi CM4/CM5)
Frequency: 433/915MHz (configurable)
TX Power: Up to 22dBm
SPI Bus: SPI1
GPIO Pins: CS=-1, Reset=25, Busy=24, IRQ=26
Additional Setup: Requires SPI1 overlay and GPS/RTC configuration (see uConsole setup guide)
Frequency Labs meshadv-mini
Hardware: FrequencyLabs meshadv-mini Hat
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=8, Reset=24, Busy=20, IRQ=16
Frequency Labs meshadv
Hardware: FrequencyLabs meshadv-mini Hat
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, TXEN=13, RXEN=12
...
## Screenshots
@@ -84,7 +125,28 @@ To reconfigure radio and hardware settings after installation, run:
```bash
sudo bash setup-radio-config.sh /etc/pymc_repeater
sudo systemctl restart pymc-repeater
```
## Upgrading
To upgrade an existing installation to the latest version:
```bash
# Navigate to your pyMC_Repeater directory
cd pyMC_Repeater
# Run the upgrade script
sudo ./upgrade.sh
```
The upgrade script will:
- Pull the latest code from the main branch
- Update all application files
- Upgrade Python dependencies if needed
- Restart the service automatically
- Preserve your existing configuration
## Uninstallation
@@ -162,3 +224,7 @@ Pre-commit hooks will automatically:
## License
This project is licensed under the MIT License - see the LICENSE file for details.
+4 -2
View File
@@ -89,9 +89,11 @@ delays:
direct_tx_delay_factor: 0.5
duty_cycle:
# Enable/disable duty cycle enforcement
# Set to false to disable airtime limits
enforcement_enabled: false
# Maximum airtime per minute in milliseconds
# US/AU FCC limit: 100% duty cycle (3600ms/min)
# EU ETSI limit: 1% duty cycle (36ms/min)
max_airtime_per_minute: 3600
logging:
+2 -10
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pymc_repeater"
version = "1.0.0"
version = "1.0.1"
authors = [
{name = "Lloyd", email = "lloyd@rightup.co.uk"},
]
@@ -29,22 +29,14 @@ classifiers = [
keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
#dependencies = [
# "pymc_core[hardware]>=1.0.1",
# "pyyaml>=6.0.0",
# "cherrypy>=18.0.0",
#]
dependencies = [
"pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@dev",
"pymc_core[hardware]>=1.0.2",
"pyyaml>=6.0.0",
"cherrypy>=18.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
+1
View File
@@ -0,0 +1 @@
{"config":{"connect_screen":{"info_message":"The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings."},"remote_management":{"repeaters":{"guest_login_enabled":true,"guest_login_disabled_message":"Guest login has been temporarily disabled. Please try again later.","guest_login_passwords":[""],"flood_routed_guest_login_enabled":true,"flood_routed_guest_login_disabled_message":"To avoid overwhelming the mesh with flood packets, please set a path to log in to a repeater as a guest."}},"suggested_radio_settings":{"info_message":"These radio settings have been suggested by the community.","entries":[{"title":"Australia","description":"915.800MHz / SF10 / BW250 / CR5","frequency":"915.800","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Australia: Victoria","description":"916.575MHz / SF7 / BW62.5 / CR8","frequency":"916.575","spreading_factor":"7","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Narrow)","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Long Range)","description":"869.525MHz / SF11 / BW250 / CR5","frequency":"869.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"EU/UK (Medium Range)","description":"869.525MHz / SF10 / BW250 / CR5","frequency":"869.525","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Czech Republic (Narrow)","description":"869.525MHz / SF7 / BW62.5 / CR5","frequency":"869.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"EU 433MHz (Long Range)","description":"433.650MHz / SF11 / BW250 / CR5","frequency":"433.650","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand","description":"917.375MHz / SF11 / BW250 / CR5","frequency":"917.375","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand (Narrow)","description":"917.375MHz / SF7 / BW62.5 / CR5","frequency":"917.375","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"Portugal 433","description":"433.375MHz / SF9 / BW62.5 / CR6","frequency":"433.375","spreading_factor":"9","bandwidth":"62.5","coding_rate":"6"},{"title":"Portugal 868","description":"869.618MHz / SF7 / BW62.5 / CR6","frequency":"869.618","spreading_factor":"7","bandwidth":"62.5","coding_rate":"6"},{"title":"Switzerland","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"USA/Canada (Recommended)","description":"910.525MHz / SF7 / BW62.5 / CR5","frequency":"910.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"USA/Canada (Alternate)","description":"910.525MHz / SF11 / BW250 / CR5","frequency":"910.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"Vietnam","description":"920.250MHz / SF11 / BW250 / CR5","frequency":"920.250","spreading_factor":"11","bandwidth":"250","coding_rate":"5"}]}}}
+13
View File
@@ -39,6 +39,19 @@
"rxen_pin": 12,
"tx_power": 22,
"preamble_length": 17
},
"meshadv": {
"name": "MeshAdv",
"bus_id": 0,
"cs_id": 0,
"cs_pin": 21,
"reset_pin": 18,
"busy_pin": 20,
"irq_pin": 16,
"txen_pin": 13,
"rxen_pin": 12,
"tx_power": 22,
"preamble_length": 17
}
}
}
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.0.0"
__version__ = "1.0.1"
+23 -44
View File
@@ -2,7 +2,7 @@ import asyncio
import logging
import time
from collections import OrderedDict
from typing import Any, Dict, Optional, Tuple
from typing import Optional, Tuple
from pymc_core.node.handlers.base import BaseHandler
from pymc_core.protocol import Packet
@@ -13,48 +13,13 @@ from pymc_core.protocol.constants import (
ROUTE_TYPE_DIRECT,
ROUTE_TYPE_FLOOD,
)
from pymc_core.protocol.packet_utils import PacketHeaderUtils
from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils
from repeater.airtime import AirtimeManager
logger = logging.getLogger("RepeaterHandler")
class PacketTimingUtils:
@staticmethod
def estimate_airtime_ms(
packet_length_bytes: int, radio_config: Optional[Dict[str, Any]] = None
) -> float:
if radio_config is None:
radio_config = {
"spreading_factor": 10,
"bandwidth": 250000,
"coding_rate": 5,
"preamble_length": 8,
}
sf = radio_config.get("spreading_factor", 10)
bw = radio_config.get("bandwidth", 250000) # Hz
cr = radio_config.get("coding_rate", 5)
preamble = radio_config.get("preamble_length", 8)
# LoRa symbol duration: Ts = 2^SF / BW
symbol_duration_ms = (2**sf) / (bw / 1000)
# Number of payload symbols
payload_symbols = max(
8, int((8 * packet_length_bytes - 4 * sf + 28 + 16) / (4 * (sf - 2))) * (cr + 4)
)
# Total time = preamble + payload
preamble_ms = (preamble + 4.25) * symbol_duration_ms
payload_ms = payload_symbols * symbol_duration_ms
return preamble_ms + payload_ms
class RepeaterHandler(BaseHandler):
@staticmethod
@@ -191,7 +156,7 @@ class RepeaterHandler(BaseHandler):
)
# Check if this is a duplicate
pkt_hash = self._packet_hash(packet)
pkt_hash = packet.calculate_packet_hash().hex()
is_dupe = pkt_hash in self.seen_packets and not transmitted
# Set drop reason for duplicates
@@ -281,11 +246,7 @@ class RepeaterHandler(BaseHandler):
for k in expired:
del self.seen_packets[k]
def _packet_hash(self, packet: Packet) -> str:
if len(packet.payload or b"") >= 8:
return packet.payload[:8].hex()
return (packet.payload or b"").hex()
def _get_drop_reason(self, packet: Packet) -> str:
@@ -389,7 +350,7 @@ class RepeaterHandler(BaseHandler):
def is_duplicate(self, packet: Packet) -> bool:
pkt_hash = self._packet_hash(packet)
pkt_hash = packet.calculate_packet_hash().hex()
if pkt_hash in self.seen_packets:
logger.debug(f"Duplicate suppressed: {pkt_hash[:16]}")
return True
@@ -397,7 +358,7 @@ class RepeaterHandler(BaseHandler):
def mark_seen(self, packet: Packet):
pkt_hash = self._packet_hash(packet)
pkt_hash = packet.calculate_packet_hash().hex()
self.seen_packets[pkt_hash] = time.time()
if len(self.seen_packets) > self.max_cache_size:
@@ -602,6 +563,20 @@ class RepeaterHandler(BaseHandler):
except Exception as e:
logger.error(f"Error sending periodic advert: {e}", exc_info=True)
def get_noise_floor(self) -> Optional[float]:
"""
Get the current noise floor (instantaneous RSSI) from the radio in dBm.
Returns None if radio is not available or reading fails.
"""
try:
radio = self.dispatcher.radio if self.dispatcher else None
if radio and hasattr(radio, 'get_noise_floor'):
return radio.get_noise_floor()
return None
except Exception as e:
logger.debug(f"Failed to get noise floor: {e}")
return None
def get_stats(self) -> dict:
uptime_seconds = time.time() - self.start_time
@@ -620,6 +595,9 @@ class RepeaterHandler(BaseHandler):
rx_per_hour = len(packets_last_hour)
forwarded_per_hour = sum(1 for p in packets_last_hour if p.get("transmitted", False))
# Get current noise floor from radio
noise_floor_dbm = self.get_noise_floor()
stats = {
"local_hash": f"0x{self.local_hash: 02x}",
"duplicate_cache_size": len(self.seen_packets),
@@ -632,6 +610,7 @@ class RepeaterHandler(BaseHandler):
"recent_packets": self.recent_packets,
"neighbors": self.neighbors,
"uptime_seconds": uptime_seconds,
"noise_floor_dbm": noise_floor_dbm,
# Add configuration data
"config": {
"node_name": repeater_config.get("node_name", "Unknown"),
+1 -1
View File
@@ -316,7 +316,7 @@
statusHtml += `<br><small class="drop-reason">${pkt.drop_reason}</small>`;
}
if (hasDuplicates) {
statusHtml += ` <span class="dupe-badge">${pkt.duplicates.length} dupe${pkt.duplicates.length > 1 ? 's' : ''}</span>`;
statusHtml += ` <span class="dupe-badge">${pkt.duplicates.length} Repeat ${pkt.duplicates.length > 1 ? 's' : ''}</span>`;
}
let mainRow = `
+272 -1
View File
@@ -84,7 +84,23 @@
</div>
</nav>
<div class="sidebar-footer">
<!-- Noise Floor Display -->
<div class="noise-floor-display">
<div class="noise-floor-label">RF Noise Floor</div>
<div class="noise-floor-value">
<span id="noise-floor-value">--</span>
<span class="noise-floor-unit">dBm</span>
</div>
<div class="noise-floor-sparkline-container">
<svg id="noise-floor-sparkline" width="220" height="48" viewBox="0 0 220 48" preserveAspectRatio="none"></svg>
</div>
</div>
<div class="sidebar-footer">
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: var(--spacing-md);">
<div class="status-badge" id="status-badge" title="System operational status">Online</div>
<div class="version-badge" id="version-badge" title="Software version">v1.0.0</div>
@@ -152,6 +168,199 @@
.github-link svg {
display: block;
}
/* Noise Floor Display Styling */
.noise-floor-sparkline-container {
width: 100%;
height: 56px;
margin-top: 8px;
background: none;
display: flex;
align-items: center;
justify-content: center;
}
#noise-floor-sparkline {
width: 220px;
height: 48px;
display: block;
background: none;
}
.noise-floor-display {
padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md);
margin: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(135deg, rgba(78, 201, 176, 0.05) 0%, rgba(86, 156, 214, 0.05) 100%);
}
.noise-floor-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #8b949e;
margin-bottom: var(--spacing-xs);
}
.noise-floor-value {
display: flex;
align-items: baseline;
gap: 0.25rem;
margin-bottom: var(--spacing-sm);
}
#noise-floor-value {
font-size: 2rem;
font-weight: 300;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
color: #4ec9b0;
line-height: 1;
letter-spacing: -0.02em;
transition: color 0.3s ease;
}
.noise-floor-unit {
font-size: 0.875rem;
font-weight: 500;
color: #8b949e;
margin-left: 0.125rem;
}
/* Control button styling */
.control-buttons {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.control-btn {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.control-btn.control-btn-active {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
color: #10b981;
}
.control-btn.control-btn-warning {
background: rgba(217, 119, 6, 0.1);
border-color: rgba(217, 119, 6, 0.3);
color: #d97706;
}
.control-btn .icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.control-label {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
}
.control-title {
font-weight: 500;
font-size: 0.875rem;
}
.control-value {
font-size: 0.75rem;
opacity: 0.8;
}
/* Duty cycle stats styling */
.duty-cycle-stats {
margin-bottom: var(--spacing-md);
}
.duty-cycle-bar-container {
width: 100%;
height: 4px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-bottom: var(--spacing-xs);
}
.duty-cycle-bar {
height: 100%;
background-color: #4ade80;
border-radius: 2px;
transition: all 0.3s ease;
width: 0%;
}
.duty-cycle-text {
color: #8b949e;
font-size: 0.75rem;
}
/* Status and version badges */
.status-badge, .version-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.status-badge {
background-color: #10b981;
color: #ffffff;
}
.version-badge {
background-color: rgba(255, 255, 255, 0.1);
color: #8b949e;
}
/* Color states for noise floor value */
#noise-floor-value.excellent {
color: #4ade80;
}
#noise-floor-value.good {
color: #4ec9b0;
}
#noise-floor-value.moderate {
color: #dcdcaa;
}
#noise-floor-value.poor {
color: #f48771;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.noise-floor-display {
padding: var(--spacing-md);
}
#noise-floor-value {
font-size: 1.75rem;
}
}
</style>
<script>
@@ -182,10 +391,72 @@
}
// Update footer stats periodically
// --- Noise Floor Sparkline State ---
const noiseFloorHistory = [];
const noiseFloorHistoryMax = 40; // Number of points in sparkline
function renderNoiseFloorSparkline() {
const svg = document.getElementById('noise-floor-sparkline');
if (!svg) return;
const w = 220, h = 48;
const minDbm = -120, maxDbm = -80;
if (noiseFloorHistory.length < 2) {
svg.innerHTML = '';
return;
}
// Map dBm to Y (inverted: lower dBm = higher Y)
const points = noiseFloorHistory.map((v, i) => {
const x = (i / (noiseFloorHistoryMax - 1)) * w;
let y = h - ((v - minDbm) / (maxDbm - minDbm)) * h;
y = Math.max(0, Math.min(h, y));
return [x, y];
});
// Build SVG path
let path = `M${points[0][0]},${points[0][1]}`;
for (let i = 1; i < points.length; i++) {
path += ` L${points[i][0]},${points[i][1]}`;
}
svg.innerHTML = `<path d="${path}" fill="none" stroke="#4ec9b0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />`;
}
function updateFooterStats() {
fetch('/api/stats')
.then(r => r.json())
.then(data => {
// Update noise floor
const noiseFloor = data.noise_floor_dbm;
const noiseFloorValue = document.getElementById('noise-floor-value');
if (noiseFloor !== null && noiseFloor !== undefined) {
// Display noise floor value
noiseFloorValue.textContent = noiseFloor.toFixed(1);
// Color code based on noise level
noiseFloorValue.className = '';
if (noiseFloor < -115) {
noiseFloorValue.classList.add('excellent'); // Very quiet
} else if (noiseFloor < -105) {
noiseFloorValue.classList.add('good'); // Normal
} else if (noiseFloor < -95) {
noiseFloorValue.classList.add('moderate'); // Moderate noise
} else {
noiseFloorValue.classList.add('poor'); // High noise
}
// --- Sparkline update ---
noiseFloorHistory.push(noiseFloor);
if (noiseFloorHistory.length > noiseFloorHistoryMax) {
noiseFloorHistory.shift();
}
renderNoiseFloorSparkline();
} else {
noiseFloorValue.textContent = '--';
noiseFloorValue.className = '';
// Sparkline: clear if no data
noiseFloorHistory.length = 0;
renderNoiseFloorSparkline();
}
// Update version badge
if (data.version) {
document.getElementById('version-badge').textContent = 'v' + data.version;
+413 -12
View File
@@ -5,6 +5,8 @@
<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">
@@ -21,6 +23,14 @@
</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>
@@ -32,6 +42,7 @@
<th>Public Key</th>
<th>Contact Type</th>
<th>Location</th>
<th>Distance</th>
<th>RSSI</th>
<th>SNR</th>
<th>Last Seen</th>
@@ -41,7 +52,7 @@
</thead>
<tbody id="neighbors-table">
<tr>
<td colspan="9" class="empty-message">
<td colspan="10" class="empty-message">
No repeaters discovered yet - waiting for adverts...
</td>
</tr>
@@ -54,6 +65,159 @@
<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() {
@@ -107,10 +271,83 @@
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
? `&lt;${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}&gt;`
: `&lt;${pubkey}&gt;`;
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');
@@ -169,6 +406,7 @@
`;
}).join('');
}
*/
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
@@ -186,6 +424,163 @@
</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;
@@ -294,16 +689,17 @@
.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: "RSSI"; }
.data-table td:nth-child(6)::before { content: "SNR"; }
.data-table td:nth-child(7)::before { content: "Last Seen"; }
.data-table td:nth-child(8)::before { content: "First Seen"; }
.data-table td:nth-child(9)::before { content: "Advert Count"; }
.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(7),
.data-table td:nth-child(8) {
.data-table td:nth-child(8),
.data-table td:nth-child(9) {
display: block;
width: 100%;
margin-right: 0;
@@ -334,8 +730,8 @@
.data-table td:nth-child(1),
.data-table td:nth-child(2),
.data-table td:nth-child(4),
.data-table td:nth-child(7),
.data-table td:nth-child(8) {
.data-table td:nth-child(8),
.data-table td:nth-child(9) {
display: block;
width: 100%;
margin-right: 0;
@@ -371,8 +767,8 @@
.data-table td:nth-child(1),
.data-table td:nth-child(2),
.data-table td:nth-child(4),
.data-table td:nth-child(7),
.data-table td:nth-child(8) {
.data-table td:nth-child(8),
.data-table td:nth-child(9) {
display: block;
width: 100%;
margin-right: 0;
@@ -390,6 +786,11 @@
padding: 2px 4px;
}
}
.distance {
color: #dcdcaa;
font-weight: 600;
}
</style>
</body>
</html>
+4 -4
View File
@@ -34,8 +34,8 @@
</div>
<div class="stat-card">
<div class="stat-label">Success Rate</div>
<div class="stat-value" id="success-rate">0<span class="stat-unit">%</span></div>
<div class="stat-label">Repeats</div>
<div class="stat-value" id="repeat-count">0<span class="stat-unit">packets</span></div>
</div>
</div>
@@ -239,11 +239,11 @@
// Update summary
const rx = data.rx_count || 0;
const tx = data.forwarded_count || 0;
const successRate = rx > 0 ? Math.round((tx / rx) * 100) : 0;
const repeats = tx - rx;
document.getElementById('total-rx').textContent = rx;
document.getElementById('total-tx').textContent = tx;
document.getElementById('success-rate').textContent = successRate;
document.getElementById('repeat-count').textContent = repeats;
// Update charts with data trends
const packets = data.recent_packets || [];
+16 -4
View File
@@ -97,13 +97,25 @@ echo ""
echo "=== Step 2: Select Radio Settings ==="
echo ""
# Fetch config from API
# Fetch config from API with 5 second timeout, fallback to local file
echo "Fetching radio settings from API..."
API_RESPONSE=$(curl -s https://api.meshcore.nz/api/v1/config)
API_RESPONSE=$(curl -s --max-time 5 https://api.meshcore.nz/api/v1/config 2>/dev/null)
if [ -z "$API_RESPONSE" ]; then
echo "Error: Failed to fetch configuration from API"
exit 1
echo "Warning: Failed to fetch configuration from API (timeout or error)"
echo "Using local radio presets file..."
LOCAL_PRESETS="$SCRIPT_DIR/radio-presets.json"
if [ ! -f "$LOCAL_PRESETS" ]; then
echo "Error: Local radio presets file not found at $LOCAL_PRESETS"
exit 1
fi
API_RESPONSE=$(cat "$LOCAL_PRESETS")
if [ -z "$API_RESPONSE" ]; then
echo "Error: Failed to read local radio presets file"
exit 1
fi
fi
# Parse JSON entries - one per line, extracting each field
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# Simple upgrade script for pyMC Repeater
set -e
INSTALL_DIR="/opt/pymc_repeater"
SERVICE_USER="repeater"
echo "=== pyMC Repeater Upgrade ==="
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Error: This script must be run as root"
exit 1
fi
# Check if pyMC Repeater is installed
if [ ! -d "$INSTALL_DIR" ]; then
echo "Error: pyMC Repeater is not installed in $INSTALL_DIR"
echo "Please run deploy.sh to install first."
exit 1
fi
# Check if we're in a git repository
if [ ! -d ".git" ]; then
echo "Error: This script must be run from the pyMC Repeater git repository root"
exit 1
fi
# Check service status
SERVICE_WAS_RUNNING=false
if systemctl is-active --quiet pymc-repeater; then
SERVICE_WAS_RUNNING=true
echo "Stopping pyMC Repeater service..."
systemctl stop pymc-repeater
fi
# Pull latest changes
echo "Pulling latest code from main branch..."
git fetch origin
git checkout main
git pull origin main
# Copy updated files
echo "Installing updated files..."
cp -r repeater "$INSTALL_DIR/"
cp pyproject.toml "$INSTALL_DIR/"
cp README.md "$INSTALL_DIR/"
cp setup-radio-config.sh "$INSTALL_DIR/"
cp radio-settings.json "$INSTALL_DIR/"
# Update systemd service if changed
if [ -f "pymc-repeater.service" ]; then
cp pymc-repeater.service /etc/systemd/system/
systemctl daemon-reload
fi
# Update Python package and dependencies
echo "Updating Python package and dependencies..."
cd "$INSTALL_DIR"
pip install --break-system-packages --upgrade --force-reinstall -e .
# Set permissions
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
# Restart service if it was running
if [ "$SERVICE_WAS_RUNNING" = true ]; then
echo "Starting pyMC Repeater service..."
systemctl start pymc-repeater
sleep 2
if systemctl is-active --quiet pymc-repeater; then
echo "✓ Service restarted successfully"
else
echo "✗ Service failed to start - check logs:"
journalctl -u pymc-repeater --no-pager -n 10
exit 1
fi
fi
echo ""
echo "=== Upgrade Complete ==="
echo "Updated to: $(git rev-parse --short HEAD)"
echo ""
echo "Check status: systemctl status pymc-repeater"
echo "View logs: journalctl -u pymc-repeater -f"
echo "Dashboard: http://$(hostname -I | awk '{print $1}'):8000"
echo "----------------------------------"