diff --git a/NOISE_FLOOR_USAGE.md b/NOISE_FLOOR_USAGE.md
new file mode 100644
index 0000000..3934c31
--- /dev/null
+++ b/NOISE_FLOOR_USAGE.md
@@ -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
diff --git a/README.md b/README.md
index 89ca14d..a0b36b0 100644
--- a/README.md
+++ b/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.
+
+
+
+
diff --git a/config.yaml.example b/config.yaml.example
index f160724..5b7288e 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -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:
diff --git a/pyproject.toml b/pyproject.toml
index d2626ac..b88f506 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
diff --git a/radio-presets.json b/radio-presets.json
new file mode 100644
index 0000000..40b4b7a
--- /dev/null
+++ b/radio-presets.json
@@ -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"}]}}}
\ No newline at end of file
diff --git a/radio-settings.json b/radio-settings.json
index aee9b0d..1235577 100644
--- a/radio-settings.json
+++ b/radio-settings.json
@@ -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
}
}
}
diff --git a/repeater/__init__.py b/repeater/__init__.py
index 5becc17..5c4105c 100644
--- a/repeater/__init__.py
+++ b/repeater/__init__.py
@@ -1 +1 @@
-__version__ = "1.0.0"
+__version__ = "1.0.1"
diff --git a/repeater/engine.py b/repeater/engine.py
index dd7b4d6..d6252dc 100644
--- a/repeater/engine.py
+++ b/repeater/engine.py
@@ -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"),
diff --git a/repeater/templates/dashboard.html b/repeater/templates/dashboard.html
index 4c65777..50946f9 100644
--- a/repeater/templates/dashboard.html
+++ b/repeater/templates/dashboard.html
@@ -316,7 +316,7 @@
statusHtml += `
${pkt.drop_reason}`;
}
if (hasDuplicates) {
- statusHtml += ` ${pkt.duplicates.length} dupe${pkt.duplicates.length > 1 ? 's' : ''}`;
+ statusHtml += ` ${pkt.duplicates.length} Repeat ${pkt.duplicates.length > 1 ? 's' : ''}`;
}
let mainRow = `
diff --git a/repeater/templates/nav.html b/repeater/templates/nav.html
index 95f9175..9eb248b 100644
--- a/repeater/templates/nav.html
+++ b/repeater/templates/nav.html
@@ -84,7 +84,23 @@
-