Add noise floor measurement feature and update dashboard display

This commit is contained in:
Lloyd
2025-10-27 21:01:34 +00:00
parent 1065949fac
commit 511321bb98
3 changed files with 362 additions and 0 deletions

153
NOISE_FLOOR_USAGE.md Normal file
View File

@@ -0,0 +1,153 @@
# Noise Floor Measurement - Usage Guide
## Overview
The noise floor measurement capability has been added to provide real-time RF environment monitoring.
## Implementation
### 1. Radio Wrapper (pyMC_core)
Added `get_noise_floor()` method to `SX1262Radio` class:
```python
def get_noise_floor(self) -> Optional[float]:
"""
Get current noise floor (instantaneous RSSI) in dBm.
Returns None if radio is not initialized or if reading fails.
"""
```
### 2. Repeater Engine (pyMC_Repeater)
Added `get_noise_floor()` method to `RepeaterHandler` class:
```python
def get_noise_floor(self) -> Optional[float]:
"""
Get the current noise floor (instantaneous RSSI) from the radio in dBm.
Returns None if radio is not available or reading fails.
"""
```
The noise floor is automatically included in the stats dictionary returned by `get_stats()`:
```python
stats = handler.get_stats()
noise_floor = stats.get('noise_floor_dbm') # Returns float or None
```
## Usage Examples
### Example 1: Get Noise Floor Directly
```python
# From the repeater engine
handler = RepeaterHandler(config, dispatcher, local_hash)
noise_floor_dbm = handler.get_noise_floor()
if noise_floor_dbm is not None:
print(f"Current noise floor: {noise_floor_dbm:.1f} dBm")
else:
print("Noise floor not available")
```
### Example 2: Access via Stats
```python
# Get all stats including noise floor
stats = handler.get_stats()
noise_floor = stats.get('noise_floor_dbm')
if noise_floor is not None:
print(f"RF Environment: {noise_floor:.1f} dBm")
```
### Example 3: Monitor RF Environment
```python
import asyncio
async def monitor_rf_environment(handler, interval=5.0):
"""Monitor noise floor every N seconds"""
while True:
noise_floor = handler.get_noise_floor()
if noise_floor is not None:
if noise_floor > -100:
print(f"⚠️ High RF noise: {noise_floor:.1f} dBm")
else:
print(f"✓ Normal RF environment: {noise_floor:.1f} dBm")
await asyncio.sleep(interval)
```
### Example 4: Channel Assessment Before TX
```python
async def should_transmit(handler, threshold_dbm=-110):
"""
Check if channel is clear before transmitting.
Returns True if noise floor is below threshold (channel clear).
"""
noise_floor = handler.get_noise_floor()
if noise_floor is None:
# Can't determine, allow transmission
return True
if noise_floor > threshold_dbm:
# Channel busy - high noise
print(f"Channel busy: {noise_floor:.1f} dBm > {threshold_dbm} dBm")
return False
# Channel clear
return True
```
## Integration with Web Dashboard
The noise floor is automatically available in the `/api/stats` endpoint:
```javascript
// JavaScript example for web dashboard
fetch('/api/stats')
.then(response => response.json())
.then(data => {
const noiseFloor = data.noise_floor_dbm;
if (noiseFloor !== null) {
updateNoiseFloorDisplay(noiseFloor);
}
});
```
## Interpretation
### Typical Values
- **-120 to -110 dBm**: Very quiet RF environment (rural, low interference)
- **-110 to -100 dBm**: Normal RF environment (typical conditions)
- **-100 to -90 dBm**: Moderate RF noise (urban, some interference)
- **-90 dBm and above**: High RF noise (congested environment, potential issues)
### Use Cases
1. **Collision Avoidance**: Check noise floor before transmitting to detect if another station is already transmitting
2. **RF Environment Monitoring**: Track RF noise levels over time for site assessment
3. **Adaptive Transmission**: Adjust TX timing or power based on channel conditions
4. **Debugging**: Identify sources of interference or poor reception
## Technical Details
### Calculation
The noise floor is calculated from the SX1262's instantaneous RSSI register:
```python
raw_rssi = self.lora.getRssiInst()
noise_floor_dbm = -(float(raw_rssi) / 2)
```
### Update Rate
The noise floor is read on-demand when `get_noise_floor()` is called. There is no caching - each call queries the radio hardware directly.
### Error Handling
- Returns `None` if radio is not initialized
- Returns `None` if read fails (hardware error)
- Logs debug message on error (doesn't raise exceptions)
## Future Enhancements
Potential future improvements:
1. **Averaging**: Average noise floor over multiple samples for stability
2. **History**: Track noise floor history for trend analysis
3. **Thresholds**: Configurable thresholds for channel busy detection
4. **Carrier Sense**: Automatic carrier sense before each transmission
5. **Spectral Analysis**: Extended to include RSSI across multiple channels

View File

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

View File

@@ -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;