Merge pull request #2 from rightup/dev

Core Improvements & Feature Enhancements
This commit is contained in:
Lloyd
2025-10-27 15:44:50 -07:00
committed by GitHub
14 changed files with 1028 additions and 71 deletions

153
NOISE_FLOOR_USAGE.md Normal file
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

View File

@@ -58,6 +58,14 @@ Frequency Labs meshadv-mini
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
...
@@ -118,7 +126,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
@@ -196,3 +225,7 @@ Pre-commit hooks will automatically:
## License
This project is licensed under the MIT License - see the LICENSE file for details.

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:

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"},
]
@@ -27,12 +27,16 @@ classifiers = [
"Topic :: System :: Networking",
]
keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
dependencies = [
"pymc_core[hardware]>=1.0.1",
"pymc_core[hardware]>=1.0.2",
"pyyaml>=6.0.0",
"cherrypy>=18.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",

1
radio-presets.json Normal file
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"}]}}}

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
}
}
}

View File

@@ -1 +1 @@
__version__ = "1.0.0"
__version__ = "1.0.1"

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"),

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 = `

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;

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>

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 || [];

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
upgrade.sh Normal file
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 "----------------------------------"