mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-07 09:23:06 +02:00
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:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"}]}}}
|
||||
@@ -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 @@
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.0.1"
|
||||
|
||||
+23
-44
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
? `<${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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "----------------------------------"
|
||||
Reference in New Issue
Block a user