mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-03-28 17:43:06 +01:00
Add noise floor measurement feature and update dashboard display
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
|
||||
@@ -563,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
|
||||
@@ -581,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),
|
||||
@@ -593,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"),
|
||||
|
||||
@@ -84,7 +84,27 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 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-bar-container">
|
||||
<div class="noise-floor-bar" id="noise-floor-bar"></div>
|
||||
</div>
|
||||
<div class="noise-floor-sparkline-container">
|
||||
<svg id="noise-floor-sparkline" width="100" height="24" viewBox="0 0 100 24" 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 +172,107 @@
|
||||
.github-link svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Noise Floor Display Styling */
|
||||
.noise-floor-sparkline-container {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
margin-top: 4px;
|
||||
background: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
#noise-floor-sparkline {
|
||||
width: 100px;
|
||||
height: 24px;
|
||||
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;
|
||||
}
|
||||
|
||||
.noise-floor-bar-container {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.noise-floor-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4ade80 0%, #4ec9b0 50%, #dcdcaa 75%, #f48771 100%);
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
/* 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 +303,80 @@
|
||||
}
|
||||
|
||||
// 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 = 100, h = 24;
|
||||
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" 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');
|
||||
const noiseFloorBar = document.getElementById('noise-floor-bar');
|
||||
|
||||
if (noiseFloor !== null && noiseFloor !== undefined) {
|
||||
// Display noise floor value
|
||||
noiseFloorValue.textContent = noiseFloor.toFixed(1);
|
||||
|
||||
// Calculate bar width (map -120 to -80 dBm range to 0-100%)
|
||||
const minDbm = -120;
|
||||
const maxDbm = -80;
|
||||
const percentage = Math.min(Math.max((noiseFloor - minDbm) / (maxDbm - minDbm) * 100, 0), 100);
|
||||
noiseFloorBar.style.width = percentage + '%';
|
||||
|
||||
// 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 = '--';
|
||||
noiseFloorBar.style.width = '0%';
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user