From 42aec4f74b7289e604276c142c2ba2fec92ad774 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 26 Oct 2025 23:28:38 +0000 Subject: [PATCH 01/17] Update dependencies in pyproject.toml to use development version of pymc_core --- pyproject.toml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d2626ac..6ee3c6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,12 +27,24 @@ classifiers = [ "Topic :: System :: Networking", ] 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]>=1.0.1", + "pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@dev", "pyyaml>=6.0.0", "cherrypy>=18.0.0", ] + + + [project.optional-dependencies] dev = [ "pytest>=7.4.0", From 71a429ebca6e2ab09b4806b7a4b5ec78fc86a5e2 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Mon, 27 Oct 2025 12:45:30 +0000 Subject: [PATCH 02/17] Refactor packet handling: replace custom hash function with built-in packet hash method and clean up unused code --- repeater/engine.py | 49 +++++----------------------------------------- 1 file changed, 5 insertions(+), 44 deletions(-) diff --git a/repeater/engine.py b/repeater/engine.py index dd7b4d6..bdbeeb0 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: From 4cab215b685774ff6c94a2ad8c93b34755b25c42 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Mon, 27 Oct 2025 14:59:16 +0000 Subject: [PATCH 03/17] Update statistics template: rename success rate to repeats and adjust calculation for repeat count --- repeater/templates/neighbors.html | 238 +++++++++++++++++++++++++++++ repeater/templates/statistics.html | 8 +- 2 files changed, 242 insertions(+), 4 deletions(-) diff --git a/repeater/templates/neighbors.html b/repeater/templates/neighbors.html index 117d6d9..dc6fb5c 100644 --- a/repeater/templates/neighbors.html +++ b/repeater/templates/neighbors.html @@ -5,6 +5,8 @@ + +
@@ -21,6 +23,14 @@
+ +
+

Repeater Network Map

+
+
+
+
+

Discovered Repeaters

@@ -54,6 +64,117 @@ From 1065949facaea9d71e9f62b126c2ddc35e8c86fa Mon Sep 17 00:00:00 2001 From: Lloyd Date: Mon, 27 Oct 2025 20:47:20 +0000 Subject: [PATCH 11/17] Add duty cycle enforcement option, update dashboard duplicate badge text, and implement API fetch with fallback to local presets --- config.yaml.example | 6 ++++-- radio-presets.json | 1 + repeater/templates/dashboard.html | 2 +- setup-radio-config.sh | 20 ++++++++++++++++---- 4 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 radio-presets.json 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/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/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/setup-radio-config.sh b/setup-radio-config.sh index ba23757..9413846 100644 --- a/setup-radio-config.sh +++ b/setup-radio-config.sh @@ -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.meshcore2.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 From 511321bb9832744fc02ddd9999fc391dc149e754 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Mon, 27 Oct 2025 21:01:34 +0000 Subject: [PATCH 12/17] Add noise floor measurement feature and update dashboard display --- NOISE_FLOOR_USAGE.md | 153 +++++++++++++++++++++++++++++ repeater/engine.py | 18 ++++ repeater/templates/nav.html | 191 ++++++++++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 NOISE_FLOOR_USAGE.md 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/repeater/engine.py b/repeater/engine.py index bdbeeb0..d6252dc 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -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"), diff --git a/repeater/templates/nav.html b/repeater/templates/nav.html index 95f9175..2d5368b 100644 --- a/repeater/templates/nav.html +++ b/repeater/templates/nav.html @@ -84,7 +84,27 @@
+ + +
+
RF Noise Floor
+
+ -- + dBm +
+
+
+
+
+ +
+
+