mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-03-28 17:43:06 +01:00
Merge pull request #2 from rightup/dev
Core Improvements & Feature Enhancements
This commit is contained in:
153
NOISE_FLOOR_USAGE.md
Normal file
153
NOISE_FLOOR_USAGE.md
Normal 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
|
||||
33
README.md
33
README.md
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
1
radio-presets.json
Normal 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"}]}}}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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
88
upgrade.sh
Normal 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 "----------------------------------"
|
||||
Reference in New Issue
Block a user