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