commit 486f210ae404e618056564455b72ce0a9bd7a0b2 Author: torlando-tech Date: Sun Oct 26 19:02:39 2025 -0400 Initial commit: BLE Reticulum interface diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..4433005 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,133 @@ +# CI/CD Workflows + +This directory contains GitHub Actions and Gitea Actions workflows for automated testing. + +## Workflows + +### test.yml - Automated Test Suite + +This workflow runs on every push and pull request. It includes **two separate jobs** that run in parallel: + +#### Job 1: Unit Tests +- **Purpose**: Test core fragmentation and prioritization logic +- **Files tested**: + - `tests/test_fragmentation.py` + - `tests/test_prioritization.py` +- **Coverage**: `BLEFragmentation.py` module +- **Matrix**: Python 3.8, 3.9, 3.10, 3.11 + +#### Job 2: Integration Tests +- **Purpose**: Test full BLE stack integration without hardware +- **Files tested**: All test files with marker `-m "not hardware"` +- **Coverage**: All `src/RNS/Interfaces/` modules +- **Runtime**: ~2 minutes per Python version +- **Matrix**: Python 3.8, 3.9, 3.10, 3.11 +- **Tests included**: + - Error recovery tests + - Peer interface tests + - Integration tests + - Prioritization tests + - Plus fragmentation unit tests + +## PR Status Checks + +When you create a pull request, you'll see two separate status checks: + +``` +✓ Unit Tests (Python 3.8) +✓ Unit Tests (Python 3.9) +✓ Unit Tests (Python 3.10) +✓ Unit Tests (Python 3.11) + +✓ Integration Tests (Python 3.8) +✓ Integration Tests (Python 3.9) +✓ Integration Tests (Python 3.10) +✓ Integration Tests (Python 3.11) +``` + +Both sets of checks must pass before merging. + +## Coverage Reports + +Coverage reports are uploaded to Codecov for Python 3.11 runs: + +- **Unit coverage**: Tagged with `flags: unit` +- **Integration coverage**: Tagged with `flags: integration` + +This allows tracking coverage trends separately for unit vs integration tests. + +## Local Testing + +To run the same tests locally that CI runs: + +```bash +# Unit tests +pytest tests/test_fragmentation.py tests/test_prioritization.py -v \ + --cov=src/RNS/Interfaces/BLEFragmentation.py \ + --cov-report=term-missing + +# Integration tests +pytest tests/ -v -m "not hardware" \ + --cov=src/RNS/Interfaces \ + --cov-report=term-missing \ + --tb=short +``` + +## Why Two Jobs? + +Separating unit and integration tests provides several benefits: + +1. **Faster Feedback**: Unit tests complete quickly (~30s), giving rapid feedback +2. **Clearer Failures**: Know immediately if it's a core logic issue or integration problem +3. **Parallel Execution**: Both jobs run simultaneously, total time = max(unit, integration) +4. **Separate Coverage**: Track unit test coverage separately from integration coverage +5. **Granular Status**: See exactly which test category failed in PR checks + +## Workflow Triggers + +Both workflows trigger on: +- **Push** to any branch +- **Pull request** to any branch + +## Dependencies + +The workflows install: +- System: `libglib2.0-dev`, `libdbus-1-dev` (for BLE D-Bus support) +- Python: `pytest`, `pytest-asyncio`, `pytest-cov`, `pytest-timeout` +- BLE: `bleak` (BLE client library), `bluezero` (GATT server), `dbus-python` +- Reticulum: `rns` (required for tests) + +## Modifying Workflows + +To add new tests: + +1. Add test file to `tests/` directory +2. Mark appropriately: + - Unit tests: Include in unit test job command + - Integration tests: Will run automatically with `-m "not hardware"` + - Hardware tests: Mark with `@pytest.mark.hardware` to exclude from CI + +The workflow will automatically pick up marked integration tests. + +## Troubleshooting + +### Workflow not triggering +- Check that workflow file is in `.github/workflows/` (GitHub) or `.gitea/workflows/` (Gitea) +- Ensure YAML syntax is valid +- Check branch name matches trigger pattern + +### Tests failing in CI but passing locally +- Check Python version (CI tests multiple versions) +- Verify all dependencies are in `requirements.txt` +- Check for environment-specific paths or configs + +### Coverage upload failing +- This is non-fatal (continue-on-error: true) +- Usually due to Codecov token issues +- Tests still pass/fail correctly + +## Related Documentation + +- Testing guide: [TESTING.md](../../TESTING.md) +- Contributing guide: [CONTRIBUTING.md](../../CONTRIBUTING.md) +- Project README: [README.md](../../README.md) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b240f20 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,119 @@ +name: Tests + +on: + push: + branches: [ "*" ] + pull_request: + branches: [ "*" ] + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libglib2.0-dev libdbus-1-dev + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-asyncio pytest-cov + pip install rns bleak bluezero dbus-python + + - name: Create package structure + run: | + touch src/RNS/__init__.py + touch src/RNS/Interfaces/__init__.py + + - name: Run unit tests + run: | + # Run only unit tests (fragmentation and prioritization) + python -m pytest tests/test_fragmentation.py tests/test_prioritization.py -v \ + --cov=src/RNS/Interfaces/BLEFragmentation.py \ + --cov-report=term-missing \ + --cov-report=xml:coverage-unit.xml + continue-on-error: false + + - name: Upload unit test coverage + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + file: ./coverage-unit.xml + flags: unit + fail_ci_if_error: false + continue-on-error: true + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libglib2.0-dev libdbus-1-dev + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-asyncio pytest-cov pytest-timeout + pip install rns bleak bluezero dbus-python + + - name: Create package structure + run: | + touch src/RNS/__init__.py + touch src/RNS/Interfaces/__init__.py + + - name: Run integration tests + run: | + # Run integration tests (no hardware required) + python -m pytest tests/ -v -m "not hardware" \ + --cov=src/RNS/Interfaces \ + --cov-report=term-missing \ + --cov-report=xml:coverage-integration.xml \ + --tb=short + continue-on-error: false + + - name: Upload integration test coverage + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + file: ./coverage-integration.xml + flags: integration + fail_ci_if_error: false + continue-on-error: true + + - name: Test summary + if: always() + run: | + echo "## Integration Test Results" >> $GITHUB_STEP_SUMMARY + echo "- Python version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY + echo "- Tests run: All integration tests (hardware tests excluded)" >> $GITHUB_STEP_SUMMARY + echo "- See test output above for details" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43cbac7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# Testing +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ +*.cover +.hypothesis/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Jupyter Notebook +.ipynb_checkpoints + +# Distribution / packaging +*.whl + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Logs +*.log + +# OS +Thumbs.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..17a9f4c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,274 @@ +# Contributing to Reticulum BLE Interface + +Thank you for your interest in contributing! This document provides guidelines and information for contributors. + +**Note:** This guide is for **developing/contributing** to the BLE interface code itself. If you want to **use** the BLE interface in your Reticulum setup, see the [Installation section in README.md](README.md#installation). + +## Getting Started + +### Prerequisites + +- Python 3.8 or higher +- Git +- Linux system with BlueZ 5.x +- BLE-enabled hardware for integration testing (Raspberry Pi Zero W recommended, but not required for unit tests) + +### Development Setup + +**Important:** Development uses a virtual environment isolated from your Reticulum installation. This prevents conflicts and allows testing without affecting your production setup. + +1. **Fork and clone the repository** + ```bash + git clone https://github.com/YOUR-USERNAME/ble-reticulum.git + cd ble-reticulum + ``` + +2. **Create and activate virtual environment** + ```bash + python3 -m venv venv + source venv/bin/activate # On Linux/macOS + ``` + +3. **Install Reticulum** (required for tests) + ```bash + pip install rns + ``` + +4. **Install dependencies** (includes runtime and development dependencies) + ```bash + pip install -r requirements-dev.txt + ``` + +5. **Create package structure** (required for imports in tests) + ```bash + touch src/RNS/__init__.py + touch src/RNS/Interfaces/__init__.py + ``` + +6. **Run tests to verify setup** + ```bash + pytest + ``` + +All tests should pass. If you encounter errors, check that you're in the virtual environment and all dependencies are installed. + +## Development Workflow + +### 1. Create a Branch + +Create a feature branch for your work: + +```bash +git checkout -b feature/your-feature-name +``` + +Use descriptive branch names: +- `feature/` - New features +- `fix/` - Bug fixes +- `docs/` - Documentation updates +- `test/` - Test improvements + +### 2. Make Changes + +- Follow existing code style and conventions +- Add tests for new functionality +- Update documentation as needed +- Keep commits focused and atomic + +### 3. Run Tests + +Before submitting, ensure all tests pass: + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src/RNS/Interfaces + +# Run specific test file +pytest tests/test_fragmentation.py -v +``` + +### 4. Commit Changes + +Use clear, descriptive commit messages: + +```bash +git commit -m "feat: Add connection retry backoff" +git commit -m "fix: Handle GATT disconnection edge case" +git commit -m "docs: Update configuration examples" +``` + +### 5. Submit Pull Request + +1. Push your branch to your fork +2. Open a pull request against the main repository +3. Describe your changes clearly +4. Reference any related issues + +## Code Style + +### Python Style + +- Follow PEP 8 guidelines +- Maximum line length: 100 characters +- Use meaningful variable names + +### Code Organization + +- Keep functions focused and single-purpose +- Add docstrings to all public functions and classes +- Use type hints where appropriate +- Handle errors gracefully with proper exception handling + +### Example + +```python +def fragment_packet(self, packet: bytes, mtu: int = 185) -> List[bytes]: + """ + Fragment a packet into BLE-sized chunks. + + Args: + packet: The packet data to fragment + mtu: Maximum transmission unit size (default: 185) + + Returns: + List of packet fragments with headers + + Raises: + ValueError: If packet is empty or MTU is too small + """ + if not packet: + raise ValueError("Cannot fragment empty packet") + # ... implementation +``` + +## Testing Guidelines + +### Writing Tests + +- Write tests for all new functionality +- Use descriptive test names: `test_fragment_packet_handles_empty_input` +- Test both success and failure cases +- Use pytest fixtures for common setup + +### Test Organization + +- Unit tests: Test individual components in isolation +- Integration tests: Test component interactions +- Use mocks for external dependencies (BLE hardware) + +### Example Test + +```python +def test_fragmenter_handles_large_packet(): + """Test that fragmenter correctly splits packets larger than MTU""" + fragmenter = BLEFragmenter(mtu=185) + large_packet = b"x" * 500 + + fragments = fragmenter.fragment_packet(large_packet) + + assert len(fragments) > 1 + assert all(len(f) <= 185 for f in fragments) +``` + +## Documentation + +### Code Documentation + +- Add docstrings to all public functions and classes +- Include parameter descriptions and return values +- Document exceptions that may be raised +- Provide usage examples in docstrings + +### User Documentation + +- Update README.md for user-facing changes +- Update examples/ for configuration changes +- Add troubleshooting tips for common issues +- Keep documentation clear and concise + +## Bug Reports + +When reporting bugs, please include: + +1. **Description**: Clear description of the issue +2. **Steps to reproduce**: Exact steps to trigger the bug +3. **Expected behavior**: What should happen +4. **Actual behavior**: What actually happens +5. **Environment**: + - OS and version + - Python version + - Reticulum version + - BLE hardware +6. **Logs**: Relevant log output (use `rnsd --verbose`) + +### Example Bug Report + +``` +**Bug**: GATT server fails to start on Raspberry Pi Zero W + +**Steps to reproduce**: +1. Install on fresh Raspberry Pi Zero W +2. Configure BLE interface in ~/.reticulum/config +3. Run `rnsd --verbose` + +**Expected**: GATT server starts and advertises + +**Actual**: Error "Failed to register GATT application" + +**Environment**: +- OS: Raspberry Pi OS (Debian 11) +- Python: 3.9.2 +- Reticulum: 1.0.0 +- Hardware: Raspberry Pi Zero W (built-in BLE) + +**Logs**: +[2025-10-26 10:15:23] [ERROR] GATT server registration failed +... +``` + +## Feature Requests + +When suggesting features: + +1. **Use case**: Describe the problem you're trying to solve +2. **Proposed solution**: How you think it should work +3. **Alternatives**: Other solutions you've considered +4. **Impact**: Who would benefit from this feature + +## Review Process + +### Pull Request Review + +Pull requests will be reviewed for: + +- **Functionality**: Does it work as intended? +- **Tests**: Are there adequate tests? +- **Code quality**: Is the code clean and maintainable? +- **Documentation**: Is it properly documented? +- **Compatibility**: Does it maintain backward compatibility? + +### Review Timeline + +- Small fixes: Usually reviewed within 1-3 days +- New features: May take 5-7 days for thorough review +- Complex changes: May require multiple review rounds + +## Questions? + +If you have questions about contributing: + +- Open an issue with the `question` label +- Check existing issues and pull requests +- Review the documentation in the repository + +## Code of Conduct + +- Be respectful and constructive +- Welcome newcomers and help them learn +- Focus on the code, not the person +- Give and receive feedback gracefully + +Thank you for contributing to Reticulum BLE Interface! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..300f94b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 torlando-tech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8a86d9 --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# Reticulum BLE Interface + +A Bluetooth Low Energy (BLE) interface for [Reticulum Network Stack](https://reticulum.network), enabling mesh networking over BLE without additional hardware on Linux devices. + +**⚠️ Platform**: Linux-only (requires BlueZ 5.x for GATT server functionality) +**✅ Tested on**: Raspberry Pi Zero W + +## Features + +- **Zero dongle requirements**: Works with built-in BLE radios (Raspberry Pi, Linux laptops, etc.) +- **Auto-discovery**: Automatically finds and connects to nearby Reticulum BLE nodes +- **Multi-peer mesh**: Supports up to 7 simultaneous connections for mesh networking (may support more, untested) +- **Dual mode operation**: Acts as both central (scanner/client) and peripheral (advertiser/server) +- **Connection prioritization**: RSSI-based smart peer selection with connection history tracking +- **Packet fragmentation**: Handles BLE MTU limitations (20-512 bytes) transparently +- **Enhanced error handling**: Retry logic, exponential backoff, connection recovery +- **Power management**: Three power modes (aggressive/balanced/saver) for battery efficiency or CPU limitations. Saver mode tested on Raspberry Pi Zero W. + +## Installation + +**Prerequisites:** +- Python 3.8 or higher +- Reticulum Network Stack already installed ([installation guide](https://reticulum.network)) +- Linux with BlueZ 5.x + +### Option A: Automated Installation (Recommended) + +The installation script automatically detects your Reticulum setup and installs dependencies in the correct environment: + +```bash +# Download and run installer +git clone https://github.com/torlando-tech/ble-reticulum.git +cd ble-reticulum +chmod +x install.sh +./install.sh +``` + +The script will: +1. ✓ Detect if Reticulum is in a venv or system-wide +2. ✓ Install system dependencies (BlueZ, dbus) +3. ✓ Install Python packages in the correct environment +4. ✓ Copy BLE interface files to `~/.reticulum/interfaces/` +5. ✓ Optionally set up Bluetooth permissions + +### Option B: Manual Installation + +#### 1. Install System Dependencies + +**Debian/Ubuntu/Raspberry Pi OS:** +```bash +sudo apt-get update +sudo apt-get install python3-pip python3-dbus bluez +``` + +**Arch Linux:** +```bash +sudo pacman -S python-pip python-dbus bluez bluez-utils +``` + +#### 2. Install Python Dependencies + +**IMPORTANT:** Install in the same environment as Reticulum! + +**If Reticulum is in a virtual environment:** +```bash +# Activate the same venv where Reticulum is installed +source /path/to/reticulum-venv/bin/activate +pip install -r requirements.txt +``` + +**If Reticulum is installed system-wide:** +```bash +# Install system-wide (may need sudo) +pip install -r requirements.txt +# OR +sudo pip install -r requirements.txt +``` + +#### 3. Copy BLE Interface Files + +```bash +# Copy to Reticulum's interface directory +mkdir -p ~/.reticulum/interfaces +cp src/RNS/Interfaces/BLE*.py ~/.reticulum/interfaces/ +``` + +#### 4. Grant Bluetooth Permissions + +For non-root operation: +```bash +sudo setcap 'cap_net_raw,cap_net_admin+eip' $(which python3) +``` + +**Note:** If Reticulum is in a venv, grant permissions to that Python: +```bash +sudo setcap 'cap_net_raw,cap_net_admin+eip' /path/to/venv/bin/python3 +``` + +## Quick Start + +### 1. Configure Reticulum + +Add the BLE interface to your Reticulum configuration (`~/.reticulum/config`): + +```toml +[[BLE Interface]] + type = BLEInterface + enabled = yes + + # Optional: customize device name + # device_name = My-Reticulum-Node +``` + +For detailed configuration options, see [`examples/config_example.toml`](examples/config_example.toml). + +### 2. Start Reticulum + +```bash +rnsd --verbose +``` + +The interface will: +1. Start advertising as a peripheral (if enabled) +2. Scan for nearby BLE peers +3. Automatically connect to discovered peers +4. Form a mesh network with other BLE nodes + +### 3. Verify Operation + +```bash +# Check interface status +rnstatus + +# Monitor announces +rnid -a +``` + +## Configuration + +The BLE interface supports extensive configuration options. See [`examples/config_example.toml`](examples/config_example.toml) for a fully documented example with all available options. + +### Key Configuration Options + +- **`device_name`**: Advertised device name (auto-generated if not specified) +- **`service_uuid`**: BLE service UUID (must match on all devices) +- **`enable_peripheral`**: Accept incoming connections (default: yes) +- **`enable_central`**: Scan and connect to peers (default: yes) +- **`discovery_interval`**: How often to scan for new peers (default: 5.0 seconds) +- **`max_connections`**: Maximum simultaneous connections (default: 7) +- **`min_rssi`**: Minimum signal strength in dBm (default: -85) +- **`power_mode`**: Power management (aggressive/balanced/saver) + +## Testing + +For detailed testing information, see [TESTING.md](TESTING.md). + +Quick test using example script (no BLE hardware required): +```bash +cd examples +python ble_minimal_test.py test +``` + +## Troubleshooting + +### No peers discovered +- Verify Bluetooth is enabled: `bluetoothctl show` +- Check `service_uuid` matches on all devices +- Try `power_mode = aggressive` for faster discovery +- Increase `min_rssi` to -90 for longer range + +### Connection timeouts +- Increase `connection_timeout` to 60 +- Reduce `max_connections` to 3-5 +- Check for BLE/WiFi interference (both use 2.4 GHz) +- Verify peer is within range (typically 10-30m) + +### GATT server failed to start +- Ensure BlueZ 5.x is installed: `bluetoothd --version` +- Check Bluetooth permissions (see Installation → Manual Installation → step 4) +- Try `sudo rnsd` temporarily to verify (not recommended for production) +- Set `enable_peripheral = no` to disable peripheral mode + +### Permission denied errors +- Grant capabilities to Python (see Installation → Manual Installation → step 4) +- Or run with sudo: `sudo rnsd` (not recommended) + +## Architecture + +The BLE interface consists of four main components: + +- **`BLEInterface.py`**: Main interface implementation, handles discovery and connections +- **`BLEGATTServer.py`**: GATT server for peripheral mode (accepting connections) +- **`BLEFragmentation.py`**: Packet fragmentation/reassembly for BLE MTU limits +- **`BLEAgent.py`**: Per-peer connection management + +## Development Setup + +For contributors and developers who want to work on the BLE interface code itself. + +**Note:** This setup is different from the production installation above. Use a virtual environment for development to avoid conflicts. + +```bash +# Clone repository +git clone https://github.com/torlando-tech/ble-reticulum.git +cd ble-reticulum + +# Create and activate virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install RNS (required for tests) +pip install rns + +# Install all dependencies (runtime + development + testing) +pip install -r requirements-dev.txt + +# Create package structure for tests +touch src/RNS/__init__.py +touch src/RNS/Interfaces/__init__.py + +# Run tests +pytest + +# Run tests with coverage +pytest --cov=src/RNS/Interfaces --cov-report=html +``` + +For detailed development and testing guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md) and [TESTING.md](TESTING.md). + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for: +- Code style guidelines +- Pull request process +- Bug report templates +- Feature request guidelines + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- [Reticulum Network Stack](https://reticulum.network) by Mark Qvist +- Built using [bleak](https://github.com/hbldh/bleak) for BLE central operations +- Built using [bluezero](https://github.com/ukBaz/python-bluezero) for GATT server + +## Links + +- [Reticulum Network Stack](https://reticulum.network) +- [Reticulum Documentation](https://markqvist.github.io/Reticulum/manual/) +- [Reticulum GitHub](https://github.com/markqvist/Reticulum) diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..25eb179 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,470 @@ +# Testing Guide + +This document describes how to test the Reticulum BLE Interface. + +## Test Suite Overview + +The test suite includes: + +- **Unit tests**: Test individual components in isolation +- **Integration tests**: Test component interactions and simulated multi-device scenarios +- **Coverage**: 98+ tests covering core functionality + +## Quick Start + +```bash +# Create and activate virtual environment (recommended) +python3 -m venv venv +source venv/bin/activate # On Linux/macOS + +# Install test dependencies +pip install -r requirements-dev.txt + +# Run all tests +pytest + +# Run with verbose output +pytest -v + +# Run with coverage report +pytest --cov=src/RNS/Interfaces --cov-report=html +``` + +## Test Organization + +### Test Files + +- `conftest.py` - Pytest fixtures and shared test utilities +- `test_fragmentation.py` - Packet fragmentation and reassembly +- `test_gatt_server.py` - GATT server functionality +- `test_ble_peer_interface.py` - Per-peer connection management +- `test_error_recovery.py` - Error handling and recovery +- `test_prioritization.py` - Connection prioritization logic +- `test_multi_device_simulation.py` - Multi-node mesh simulation +- `test_integration.py` - Configuration and integration tests + +### Running Specific Tests + +```bash +# Run single test file +pytest tests/test_fragmentation.py + +# Run single test function +pytest tests/test_fragmentation.py::test_fragment_single_packet + +# Run tests matching pattern +pytest -k "fragment" + +# Run with specific markers +pytest -m "not slow" +``` + +## Test Categories + +### 1. Fragmentation Tests + +Tests for packet fragmentation and reassembly: + +```bash +pytest tests/test_fragmentation.py -v +``` + +Key tests: +- Single packet fragmentation +- Large packet handling (multiple fragments) +- Packet reassembly +- Fragment ordering +- Error cases (corrupted fragments, timeout) + +### 2. GATT Server Tests + +Tests for peripheral mode (GATT server): + +```bash +pytest tests/test_gatt_server.py -v +``` + +Key tests: +- GATT server initialization +- Service registration +- Characteristic read/write +- Notification handling +- Multiple client connections + +### 3. Connection Management Tests + +Tests for peer discovery and connection: + +```bash +pytest tests/test_ble_peer_interface.py -v +``` + +Key tests: +- Peer discovery +- Connection establishment +- Disconnection handling +- Connection state management +- Data transmission + +### 4. Error Recovery Tests + +Tests for error handling: + +```bash +pytest tests/test_error_recovery.py -v +``` + +Key tests: +- Connection timeout handling +- Retry logic +- Exponential backoff +- Blacklist management +- Recovery from errors + +### 5. Prioritization Tests + +Tests for connection prioritization: + +```bash +pytest tests/test_prioritization.py -v +``` + +Key tests: +- RSSI-based scoring +- Connection history tracking +- Peer selection algorithm +- Blacklist expiration + +### 6. Multi-Device Simulation + +Tests for multi-node mesh networking: + +```bash +pytest tests/test_multi_device_simulation.py -v +``` + +Key tests: +- Multiple simultaneous connections +- Packet routing through mesh +- Network topology changes +- Connection rotation + +## Coverage + +### Generate Coverage Report + +```bash +# HTML report (recommended) +pytest --cov=src/RNS/Interfaces --cov-report=html +# Open htmlcov/index.html in browser + +# Terminal report +pytest --cov=src/RNS/Interfaces --cov-report=term-missing + +# XML report (for CI) +pytest --cov=src/RNS/Interfaces --cov-report=xml +``` + +### Coverage Goals + +- Overall coverage: >90% +- Core modules (BLEInterface, BLEFragmentation): >95% +- Error handling paths: >85% + +## Integration Testing + +### Prerequisites + +For integration testing with real BLE hardware: + +- 2+ BLE-enabled devices (e.g., Raspberry Pi Zero W) +- BlueZ 5.x installed +- Devices on same network (for coordination) + +### Setup + +1. Install on each device: + ```bash + pip install -r requirements.txt + cp src/RNS/Interfaces/BLE*.py ~/.reticulum/interfaces/ + ``` + +2. Configure interface on each device (same `service_uuid`): + ```toml + [[BLE Interface]] + type = BLEInterface + enabled = yes + device_name = Device-1 # Unique per device + service_uuid = 00000001-5824-4f48-9e1a-3b3e8f0c1234 + ``` + +3. Start Reticulum on each device: + ```bash + rnsd --verbose + ``` + +### Integration Test Scenarios + +#### Test 1: Peer Discovery + +**Objective**: Verify devices discover each other + +1. Start `rnsd` on both devices +2. Monitor logs for discovery messages +3. Verify: Each device discovers the other within 10 seconds + +Expected output: +``` +[2025-10-26 10:00:15] [INFO] Discovered peer: Device-2 (RSSI: -65 dBm) +``` + +#### Test 2: Connection Establishment + +**Objective**: Verify devices connect successfully + +1. Wait for discovery +2. Monitor logs for connection +3. Check `rnstatus` for active connections + +Expected output: +``` +BLE Interface [Enabled] + Peers: 1 connected, 0 discovered + Active connections: Device-2 (RSSI: -65 dBm) +``` + +#### Test 3: Packet Exchange + +**Objective**: Verify data transmission + +1. Establish connection +2. Send announces from one device +3. Monitor reception on other device + +```bash +# On Device 1 +rnid -a + +# On Device 2 - should receive announce +tail -f ~/.reticulum/logfile +``` + +#### Test 4: Multi-Hop Routing + +**Objective**: Verify mesh routing (requires 3+ devices) + +1. Place devices in line: A <-> B <-> C +2. Ensure A and C can only connect via B +3. Send packets from A to C +4. Verify routing through B + +#### Test 5: Connection Recovery + +**Objective**: Verify reconnection after disconnection + +1. Establish connection +2. Move devices out of range or restart one device +3. Return to range +4. Verify: Automatic reconnection within 60 seconds + +## Performance Testing + +### Throughput Test + +Measure packet transmission rate: + +```python +# Run from examples/ +python ble_minimal_test.py test +``` + +Expected results: +- BLE 4.2 (185 byte MTU): ~15-20 KB/s +- BLE 5.0 (512 byte MTU): ~30-40 KB/s + +### Latency Test + +Measure round-trip time: + +1. Send echo request from Device A +2. Device B responds immediately +3. Measure time from send to receive + +Expected latency: +- Local (same room): 50-200ms +- Medium range (10-15m): 100-500ms + +### Connection Scaling + +Test maximum connections: + +1. Configure `max_connections = 7` +2. Connect 7 devices simultaneously +3. Verify all connections stable + +Expected: All 7 connections maintained for >5 minutes + +## Troubleshooting Tests + +### Test Not Running + +**Problem**: Pytest can't find tests + +**Solution**: +```bash +# Ensure you're in project root +cd /path/to/ble-reticulum + +# Run from root directory +pytest + +# Or specify path explicitly +pytest tests/ +``` + +### Import Errors + +**Problem**: `ModuleNotFoundError: No module named 'RNS'` + +**Solution**: +```bash +# Install in development mode +pip install -e . + +# Or set PYTHONPATH +export PYTHONPATH="${PYTHONPATH}:$(pwd)/src" +pytest +``` + +### Async Warnings + +**Problem**: Warnings about unclosed asyncio resources + +**Solution**: These are usually harmless in tests, but can be suppressed: +```bash +pytest -W ignore::DeprecationWarning +``` + +### BLE Hardware Tests Skipped + +**Problem**: Integration tests marked as skipped + +**Reason**: Unit tests don't require real BLE hardware (they use mocks) + +**Info**: This is expected behavior. Integration tests with real hardware should be run manually. + +## Continuous Integration + +### GitHub Actions + +The repository includes CI configuration in `.github/workflows/test.yml`: + +- Runs on: Python 3.8, 3.9, 3.10, 3.11 +- Tests: All unit tests +- Coverage: Generates coverage report +- Linting: Code style checks (if configured) + +### Running Locally + +Simulate CI environment: + +```bash +# Test on specific Python version +python3.9 -m pytest + +# Test with clean environment +python -m venv test-env +source test-env/bin/activate +pip install -r requirements-dev.txt +pytest +deactivate +``` + +## Test Development + +### Writing New Tests + +1. Create test file in `tests/` directory +2. Import required fixtures from `conftest.py` +3. Write test functions (prefix with `test_`) +4. Use descriptive names and docstrings + +Example: + +```python +import pytest +from RNS.Interfaces.BLEFragmentation import BLEFragmenter + +def test_fragmenter_handles_empty_packet(): + """Test that fragmenter raises error for empty packets""" + fragmenter = BLEFragmenter(mtu=185) + + with pytest.raises(ValueError, match="empty"): + fragmenter.fragment_packet(b"") +``` + +### Using Fixtures + +Common fixtures available in `conftest.py`: + +```python +def test_with_fragmenter(ble_fragmenter): + """Use fragmenter fixture from conftest.py""" + fragments = ble_fragmenter.fragment_packet(b"test data") + assert len(fragments) >= 1 +``` + +### Async Tests + +For async code: + +```python +import pytest + +@pytest.mark.asyncio +async def test_async_operation(): + """Test asynchronous BLE operations""" + result = await some_async_function() + assert result is not None +``` + +## Best Practices + +1. **Run tests before committing** + ```bash + pytest + ``` + +2. **Check coverage for new code** + ```bash + pytest --cov=src/RNS/Interfaces --cov-report=term-missing + ``` + +3. **Test both success and failure cases** + - Happy path + - Error conditions + - Edge cases + +4. **Use meaningful assertions** + ```python + # Good + assert len(fragments) == 3, "Expected 3 fragments for 500-byte packet" + + # Less helpful + assert len(fragments) == 3 + ``` + +5. **Keep tests independent** + - Each test should work in isolation + - Don't rely on test execution order + - Clean up resources in teardown + +## Additional Resources + +- [pytest documentation](https://docs.pytest.org/) +- [pytest-asyncio documentation](https://pytest-asyncio.readthedocs.io/) +- [Coverage.py documentation](https://coverage.readthedocs.io/) + +## Questions? + +If you have questions about testing, please open an issue with the `testing` label. diff --git a/examples/ble_minimal_test.py b/examples/ble_minimal_test.py new file mode 100755 index 0000000..e9f6f0c --- /dev/null +++ b/examples/ble_minimal_test.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +Minimal BLE Interface Test + +This script demonstrates basic BLE interface functionality without +requiring a full Reticulum installation. Use this for development +and testing of the BLE interface itself. + +Usage: + python ble_minimal_test.py [scan|test] + +Commands: + scan - Scan for BLE devices and show what's nearby + test - Test fragmentation without BLE radio +""" + +import sys +import os +import asyncio + +# Add parent directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src')) + +from RNS.Interfaces.BLEFragmentation import BLEFragmenter, BLEReassembler + + +def test_fragmentation(): + """Test fragmentation and reassembly without BLE radio""" + print("=" * 60) + print("BLE Fragmentation Test") + print("=" * 60) + + # Create fragmenter and reassembler + mtu = 185 # Typical BLE 4.2 MTU + fragmenter = BLEFragmenter(mtu=mtu) + reassembler = BLEReassembler() + + # Test different packet sizes + test_cases = [ + (50, "Small packet (no fragmentation)"), + (185, "Exact MTU size"), + (300, "Medium packet (2 fragments)"), + (500, "Large packet (3 fragments)"), + ] + + for size, description in test_cases: + print(f"\n{description}:") + print(f" Packet size: {size} bytes") + + # Create test packet + packet = bytes([0x41 + (i % 26) for i in range(size)]) # A-Z pattern + + # Fragment + fragments = fragmenter.fragment_packet(packet) + print(f" Fragments: {len(fragments)}") + + # Calculate overhead + num_frags, overhead, pct = fragmenter.get_fragment_overhead(size) + print(f" Overhead: {overhead} bytes ({pct:.1f}%)") + + # Show fragment details + for i, frag in enumerate(fragments): + frag_type = {1: "START", 2: "CONTINUE", 3: "END"}.get(frag[0], "UNKNOWN") + print(f" Fragment {i}: {len(frag)} bytes, type={frag_type}") + + # Reassemble + result = None + for frag in fragments: + result = reassembler.receive_fragment(frag, "test_device") + if result is not None: + break + + # Verify + if result == packet: + print(f" ✓ Reassembly successful!") + else: + print(f" ✗ Reassembly failed!") + return False + + # Show statistics + print(f"\nReassembler Statistics:") + stats = reassembler.get_statistics() + for key, value in stats.items(): + print(f" {key}: {value}") + + print("\n" + "=" * 60) + print("All tests passed! ✓") + print("=" * 60) + return True + + +async def scan_ble_devices(): + """Scan for nearby BLE devices""" + print("=" * 60) + print("BLE Device Scanner") + print("=" * 60) + print("Scanning for BLE devices...") + print("(This will take a few seconds)") + print() + + try: + from bleak import BleakScanner + + devices = await BleakScanner.discover(timeout=5.0) + + if not devices: + print("No BLE devices found.") + return + + print(f"Found {len(devices)} device(s):\n") + + for i, device in enumerate(devices, 1): + print(f"{i}. {device.name or 'Unknown'}") + print(f" Address: {device.address}") + + # Get RSSI (API varies by bleak version) + rssi = getattr(device, 'rssi', device.metadata.get('rssi', 'N/A') if hasattr(device, 'metadata') else 'N/A') + print(f" RSSI: {rssi} dBm") + + # Get UUIDs (API varies by bleak version) + uuids = getattr(device, 'uuids', device.metadata.get("uuids", []) if hasattr(device, 'metadata') else []) + if uuids: + print(f" Services: {len(uuids)} advertised") + for uuid in uuids[:3]: # Show first 3 + print(f" - {uuid}") + + print() + + except ImportError: + print("ERROR: bleak library not installed") + print("Install with: pip install bleak>=0.21.0") + return + except Exception as e: + print(f"ERROR: {e}") + return + + print("=" * 60) + + +def show_help(): + """Show usage information""" + print(""" +BLE Interface Minimal Test + +Usage: + python ble_minimal_test.py [command] + +Commands: + scan - Scan for nearby BLE devices + test - Test fragmentation logic (no BLE radio needed) + help - Show this help message + +Examples: + # Test fragmentation + python ble_minimal_test.py test + + # Scan for BLE devices + python ble_minimal_test.py scan + """) + + +def main(): + """Main entry point""" + if len(sys.argv) < 2: + command = "test" # Default command + else: + command = sys.argv[1].lower() + + if command == "test": + test_fragmentation() + elif command == "scan": + asyncio.run(scan_ble_devices()) + elif command == "help": + show_help() + else: + print(f"Unknown command: {command}") + show_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/config_example.toml b/examples/config_example.toml new file mode 100644 index 0000000..70b7bab --- /dev/null +++ b/examples/config_example.toml @@ -0,0 +1,275 @@ +# Example Reticulum configuration with BLE interface +# Place this in ~/.reticulum/config or /etc/reticulum/config + +[reticulum] +enable_transport = No +share_instance = Yes +shared_instance_port = 37428 +instance_control_port = 37429 + +[[BLE Interface]] +type = BLEInterface +enabled = yes + +# ============================================================================ +# DEVICE IDENTIFICATION +# ============================================================================ + +# Device name advertised to other BLE devices +# A unique device name will be auto-generated if not specified +# Max length: ~20 characters for compatibility +# device_name = Reticulum-Node-01 + +# ============================================================================ +# BLE SERVICE UUID +# ============================================================================ + +# BLE Service UUID for Reticulum +# Default: 00000001-5824-4f48-9e1a-3b3e8f0c1234 +# All devices must use the same UUID to discover each other +# Change this to create isolated BLE networks that won't interfere +# service_uuid = 00000001-5824-4f48-9e1a-3b3e8f0c1234 + +# ============================================================================ +# PERIPHERAL MODE (GATT Server) +# ============================================================================ + +# Enable peripheral mode to allow other devices to connect to you +# When enabled: +# - Your device advertises itself as a Reticulum BLE service +# - Other devices can discover and connect to you (you act as server) +# - Supports up to 7 simultaneous central connections +# - Enables true peer-to-peer mesh networking +# +# When disabled: +# - Your device only acts as central (connects to others) +# - You will not be visible in BLE scanner apps +# - Other devices cannot initiate connections to you +# +# Default: yes (recommended for mesh networking) +# Requires: bluezero library (pip install bluezero>=0.9.1) +enable_peripheral = yes + +# ============================================================================ +# CENTRAL MODE (BLE Client) +# ============================================================================ + +# Enable central mode to scan for and connect to other BLE devices +# When enabled: +# - Your device actively scans for nearby Reticulum BLE peripherals +# - Automatically connects to discovered peers (you act as client) +# - Enables outbound connections for mesh networking +# +# When disabled: +# - Your device only acts as peripheral (accepts connections) +# - You will not discover or connect to other devices +# - Other devices must connect to you +# +# Default: yes (recommended for mesh networking) +# Requires: bleak library (pip install bleak>=1.1.1) +enable_central = yes + +# ============================================================================ +# DISCOVERY PARAMETERS +# ============================================================================ + +# How often to scan for new peers (seconds) +# Lower values = faster discovery, higher power consumption +# Higher values = slower discovery, lower power consumption +# Range: 1.0 - 30.0 seconds +# Default: 5.0 +discovery_interval = 5.0 + +# Maximum simultaneous BLE connections +# Conservative default for stability across different BLE adapters +# Linux with BlueZ may support more, but 7 is recommended for reliability +# Range: 1 - 20+ (hardware dependent) +# Default: 7 +max_connections = 7 + +# Minimum signal strength to consider (dBm) +# Range: -100 (very weak) to -30 (very strong) +# Typical values: +# -60 or higher: Close range, high reliability (0-10m) +# -70: Medium range (10-15m) +# -80: Balanced (15-25m) +# -85: Default, more permissive (15-30m) +# -90: Long range, lower reliability (25-30m+) +# Default: -85 +min_rssi = -85 + +# Connection timeout (seconds) +# How long to wait for connection establishment before giving up +# Increase if you see frequent connection timeouts +# Range: 10.0 - 60.0 seconds +# Default: 30.0 +connection_timeout = 30.0 + +# ============================================================================ +# CONNECTION PRIORITIZATION +# ============================================================================ + +# The BLE interface uses intelligent peer selection based on: +# 1. Signal Strength (RSSI): 60% weight - Prioritizes physically close peers +# 2. Connection History: 30% weight - Rewards reliable peers +# 3. Recency: 10% weight - Prefers recently seen active peers +# +# Scoring algorithm automatically selects the best peers to connect to +# based on these factors. No user configuration required for scoring. + +# Connection rotation interval (seconds) +# How often to evaluate existing connections and potentially rotate +# to new peers for mesh diversity +# Set to 0 to disable rotation (not yet fully implemented) +# Range: 60 - 3600 seconds (1 minute - 1 hour) +# Default: 600 (10 minutes) +connection_rotation_interval = 600 + +# Connection retry backoff (seconds) +# Base time to wait before retrying a failed connection +# Actual backoff uses exponential backoff: base * (2^failures) +# Example with base=60: 60s, 120s, 240s, 480s (capped at 8x = 480s) +# Range: 30 - 300 seconds +# Default: 60 +connection_retry_backoff = 60 + +# Maximum connection failures before blacklisting +# Number of consecutive failures before temporarily blacklisting a peer +# Blacklist duration increases with exponential backoff (see above) +# Set higher (e.g., 5) to be more tolerant of intermittent failures +# Set lower (e.g., 2) to be more aggressive about avoiding bad peers +# Range: 1 - 10 +# Default: 3 +max_connection_failures = 3 + +# Blacklist behavior: +# - After max_connection_failures, peer is blacklisted +# - Blacklist duration: connection_retry_backoff * (failures - threshold + 1) +# - Example: 3 failures → 60s, 4 failures → 120s, 5 failures → 240s +# - Blacklist expires automatically, allowing retries +# - Successful connection clears blacklist immediately + +# ============================================================================ +# POWER MANAGEMENT +# ============================================================================ + +# Power management mode +# Options: +# aggressive - Continuous scanning (high power, fastest discovery <5s) +# balanced - Periodic scanning every 5 seconds (default, good balance) +# saver - Minimal scanning every 10 seconds (low power, slow discovery) +# +# Affects: +# - BLE scan frequency and duration +# - Discovery latency (time to find new peers) +# - Battery consumption on mobile devices +# - CPU usage +# +# Recommendations: +# - Desktop/laptop: aggressive or balanced +# - Battery-powered: balanced or saver +# - Critical battery: saver +# Default: balanced +power_mode = balanced + +# ============================================================================ +# ADVANCED / EXPERIMENTAL OPTIONS +# ============================================================================ + +# Local announce forwarding (experimental workaround) +# By default, Reticulum Transport.py does not forward locally-originated +# announces (hops=0) to physical interfaces. This is intentional behavior +# to prevent announce loops, but may affect some use cases where you want +# your local announces to propagate via BLE. +# +# When enabled: +# - Locally-originated announces (from this device) are manually forwarded to BLE peers +# - May help with certain network topologies where local announces need BLE propagation +# - Could potentially cause announce loops if not carefully configured +# +# When disabled (default): +# - Normal Reticulum Transport behavior applies +# - Only relayed announces (hops > 0) are forwarded via BLE +# - Recommended for most use cases +# +# Default: no (disabled) +# Only enable if you understand the implications and have a specific use case +# enable_local_announce_forwarding = no + +# ============================================================================ +# INTERFACE ACCESS CODE (IFAC) SETTINGS +# ============================================================================ + +# Used for network segregation at the Reticulum level +# Optional - leave commented for most use cases +# ifac_size = 8 +# ifac_netname = +# ifac_netkey = + +# ============================================================================ +# ANNOUNCE SETTINGS +# ============================================================================ + +# Announce settings control how much bandwidth is allocated for announces +# Optional - leave commented for most use cases +# announce_cap = 2 # Percentage of bandwidth for announces + +# ============================================================================ +# TROUBLESHOOTING +# ============================================================================ + +# If you experience issues: +# +# 1. No peers discovered: +# - Verify Bluetooth is enabled +# - Check service_uuid matches on all devices +# - Try power_mode = aggressive +# - Increase min_rssi to -90 for longer range +# - Check Bluetooth permissions (Linux: see README) +# +# 2. Connection timeouts: +# - Increase connection_timeout to 60 +# - Reduce max_connections to 3-5 +# - Check for BLE/WiFi interference +# - Verify peer is within range (10-30m) +# +# 3. GATT server failed to start: +# - Install bluezero: pip install bluezero>=0.9.1 dbus-python>=1.2.18 +# - Check Bluetooth permissions (Linux: sudo setcap 'cap_net_raw,cap_net_admin+eip' $(which python3)) +# - Try running with sudo (not recommended for production) +# - Set enable_peripheral = no to disable peripheral mode +# +# 4. Peers blacklisted frequently: +# - Increase max_connection_failures to 5 +# - Check RSSI values (may be at edge of range) +# - Reduce connection_retry_backoff to 30 for faster retries +# +# 5. Device not visible in BLE scanners: +# - Verify enable_peripheral = yes +# - Check GATT server started (logs: "GATT server started and advertising") +# - Restart Bluetooth service (Linux: sudo systemctl restart bluetooth) +# - Check device_name is not too long (max ~20 characters) +# +# For more troubleshooting, see README.md + +# ============================================================================ +# PERFORMANCE TUNING +# ============================================================================ + +# For best performance: +# - Use balanced or aggressive power mode +# - Keep max_connections at 7 (recommended default) +# - Set min_rssi to -80 (reliable range: 15-25m) +# - Use connection prioritization defaults (optimized for most scenarios) +# +# For maximum battery life: +# - Use saver power mode +# - Reduce max_connections to 3-5 +# - Set min_rssi to -70 (shorter range, more reliable connections) +# - Increase discovery_interval to 10 +# +# For maximum range: +# - Use aggressive power mode +# - Set min_rssi to -90 (accept weaker signals) +# - Increase connection_timeout to 45-60 +# - Expect lower reliability and throughput diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..82d5c8f --- /dev/null +++ b/install.sh @@ -0,0 +1,244 @@ +#!/bin/bash +# Reticulum BLE Interface Installation Script +# This script installs the BLE interface to an existing Reticulum installation + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Print functions +print_header() { + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}========================================${NC}" +} + +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +print_error() { + echo -e "${RED}✗${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +print_info() { + echo -e "${BLUE}ℹ${NC} $1" +} + +# Check if running on Linux +if [[ "$OSTYPE" != "linux-gnu"* ]]; then + print_error "This interface only works on Linux (requires BlueZ)" + exit 1 +fi + +print_header "Reticulum BLE Interface Installer" +echo + +# Step 1: Check for Reticulum installation +print_info "Checking for Reticulum installation..." + +RNS_VENV="" +RNS_PYTHON="" +INSTALL_MODE="" + +# Check if rnsd is available +if command -v rnsd &> /dev/null; then + print_success "Found rnsd command" + + # Try to import RNS and find its location + RNS_LOCATION=$(python3 -c "import RNS; print(RNS.__file__)" 2>/dev/null || echo "") + + if [ -n "$RNS_LOCATION" ]; then + print_success "Found RNS Python package at: $RNS_LOCATION" + + # Check if it's in a virtual environment + if [[ "$RNS_LOCATION" == *"/venv/"* ]] || [[ "$RNS_LOCATION" == *"/env/"* ]] || [[ "$VIRTUAL_ENV" != "" ]]; then + # RNS is in a venv + if [ -n "$VIRTUAL_ENV" ]; then + RNS_VENV="$VIRTUAL_ENV" + print_info "RNS is installed in active virtual environment: $VIRTUAL_ENV" + else + # Try to find the venv root + RNS_VENV=$(echo "$RNS_LOCATION" | grep -oP '^.*?/(venv|env)' || echo "") + if [ -n "$RNS_VENV" ]; then + print_info "RNS is installed in virtual environment: $RNS_VENV" + fi + fi + INSTALL_MODE="venv" + RNS_PYTHON="$RNS_VENV/bin/python3" + else + # RNS is system-wide + print_info "RNS is installed system-wide" + INSTALL_MODE="system" + RNS_PYTHON="python3" + fi + fi +else + print_error "Reticulum (rnsd) not found!" + echo + echo "Please install Reticulum first:" + echo " pip install rns" + echo "Or visit: https://reticulum.network" + exit 1 +fi + +echo + +# Step 2: Install system dependencies +print_header "Installing System Dependencies" + +if command -v apt-get &> /dev/null; then + # Debian/Ubuntu/Raspberry Pi OS + print_info "Detected Debian/Ubuntu-based system" + echo "Installing: python3-pip python3-dbus bluez" + sudo apt-get update + sudo apt-get install -y python3-pip python3-dbus bluez + print_success "System dependencies installed" +elif command -v pacman &> /dev/null; then + # Arch Linux + print_info "Detected Arch Linux" + echo "Installing: python-pip python-dbus bluez bluez-utils" + sudo pacman -S --noconfirm python-pip python-dbus bluez bluez-utils + print_success "System dependencies installed" +else + print_warning "Could not detect package manager" + print_info "Please manually install: BlueZ 5.x, python3-dbus" + read -p "Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +echo + +# Step 3: Install Python dependencies +print_header "Installing Python Dependencies" + +if [ "$INSTALL_MODE" = "venv" ]; then + print_info "Installing to virtual environment: $RNS_VENV" + + if [ ! -f "$RNS_PYTHON" ]; then + print_error "Python not found at: $RNS_PYTHON" + exit 1 + fi + + # Activate venv and install + source "$RNS_VENV/bin/activate" + pip install -r requirements.txt + print_success "Python dependencies installed in virtual environment" + +elif [ "$INSTALL_MODE" = "system" ]; then + print_info "Installing system-wide Python packages" + + # Try without sudo first + if pip install -r requirements.txt 2>/dev/null; then + print_success "Python dependencies installed (user)" + else + print_warning "User install failed, trying with sudo..." + sudo pip install -r requirements.txt + print_success "Python dependencies installed (system)" + fi +else + print_error "Could not determine installation mode" + exit 1 +fi + +echo + +# Step 4: Copy BLE interface files +print_header "Installing BLE Interface Files" + +# Determine where to copy files +INTERFACES_DIR="$HOME/.reticulum/interfaces" + +# Create directory if it doesn't exist +mkdir -p "$INTERFACES_DIR" + +# Copy interface files +print_info "Copying BLE interface files to: $INTERFACES_DIR" +cp src/RNS/Interfaces/BLE*.py "$INTERFACES_DIR/" + +# Create __init__.py if it doesn't exist +if [ ! -f "$INTERFACES_DIR/__init__.py" ]; then + touch "$INTERFACES_DIR/__init__.py" +fi + +print_success "BLE interface files installed" +echo " - BLEInterface.py" +echo " - BLEGATTServer.py" +echo " - BLEFragmentation.py" +echo " - BLEAgent.py" + +echo + +# Step 5: Bluetooth permissions +print_header "Bluetooth Permissions" + +print_info "For BLE to work without root, Python needs network capabilities" +echo + +PYTHON_PATH=$(which python3) + +echo "The following command will grant capabilities to Python:" +echo " sudo setcap 'cap_net_raw,cap_net_admin+eip' $PYTHON_PATH" +echo + +read -p "Grant Bluetooth permissions now? (y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + sudo setcap 'cap_net_raw,cap_net_admin+eip' "$PYTHON_PATH" + print_success "Bluetooth permissions granted" +else + print_warning "Skipped. You may need to run rnsd with sudo" + echo " To grant permissions later, run:" + echo " sudo setcap 'cap_net_raw,cap_net_admin+eip' \$(which python3)" +fi + +echo + +# Step 6: Configuration +print_header "Configuration" + +CONFIG_FILE="$HOME/.reticulum/config" + +print_info "Next steps:" +echo +echo "1. Add the BLE interface to your Reticulum config:" +echo " File: $CONFIG_FILE" +echo +echo " Add this section:" +echo " ┌─────────────────────────────────────────┐" +echo " │ [[BLE Interface]] │" +echo " │ type = BLEInterface │" +echo " │ enabled = yes │" +echo " │ │" +echo " │ # Enable both modes for mesh │" +echo " │ enable_peripheral = yes │" +echo " │ enable_central = yes │" +echo " └─────────────────────────────────────────┘" +echo +echo "2. See examples/config_example.toml for all configuration options" +echo +echo "3. Start Reticulum:" +echo " rnsd --verbose" +echo +echo "4. Verify the interface is running:" +echo " rnstatus" +echo + +print_header "Installation Complete!" +print_success "BLE interface is ready to use" +echo +echo "For troubleshooting, see: README.md#troubleshooting" +echo "For configuration options, see: examples/config_example.toml" +echo diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5424732 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,53 @@ +[pytest] +# Pytest configuration for BLE Interface tests + +# Test discovery patterns +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Test paths +testpaths = tests + +# Console output options +console_output_style = progress +addopts = + -v + --strict-markers + --tb=short + --disable-warnings + # Legacy tests with import issues - run explicitly if needed + --ignore=tests/test_bleak_threading_hang.py + --ignore=tests/test_bleak_with_exec_loading.py + --ignore=tests/test_gatt_server.py + --ignore=tests/test_gatt_integration.py + --ignore=tests/test_ble_integration.py + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function + +# Markers for categorizing tests +markers = + unit: Unit tests (fast, no external dependencies) + integration: Integration tests (software-based, no hardware required) + hardware: Tests requiring actual BLE hardware + slow: Slow-running tests (> 5 seconds) + skip_ci: Tests to skip in CI environment + simulation: Multi-device simulation tests + asyncio: Async tests using pytest-asyncio + +# Minimum Python version +minversion = 7.0 + +# Coverage options (if using pytest-cov) +[coverage:run] +source = src/RNS/Interfaces +omit = + */tests/* + */test_*.py + +[coverage:report] +precision = 2 +show_missing = True +skip_covered = False diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..17418b5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,10 @@ +# Development Dependencies for Reticulum BLE Interface +# Install with: pip install -r requirements-dev.txt + +# Include all runtime dependencies +-r requirements.txt + +# Testing frameworks +pytest>=7.0.0 +pytest-asyncio>=0.21.0 +pytest-cov>=4.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ba3836c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +# Reticulum BLE Interface Requirements +# Python >= 3.8 required +# Platform: Linux with BlueZ 5.x (for GATT server support) + +# Core BLE library (central mode - scanning and connecting) +# Version 1.1.1 provides improved BlueZ backend stability +bleak==1.1.1 + +# BLE GATT Server library (peripheral mode - accepting connections) +# Linux-only, requires BlueZ D-Bus API +bluezero>=0.9.1 +dbus-python>=1.2.18 + +# Reticulum Network Stack +# If not already installed, uncomment: +# rns>=1.0.0 + +# Development Dependencies +# For testing and development, use: +# pip install -r requirements-dev.txt diff --git a/src/RNS/Interfaces/BLEAgent.py b/src/RNS/Interfaces/BLEAgent.py new file mode 100644 index 0000000..b21e7e6 --- /dev/null +++ b/src/RNS/Interfaces/BLEAgent.py @@ -0,0 +1,284 @@ +""" +BLE Agent for Automatic Pairing - Reticulum BLE Interface + +This module implements a BlueZ D-Bus agent for handling BLE pairing +automatically without user interaction. This is required for zero-touch +mesh networking where devices need to pair automatically. + +Background: +----------- +BlueZ's GATT caching mechanism (Bluetooth 5.1 Database Hash) triggers +automatic pairing when connecting to BlueZ-based GATT servers. This +happens even when GATT characteristics have no security requirements. + +The pairing is needed for "Service Changed" indication subscriptions +to persist across connections. Without an agent to handle the pairing, +the pairing fails with "Numeric Comparison failed" error. + +Solution: +--------- +Register a BlueZ agent with DisplayOnly or NoInputNoOutput capability +to force "Just Works" pairing method, which auto-completes without +user interaction. + +Security: +--------- +Just Works pairing provides: +- Unauthenticated encryption (BLE Security Mode 1 Level 2) +- Vulnerable to MITM attacks during pairing +- Acceptable for Reticulum use case because: + * BLE is just transport layer + * Reticulum has its own cryptographic security + * Physical BLE range (~10-30m) limits attack surface + * Standard practice for IoT mesh devices + +Author: Reticulum BLE Interface Contributors +License: MIT +Date: 2025-10-15 +""" + +import dbus +import dbus.service +from dbus.mainloop.glib import DBusGMainLoop +import logging +from typing import Optional + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class BLEAgent(dbus.service.Object): + """ + BlueZ Agent for automatic BLE pairing + + Implements org.bluez.Agent1 D-Bus interface to handle pairing + requests automatically without user interaction. + + This enables zero-touch mesh networking where BLE devices can + discover and pair with each other automatically. + """ + + AGENT_PATH = "/org/bluez/reticulum_ble_agent" + + def __init__(self, bus: dbus.SystemBus, capability: str = "NoInputNoOutput"): + """ + Initialize BLE agent + + Args: + bus: D-Bus system bus connection + capability: IO capability - "NoInputNoOutput" (default) or "DisplayOnly" + NoInputNoOutput: Recommended for Linux-to-Linux (avoids MITM requirement) + DisplayOnly: Alternative capability mode (not typically needed for Linux-to-Linux) + """ + super().__init__(bus, self.AGENT_PATH) + self.capability = capability + self._log(f"BLE Agent initialized with capability: {capability}", "INFO") + + def _log(self, message: str, level: str = "INFO"): + """Log message with RNS logging if available, else standard logging""" + try: + import RNS + level_map = { + "DEBUG": RNS.LOG_DEBUG, + "INFO": RNS.LOG_INFO, + "WARNING": RNS.LOG_WARNING, + "ERROR": RNS.LOG_ERROR, + } + RNS.log(f"BLEAgent[{self.capability}] {message}", level_map.get(level, RNS.LOG_INFO)) + except: + # Fallback to standard logging + log_func = getattr(logger, level.lower(), logger.info) + log_func(f"BLEAgent[{self.capability}] {message}") + + # ========== org.bluez.Agent1 Interface Methods ========== + + @dbus.service.method("org.bluez.Agent1", in_signature="", out_signature="") + def Release(self): + """ + Called when agent is unregistered + + This is invoked when the service daemon unregisters the agent. + An agent can use it to do cleanup tasks. + """ + self._log("Agent released by BlueZ", "DEBUG") + + @dbus.service.method("org.bluez.Agent1", in_signature="os", out_signature="") + def AuthorizeService(self, device, uuid): + """ + Auto-authorize all GATT service access + + This method gets called when the service daemon needs to + authorize a connection/service to a device. + + Args: + device: D-Bus object path of the device + uuid: Service UUID to authorize + """ + device_addr = self._format_device_path(device) + self._log(f"Auto-authorizing service {uuid} for {device_addr}", "DEBUG") + return # Implicit success + + @dbus.service.method("org.bluez.Agent1", in_signature="o", out_signature="") + def RequestAuthorization(self, device): + """ + Auto-authorize general authorization requests + + Args: + device: D-Bus object path of the device + """ + device_addr = self._format_device_path(device) + self._log(f"Auto-authorizing connection for {device_addr}", "DEBUG") + return # Implicit success + + @dbus.service.method("org.bluez.Agent1", in_signature="ou", out_signature="") + def RequestConfirmation(self, device, passkey): + """ + Auto-confirm pairing (Just Works method) + + This method gets called for Just Works pairing where both + devices auto-accept the pairing without user interaction. + + Args: + device: D-Bus object path of the device + passkey: Numeric passkey (usually 0 for Just Works) + """ + device_addr = self._format_device_path(device) + self._log(f"Auto-confirming Just Works pairing for {device_addr} (passkey: {passkey})", "INFO") + return # Implicit success - pairing accepted! + + @dbus.service.method("org.bluez.Agent1", in_signature="o", out_signature="u") + def RequestPasskey(self, device): + """ + Return passkey for pairing (fallback) + + Not typically used with DisplayOnly, but implemented for completeness. + + Args: + device: D-Bus object path of the device + + Returns: + Passkey (0 for auto-accept) + """ + device_addr = self._format_device_path(device) + self._log(f"Passkey requested for {device_addr}, returning 0", "DEBUG") + return dbus.UInt32(0) + + @dbus.service.method("org.bluez.Agent1", in_signature="", out_signature="") + def Cancel(self): + """ + Handle pairing cancellation + + Called when pairing is cancelled by the remote device or timeout. + """ + self._log("Pairing cancelled", "WARNING") + + # ========== Helper Methods ========== + + def _format_device_path(self, device_path: str) -> str: + """ + Format D-Bus device path to readable address + + Args: + device_path: D-Bus path like /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF + + Returns: + MAC address like AA:BB:CC:DD:EE:FF + """ + try: + # Extract device part and convert underscores to colons + if isinstance(device_path, str) and "dev_" in device_path: + addr = device_path.split("dev_")[-1].replace("_", ":") + return addr + return str(device_path) + except: + return str(device_path) + + +def register_agent(capability: str = "NoInputNoOutput") -> Optional[BLEAgent]: + """ + Register BLE agent with BlueZ for automatic pairing + + This function creates and registers a D-Bus agent that handles + BLE pairing requests automatically. The agent capability determines + which pairing method is used. + + Capabilities: + ------------- + - NoInputNoOutput: Forces Just Works pairing without MITM (RECOMMENDED) + * Auto-accepts pairing without user interaction + * Avoids MITM (Man-In-The-Middle) protection requirements + * Compatible with headless Linux-to-Linux connections + * Suitable for IoT mesh devices + + - DisplayOnly: Alternative for Just Works with MITM capable devices + * May request MITM protection which requires compatible central device + + Args: + capability: Agent IO capability ("DisplayOnly" or "NoInputNoOutput") + + Returns: + BLEAgent instance if successful, None if failed + + Raises: + Exception: If D-Bus connection or agent registration fails + """ + try: + # Set up D-Bus main loop (required for agents) + DBusGMainLoop(set_as_default=True) + + # Connect to system bus + bus = dbus.SystemBus() + + # Create agent + agent = BLEAgent(bus, capability) + + # Get AgentManager interface + manager_obj = bus.get_object("org.bluez", "/org/bluez") + manager = dbus.Interface(manager_obj, "org.bluez.AgentManager1") + + # Register agent with BlueZ + manager.RegisterAgent(BLEAgent.AGENT_PATH, capability) + + # Request this agent to be the default + manager.RequestDefaultAgent(BLEAgent.AGENT_PATH) + + agent._log(f"✓ Agent registered as default with capability: {capability}", "INFO") + + return agent + + except dbus.exceptions.DBusException as e: + logger.error(f"D-Bus error registering agent: {e}") + raise + except Exception as e: + logger.error(f"Failed to register agent: {type(e).__name__}: {e}") + raise + + +def unregister_agent(agent: Optional[BLEAgent] = None): + """ + Unregister BLE agent from BlueZ + + Args: + agent: BLEAgent instance to unregister (can be None) + """ + try: + bus = dbus.SystemBus() + manager_obj = bus.get_object("org.bluez", "/org/bluez") + manager = dbus.Interface(manager_obj, "org.bluez.AgentManager1") + + # Unregister agent + manager.UnregisterAgent(BLEAgent.AGENT_PATH) + + logger.info(f"Agent unregistered from BlueZ") + + except dbus.exceptions.DBusException as e: + # Agent might not be registered, ignore + logger.debug(f"Agent unregister warning: {e}") + except Exception as e: + logger.warning(f"Error unregistering agent: {e}") + + +# Convenience aliases +register_ble_agent = register_agent +unregister_ble_agent = unregister_agent diff --git a/src/RNS/Interfaces/BLEFragmentation.py b/src/RNS/Interfaces/BLEFragmentation.py new file mode 100644 index 0000000..60c7025 --- /dev/null +++ b/src/RNS/Interfaces/BLEFragmentation.py @@ -0,0 +1,543 @@ +# MIT License +# +# Copyright (c) 2025 Reticulum BLE Interface Contributors +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +BLE Fragmentation Protocol + +Handles fragmentation and reassembly of Reticulum packets for BLE transport. + +BLE has MTU limitations (typically 20-512 bytes) while Reticulum packets +can be up to 500 bytes. This module splits packets into fragments with +headers for reassembly. + +Fragment Header Format (5 bytes): + [Type: 1 byte][Sequence: 2 bytes][Total: 2 bytes][Data: variable] + +Fragment Types: + 0x01 = START - First fragment + 0x02 = CONTINUE - Middle fragment + 0x03 = END - Last fragment +""" + +import time +import struct + +# Import RNS for logging +try: + import RNS +except ImportError: + # Fallback for testing without RNS + RNS = None + + +class BLEFragmenter: + """ + Fragments Reticulum packets into BLE-sized chunks. + + Each fragment includes a header with type, sequence number, and total + fragment count to enable reassembly on the receiving end. + """ + + # Fragment types + TYPE_START = 0x01 + TYPE_CONTINUE = 0x02 + TYPE_END = 0x03 + + # Header size + HEADER_SIZE = 5 # 1 byte type + 2 bytes sequence + 2 bytes total + + def __init__(self, mtu=185): + """ + Initialize fragmenter. + + Args: + mtu: Maximum transmission unit for BLE (default 185 for BLE 4.2) + """ + self.mtu = max(mtu, 20) # Minimum 20 bytes for BLE + # Data payload per fragment = MTU - header + self.payload_size = self.mtu - self.HEADER_SIZE + + if self.payload_size < 1: + raise ValueError(f"MTU {mtu} too small for fragmentation (min {self.HEADER_SIZE + 1})") + + def fragment_packet(self, packet): + """ + Split a Reticulum packet into BLE fragments. + + Args: + packet: bytes, the full Reticulum packet + + Returns: + list of bytes, each element is one BLE fragment with header + data + """ + # DIAGNOSTIC: Entry logging + if RNS: + RNS.log(f"BLEFragmenter: ENTRY fragment_packet({len(packet) if isinstance(packet, bytes) else 'NOT BYTES'} bytes)", RNS.LOG_DEBUG) + + if not isinstance(packet, bytes): + raise TypeError("Packet must be bytes") + + if len(packet) == 0: + raise ValueError("Cannot fragment empty packet") + + packet_size = len(packet) + + # Calculate number of fragments needed + num_fragments = (packet_size + self.payload_size - 1) // self.payload_size + + # MEDIUM #10: Check for sequence number wraparound (16-bit limit: 0-65535) + # Maximum packet size = 65535 * (MTU - 5) + # For MTU=185: max packet = 65535 * 180 = 11,796,300 bytes (~11MB) + # This should be sufficient for Reticulum's use case (typical packets < 500 bytes) + if num_fragments > 65535: + if RNS: + RNS.log(f"BLEFragmenter: Packet too large: {packet_size} bytes requires {num_fragments} fragments (max 65535)", RNS.LOG_ERROR) + max_packet_size = 65535 * self.payload_size + RNS.log(f"BLEFragmenter: Maximum packet size for MTU {self.mtu}: {max_packet_size} bytes", RNS.LOG_ERROR) + raise ValueError( + f"Packet requires {num_fragments} fragments, exceeds max (65535). " + f"Packet size too large for BLE MTU {self.mtu}. " + f"Maximum supported: {65535 * self.payload_size} bytes" + ) + + # Log fragmentation for multi-fragment packets + if RNS and num_fragments > 1: + RNS.log(f"BLEFragmenter: Fragmenting {packet_size} byte packet into {num_fragments} fragments (MTU={self.mtu}, payload={self.payload_size})", RNS.LOG_DEBUG) + elif RNS and num_fragments > 10: + # Warn about very high fragment counts (possible performance issue) + RNS.log(f"BLEFragmenter: High fragment count: {num_fragments} fragments for {packet_size} bytes", RNS.LOG_WARNING) + + # Always use fragmentation protocol for consistency + # Even single-fragment packets get headers for uniform handling + + fragments = [] + + for i in range(num_fragments): + # Determine fragment type + if i == 0: + frag_type = self.TYPE_START + elif i == num_fragments - 1: + frag_type = self.TYPE_END + else: + frag_type = self.TYPE_CONTINUE + + # Extract data for this fragment + start_idx = i * self.payload_size + end_idx = min(start_idx + self.payload_size, packet_size) + data = packet[start_idx:end_idx] + + # Build fragment header + header = struct.pack( + "!BHH", # Network byte order: unsigned char, unsigned short, unsigned short + frag_type, + i, # sequence number + num_fragments # total fragments + ) + + # Combine header + data + fragment = header + data + fragments.append(fragment) + + return fragments + + def get_fragment_overhead(self, packet_size): + """ + Calculate fragmentation overhead for a given packet size. + + Args: + packet_size: Size of packet in bytes + + Returns: + tuple of (num_fragments, total_overhead_bytes, overhead_percentage) + """ + # Always calculate with headers for consistency + num_fragments = (packet_size + self.payload_size - 1) // self.payload_size + overhead_bytes = num_fragments * self.HEADER_SIZE + overhead_pct = (overhead_bytes / packet_size) * 100 if packet_size > 0 else 0.0 + + return (num_fragments, overhead_bytes, overhead_pct) + + +class BLEReassembler: + """ + Reassembles fragmented BLE packets into complete Reticulum packets. + + Maintains reassembly buffers per sender and handles timeouts for + incomplete packets. + """ + + # Default timeout for incomplete packets (30 seconds) + DEFAULT_TIMEOUT = 30.0 + + def __init__(self, timeout=None): + """ + Initialize reassembler. + + Args: + timeout: Seconds to wait for complete packet before discarding (default 30) + """ + self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT + + # Reassembly buffers: {sender_id: buffer_dict} + # buffer_dict: {'fragments': {seq: data}, 'total': int, 'start_time': float} + self.reassembly_buffers = {} + + # Statistics + self.packets_reassembled = 0 + self.packets_timeout = 0 + self.fragments_received = 0 + + def receive_fragment(self, fragment, sender_id=None): + """ + Process incoming fragment and reassemble if complete. + + Args: + fragment: bytes, one BLE fragment (header + data) + sender_id: Identifier of sending device (default None uses 'default') + + Returns: + bytes or None: Complete packet if ready, None if waiting for more fragments + + Raises: + ValueError: If fragment is malformed + """ + # DIAGNOSTIC: Entry logging + if RNS: + RNS.log(f"BLEReassembler: ENTRY receive_fragment({len(fragment) if isinstance(fragment, bytes) else 'NOT BYTES'} bytes, sender={sender_id})", RNS.LOG_DEBUG) + + if not isinstance(fragment, bytes): + raise TypeError("Fragment must be bytes") + + if len(fragment) < BLEFragmenter.HEADER_SIZE: + raise ValueError(f"Fragment too short: {len(fragment)} bytes (min {BLEFragmenter.HEADER_SIZE})") + + sender_id = sender_id if sender_id is not None else "default" + self.fragments_received += 1 + + # Parse header + frag_type, sequence, total = struct.unpack("!BHH", fragment[:BLEFragmenter.HEADER_SIZE]) + data = fragment[BLEFragmenter.HEADER_SIZE:] + + # Validate fragment type + if frag_type not in [BLEFragmenter.TYPE_START, BLEFragmenter.TYPE_CONTINUE, BLEFragmenter.TYPE_END]: + if RNS: + RNS.log(f"BLEReassembler: Invalid fragment type 0x{frag_type:02x} from {sender_id}", RNS.LOG_WARNING) + raise ValueError(f"Invalid fragment type: 0x{frag_type:02x}") + + # Validate sequence and total + if sequence >= total: + if RNS: + RNS.log(f"BLEReassembler: Invalid sequence {sequence} >= total {total} from {sender_id}", RNS.LOG_WARNING) + raise ValueError(f"Invalid sequence {sequence} >= total {total}") + + if total == 0: + if RNS: + RNS.log(f"BLEReassembler: Total fragments cannot be zero from {sender_id}", RNS.LOG_WARNING) + raise ValueError("Total fragments cannot be zero") + + # Log fragment reception (EXTREME level for high-volume operations) + if RNS: + frag_type_name = {1: "START", 2: "CONTINUE", 3: "END"}.get(frag_type, "UNKNOWN") + RNS.log(f"BLEReassembler: Received {frag_type_name} fragment {sequence+1}/{total} from {sender_id} ({len(data)} bytes)", RNS.LOG_EXTREME) + + # Create unique packet key + packet_key = (sender_id, sequence // total, total) # Approximate packet ID + + # If this is the first fragment (sequence 0), create new buffer + if sequence == 0: + # Create new reassembly buffer + self.reassembly_buffers[packet_key] = { + 'fragments': {sequence: data}, + 'total': total, # MEDIUM #7: Store expected total for validation + 'start_time': time.time(), + 'sender_id': sender_id + } + else: + # Find existing buffer for this packet + buffer_key = None + for key, buffer in self.reassembly_buffers.items(): + if (key[0] == sender_id and + buffer['total'] == total and + time.time() - buffer['start_time'] < self.timeout): + buffer_key = key + break + + if buffer_key is None: + # No buffer found - either fragment 0 not received yet or timed out + # Create a temporary buffer in case fragment 0 arrives later + packet_key = (sender_id, sequence // total, total) + if packet_key not in self.reassembly_buffers: + self.reassembly_buffers[packet_key] = { + 'fragments': {}, + 'total': total, + 'start_time': time.time(), + 'sender_id': sender_id + } + + # CRITICAL #3: Duplicate fragment detection (data corruption prevention) + # Check if this fragment was already received with different data + if sequence in self.reassembly_buffers[packet_key]['fragments']: + existing_data = self.reassembly_buffers[packet_key]['fragments'][sequence] + if existing_data == data: + # Benign duplicate (retransmit) - ignore + if RNS: + RNS.log(f"BLEReassembler: Duplicate fragment {sequence} from {sender_id} (ignored)", + RNS.LOG_DEBUG) + return None + else: + # DATA MISMATCH - corruption or protocol error! + if RNS: + RNS.log(f"BLEReassembler: Fragment {sequence} from {sender_id} received twice with " + f"different data! Possible corruption. Discarding buffer.", RNS.LOG_ERROR) + # Discard the entire buffer as it's corrupted + del self.reassembly_buffers[packet_key] + raise ValueError( + f"Fragment {sequence} from {sender_id} received twice with " + f"different data! Possible corruption." + ) + + self.reassembly_buffers[packet_key]['fragments'][sequence] = data + return None + else: + packet_key = buffer_key + + # MEDIUM #7: Validate fragment total consistency + # Ensure all fragments in this packet report the same total count + buffer = self.reassembly_buffers[packet_key] + if buffer['total'] != total: + if RNS: + RNS.log(f"BLEReassembler: Fragment total mismatch for {sender_id}: " + f"expected {buffer['total']}, got {total}. Discarding buffer.", RNS.LOG_ERROR) + # Discard the entire buffer as it's corrupted + del self.reassembly_buffers[packet_key] + raise ValueError( + f"Fragment total mismatch for {sender_id}: " + f"expected {buffer['total']}, got {total}" + ) + + # CRITICAL #3: Duplicate fragment detection (data corruption prevention) + # Check if this fragment was already received with different data + if sequence in self.reassembly_buffers[packet_key]['fragments']: + existing_data = self.reassembly_buffers[packet_key]['fragments'][sequence] + if existing_data == data: + # Benign duplicate (retransmit) - ignore + if RNS: + RNS.log(f"BLEReassembler: Duplicate fragment {sequence} from {sender_id} (ignored)", + RNS.LOG_DEBUG) + return None + else: + # DATA MISMATCH - corruption or protocol error! + if RNS: + RNS.log(f"BLEReassembler: Fragment {sequence} from {sender_id} received twice with " + f"different data! Possible corruption. Discarding buffer.", RNS.LOG_ERROR) + # Discard the entire buffer as it's corrupted + del self.reassembly_buffers[packet_key] + raise ValueError( + f"Fragment {sequence} from {sender_id} received twice with " + f"different data! Possible corruption." + ) + + self.reassembly_buffers[packet_key]['fragments'][sequence] = data + + buffer = self.reassembly_buffers[packet_key] + + # Check if we have all fragments + if len(buffer['fragments']) == total: + # Check for missing sequences + for i in range(total): + if i not in buffer['fragments']: + # Missing fragment + return None + + # All fragments received - reassemble + packet = self._reassemble(buffer) + + # Clean up buffer + del self.reassembly_buffers[packet_key] + + self.packets_reassembled += 1 + + # Log successful reassembly + if RNS: + RNS.log(f"BLEReassembler: Reassembled {len(packet)} byte packet from {total} fragments (sender: {sender_id})", RNS.LOG_DEBUG) + + return packet + + # Not complete yet + return None + + def _reassemble(self, buffer): + """ + Combine fragments in sequence order. + + Args: + buffer: Buffer dict with fragments + + Returns: + bytes: Complete packet data + """ + fragments = buffer['fragments'] + total = buffer['total'] + + # Combine in sequence order + packet_parts = [] + for i in range(total): + if i not in fragments: + raise ValueError(f"Missing fragment {i} during reassembly") + packet_parts.append(fragments[i]) + + return b''.join(packet_parts) + + def cleanup_stale_buffers(self): + """ + Remove packets that timed out. + + Returns: + int: Number of buffers removed + """ + now = time.time() + stale_keys = [] + + for packet_key, buffer in self.reassembly_buffers.items(): + if now - buffer['start_time'] > self.timeout: + stale_keys.append(packet_key) + + for key in stale_keys: + buffer = self.reassembly_buffers[key] + if RNS: + sender = buffer.get('sender_id', 'unknown') + received = len(buffer['fragments']) + total = buffer['total'] + RNS.log(f"BLEReassembler: Packet timeout from {sender} ({received}/{total} fragments received, age: {now - buffer['start_time']:.1f}s)", RNS.LOG_WARNING) + + del self.reassembly_buffers[key] + self.packets_timeout += 1 + + return len(stale_keys) + + def get_statistics(self): + """ + Get reassembly statistics. + + Returns: + dict: Statistics including packets reassembled, timeouts, etc. + """ + return { + 'packets_reassembled': self.packets_reassembled, + 'packets_timeout': self.packets_timeout, + 'fragments_received': self.fragments_received, + 'pending_packets': len(self.reassembly_buffers) + } + + def reset_statistics(self): + """Reset statistics counters.""" + self.packets_reassembled = 0 + self.packets_timeout = 0 + self.fragments_received = 0 + + +class HDLCFramer: + """ + HDLC-style byte stuffing for packet framing. + + Provides an alternative framing method using HDLC byte stuffing, + similar to what RNode uses. This can mark packet boundaries in a + continuous byte stream. + """ + + FLAG = 0x7E # Frame delimiter + ESCAPE = 0x7D # Escape character + ESCAPE_XOR = 0x20 # XOR mask for escaped bytes + + @staticmethod + def frame_packet(packet): + """ + Frame a packet with HDLC byte stuffing. + + Args: + packet: bytes to frame + + Returns: + bytes: Framed packet with FLAG delimiters + """ + if not isinstance(packet, bytes): + raise TypeError("Packet must be bytes") + + # Byte stuff the data + stuffed = bytearray() + for byte in packet: + if byte == HDLCFramer.FLAG or byte == HDLCFramer.ESCAPE: + stuffed.append(HDLCFramer.ESCAPE) + stuffed.append(byte ^ HDLCFramer.ESCAPE_XOR) + else: + stuffed.append(byte) + + # Add FLAG delimiters + frame = bytes([HDLCFramer.FLAG]) + bytes(stuffed) + bytes([HDLCFramer.FLAG]) + return frame + + @staticmethod + def deframe_packet(frame): + """ + Remove HDLC framing and unstuff bytes. + + Args: + frame: bytes, framed packet + + Returns: + bytes: Original packet data + + Raises: + ValueError: If frame is malformed + """ + if not isinstance(frame, bytes): + raise TypeError("Frame must be bytes") + + if len(frame) < 2: + raise ValueError("Frame too short (minimum 2 bytes for delimiters)") + + # Check for FLAG delimiters + if frame[0] != HDLCFramer.FLAG or frame[-1] != HDLCFramer.FLAG: + raise ValueError("Invalid frame: missing FLAG delimiters") + + # Remove delimiters + data = frame[1:-1] + + # Unstuff bytes + unstuffed = bytearray() + escape_next = False + + for byte in data: + if escape_next: + unstuffed.append(byte ^ HDLCFramer.ESCAPE_XOR) + escape_next = False + elif byte == HDLCFramer.ESCAPE: + escape_next = True + elif byte == HDLCFramer.FLAG: + raise ValueError("Unexpected FLAG in frame data") + else: + unstuffed.append(byte) + + if escape_next: + raise ValueError("Frame ends with ESCAPE character") + + return bytes(unstuffed) diff --git a/src/RNS/Interfaces/BLEGATTServer.py b/src/RNS/Interfaces/BLEGATTServer.py new file mode 100644 index 0000000..9991540 --- /dev/null +++ b/src/RNS/Interfaces/BLEGATTServer.py @@ -0,0 +1,628 @@ +""" +BLE GATT Server for Reticulum + +This module implements BLE peripheral mode (GATT server) using the bluezero library +to enable devices to advertise themselves and accept connections from BLE centrals. + +Implementation details: +- Uses bluezero (direct BlueZ D-Bus API) +- Linux-only (requires BlueZ 5.x) +- Thread-based architecture (bluezero publish() blocks) +- Supports multiple concurrent central connections +- MTU negotiation via BlueZ callback options + +Author: Reticulum BLE Interface Contributors +License: MIT +""" + +from __future__ import annotations + +import asyncio +import time +import threading +import queue +from typing import Any, Dict, Optional, Callable +import logging + +try: + from bluezero import peripheral, adapter + BLUEZERO_AVAILABLE = True +except ImportError: + BLUEZERO_AVAILABLE = False + +# Import BLE agent for automatic pairing +try: + from BLEAgent import register_agent, unregister_agent + HAS_BLE_AGENT = True +except ImportError: + try: + from RNS.Interfaces.BLEAgent import register_agent, unregister_agent + HAS_BLE_AGENT = True + except ImportError: + HAS_BLE_AGENT = False + + +class BLEGATTServer: + """ + BLE GATT Server for Reticulum (Peripheral Mode) + + Handles: + - Advertising Reticulum service + - Accepting connections from BLE centrals + - Receiving data via RX characteristic (centrals write to us) + - Sending data via TX characteristic (we notify centrals) + - Managing multiple concurrent central connections + + This enables a device to be discovered by other BLE devices acting as centrals. + """ + + # Service UUID for Reticulum BLE + SERVICE_UUID = "00000001-5824-4f48-9e1a-3b3e8f0c1234" + + # RX Characteristic: Centrals write to this (we receive) + RX_CHAR_UUID = "00000002-5824-4f48-9e1a-3b3e8f0c1234" + + # TX Characteristic: We notify on this (centrals receive) + TX_CHAR_UUID = "00000003-5824-4f48-9e1a-3b3e8f0c1234" + + def __init__(self, interface, device_name: str = "Reticulum-Node", agent_capability: str = "NoInputNoOutput"): + """ + Initialize BLE GATT Server + + Args: + interface: Parent BLEInterface instance + device_name: BLE device name for advertising + agent_capability: Pairing agent capability ("NoInputNoOutput" for Just Works pairing, or "DisplayOnly") + Default "NoInputNoOutput" avoids MITM protection requirements + """ + if not BLUEZERO_AVAILABLE: + raise ImportError("BLE GATT Server requires 'bluezero' library. Install with: pip install bluezero>=0.9.1 dbus-python>=1.2.18") + + self.interface = interface + self.device_name = device_name + self.agent_capability = agent_capability + self.running = False + + # bluezero objects (created in thread) + self.peripheral_obj = None + self.tx_characteristic = None + self.rx_characteristic = None + + # BLE agent for automatic pairing + self.ble_agent = None + + # Threading + self.server_thread = None + self.stop_event = threading.Event() + self.started_event = threading.Event() + self.notification_queue = queue.Queue() + + # Track connected centrals + # Key: central address (if available), Value: connection info + self.connected_centrals: Dict[str, dict] = {} + self._centrals_lock = threading.RLock() # Reentrant lock to allow nested acquisitions in callback chains + + # Callbacks for data handling + self.on_data_received: Optional[Callable] = None # Called when data written to RX + self.on_central_connected: Optional[Callable] = None + self.on_central_disconnected: Optional[Callable] = None + + # Logging + self.log_prefix = f"BLEGATTServer[{device_name}]" + + self._log(f"Initialized bluezero GATT server (agent capability: {agent_capability})", level="DEBUG") + + def _log(self, message: str, level: str = "INFO"): + """Log message with appropriate level""" + # Use RNS.log for consistent logging with interface + try: + import RNS + # Map string level to RNS log levels + level_map = { + "DEBUG": RNS.LOG_DEBUG, + "INFO": RNS.LOG_INFO, + "WARNING": RNS.LOG_WARNING, + "ERROR": RNS.LOG_ERROR, + } + rns_level = level_map.get(level.upper(), RNS.LOG_INFO) + RNS.log(f"{self.log_prefix} {message}", rns_level) + except: + # Fallback to standard logging if RNS not available + log_func = getattr(logging, level.lower(), logging.info) + log_func(f"{self.log_prefix} {message}") + + # ========== bluezero Callbacks (run in server thread) ========== + + def _handle_write_rx(self, value, options): + """ + Handle write request from central (bluezero callback) + + Called when a central writes data to RX characteristic. + This runs in the bluezero thread. + + Args: + value: The data written by the central (list of ints) + options: D-Bus options dict (may contain 'device' address) + + Returns: + value: Echo back the value (required by bluezero) + """ + # DIAGNOSTIC: Entry point for peripheral data reception + value_len = len(value) if hasattr(value, '__len__') else 'N/A' + self._log(f"_handle_write_rx ENTRY: value_len={value_len}, options_keys={list(options.keys())}", level="DEBUG") + + # Convert to bytes - ensure we always have bytes type + if isinstance(value, list): + data = bytes(value) + elif isinstance(value, bytes): + data = value + else: + # Handle other types (bytearray, etc.) + data = bytes(value) + + # Extract central address and MTU from options (if available) + central_address = options.get("device", "unknown") + if central_address and central_address != "unknown": + central_address = central_address.split("/")[-1].replace("_", ":") + + # Extract negotiated MTU from options (BlueZ provides this in GATT server callbacks) + mtu = options.get("mtu", None) + + self._log(f">>> WRITE REQUEST from {central_address}: {len(data)} bytes (type: {type(data).__name__}, MTU: {mtu})", level="INFO") + + # Track this central if not already tracked, and update MTU if provided + with self._centrals_lock: + already_connected = central_address in self.connected_centrals + self._log(f"Central membership check: {central_address} already_connected={already_connected}, dict_size={len(self.connected_centrals)}", level="DEBUG") + + if not already_connected: + self._log(f"New central detected, calling _handle_central_connected({central_address}, mtu={mtu})", level="DEBUG") + self._handle_central_connected(central_address, mtu) + elif mtu is not None: + # Update MTU for existing central (may be negotiated after first connection) + old_mtu = self.connected_centrals[central_address].get("mtu", "unknown") + if old_mtu != mtu: + self.connected_centrals[central_address]["mtu"] = mtu + self._log(f"Updated MTU for {central_address}: {old_mtu} -> {mtu}", level="DEBUG") + + # Pass data to callback for processing + # IMPORTANT: Ensure data is bytes before passing to reassembler + if self.on_data_received: + self._log(f"DIAGNOSTIC: on_data_received callback EXISTS, preparing to call with {len(data)} bytes for {central_address}", level="DEBUG") + try: + # Verify data is bytes before callback + if not isinstance(data, bytes): + self._log(f"WARNING: Converting {type(data).__name__} to bytes before callback", level="WARNING") + data = bytes(data) + + # Call the callback (synchronous call - runs in bluezero thread) + self._log(f"DIAGNOSTIC: CALLING on_data_received({len(data)} bytes, {central_address})", level="DEBUG") + self.on_data_received(data, central_address) + self._log(f"DIAGNOSTIC: on_data_received RETURNED successfully", level="DEBUG") + except Exception as e: + self._log(f"ERROR in data received callback: {type(e).__name__}: {e}", level="ERROR") + import traceback + self._log(f"Traceback: {traceback.format_exc()}", level="ERROR") + else: + self._log(f"DIAGNOSTIC: on_data_received callback is NONE! Data LOST: {len(data)} bytes from {central_address}", level="ERROR") + + return value # bluezero expects us to return the value + + def _handle_central_connected(self, central_address: str, mtu: Optional[int] = None): + """ + Handle new central connection + + Args: + central_address: Address of connected central + mtu: Negotiated MTU size (if available from BlueZ callback) + """ + # DIAGNOSTIC: Method entry + self._log(f"_handle_central_connected ENTRY: address={central_address}, mtu={mtu}, dict_size={len(self.connected_centrals)}", level="DEBUG") + + if central_address in self.connected_centrals: + self._log(f"_handle_central_connected: {central_address} ALREADY in connected_centrals (duplicate call), skipping", level="WARNING") + return + + # Default MTU: 185 bytes is common for BLE 4.2 + # Will be updated if BlueZ provides actual negotiated MTU + effective_mtu = mtu if mtu is not None else 185 + + self.connected_centrals[central_address] = { + "address": central_address, + "connected_at": time.time(), + "bytes_received": 0, + "bytes_sent": 0, + "mtu": effective_mtu, + } + + self._log(f"Central connected: {central_address} (MTU: {effective_mtu})", level="INFO") + + # DIAGNOSTIC: Check callback registration and invoke + callback_registered = self.on_central_connected is not None + self._log(f"on_central_connected callback: registered={callback_registered}", level="DEBUG") + + if self.on_central_connected: + try: + self._log(f"Invoking on_central_connected({central_address})...", level="DEBUG") + self.on_central_connected(central_address) + self._log(f"on_central_connected callback completed successfully for {central_address}", level="DEBUG") + except Exception as e: + self._log(f"Error in central connected callback: {e}", level="ERROR") + + def _handle_central_disconnected(self, central_address: str): + """ + Handle central disconnection + + Args: + central_address: Address of disconnected central + """ + if central_address not in self.connected_centrals: + return + + info = self.connected_centrals[central_address] + self._log( + f"Central disconnected: {central_address} " + f"(RX: {info['bytes_received']}, TX: {info['bytes_sent']})", + level="INFO" + ) + + del self.connected_centrals[central_address] + + if self.on_central_disconnected: + try: + self.on_central_disconnected(central_address) + except Exception as e: + self._log(f"Error in central disconnected callback: {e}", level="ERROR") + + # ========== Server Thread ========== + + def _run_server_thread(self): + """ + Run bluezero GATT server in separate thread + + This thread blocks in peripheral.publish() until stopped. + """ + try: + self._log("Server thread starting...", level="DEBUG") + + # Register BLE agent for automatic pairing (if available) + # MUST be done before creating peripheral to handle initial pairing + if HAS_BLE_AGENT: + try: + self.ble_agent = register_agent(self.agent_capability) + self._log(f"✓ BLE agent registered with capability: {self.agent_capability}", level="INFO") + except Exception as e: + self._log(f"Failed to register BLE agent: {e}. Pairing may fail.", level="WARNING") + self.ble_agent = None + else: + self._log("BLEAgent module not available. Pairing will require manual interaction.", level="WARNING") + + # Suppress bluezero INFO logging to prevent TUI interference + # bluezero logs things like "Notifying already, nothing to do" which + # pollute stdout/stderr and break Nomadnet TUI display + import logging + logging.getLogger('bluezero').setLevel(logging.WARNING) + logging.getLogger('bluezero.GATT').setLevel(logging.WARNING) + logging.getLogger('bluezero.localGATT').setLevel(logging.WARNING) + logging.getLogger('bluezero.adapter').setLevel(logging.WARNING) + logging.getLogger('bluezero.peripheral').setLevel(logging.WARNING) + + # Get Bluetooth adapter + adapters = adapter.list_adapters() + if not adapters: + self._log("No Bluetooth adapters found!", level="ERROR") + self.started_event.set() # Signal failure + return + + local_adapter = adapter.Adapter(adapters[0]) + adapter_address = local_adapter.address + self._log(f"Using adapter: {adapter_address}", level="DEBUG") + + # Create peripheral + self.peripheral_obj = peripheral.Peripheral( + adapter_address, + local_name=self.device_name + ) + + # Add Reticulum service + self.peripheral_obj.add_service( + srv_id=1, + uuid=self.SERVICE_UUID, + primary=True + ) + self._log(f"Added service: {self.SERVICE_UUID}", level="DEBUG") + + # Add RX characteristic (write from central) + self.peripheral_obj.add_characteristic( + srv_id=1, + chr_id=1, + uuid=self.RX_CHAR_UUID, + value=[], + notifying=False, + flags=['write', 'write-without-response'], + write_callback=self._handle_write_rx + ) + self._log(f"Added RX characteristic: {self.RX_CHAR_UUID} (WRITE)", level="DEBUG") + + # Add TX characteristic (notify to central) + self.peripheral_obj.add_characteristic( + srv_id=1, + chr_id=2, + uuid=self.TX_CHAR_UUID, + value=[], + notifying=True, # Enable notifications + flags=['read', 'notify'] + ) + self._log(f"Added TX characteristic: {self.TX_CHAR_UUID} (READ, NOTIFY)", level="DEBUG") + + # Find and save TX characteristic for later notification sends + # Characteristics are stored in order added: chr_id=1 (RX) is index 0, chr_id=2 (TX) is index 1 + if len(self.peripheral_obj.characteristics) >= 2: + self.tx_characteristic = self.peripheral_obj.characteristics[1] # chr_id=2 (TX) + self._log(f"Saved TX characteristic reference (chr_id=2)", level="DEBUG") + else: + self._log(f"ERROR: TX characteristic not found! Only {len(self.peripheral_obj.characteristics)} characteristics", level="ERROR") + self.started_event.set() + return + + self._log("✓ GATT server configured successfully", level="INFO") + + # Signal that server is ready + self.running = True + self.started_event.set() + + # Start publishing (this blocks until stopped) + self._log("Publishing (blocking call)...", level="DEBUG") + self.peripheral_obj.publish() + + except Exception as e: + self._log(f"Server thread error: {type(e).__name__}: {e}", level="ERROR") + import traceback + traceback.print_exc() + self.started_event.set() # Signal failure + finally: + # Unregister agent + if self.ble_agent and HAS_BLE_AGENT: + try: + unregister_agent(self.ble_agent) + self._log("BLE agent unregistered", level="DEBUG") + except Exception as e: + self._log(f"Error unregistering agent: {e}", level="DEBUG") + self.ble_agent = None + + self.running = False + self._log("Server thread exiting", level="DEBUG") + + # ========== Public API (async, compatible with BLEGATTServer) ========== + + async def start(self): + """ + Start the GATT server and begin advertising + + This creates the BLE service and characteristics, then starts advertising + so that BLE centrals can discover and connect to this device. + """ + if self.running: + self._log("Server already running", level="WARNING") + return + + try: + self._log("Starting GATT server...") + + # Reset events + self.stop_event.clear() + self.started_event.clear() + + # Start server thread + self.server_thread = threading.Thread( + target=self._run_server_thread, + daemon=True, + name="bluezero-gatt-server" + ) + self.server_thread.start() + + # Wait for server to start (with timeout) + started = self.started_event.wait(timeout=10.0) + + if not started or not self.running: + self._log("GATT server failed to start within timeout", level="ERROR") + raise TimeoutError("GATT server startup timeout") + + self._log("✓ GATT server started and advertising", level="INFO") + self._log(f"Device name: {self.device_name}", level="INFO") + self._log(f"Service UUID: {self.SERVICE_UUID}", level="DEBUG") + + except Exception as e: + error_type = type(e).__name__ + self._log(f"Failed to start GATT server: {error_type}: {e}", level="ERROR") + self.running = False + raise + + async def stop(self): + """ + Stop the GATT server and advertising + + Disconnects all centrals and stops advertising. + """ + if not self.running: + return + + try: + self._log("Stopping GATT server...") + + # Signal server thread to stop + self.stop_event.set() + self.running = False + + # Wait for thread to exit (with timeout) + if self.server_thread and self.server_thread.is_alive(): + self.server_thread.join(timeout=5.0) + if self.server_thread.is_alive(): + self._log("Server thread did not exit cleanly", level="WARNING") + + # Clean up connected centrals + num_centrals = len(self.connected_centrals) + if num_centrals > 0: + self._log(f"Disconnecting {num_centrals} connected central(s)", level="DEBUG") + + with self._centrals_lock: + self.connected_centrals.clear() + + self._log("✓ GATT server stopped", level="INFO") + + except Exception as e: + error_type = type(e).__name__ + self._log(f"Error stopping GATT server: {error_type}: {e}", level="ERROR") + # Ensure cleanup even on error + self.running = False + with self._centrals_lock: + self.connected_centrals.clear() + + async def send_notification(self, data: bytes, central_address: Optional[str] = None): + """ + Send notification to connected central(s) + + Sends data to a specific central or broadcasts to all connected centrals. + Uses BLE notification mechanism on TX characteristic. + + Args: + data: Data to send (BLE fragment) + central_address: Specific central to send to, or None for broadcast + + Returns: + bool: True if sent successfully, False otherwise + """ + if not self.running or not self.tx_characteristic: + self._log("Cannot send notification: server not running", level="WARNING") + return False + + if not data: + self._log("Cannot send notification: empty data", level="WARNING") + return False + + # Check if target central is connected + if central_address: + with self._centrals_lock: + if central_address not in self.connected_centrals: + self._log(f"Cannot send notification: central {central_address} not connected", level="WARNING") + return False + + try: + # Convert bytes to list of ints (bluezero format) + if isinstance(data, bytes): + value = list(data) + else: + value = data + + # Update TX characteristic value + # bluezero automatically sends notification to subscribed centrals + self.tx_characteristic.set_value(value) + + # Update statistics + with self._centrals_lock: + if central_address and central_address in self.connected_centrals: + self.connected_centrals[central_address]["bytes_sent"] += len(data) + else: + # Broadcast: update all centrals + for addr in self.connected_centrals: + self.connected_centrals[addr]["bytes_sent"] += len(data) + + self._log( + f"Sent notification: {len(data)} bytes to " + f"{central_address if central_address else 'all centrals'}", + level="DEBUG" + ) + + return True + + except Exception as e: + error_type = type(e).__name__ + self._log(f"Error sending notification: {error_type}: {e}", level="ERROR") + return False + + # ========== Connection Management ========== + + def is_connected(self, central_address: str) -> bool: + """ + Check if a central is currently connected + + Args: + central_address: Address to check + + Returns: + bool: True if connected, False otherwise + """ + with self._centrals_lock: + return central_address in self.connected_centrals + + def get_connected_centrals(self) -> list: + """ + Get list of currently connected central addresses + + Returns: + list: List of central addresses + """ + with self._centrals_lock: + return list(self.connected_centrals.keys()) + + def get_connection_info(self, central_address: str) -> Optional[dict]: + """ + Get connection information for a specific central + + Args: + central_address: Address of central + + Returns: + dict: Connection info or None if not connected + """ + with self._centrals_lock: + return self.connected_centrals.get(central_address) + + def get_central_mtu(self, central_address: str) -> int: + """ + Get negotiated MTU for a connected central + + Args: + central_address: Address of central + + Returns: + int: Negotiated MTU size, or 185 (default) if not connected or unknown + """ + with self._centrals_lock: + if central_address in self.connected_centrals: + return self.connected_centrals[central_address].get("mtu", 185) + return 185 # Default fallback + + def get_statistics(self) -> dict: + """ + Get server statistics + + Returns: + dict: Statistics including connected centrals, bytes transferred, etc. + """ + with self._centrals_lock: + total_rx = sum(info["bytes_received"] for info in self.connected_centrals.values()) + total_tx = sum(info["bytes_sent"] for info in self.connected_centrals.values()) + + return { + "running": self.running, + "device_name": self.device_name, + "connected_centrals": len(self.connected_centrals), + "total_bytes_received": total_rx, + "total_bytes_sent": total_tx, + "centrals": list(self.connected_centrals.values()), + } + + def __str__(self) -> str: + """String representation""" + status = "running" if self.running else "stopped" + centrals = len(self.connected_centrals) + return f"BLEGATTServer({self.device_name}, {status}, {centrals} centrals)" + + def __repr__(self) -> str: + """Detailed representation""" + return ( + f"BLEGATTServer(device_name='{self.device_name}', " + f"running={self.running}, " + f"connected_centrals={len(self.connected_centrals)})" + ) diff --git a/src/RNS/Interfaces/BLEInterface.py b/src/RNS/Interfaces/BLEInterface.py new file mode 100644 index 0000000..4bd5127 --- /dev/null +++ b/src/RNS/Interfaces/BLEInterface.py @@ -0,0 +1,2066 @@ +# MIT License +# +# Copyright (c) 2025 Reticulum BLE Interface Contributors +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +BLEInterface - Bluetooth Low Energy interface for Reticulum + +This interface enables Reticulum mesh networking over BLE on Linux devices +without additional hardware. + +Key features: +- Auto-discovery of BLE peers +- Multi-peer mesh support (up to 7 simultaneous connections) +- Packet fragmentation for BLE MTU limits +- Power management modes for battery efficiency +- Linux-only (requires BlueZ 5.x for GATT server) +""" + +import RNS +import sys +import os +import threading +import time +import asyncio +from collections import deque + +# Add interface directory to path for importing other BLE modules +# This is needed when loaded as external interface +try: + # __file__ exists when imported normally + _interface_dir = os.path.dirname(os.path.abspath(__file__)) +except NameError: + # __file__ doesn't exist when loaded via exec() by Reticulum + # Use the default external interface directory + _interface_dir = os.path.expanduser("~/.reticulum/interfaces") + +if _interface_dir not in sys.path: + sys.path.insert(0, _interface_dir) + +# Import base Interface class +# When integrated into Reticulum, this will be: +# from RNS.Interfaces.Interface import Interface +# For now, we'll need to handle the import path +try: + from RNS.Interfaces.Interface import Interface +except ImportError: + # Fallback for development + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../')) + from RNS.Interfaces.Interface import Interface + +# Import fragmentation module +# Note: When loaded as external interface, use absolute imports +try: + from BLEFragmentation import BLEFragmenter, BLEReassembler +except ImportError: + # Fallback for when loaded as part of RNS package + from RNS.Interfaces.BLEFragmentation import BLEFragmenter, BLEReassembler + +# Import GATT server for peripheral mode +try: + from BLEGATTServer import BLEGATTServer + HAS_GATT_SERVER = True +except ImportError: + try: + from RNS.Interfaces.BLEGATTServer import BLEGATTServer + HAS_GATT_SERVER = True + except ImportError: + HAS_GATT_SERVER = False + +# Check for bleak dependency +try: + import bleak + from bleak import BleakScanner, BleakClient + HAS_BLEAK = True +except ImportError: + HAS_BLEAK = False + +# ============================================================================ +# Monkey patch for Bleak 1.1.1 BlueZ ServicesResolved race condition +# ============================================================================ +# Issue: When connecting to BlueZ-based GATT servers (like bluezero), BlueZ +# sets ServicesResolved=True BEFORE services are fully exported to D-Bus +# Cause: BlueZ GATT database cache timing issue (bluez/bluez#1489) +# Impact: Bleak attempts to enumerate services before they're available, +# causing -5 (EIO) error and immediate disconnect +# Fix: Poll D-Bus service map to verify services actually exist before proceeding +# Status: Works with bluezero; proper fix should be in BlueZ or Bleak upstream +# GitHub: https://github.com/hbldh/bleak/issues/1677 +# ============================================================================ +if HAS_BLEAK: + try: + from bleak.backends.bluezdbus.manager import BlueZManager + + # Store original method + _original_wait_for_services_discovery = BlueZManager._wait_for_services_discovery + + async def _patched_wait_for_services_discovery(self, device_path: str) -> None: + """ + Patched version that waits for services to actually appear in D-Bus. + + Fixes race condition where ServicesResolved=True before services + are fully exported to D-Bus (common when connecting to BlueZ peripherals). + """ + # Call original wait for ServicesResolved property + await _original_wait_for_services_discovery(self, device_path) + + # Additional verification: Poll until services actually appear in D-Bus + max_attempts = 20 # 20 attempts * 100ms = 2 seconds max + retry_delay = 0.1 # 100ms between attempts + + for attempt in range(max_attempts): + # Check if services are actually present in the service map + service_paths = self._service_map.get(device_path, set()) + + if service_paths and len(service_paths) > 0: + # Services found! Verify at least one service has been fully loaded + # by checking if it exists in the properties dictionary + try: + first_service_path = next(iter(service_paths)) + if first_service_path in self._properties: + # Success: Services are actually in D-Bus + RNS.log(f"BLE BlueZ timing fix: Services verified in D-Bus after {attempt * retry_delay:.2f}s", RNS.LOG_DEBUG) + return + except (StopIteration, KeyError): + pass # Service not ready yet + + # Services not ready yet, wait before next check + if attempt < max_attempts - 1: # Don't sleep on last attempt + await asyncio.sleep(retry_delay) + + # If we get here, services didn't appear within timeout + # Log warning but don't raise - let get_services() handle it + RNS.log(f"BLE BlueZ timing fix: Services not found in D-Bus after {max_attempts * retry_delay}s, proceeding anyway", RNS.LOG_WARNING) + + # Apply the patch + BlueZManager._wait_for_services_discovery = _patched_wait_for_services_discovery + + RNS.log("Applied Bleak 1.1.1 BlueZ ServicesResolved timing patch for bluezero compatibility", RNS.LOG_INFO) + + except Exception as e: + # If patching fails, log warning but don't prevent interface from loading + RNS.log(f"Failed to apply Bleak BlueZ timing patch: {e}. Connections to bluezero peripherals may fail.", RNS.LOG_WARNING) + + +class DiscoveredPeer: + """ + Tracks information about a discovered BLE peer for connection prioritization. + + This class stores signal strength (RSSI), connection history, and timing + information to enable smart peer selection in mesh networks. + + Algorithm Design Decisions: + --------------------------- + 1. RSSI Tracking: Signal strength is the primary indicator of connection + quality in BLE networks. We track and update RSSI on every discovery + to adapt to changing environmental conditions (movement, obstacles). + + 2. Connection History: Past behavior is a strong predictor of future + reliability. We track attempts vs successes to identify consistently + reachable peers vs flaky ones. + + 3. Temporal Data: Both first_seen and last_seen timestamps enable: + - Recency-based prioritization (prefer active peers) + - Stale peer cleanup (remove disappeared peers) + - Connection attempt rate limiting + + 4. Separation of Concerns: We track successful_connections separately + from failed_connections to enable nuanced scoring (e.g., a peer with + 80% success from 100 attempts is more reliable than one with 100% + from 2 attempts). + """ + + def __init__(self, address, name, rssi): + """ + Initialize a discovered peer. + + Args: + address: BLE MAC address of the peer + name: Advertised device name + rssi: Signal strength in dBm (typically -30 to -100) + """ + self.address = address + self.name = name + self.rssi = rssi + self.first_seen = time.time() + self.last_seen = time.time() + + # Connection tracking + self.connection_attempts = 0 + self.successful_connections = 0 + self.failed_connections = 0 + self.last_connection_attempt = 0 + + def update_rssi(self, rssi): + """Update RSSI and last seen timestamp.""" + self.rssi = rssi + self.last_seen = time.time() + + def record_connection_attempt(self): + """Record that a connection attempt is being made.""" + self.connection_attempts += 1 + self.last_connection_attempt = time.time() + + def record_connection_success(self): + """Record a successful connection.""" + self.successful_connections += 1 + + def record_connection_failure(self): + """Record a failed connection.""" + self.failed_connections += 1 + + def get_success_rate(self): + """ + Get the connection success rate. + + Returns: + float: Success rate from 0.0 to 1.0, or 0.0 if no attempts + """ + if self.connection_attempts == 0: + return 0.0 + return self.successful_connections / self.connection_attempts + + def __repr__(self): + return (f"DiscoveredPeer({self.address}, {self.name}, " + f"RSSI={self.rssi}, attempts={self.connection_attempts}, " + f"success_rate={self.get_success_rate():.2f})") + + +class BLEInterface(Interface): + """ + BLE interface for Reticulum networking. + + Implements the Reticulum Interface API for Bluetooth Low Energy + transport, enabling mesh networking over BLE connections. + + ARCHITECTURE: + - Dual-mode: Acts as both central (client) and peripheral (server) + - Spawns BLEPeerInterface for each connected peer + - Fragments packets larger than BLE MTU (~185 bytes) + - Auto-reconnects on connection loss + + THREADING MODEL: + - Main asyncio loop in separate thread (_run_async_loop) + - LOCK ORDERING CONVENTION (to prevent deadlocks): + 1. peer_lock - ALWAYS acquire first for peer state access + 2. frag_lock - THEN acquire for fragmentation state + NEVER acquire locks in reverse order! (HIGH #2: deadlock prevention) + - Uses asyncio.run_coroutine_threadsafe for cross-thread calls + + MEMORY USAGE (per-peer overhead): + - Fragmenter + Reassembler: ~400 bytes per peer + - Max peers: configurable (default 7) + - Reassembly buffers: Auto-cleanup after 30s timeout (CRITICAL #2) + - Discovery cache: ~100 bytes per discovered device (limited to 100) + + ERROR RECOVERY: + - Connection failure: Exponential backoff + blacklist + - Transmission timeout: Packet dropped (Reticulum retransmits) + - Fragmentation failure: Buffer cleanup after timeout + - Adapter error: Interface marked offline, Transport handles + """ + + # Interface constants + HW_MTU = 500 # Reticulum standard MTU + BITRATE_GUESS = 700_000 # ~700 Kbps average BLE throughput + DEFAULT_IFAC_SIZE = 16 + + # BLE-specific constants + SERVICE_UUID = "00000001-5824-4f48-9e1a-3b3e8f0c1234" # Custom Reticulum BLE service + CHARACTERISTIC_RX_UUID = "00000002-5824-4f48-9e1a-3b3e8f0c1234" # RX characteristic + CHARACTERISTIC_TX_UUID = "00000003-5824-4f48-9e1a-3b3e8f0c1234" # TX characteristic + + # Discovery and connection settings + DISCOVERY_INTERVAL = 5.0 # seconds between discovery scans + CONNECTION_TIMEOUT = 30.0 # seconds before connection times out + MAX_PEERS = 7 # Maximum simultaneous BLE connections (conservative default) + MIN_RSSI = -85 # Minimum signal strength (dBm) - more permissive for better peer discovery + + # Power management modes + POWER_MODE_AGGRESSIVE = "aggressive" # Continuous scanning + POWER_MODE_BALANCED = "balanced" # Intermittent scanning (default) + POWER_MODE_SAVER = "saver" # Minimal scanning + + # Fragmentation constants + FRAG_TYPE_START = 0x01 + FRAG_TYPE_CONTINUE = 0x02 + FRAG_TYPE_END = 0x03 + FRAG_HEADER_SIZE = 5 # bytes: type(1) + sequence(2) + total(2) + + def __init__(self, owner, configuration): + """ + Initialize BLE interface. + + Args: + owner: The Reticulum.Transport instance that owns this interface + configuration: Dictionary or ConfigObj with interface settings + """ + # Check dependencies + if not HAS_BLEAK: + raise ImportError( + "BLEInterface requires the 'bleak' library. " + "Install with: pip install bleak==1.1.1" + ) + + super().__init__() + + # Parse configuration + c = Interface.get_config_obj(configuration) + + # Basic interface setup + self.IN = True + self.OUT = True # Enable bidirectional communication + self.name = c.get("name", "BLEInterface") + self.owner = owner + self.online = False + self.bitrate = BLEInterface.BITRATE_GUESS + self.mode = Interface.MODE_FULL # Full mode: enable announce propagation, meshing, transport + + # BLE configuration + self.service_uuid = c.get("service_uuid", BLEInterface.SERVICE_UUID) + self.device_name = c.get("device_name", f"Reticulum-{RNS.Identity.full_hash(self.name.encode())[:4].hex()}") + self.discovery_interval = float(c.get("discovery_interval", BLEInterface.DISCOVERY_INTERVAL)) + self.max_peers = int(c.get("max_connections", BLEInterface.MAX_PEERS)) + self.min_rssi = int(c.get("min_rssi", BLEInterface.MIN_RSSI)) + self.connection_timeout = float(c.get("connection_timeout", BLEInterface.CONNECTION_TIMEOUT)) + + # Service discovery delay (for bluezero D-Bus registration timing) + # bluezero registers characteristics asynchronously with BlueZ D-Bus + # A small delay after connection allows registration to complete before discovery + self.service_discovery_delay = float(c.get("service_discovery_delay", 1.5)) # Default 1.5s + + # Power management + self.power_mode = c.get("power_mode", BLEInterface.POWER_MODE_BALANCED) + if self.power_mode not in [BLEInterface.POWER_MODE_AGGRESSIVE, + BLEInterface.POWER_MODE_BALANCED, + BLEInterface.POWER_MODE_SAVER]: + RNS.log(f"{self} Invalid power mode '{self.power_mode}', using balanced", RNS.LOG_WARNING) + self.power_mode = BLEInterface.POWER_MODE_BALANCED + + # Central mode (scanning and connecting) configuration + enable_central_val = c.get("enable_central", True) + # Convert string "yes"/"no" to boolean + if isinstance(enable_central_val, str): + self.enable_central = enable_central_val.lower() in ["yes", "true", "1"] + else: + self.enable_central = bool(enable_central_val) + + # Peripheral mode (GATT server) configuration + enable_peripheral_val = c.get("enable_peripheral", True) + # Convert string "yes"/"no" to boolean + if isinstance(enable_peripheral_val, str): + self.enable_peripheral = enable_peripheral_val.lower() in ["yes", "true", "1"] + else: + self.enable_peripheral = bool(enable_peripheral_val) + if self.enable_peripheral and not HAS_GATT_SERVER: + RNS.log(f"{self} Peripheral mode requested but BLEGATTServer not available", RNS.LOG_WARNING) + self.enable_peripheral = False + + # Local announce forwarding workaround + # WORKAROUND: Reticulum Transport.py doesn't forward locally-originated announces (hops=0) + # to physical interfaces. This option enables manual forwarding of local announces to BLE peers. + # See: Transport.py lines 987-1069 (locally originated announces skip forwarding block) + # Default: False (disabled, assume Transport behavior is intentional) + enable_local_announce_val = c.get("enable_local_announce_forwarding", False) + if isinstance(enable_local_announce_val, str): + self.enable_local_announce_forwarding = enable_local_announce_val.lower() in ["yes", "true", "1"] + else: + self.enable_local_announce_forwarding = bool(enable_local_announce_val) + + # State tracking + self.peers = {} # address -> (client, last_seen, mtu) + self.peer_lock = threading.Lock() + self.spawned_interfaces = {} # connection_id -> BLEPeerInterface + # connection_id format: "AA:BB:CC:DD:EE:FF-central" or "AA:BB:CC:DD:EE:FF-peripheral" + # Dual connections: Same peer has TWO interfaces (BitChat model) + + # GATT server for peripheral mode + self.gatt_server = None + if self.enable_peripheral: + try: + self.gatt_server = BLEGATTServer(self, device_name=self.device_name) + # Set up callbacks for server events + self.gatt_server.on_data_received = self.handle_peripheral_data + self.gatt_server.on_central_connected = self.handle_central_connected + self.gatt_server.on_central_disconnected = self.handle_central_disconnected + RNS.log(f"{self} GATT server initialized for peripheral mode", RNS.LOG_DEBUG) + RNS.log(f"{self} registered peripheral callbacks: on_data_received={self.handle_peripheral_data.__name__}, on_central_connected={self.handle_central_connected.__name__}", RNS.LOG_DEBUG) + except Exception as e: + RNS.log(f"{self} Failed to initialize GATT server: {e}", RNS.LOG_ERROR) + self.gatt_server = None + self.enable_peripheral = False + + # Fragmentation + self.fragmenters = {} # address -> BLEFragmenter (per MTU) + self.reassemblers = {} # address -> BLEReassembler + self.frag_lock = threading.Lock() + + # Async event loop (will be created in separate thread) + self.loop = None + self.loop_thread = None + + # Discovery state with prioritization + self.discovered_peers = {} # address -> DiscoveredPeer + self.connection_blacklist = {} # address -> (blacklist_until_timestamp, failure_count) + self.scanning = False + + # HIGH #4: Limit discovered peers to prevent unbounded memory growth + self.max_discovered_peers = int(c.get("max_discovered_peers", 100)) # Reasonable limit for discovery cache + + # Connection prioritization configuration + self.connection_rotation_interval = float(c.get("connection_rotation_interval", 600)) # 10 minutes + self.connection_retry_backoff = float(c.get("connection_retry_backoff", 60)) # 1 minute + self.max_connection_failures = int(c.get("max_connection_failures", 3)) # blacklist threshold + + # Local adapter address (will be populated on first scan) + self.local_address = None + + # BlueZ version and capabilities (for LE-specific connection support) + self.bluez_version = self._detect_bluez_version() + self.has_connect_device = False # Set to True if ConnectDevice() available + + RNS.log(f"{self} initializing with service UUID {self.service_uuid}", RNS.LOG_INFO) + RNS.log(f"{self} power mode: {self.power_mode}, max peers: {self.max_peers}", RNS.LOG_DEBUG) + RNS.log(f"{self} central mode: {'ENABLED' if self.enable_central else 'DISABLED'}", RNS.LOG_INFO) + RNS.log(f"{self} peripheral mode: {'ENABLED' if self.enable_peripheral else 'DISABLED'}", RNS.LOG_INFO) + + # Local announce forwarding status log + if self.enable_local_announce_forwarding: + RNS.log(f"{self} local packet forwarding ENABLED (workaround for Transport hops=0 bug)", RNS.LOG_INFO) + else: + RNS.log(f"{self} local packet forwarding DISABLED (relies on Transport for propagation)", RNS.LOG_DEBUG) + + # Start the interface + self.start() + + def start(self): + """Start the BLE interface operations.""" + RNS.log(f"{self} starting BLE operations", RNS.LOG_INFO) + + # Create and start async event loop in separate thread + self.loop_thread = threading.Thread(target=self._run_async_loop, daemon=True) + self.loop_thread.start() + + # Wait for loop to initialize + max_wait = 5 + waited = 0 + while self.loop is None and waited < max_wait: + time.sleep(0.1) + waited += 0.1 + + if self.loop is None: + RNS.log(f"{self} failed to start async event loop", RNS.LOG_ERROR) + return + + # Schedule discovery to start (if central mode enabled) + if self.enable_central: + asyncio.run_coroutine_threadsafe(self._start_discovery(), self.loop) + else: + RNS.log(f"{self} central mode disabled, skipping peer discovery", RNS.LOG_INFO) + + # Start GATT server if peripheral mode is enabled + if self.gatt_server: + asyncio.run_coroutine_threadsafe(self._start_server(), self.loop) + + # Start periodic cleanup task (CRITICAL #2: prevent unbounded reassembly buffer growth) + asyncio.run_coroutine_threadsafe(self._periodic_cleanup(), self.loop) + + # Bug #13 workaround: Clear stale BLE paths from Transport.path_table + # Reticulum core bug: Paths loaded from storage may have timestamp=0, + # causing immediate expiration and message delivery failures. + # This workaround removes stale BLE paths on interface startup. + # TODO: Remove when upstream Transport.py is fixed (see session notes) + self._clear_stale_ble_paths() + + self.online = True + RNS.log(f"{self} started successfully", RNS.LOG_INFO) + + def _run_async_loop(self): + """Run the asyncio event loop in a separate thread.""" + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.loop.run_forever() + + def _clear_stale_ble_paths(self): + """ + Clear stale BLE paths from Transport.path_table on interface startup. + + Bug #13 workaround: Reticulum core loads path table entries from storage + with timestamp=0 (or very old timestamps), causing paths to immediately + expire. This prevents LXMF message delivery as messages wait for paths + that are constantly expiring and being recreated. + + This workaround clears any BLE paths with invalid timestamps on startup, + forcing fresh path discovery via announces. + + TODO: Remove this workaround when Reticulum core is fixed to refresh + timestamps when loading paths from storage (Transport.py:252). + """ + try: + import RNS.Transport as Transport + + if not hasattr(Transport, 'path_table') or not Transport.path_table: + return + + current_time = time.time() + stale_threshold = 60 # Paths older than 60 seconds are considered stale + stale_paths = [] + + # Scan for stale BLE paths + for dest_hash, entry in list(Transport.path_table.items()): + try: + timestamp = entry[0] # IDX_PT_TIMESTAMP + receiving_interface = entry[5] # IDX_PT_RVCD_IF + + # Check if this is a BLE path + if receiving_interface and "BLE" in str(type(receiving_interface).__name__): + # Check for timestamp=0 bug or very old timestamps + if timestamp == 0: + stale_paths.append((dest_hash, timestamp, "timestamp=0 (Unix epoch bug)")) + elif (current_time - timestamp) > stale_threshold: + stale_paths.append((dest_hash, timestamp, f"age={(current_time - timestamp):.0f}s (stale from previous session)")) + except (IndexError, TypeError) as e: + # Malformed path entry + RNS.log(f"{self} Skipping malformed path table entry: {e}", RNS.LOG_DEBUG) + continue + + # Remove stale paths + if stale_paths: + RNS.log(f"{self} Bug #13 workaround: Found {len(stale_paths)} stale BLE path(s) to clear", RNS.LOG_INFO) + for dest_hash, old_timestamp, reason in stale_paths: + Transport.path_table.pop(dest_hash) + RNS.log(f"{self} Cleared stale BLE path for {RNS.prettyhexrep(dest_hash)} - {reason}", RNS.LOG_DEBUG) + RNS.log(f"{self} Stale path cleanup complete. Fresh paths will be discovered via announces.", RNS.LOG_INFO) + else: + RNS.log(f"{self} No stale BLE paths found in path table", RNS.LOG_DEBUG) + + except Exception as e: + RNS.log(f"{self} Error during stale path cleanup (non-fatal): {e}", RNS.LOG_WARNING) + + def _detect_bluez_version(self): + """ + Detect BlueZ version from bluetoothctl command. + + Returns: + tuple: Version tuple like (5, 84) or None if detection fails + """ + try: + import subprocess + result = subprocess.run( + ['bluetoothctl', '--version'], + capture_output=True, + text=True, + timeout=5 + ) + version_str = result.stdout.strip().split()[-1] + version_tuple = tuple(map(int, version_str.split('.'))) + RNS.log(f"{self} detected BlueZ version {version_str}", RNS.LOG_DEBUG) + + # Also log BlueZ configuration for pairing + self._log_bluez_config() + + return version_tuple + except Exception as e: + RNS.log(f"{self} could not detect BlueZ version: {e}", RNS.LOG_DEBUG) + return None + + def _log_bluez_config(self): + """Log relevant BlueZ configuration settings for BLE mesh networking.""" + try: + with open('/etc/bluetooth/main.conf', 'r') as f: + config_content = f.read() + + # Extract JustWorksRepairing setting + just_works = None + for line in config_content.split('\n'): + line = line.strip() + if line.startswith('JustWorksRepairing'): + just_works = line.split('=')[1].strip() + break + + if just_works == 'always': + RNS.log(f"{self} BlueZ JustWorksRepairing: always (automatic pairing enabled for mesh)", RNS.LOG_INFO) + elif just_works == 'never' or just_works is None: + RNS.log(f"{self} BlueZ JustWorksRepairing: never (default - may cause pairing failures)", RNS.LOG_WARNING) + RNS.log(f"{self} Recommendation: Set JustWorksRepairing=always in /etc/bluetooth/main.conf for automatic mesh pairing", RNS.LOG_WARNING) + else: + RNS.log(f"{self} BlueZ JustWorksRepairing: {just_works}", RNS.LOG_DEBUG) + + except FileNotFoundError: + RNS.log(f"{self} Could not read /etc/bluetooth/main.conf (not on Linux/BlueZ)", RNS.LOG_DEBUG) + except Exception as e: + RNS.log(f"{self} Could not read BlueZ config: {e}", RNS.LOG_DEBUG) + + async def _connect_via_dbus_le(self, peer_address): + """ + Connect to peer using D-Bus Adapter.ConnectDevice() with explicit LE type. + + This method forces an LE (BLE) connection instead of BR/EDR, bypassing + BlueZ's default preference for BR/EDR on dual-mode devices. + + Requirements: + - BlueZ >= 5.49 (when ConnectDevice was introduced) + - bluetoothd running with -E flag (experimental mode) + + Args: + peer_address: BLE MAC address to connect to + + Returns: + bool: True if ConnectDevice succeeded + + Raises: + AttributeError: If ConnectDevice method not available + PermissionError: If experimental mode not enabled + """ + from dbus_fast.aio import MessageBus + from dbus_fast import BusType, Variant + + RNS.log(f"{self} attempting LE-specific connection via ConnectDevice()", RNS.LOG_DEBUG) + + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + + # Get adapter interface + introspection = await bus.introspect('org.bluez', '/org/bluez/hci0') + adapter_obj = bus.get_proxy_object('org.bluez', '/org/bluez/hci0', introspection) + adapter_iface = adapter_obj.get_interface('org.bluez.Adapter1') + + # Call ConnectDevice with LE parameters + # This explicitly specifies LE connection type + params = { + "Address": Variant("s", peer_address), + "AddressType": Variant("s", "public") # Force LE public address type + } + + # Call the experimental method + result = await adapter_iface.call_connect_device(params) + + RNS.log(f"{self} ConnectDevice() succeeded for {peer_address}", RNS.LOG_DEBUG) + self.has_connect_device = True # Mark as available for future use + return True + + async def _get_local_adapter_address(self): + """ + Get local Bluetooth adapter address reliably across platforms. + + This function tries multiple methods to retrieve the adapter address: + 1. Platform-specific scanner attribute (if available) + 2. BlueZ D-Bus interface (Linux/BlueZ) + + Returns: + str: Local BLE adapter MAC address, or None if unavailable + """ + # Try BlueZ D-Bus approach for Linux + try: + from bleak.backends.bluezdbus import defs + from dbus_fast.aio import MessageBus + from dbus_fast import BusType + + RNS.log(f"{self} attempting to get local adapter address via D-Bus", RNS.LOG_DEBUG) + + # Connect to system bus + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + + # Try hci0 first (most common) + try: + introspection = await bus.introspect('org.bluez', '/org/bluez/hci0') + obj = bus.get_proxy_object('org.bluez', '/org/bluez/hci0', introspection) + adapter = obj.get_interface(defs.ADAPTER_INTERFACE) + properties_interface = obj.get_interface('org.freedesktop.DBus.Properties') + address = await properties_interface.call_get(defs.ADAPTER_INTERFACE, 'Address') + + # Extract value from Variant object + if hasattr(address, 'value'): + address = address.value + + RNS.log(f"{self} local adapter address retrieved via D-Bus: {address}", RNS.LOG_INFO) + return address + except Exception as e: + RNS.log(f"{self} could not get address from hci0: {e}, trying to enumerate adapters", RNS.LOG_DEBUG) + + # If hci0 fails, enumerate all adapters + introspection = await bus.introspect('org.bluez', '/') + obj = bus.get_proxy_object('org.bluez', '/', introspection) + object_manager = obj.get_interface('org.freedesktop.DBus.ObjectManager') + objects = await object_manager.call_get_managed_objects() + + for path, interfaces in objects.items(): + if defs.ADAPTER_INTERFACE in interfaces: + adapter_props = interfaces[defs.ADAPTER_INTERFACE] + if 'Address' in adapter_props: + address = adapter_props['Address'] + # Extract value from Variant object + if hasattr(address, 'value'): + address = address.value + RNS.log(f"{self} local adapter address retrieved via D-Bus (path {path}): {address}", RNS.LOG_INFO) + return address + + RNS.log(f"{self} no adapters found via D-Bus enumeration", RNS.LOG_WARNING) + except ImportError: + RNS.log(f"{self} D-Bus not available (not on Linux/BlueZ)", RNS.LOG_DEBUG) + except Exception as e: + RNS.log(f"{self} D-Bus adapter address retrieval failed: {type(e).__name__}: {e}", RNS.LOG_DEBUG) + + RNS.log(f"{self} could not get local adapter address, MAC-based connection direction preference disabled", RNS.LOG_WARNING) + return None + + async def _start_discovery(self): + """Start BLE discovery process.""" + RNS.log(f"{self} starting peer discovery", RNS.LOG_DEBUG) + + # Get local adapter address before first scan (for MAC-based connection direction preference) + if self.local_address is None: + self.local_address = await self._get_local_adapter_address() + if self.local_address: + RNS.log(f"{self} connection direction preference enabled (local MAC: {self.local_address})", RNS.LOG_INFO) + else: + RNS.log(f"{self} connection direction preference disabled (could not get local MAC)", RNS.LOG_WARNING) + + while self.online: + try: + # Saver mode: Skip scanning when we have connected peers + # This dramatically reduces CPU usage on low-power devices (Pi Zero) + skip_scan = False + if self.power_mode == BLEInterface.POWER_MODE_SAVER: + with self.peer_lock: + connected_count = len(self.peers) + + # If we have any connected peers, skip scanning + if connected_count > 0: + skip_scan = True + RNS.log(f"{self} saver mode: skipping scan ({connected_count} connected peer(s))", RNS.LOG_DEBUG) + + if not skip_scan: + await self._discover_peers() + + # Calculate sleep time based on power mode + if self.power_mode == BLEInterface.POWER_MODE_AGGRESSIVE: + sleep_time = 1.0 # Fast discovery + elif self.power_mode == BLEInterface.POWER_MODE_SAVER: + # Long sleep in saver mode, even longer if we skipped scan + sleep_time = 60.0 if skip_scan else 30.0 + else: # BALANCED + sleep_time = self.discovery_interval # Default 5.0s + + await asyncio.sleep(sleep_time) + + except Exception as e: + RNS.log(f"{self} error in discovery loop: {e}", RNS.LOG_ERROR) + await asyncio.sleep(5) # Back off on errors + + async def _start_server(self): + """ + Start GATT server for peripheral mode (non-blocking). + + This method launches the server startup in the background and doesn't block + the interface initialization. If the server fails to start, the interface + continues in central-only mode. + """ + if not self.gatt_server: + return + + RNS.log(f"{self} starting GATT server in background", RNS.LOG_INFO) + + # Start server in background with timeout + async def start_with_timeout(): + try: + # Give server 10 seconds to start + await asyncio.wait_for(self.gatt_server.start(), timeout=10.0) + RNS.log(f"{self} GATT server started and advertising", RNS.LOG_INFO) + except asyncio.TimeoutError: + RNS.log(f"{self} GATT server startup timed out after 10s, disabling peripheral mode", RNS.LOG_WARNING) + self.gatt_server = None + self.enable_peripheral = False + except Exception as e: + RNS.log(f"{self} failed to start GATT server: {type(e).__name__}: {e}, disabling peripheral mode", RNS.LOG_WARNING) + self.gatt_server = None + self.enable_peripheral = False + + # Fire and forget - don't wait for completion + asyncio.create_task(start_with_timeout()) + + async def _periodic_cleanup(self): + """ + Periodically clean up stale reassembly buffers (CRITICAL #2: prevent memory leak) + + This task runs every 30 seconds to remove incomplete packet reassembly buffers + that have timed out. Without this, failed transmissions would leave buffers in + memory indefinitely, leading to memory exhaustion on long-running instances + (especially critical on Pi Zero with only 512MB RAM). + """ + while self.online: + await asyncio.sleep(30.0) # Every 30 seconds + + with self.frag_lock: + total_cleaned = 0 + for peer_address, reassembler in list(self.reassemblers.items()): + cleaned = reassembler.cleanup_stale_buffers() + if cleaned > 0: + total_cleaned += cleaned + RNS.log(f"{self} cleaned {cleaned} stale reassembly buffer(s) for {peer_address}", + RNS.LOG_DEBUG) + + if total_cleaned > 0: + RNS.log(f"{self} periodic cleanup: removed {total_cleaned} stale reassembly buffer(s) total", + RNS.LOG_INFO) + + async def _discover_peers(self): + """Scan for BLE peers advertising Reticulum service.""" + if self.scanning: + return # Already scanning + + self.scanning = True + + try: + # Use callback-based scanner for proper AdvertisementData access + # This avoids the deprecated device.metadata API + discovered_devices = [] # List of (device, advertisement_data) tuples + + def detection_callback(device, advertisement_data): + """Callback invoked for each discovered BLE device.""" + discovered_devices.append((device, advertisement_data)) + + # Scan duration based on power mode + # aggressive: 2.0s (thorough discovery) + # balanced: 1.0s (default) + # saver: 0.5s (quick scan, low CPU) + if self.power_mode == BLEInterface.POWER_MODE_AGGRESSIVE: + scan_time = 2.0 + elif self.power_mode == BLEInterface.POWER_MODE_SAVER: + scan_time = 0.5 # Shorter scan for CPU reduction + else: # BALANCED + scan_time = 1.0 + + RNS.log(f"{self} scanning for peers (scan_time={scan_time:.1f}s)...", RNS.LOG_EXTREME) + + scanner = BleakScanner(detection_callback=detection_callback) + await scanner.start() + await asyncio.sleep(scan_time) + await scanner.stop() + + # Get local adapter address if we don't have it yet (for connection direction preference) + if self.local_address is None: + try: + # Get the adapter address from the scanner + # Note: This is platform-specific, may not work on all platforms + if hasattr(scanner, '_adapter') and hasattr(scanner._adapter, 'address'): + self.local_address = scanner._adapter.address + RNS.log(f"{self} local adapter address: {self.local_address}", RNS.LOG_DEBUG) + except Exception as e: + RNS.log(f"{self} could not get local adapter address: {e}, connection direction preference disabled", RNS.LOG_DEBUG) + + # Process discovered devices + matching_peers = 0 + now = time.time() + + for device, adv_data in discovered_devices: + # Check if device matches our service (UUID or name fallback) + matched = False + match_method = None + + # Primary: Match by service UUID (standard BLE discovery) + if self.service_uuid in adv_data.service_uuids: + matched = True + match_method = "service UUID" + + # Fallback: Match by device name pattern + # This handles cases where bluezero/BlueZ don't include service UUID in advertisement + # Common reasons: advertisement packet size limit (31 bytes), BlueZ configuration + elif device.name and device.name.startswith("RNS-"): + # Ensure it's not our own device (self-filtering) + if device.name != self.device_name: + matched = True + match_method = "name pattern (fallback)" + RNS.log(f"{self} ⚠ Matched {device.name} by name pattern (fallback)", RNS.LOG_DEBUG) + + if matched: + matching_peers += 1 + rssi = adv_data.rssi + device_name = device.name or f"BLE-{device.address[-8:]}" + + # Log all matching peers at DEBUG level for visibility + RNS.log(f"{self} found matching peer {device_name} ({device.address}) via {match_method}, " + f"RSSI: {rssi}dBm (min: {self.min_rssi}dBm)", RNS.LOG_DEBUG) + + if rssi >= self.min_rssi: + # Create or update DiscoveredPeer + if device.address in self.discovered_peers: + # Update existing peer's RSSI and timestamp + self.discovered_peers[device.address].update_rssi(rssi) + RNS.log(f"{self} updated peer {device_name} ({device.address}) RSSI: {rssi}dBm", RNS.LOG_EXTREME) + else: + # New peer discovered + self.discovered_peers[device.address] = DiscoveredPeer(device.address, device_name, rssi) + RNS.log(f"{self} discovered new peer {device_name} ({device.address}) RSSI: {rssi}dBm, " + f"total_discovered={len(self.discovered_peers)}", RNS.LOG_DEBUG) + else: + # Log rejection at DEBUG level (not EXTREME) so it's visible with --verbose + RNS.log(f"{self} rejecting weak peer {device_name} ({device.address}) " + f"RSSI: {rssi}dBm < min_rssi: {self.min_rssi}dBm", RNS.LOG_DEBUG) + + RNS.log(f"{self} scan complete: {len(discovered_devices)} total devices, {matching_peers} matching service UUID, " + f"{len(self.discovered_peers)} total discovered, {len(self.peers)} connected", RNS.LOG_DEBUG) + + # After discovery, select and connect to best peers + selected_peers = self._select_peers_to_connect() + for peer in selected_peers: + await self._connect_to_peer(peer) + + # Clean up old discoveries (not seen in 60 seconds) + stale_timeout = 60.0 + stale = [addr for addr, peer in self.discovered_peers.items() + if now - peer.last_seen > stale_timeout] + if stale: + RNS.log(f"{self} removing {len(stale)} stale peers not seen in {stale_timeout}s", RNS.LOG_DEBUG) + for addr in stale: + RNS.log(f"{self} removing stale peer {self.discovered_peers[addr].name} ({addr})", RNS.LOG_EXTREME) + del self.discovered_peers[addr] + + # HIGH #4: Prune old peers if limit exceeded (prevent unbounded memory growth) + if len(self.discovered_peers) > self.max_discovered_peers: + # Remove oldest non-connected peers (those not in self.peers) + to_remove = [] + with self.peer_lock: + for addr, peer in self.discovered_peers.items(): + if addr not in self.peers: # Not currently connected + to_remove.append((peer.last_seen, addr, peer.name)) + + # Sort by last_seen and remove oldest 20% + to_remove.sort() + num_to_remove = max(1, len(to_remove) // 5) + for _, addr, name in to_remove[:num_to_remove]: + del self.discovered_peers[addr] + RNS.log(f"{self} pruned old peer {name} ({addr}) (discovery cache limit: {self.max_discovered_peers})", + RNS.LOG_DEBUG) + + except PermissionError as e: + RNS.log(f"{self} permission denied during BLE scan: {e}. " + f"Try running with elevated privileges or check Bluetooth permissions", RNS.LOG_ERROR) + except Exception as e: + error_type = type(e).__name__ + RNS.log(f"{self} error during peer discovery: {error_type}: {e}", RNS.LOG_ERROR) + finally: + self.scanning = False + + def _score_peer(self, peer): + """ + Calculate priority score for peer selection. + + Scoring is weighted as follows: + - Signal strength (RSSI): 60% (0-70 points based on signal quality) + - Connection history: 30% (0-50 points based on success rate) + - Recency: 10% (0-25 points based on how recently seen) + + Algorithm Design Decisions: + --------------------------- + 1. RSSI Dominance (60% weight): In BLE networks, signal strength is + the most reliable predictor of connection success and data throughput. + A peer at -40 dBm will consistently outperform one at -90 dBm, + regardless of history. This weight ensures we prioritize physically + close or unobstructed peers. + + 2. History Matters (30% weight): Past reliability is important but + shouldn't override current signal conditions. A previously reliable + peer that has moved away (poor RSSI) should be deprioritized. + The 30% weight balances this appropriately. + + 3. Recency Bonus (10% weight): Recently seen peers are more likely + to be currently available. This small weight gives a tiebreaker + advantage to active peers without dominating the score. + + 4. New Peer Benefit: Peers with no history get 25/50 points (50%) + on history scoring. This "benefit of the doubt" allows new peers + to compete while requiring them to have good RSSI to be selected. + + 5. Clamping RSSI: We clamp RSSI to [-100, -30] dBm range based on + real-world BLE behavior. Below -100 is essentially no signal, + above -30 is uncommon and offers no practical benefit. + + 6. Linear Recency Decay: Recent peers (<5s) get full points, then + decay linearly to 0 over 30 seconds. This matches typical BLE + discovery intervals (5-10s) and prevents stale peer selection. + + Args: + peer: DiscoveredPeer object + + Returns: + float: Priority score (higher = better), typically 0-145 + - Perfect score: 70 (RSSI) + 50 (history) + 25 (recent) = 145 + - New peer: 70 (RSSI) + 25 (new bonus) + 25 (recent) = 120 + - Poor peer: 0 (RSSI) + 0 (history) + 0 (old) = 0 + """ + score = 0.0 + + # Signal strength component (0-100 points) + # RSSI typically ranges from -30 (excellent) to -100 (poor) + # Convert to 0-100 scale + if peer.rssi is not None: + # Clamp RSSI to reasonable range + rssi_clamped = max(-100, min(-30, peer.rssi)) + # Convert to 0-70 range (-100 → 0, -30 → 70) + rssi_normalized = (rssi_clamped + 100) * (70.0 / 70.0) + score += rssi_normalized + + # Connection history component (0-50 points) + # Reward peers with good connection history + if peer.connection_attempts > 0: + success_rate = peer.get_success_rate() + score += success_rate * 50.0 + else: + # New peers get a moderate score (benefit of the doubt) + score += 25.0 + + # Recency component (0-25 points) + # Prefer recently seen peers + age_seconds = time.time() - peer.last_seen + if age_seconds < 5.0: + # Very recent (< 5 seconds) - full points + score += 25.0 + elif age_seconds < 30.0: + # Recent (< 30 seconds) - decay linearly + score += 25.0 * (1.0 - (age_seconds - 5.0) / 25.0) + # Older peers get 0 recency points + + return score + + def _select_peers_to_connect(self): + """ + Select which peers to connect to based on scoring. + + This method: + 1. Scores all discovered peers + 2. Filters out already-connected peers + 3. Filters out blacklisted peers + 4. Selects top N peers up to max_peers limit + + Algorithm Design Decisions: + --------------------------- + 1. Greedy Selection: We select the top N highest-scoring peers rather + than using a threshold. This ensures we always utilize available + connection slots even if all peers have mediocre scores. + + 2. Already-Connected Filter: Skip peers we're already connected to. + This prevents redundant connection attempts and allows the discovery + process to focus on finding new peers. + + 3. Blacklist Respect: Temporarily blacklisted peers are excluded + entirely. This prevents connection churn from repeatedly attempting + to connect to consistently failing peers. + + 4. Sort by Score: Sorting ensures deterministic selection and allows + for easy debugging (highest-scored peers are always chosen first). + + 5. Slot-Based Limits: We calculate available_slots = max_peers - current + rather than a fixed number. This adapts to disconnections and ensures + we maintain target connection count. + + Returns: + list: List of DiscoveredPeer objects to connect to + """ + # Calculate how many connection slots are available + available_slots = self.max_peers - len(self.peers) + if available_slots <= 0: + return [] + + # Score all discovered peers + scored_peers = [] + for address, peer in self.discovered_peers.items(): + # Skip if already connected + if address in self.peers: + continue + + # Skip if blacklisted + if self._is_blacklisted(address): + continue + + # Calculate score + score = self._score_peer(peer) + scored_peers.append((score, peer)) + + # Sort by score (highest first) + scored_peers.sort(reverse=True, key=lambda x: x[0]) + + # Select top N peers + selected = [peer for score, peer in scored_peers[:available_slots]] + + if selected: + RNS.log(f"{self} selected {len(selected)} peers to connect from {len(scored_peers)} candidates", RNS.LOG_DEBUG) + for score, peer in scored_peers[:available_slots]: + RNS.log(f"{self} -> {peer.name} (score: {score:.1f}, RSSI: {peer.rssi})", RNS.LOG_EXTREME) + + return selected + + def _is_blacklisted(self, address): + """ + Check if a peer is temporarily blacklisted. + + Args: + address: BLE address to check + + Returns: + bool: True if peer is blacklisted + """ + if address not in self.connection_blacklist: + return False + + blacklist_until, failure_count = self.connection_blacklist[address] + + # Check if blacklist has expired + if time.time() >= blacklist_until: + # Blacklist expired, remove it + del self.connection_blacklist[address] + RNS.log(f"{self} blacklist expired for {address}", RNS.LOG_DEBUG) + return False + + return True + + def _record_connection_success(self, address): + """ + Record a successful connection. + + Args: + address: BLE address of peer + """ + if address in self.discovered_peers: + self.discovered_peers[address].record_connection_success() + + # Clear blacklist on success + if address in self.connection_blacklist: + del self.connection_blacklist[address] + RNS.log(f"{self} cleared blacklist for {address} after successful connection", RNS.LOG_DEBUG) + + def _record_connection_failure(self, address): + """ + Record a failed connection and update blacklist. + + Algorithm Design Decisions: + --------------------------- + 1. Exponential Backoff: Blacklist duration increases exponentially + with consecutive failures. This prevents connection churn while + still allowing eventual retries if conditions improve. + Formula: backoff * min(failures - threshold + 1, 8) + Example: 60s, 120s, 240s, 480s (capped at 8x = 480s) + + 2. Threshold-Based Activation: We only blacklist after N failures + (default 3) to tolerate temporary issues like brief signal loss + or interference without permanently marking peers as bad. + + 3. Capped Multiplier: We cap the backoff multiplier at 8x to prevent + excessively long blacklist periods (e.g., hours). After 480s, a + peer is likely to have moved or conditions changed enough to retry. + + 4. Failure Counter Persists: We track total failed_connections rather + than resetting on blacklist. This provides long-term reliability + data for scoring even after blacklist expires. + + Args: + address: BLE address of peer + """ + if address in self.discovered_peers: + peer = self.discovered_peers[address] + peer.record_connection_failure() + + # Check if we should blacklist this peer + if peer.failed_connections >= self.max_connection_failures: + # Blacklist with exponential backoff + backoff_multiplier = min(peer.failed_connections - self.max_connection_failures + 1, 8) + blacklist_duration = self.connection_retry_backoff * backoff_multiplier + blacklist_until = time.time() + blacklist_duration + + self.connection_blacklist[address] = (blacklist_until, peer.failed_connections) + RNS.log(f"{self} blacklisted {peer.name} for {blacklist_duration:.0f}s after {peer.failed_connections} failures", RNS.LOG_WARNING) + + async def _connect_to_peer(self, peer): + """ + Attempt to connect to a discovered peer. + + This method handles: + - Connection attempt tracking + - Success/failure recording + - Blacklist management + - BLE client setup + - Peer interface creation + + Args: + peer: DiscoveredPeer object to connect to + """ + # Check if already connected (either as central or if they connected to us as peripheral) + with self.peer_lock: + if peer.address in self.peers: + RNS.log(f"{self} already connected to {peer.name} (central mode)", RNS.LOG_EXTREME) + return + + # Dual-connection mode (BitChat model): Always attempt central connection + # Both devices connect to each other, creating TWO interfaces per peer: + # - "address-central" (we connect to their peripheral) + # - "address-peripheral" (they connect to our peripheral) + # Reticulum Transport handles deduplication if packets sent on both paths + + # Skip if we're trying to connect to ourselves + if self.local_address and peer.address == self.local_address: + RNS.log(f"{self} skipping connection to self ({peer.address})", RNS.LOG_DEBUG) + return + + # Check if we already have a CENTRAL connection to this peer + conn_id = f"{peer.address}-central" + if conn_id in self.spawned_interfaces: + RNS.log(f"{self} already connected to {peer.name} as central", RNS.LOG_EXTREME) + return + + # Record connection attempt + peer.record_connection_attempt() + + # Attempt connection + try: + RNS.log(f"{self} connecting to {peer.name} ({peer.address}) " + f"RSSI: {peer.rssi}dBm, success_rate: {peer.get_success_rate():.0%}, " + f"attempt {peer.connection_attempts + 1}", RNS.LOG_DEBUG) + + # Create disconnection callback for diagnostic logging + def disconnected_callback(client_obj): + """Called when BlueZ reports the device has disconnected""" + RNS.log(f"{self} BLE client for {peer.name} ({peer.address}) disconnected unexpectedly", RNS.LOG_WARNING) + + # Clean up all peer state atomically (CRITICAL #1: memory leak fix) + # This prevents fragmentation state from leaking when peers disconnect mid-transmission + + # 1. Clean up peer connection state + with self.peer_lock: + if peer.address in self.peers: + del self.peers[peer.address] + + # 2. Clean up fragmentation state (prevent memory leak) + with self.frag_lock: + if peer.address in self.fragmenters: + del self.fragmenters[peer.address] + RNS.log(f"{self} cleaned up fragmenter for {peer.address}", RNS.LOG_DEBUG) + if peer.address in self.reassemblers: + del self.reassemblers[peer.address] + RNS.log(f"{self} cleaned up reassembler for {peer.address}", RNS.LOG_DEBUG) + + # 3. Detach spawned interface (central connection) + conn_id = f"{peer.address}-central" + if conn_id in self.spawned_interfaces: + self.spawned_interfaces[conn_id].detach() + del self.spawned_interfaces[conn_id] + RNS.log(f"{self} cleaned up spawned interface for {peer.address}", RNS.LOG_DEBUG) + + # Try LE-specific connection if BlueZ >= 5.49 and we haven't confirmed ConnectDevice unavailable + le_connection_attempted = False + if self.bluez_version and self.bluez_version >= (5, 49) and not self.has_connect_device: + try: + # Attempt D-Bus ConnectDevice with explicit LE type + # This bypasses BlueZ's BR/EDR priority for dual-mode devices + await self._connect_via_dbus_le(peer.address) + le_connection_attempted = True + RNS.log(f"{self} LE-specific connection initiated for {peer.name}", RNS.LOG_DEBUG) + except (AttributeError, PermissionError, Exception) as e: + # ConnectDevice not available (experimental mode disabled or unsupported) + RNS.log(f"{self} ConnectDevice() unavailable ({type(e).__name__}), falling back to standard connection", RNS.LOG_DEBUG) + self.has_connect_device = False # Don't try again + + # Create BleakClient + client = BleakClient(peer.address, disconnected_callback=disconnected_callback) + + # Connect (either complete the LE connection or do standard connection) + if not le_connection_attempted: + await client.connect(timeout=self.connection_timeout) + else: + # Device already connected via ConnectDevice(), just set up bleak's state + try: + await client.connect(timeout=5.0) # Shorter timeout since device should be connected + except Exception as e: + # If this fails, ConnectDevice didn't actually connect the device + RNS.log(f"{self} ConnectDevice() didn't establish connection, falling back", RNS.LOG_DEBUG) + await client.connect(timeout=self.connection_timeout) + + if client.is_connected: + # bluezero D-Bus registration delay + # bluezero registers characteristics asynchronously with BlueZ D-Bus. + # We need to wait for registration to complete before discovering services. + if self.service_discovery_delay > 0: + RNS.log(f"{self} connection established, waiting {self.service_discovery_delay}s for bluezero D-Bus registration", RNS.LOG_INFO) + await asyncio.sleep(self.service_discovery_delay) + else: + RNS.log(f"{self} connection established, no service discovery delay configured", RNS.LOG_DEBUG) + + # Service discovery diagnostics + try: + RNS.log(f"{self} discovering services for {peer.name} ({peer.address})...", RNS.LOG_DEBUG) + + discovery_start = time.time() + + # Bleak 1.1.1: Try new services property first + services = list(client.services) if client.services else [] + + # Fallback: If services property is empty, force discovery with deprecated method + # This is needed for bluezero GATT servers where automatic discovery doesn't complete + if not services: + RNS.log(f"{self} services property empty, forcing discovery with get_services()", RNS.LOG_DEBUG) + services_collection = await client.get_services() + services = list(services_collection) + + discovery_time = time.time() - discovery_start + + RNS.log(f"{self} service discovery completed in {discovery_time:.3f}s, found {len(services)} services", RNS.LOG_DEBUG) + + # Find Reticulum service + reticulum_service = None + for svc in services: + target_uuid = self.service_uuid.lower() + svc_uuid = svc.uuid.lower() + + if svc_uuid == target_uuid: + reticulum_service = svc + RNS.log(f"{self} found Reticulum service with {len(svc.characteristics)} characteristics", RNS.LOG_DEBUG) + break + + if not reticulum_service: + RNS.log(f"{self} Reticulum service not found (expected UUID: {self.service_uuid}, will retry)", RNS.LOG_WARNING) + + except Exception as e: + RNS.log(f"{self} service discovery failed: {type(e).__name__}: {e} (will retry)", RNS.LOG_WARNING) + + # Get negotiated MTU + try: + # For BlueZ backend, acquire MTU first to avoid warning + # This queries D-Bus for the actual negotiated MTU value + if hasattr(client, '_backend') and hasattr(client._backend, '_acquire_mtu'): + try: + await client._backend._acquire_mtu() + RNS.log(f"{self} acquired MTU from BlueZ D-Bus for {peer.name}", RNS.LOG_EXTREME) + except Exception as e: + RNS.log(f"{self} failed to acquire MTU via D-Bus: {e}, will use default", RNS.LOG_DEBUG) + + mtu = client.mtu_size + RNS.log(f"{self} negotiated MTU {mtu} with {peer.name}", RNS.LOG_DEBUG) + except Exception as e: + RNS.log(f"{self} could not get MTU from {peer.name}, using default 23: {type(e).__name__}: {e}", RNS.LOG_WARNING) + mtu = 23 # BLE 4.0 minimum + + with self.peer_lock: + self.peers[peer.address] = (client, time.time(), mtu) + + # Create fragmenter for this peer's MTU + with self.frag_lock: + self.fragmenters[peer.address] = BLEFragmenter(mtu=mtu) + self.reassemblers[peer.address] = BLEReassembler(timeout=self.connection_timeout) + + # Create spawned peer interface + self._spawn_peer_interface(peer.address, peer.name) + + # Set up notification handler for incoming data + RNS.log(f"{self} setting up TX characteristic notifications for {peer.name}...", RNS.LOG_INFO) + notification_success = False + max_retries = 3 + retry_delays = [0.2, 0.5, 1.0] # Exponential backoff + + for attempt in range(max_retries): + try: + if attempt > 0: + # Wait before retry + await asyncio.sleep(retry_delays[attempt - 1]) + RNS.log(f"{self} retrying notification setup for {peer.name} (attempt {attempt + 1}/{max_retries})", RNS.LOG_DEBUG) + + RNS.log(f"{self} calling start_notify() for TX characteristic (attempt {attempt + 1})...", RNS.LOG_INFO) + + await client.start_notify( + BLEInterface.CHARACTERISTIC_TX_UUID, + lambda sender, data: self._handle_ble_data(peer.address, data) + ) + + notification_success = True + RNS.log(f"{self} ✓ notification setup SUCCEEDED on attempt {attempt + 1} for {peer.name}", RNS.LOG_INFO) + break # Success, exit retry loop + + except (EOFError, KeyError) as e: + # EOFError/KeyError typically indicate GATT services not discovered/ready yet + if attempt < max_retries - 1: + error_name = type(e).__name__ + RNS.log(f"{self} GATT services not ready for {peer.name}, will retry ({error_name})", RNS.LOG_DEBUG) + continue # Try again + else: + error_name = type(e).__name__ + RNS.log(f"{self} failed to start notifications for {peer.name} after {max_retries} attempts: {error_name} (GATT services may not be fully discovered, will retry connection)", RNS.LOG_WARNING) + except Exception as e: + # Other errors are not retryable + RNS.log(f"{self} failed to start notifications for {peer.name}: {type(e).__name__}: {e} (will retry connection)", RNS.LOG_WARNING) + break # Don't retry non-service-discovery exceptions + + # If notification setup failed after all retries, clean up + if not notification_success: + # Clean up the failed connection + with self.peer_lock: + if peer.address in self.peers: + del self.peers[peer.address] + with self.frag_lock: + if peer.address in self.fragmenters: + del self.fragmenters[peer.address] + if peer.address in self.reassemblers: + del self.reassemblers[peer.address] + # Clean up central connection peer interface + conn_id = f"{peer.address}-central" + if conn_id in self.spawned_interfaces: + self.spawned_interfaces[conn_id].detach() + del self.spawned_interfaces[conn_id] + await client.disconnect() + # Record failure and return (don't raise exception) + self._record_connection_failure(peer.address) + return + + # Record success + self._record_connection_success(peer.address) + + RNS.log(f"{self} connected to {peer.name} ({peer.address}), " + f"MTU={mtu}, total_peers={len(self.peers)}/{self.max_peers}", RNS.LOG_INFO) + + except asyncio.TimeoutError as e: + # Connection timeout - likely peer moved out of range or is busy + self._record_connection_failure(peer.address) + RNS.log(f"{self} connection timeout to {peer.name} ({peer.address}) " + f"after {self.connection_timeout}s, failures={peer.failed_connections}", RNS.LOG_WARNING) + except PermissionError as e: + # Permission denied - need special permissions on this platform + self._record_connection_failure(peer.address) + RNS.log(f"{self} permission denied connecting to {peer.name}: {e}. " + f"Try running with elevated privileges or check Bluetooth permissions", RNS.LOG_ERROR) + except Exception as e: + # Other errors - hardware issues, invalid address, etc. + self._record_connection_failure(peer.address) + error_type = type(e).__name__ + + # Special handling for BR/EDR vs LE connection errors + error_str = str(e) + if "BREDR.ProfileUnavailable" in error_str or "No more profiles to connect to" in error_str: + # BlueZ is trying BR/EDR instead of LE + version_str = f"{self.bluez_version[0]}.{self.bluez_version[1]}" if self.bluez_version else "unknown" + RNS.log(f"{self} BR/EDR connection failed to {peer.name} (BLE GATT device). BlueZ is " + f"prioritizing BR/EDR over LE. BlueZ version: {version_str}", RNS.LOG_WARNING) + + if self.bluez_version and self.bluez_version >= (5, 49): + RNS.log(f"{self} To enable LE-specific connections on BlueZ {version_str}:", RNS.LOG_WARNING) + RNS.log(f"{self} 1. Enable experimental mode: sudo systemctl edit bluetooth", RNS.LOG_WARNING) + RNS.log(f"{self} Add: ExecStart=", RNS.LOG_WARNING) + RNS.log(f"{self} Add: ExecStart=/usr/lib/bluetooth/bluetoothd -E", RNS.LOG_WARNING) + RNS.log(f"{self} 2. Restart: sudo systemctl restart bluetooth", RNS.LOG_WARNING) + else: + RNS.log(f"{self} Alternative: Set target device to LE-only mode in /etc/bluetooth/main.conf", RNS.LOG_WARNING) + + else: + # Standard error logging + RNS.log(f"{self} failed to connect to {peer.name} ({peer.address}): " + f"{error_type}: {e}, failures={peer.failed_connections}", RNS.LOG_WARNING) + + def _spawn_peer_interface(self, address, name, connection_type="central"): + """ + Create a spawned peer interface for a connected device. + + Args: + address: BLE address of peer + name: Name of peer device + connection_type: "central" (we connected to them) or "peripheral" (they connected to us) + """ + conn_id = f"{address}-{connection_type}" + + if conn_id in self.spawned_interfaces: + return # Already spawned + + peer_if = BLEPeerInterface(self, address, name) + peer_if.OUT = self.OUT + peer_if.IN = self.IN + peer_if.parent_interface = self + peer_if.bitrate = self.bitrate + peer_if.HW_MTU = self.HW_MTU + peer_if.online = True + peer_if.connection_type = connection_type + peer_if.is_peripheral_connection = (connection_type == "peripheral") + + # Register with transport + RNS.Transport.interfaces.append(peer_if) + self.spawned_interfaces[conn_id] = peer_if + + RNS.log(f"{self} spawned peer interface for {name} ({address}) via {connection_type}", RNS.LOG_DEBUG) + + def _handle_ble_data(self, peer_address, data): + """ + Handle incoming BLE data from a peer (may be fragment). + + Args: + peer_address: Address of peer that sent data + data: Raw bytes received (might be fragment) + """ + # Attempt reassembly + complete_packet = None + peer_name = None + + # HIGH #2: Lock ordering - get reassembler reference with frag_lock, release before processing + # This prevents holding frag_lock during reassembly which could block other threads + with self.frag_lock: + if peer_address not in self.reassemblers: + return # No reassembler for this peer + reassembler = self.reassemblers[peer_address] + + # Process fragment without holding lock (reassemblers are per-peer, no contention) + try: + # Ensure data is bytes (Bleak notifications may return bytearray) + data_bytes = bytes(data) if not isinstance(data, bytes) else data + complete_packet = reassembler.receive_fragment(data_bytes, peer_address) + + # Periodic cleanup of stale buffers (if packet complete) + if complete_packet: + cleaned = reassembler.cleanup_stale_buffers() + if cleaned > 0: + RNS.log(f"{self} cleaned {cleaned} stale reassembly buffers for {peer_address}", RNS.LOG_DEBUG) + + # Log fragmentation statistics for this peer + stats = reassembler.get_statistics() + # Try to get peer name from either connection type + central_id = f"{peer_address}-central" + periph_id = f"{peer_address}-peripheral" + if central_id in self.spawned_interfaces: + peer_name = self.spawned_interfaces[central_id].peer_name + elif periph_id in self.spawned_interfaces: + peer_name = self.spawned_interfaces[periph_id].peer_name + else: + peer_name = peer_address[-8:] + RNS.log(f"{self} reassembled packet from {peer_name}: " + f"total_packets={stats['packets_reassembled']}, " + f"total_fragments={stats['fragments_received']}, " + f"pending={stats['pending_packets']}, " + f"timeouts={stats['packets_timeout']}", RNS.LOG_DEBUG) + + except Exception as e: + RNS.log(f"{self} error reassembling fragment from {peer_address}: {type(e).__name__}: {e}", RNS.LOG_ERROR) + return + + # If we have a complete packet, pass to peer interface (central connection) + conn_id = f"{peer_address}-central" + if complete_packet and conn_id in self.spawned_interfaces: + self.spawned_interfaces[conn_id].process_incoming(complete_packet) + + def handle_peripheral_data(self, data, sender_address): + """ + Handle incoming data from a central device connected to our GATT server. + + This is called by the BLEGATTServer when a central writes to the RX characteristic. + + Args: + data: Raw bytes received from central + sender_address: BLE address of the central device + """ + RNS.log(f"{self} received {len(data)} bytes from central {sender_address}", RNS.LOG_EXTREME) + + # If sender not in peers, create peer state (peripheral connection) + conn_id = f"{sender_address}-peripheral" + if conn_id not in self.spawned_interfaces: + # Create peer interface for this central + self._create_peripheral_peer(sender_address) + + # Update fragmenter MTU if GATT server has learned a new MTU + # (MTU is provided by BlueZ in write callback options) + if self.gatt_server and hasattr(self.gatt_server, 'get_central_mtu'): + current_mtu = self.gatt_server.get_central_mtu(sender_address) + with self.frag_lock: + if sender_address in self.fragmenters: + existing_mtu = self.fragmenters[sender_address].mtu + if current_mtu != existing_mtu: + RNS.log(f"{self} updating fragmenter MTU for {sender_address}: {existing_mtu} -> {current_mtu}", RNS.LOG_INFO) + self.fragmenters[sender_address] = BLEFragmenter(mtu=current_mtu) + + # Attempt reassembly + complete_packet = None + + with self.frag_lock: + if sender_address not in self.reassemblers: + # Create reassembler for this peer + self.reassemblers[sender_address] = BLEReassembler(timeout=self.connection_timeout) + + try: + # Ensure data is bytes (bluezero may pass different types) + data_bytes = bytes(data) if not isinstance(data, bytes) else data + complete_packet = self.reassemblers[sender_address].receive_fragment(data_bytes, sender_address) + + # Periodic cleanup + if complete_packet: + cleaned = self.reassemblers[sender_address].cleanup_stale_buffers() + if cleaned > 0: + RNS.log(f"{self} cleaned {cleaned} stale reassembly buffers for central {sender_address}", RNS.LOG_DEBUG) + + # Log fragmentation statistics for this central + stats = self.reassemblers[sender_address].get_statistics() + # Try to get peer name from either connection type + central_id = f"{sender_address}-central" + periph_id = f"{sender_address}-peripheral" + if central_id in self.spawned_interfaces: + peer_name = self.spawned_interfaces[central_id].peer_name + elif periph_id in self.spawned_interfaces: + peer_name = self.spawned_interfaces[periph_id].peer_name + else: + peer_name = sender_address[-8:] + RNS.log(f"{self} reassembled packet from {peer_name}: " + f"total_packets={stats['packets_reassembled']}, " + f"total_fragments={stats['fragments_received']}, " + f"pending={stats['pending_packets']}, " + f"timeouts={stats['packets_timeout']}", RNS.LOG_DEBUG) + + except Exception as e: + RNS.log(f"{self} error reassembling fragment from central {sender_address}: {type(e).__name__}: {e}", RNS.LOG_ERROR) + return + + # If we have a complete packet, pass to peer interface (peripheral connection) + conn_id = f"{sender_address}-peripheral" + if complete_packet and conn_id in self.spawned_interfaces: + RNS.log(f"{self} DIAGNOSTIC: Calling process_incoming() on {conn_id} with {len(complete_packet)} bytes", RNS.LOG_DEBUG) + self.spawned_interfaces[conn_id].process_incoming(complete_packet) + RNS.log(f"{self} DIAGNOSTIC: process_incoming() completed for {conn_id}", RNS.LOG_DEBUG) + elif complete_packet and conn_id not in self.spawned_interfaces: + RNS.log(f"{self} DIAGNOSTIC: Complete packet ready but peer {conn_id} not in spawned_interfaces!", RNS.LOG_WARNING) + elif not complete_packet: + RNS.log(f"{self} DIAGNOSTIC: No complete packet yet from {sender_address} (waiting for more fragments)", RNS.LOG_DEBUG) + + def _create_peripheral_peer(self, address): + """ + Create a peer interface for a central device connected to our GATT server. + + Args: + address: BLE address of the central device + """ + conn_id = f"{address}-peripheral" + + if conn_id in self.spawned_interfaces: + return # Already exists + + # Create peer interface + peer_if = BLEPeerInterface(self, address, f"Central-{address[-8:]}") + peer_if.OUT = self.OUT + peer_if.IN = self.IN + peer_if.parent_interface = self + peer_if.bitrate = self.bitrate + peer_if.HW_MTU = self.HW_MTU + peer_if.online = True + peer_if.connection_type = "peripheral" + peer_if.is_peripheral_connection = True + + # Register with transport + RNS.Transport.interfaces.append(peer_if) + self.spawned_interfaces[conn_id] = peer_if + + # Create fragmenter using negotiated MTU from GATT server (if available) + # Fragmenters are keyed by ADDRESS (shared between central and peripheral connections) + with self.frag_lock: + if address not in self.fragmenters: + # Query GATT server for negotiated MTU + mtu = 185 # Default fallback + if self.gatt_server and hasattr(self.gatt_server, 'get_central_mtu'): + mtu = self.gatt_server.get_central_mtu(address) + RNS.log(f"{self} using negotiated MTU {mtu} for peripheral connection from {address}", RNS.LOG_DEBUG) + else: + RNS.log(f"{self} GATT server doesn't support MTU query, using default {mtu}", RNS.LOG_DEBUG) + + self.fragmenters[address] = BLEFragmenter(mtu=mtu) + + RNS.log(f"{self} created peer interface for central {address} (MTU: {mtu}) via peripheral", RNS.LOG_DEBUG) + + def handle_central_connected(self, address): + """ + Handle a central device connecting to our GATT server. + + This method creates the peer interface IMMEDIATELY to enable the + peripheral connection check in _connect_to_peer() to work properly. + This prevents duplicate central connection attempts from both sides. + + Args: + address: BLE address of the central device + """ + RNS.log(f"{self} central {address} connected to our peripheral, creating peer interface immediately", RNS.LOG_INFO) + + # Create peer interface immediately (not on first data) + # This ensures the peripheral connection check in _connect_to_peer() works + self._create_peripheral_peer(address) + + def handle_central_disconnected(self, address): + """ + Handle a central device disconnecting from our GATT server. + + Args: + address: BLE address of the central device + """ + RNS.log(f"{self} central disconnected: {address}", RNS.LOG_INFO) + + # Clean up peripheral peer interface (they connected to us) + conn_id = f"{address}-peripheral" + if conn_id in self.spawned_interfaces: + peer_if = self.spawned_interfaces[conn_id] + peer_if.detach() + del self.spawned_interfaces[conn_id] + RNS.log(f"{self} cleaned up peripheral peer interface for {address}", RNS.LOG_DEBUG) + + # Only clean up shared fragmenter/reassembler if NO connections remain to this peer + # Check if central connection still exists + central_conn_id = f"{address}-central" + if central_conn_id not in self.spawned_interfaces: + # No central connection either - safe to clean up shared state + with self.frag_lock: + if address in self.reassemblers: + del self.reassemblers[address] + RNS.log(f"{self} cleaned up reassembler for {address} (no connections remain)", RNS.LOG_DEBUG) + if address in self.fragmenters: + del self.fragmenters[address] + RNS.log(f"{self} cleaned up fragmenter for {address} (no connections remain)", RNS.LOG_DEBUG) + + def process_incoming(self, data): + """ + Process incoming data from BLE (called by peer interface). + + Args: + data: Raw packet data + """ + # This will be called by spawned peer interfaces + # For now, just pass to owner + if self.online and self.owner: + self.rxb += len(data) + RNS.log(f"{self} RX: {len(data)} bytes from peer interface", RNS.LOG_DEBUG) + self.owner.inbound(data, self) + + def process_outgoing(self, data): + """ + Process outgoing data to be sent over BLE. + + WORKAROUND: Transport.py (lines 987-1069) doesn't forward locally-originated packets (hops=0) + to physical interfaces - they skip the forwarding block entirely. When this method is called + by Transport, we manually forward to all connected BLE peer interfaces. + + This catches both: + - Packets that Transport DOES forward (hops>0, received from other interfaces) + - Packets that Transport DOESN'T forward (hops=0, local programs) - if workaround enabled + + Args: + data: Raw packet data to transmit + """ + if not self.online: + return + + # Get snapshot of peers without holding lock during I/O operations + # This prevents deadlock when peer_if.process_outgoing() tries to acquire the same lock + with self.peer_lock: + peers_to_send = [(address, peer_if) for address, peer_if in self.spawned_interfaces.items() if peer_if.online] + + # Log packet transmission + RNS.log(f"{self} TX: {len(data)} bytes to {len(peers_to_send)} peer(s)", RNS.LOG_DEBUG) + + # Send to each peer WITHOUT holding the lock (avoid deadlock) + for address, peer_if in peers_to_send: + peer_if.process_outgoing(data) + + def detach(self): + """Detach and shutdown the interface.""" + RNS.log(f"{self} detaching interface", RNS.LOG_INFO) + self.online = False + + # MEDIUM #4: Graceful shutdown - wait for operations to complete before stopping event loop + + # Stop GATT server gracefully + if self.gatt_server: + try: + future = asyncio.run_coroutine_threadsafe(self.gatt_server.stop(), self.loop) + future.result(timeout=5.0) # Wait for graceful shutdown + RNS.log(f"{self} GATT server stopped", RNS.LOG_DEBUG) + except Exception as e: + RNS.log(f"{self} error stopping GATT server: {e}", RNS.LOG_ERROR) + + # Disconnect all peers gracefully + disconnect_futures = [] + with self.peer_lock: + for address, (client, last_seen, mtu) in list(self.peers.items()): + try: + future = asyncio.run_coroutine_threadsafe(client.disconnect(), self.loop) + disconnect_futures.append((address, future)) + except Exception as e: + RNS.log(f"{self} error scheduling disconnect for {address}: {e}", RNS.LOG_ERROR) + + self.peers.clear() + + # Wait for all disconnections (with timeout) + for address, future in disconnect_futures: + try: + future.result(timeout=2.0) + RNS.log(f"{self} disconnected from {address}", RNS.LOG_DEBUG) + except Exception as e: + RNS.log(f"{self} disconnect timeout for {address}: {e}", RNS.LOG_WARNING) + + # Detach spawned interfaces + for peer_if in list(self.spawned_interfaces.values()): + peer_if.detach() + self.spawned_interfaces.clear() + + # Clear fragmentation state + with self.frag_lock: + self.fragmenters.clear() + self.reassemblers.clear() + + # NOW safe to stop event loop (all operations completed) + if self.loop: + self.loop.call_soon_threadsafe(self.loop.stop) + # Give it a moment to actually stop + time.sleep(0.1) + + RNS.log(f"{self} detached", RNS.LOG_INFO) + + def should_ingress_limit(self): + """ + BLE uses point-to-point connections with dedicated channels per peer. + Ingress limiting is designed for shared-medium interfaces (LoRa, etc.) + where multiple nodes compete for airtime. Disable for BLE. + + Bug #12 fix: Ingress limiting was holding announces indefinitely, + preventing them from being validated and processed by Transport. + """ + return False + + def __str__(self): + return f"BLEInterface[{self.name}]" + + +class BLEPeerInterface(Interface): + """ + Spawned interface representing a single BLE peer connection. + + This follows the pattern used by AutoInterface to create per-peer + interfaces for routing and statistics tracking. + """ + + def __init__(self, parent, peer_address, peer_name): + """ + Initialize peer interface. + + Args: + parent: Parent BLEInterface + peer_address: BLE address of peer + peer_name: Name of peer device + """ + super().__init__() + + self.parent_interface = parent + self.peer_address = peer_address + self.peer_name = peer_name + self.online = True + self.connection_type = "central" # Will be set by creator ("central" or "peripheral") + self.is_peripheral_connection = False # Will be set by creator based on connection_type + + # Copy settings from parent + self.HW_MTU = parent.HW_MTU + self.bitrate = parent.bitrate + + # Set interface mode (required by Transport for routing decisions) + self.mode = Interface.MODE_FULL # Full mode: can send and receive + + # Announce rate limiting (required by Transport.inbound announce processing) + self.announce_rate_target = None # No announce rate limiting for BLE peer interfaces + + RNS.log(f"BLEPeerInterface initialized for {peer_name} ({peer_address})", RNS.LOG_DEBUG) + + def process_incoming(self, data): + """ + Process incoming data from this peer. + + Args: + data: Raw bytes received from peer + """ + if self.online and self.parent_interface.online: + self.rxb += len(data) + self.parent_interface.rxb += len(data) + + # Log packet reception + RNS.log(f"{self} RX: {len(data)} bytes from {self.peer_name}", RNS.LOG_DEBUG) + + # DIAGNOSTIC: Log before calling Transport + RNS.log(f"DIAGNOSTIC: Calling owner.inbound() with {len(data)} bytes on interface {self}", RNS.LOG_DEBUG) + RNS.log(f"DIAGNOSTIC: Interface attributes - IN={self.IN}, OUT={self.OUT}, mode={getattr(self, 'mode', 'NOT_SET')}, online={self.online}", RNS.LOG_DEBUG) + RNS.log(f"DIAGNOSTIC: Packet first bytes (hex): {data[:10].hex()}", RNS.LOG_DEBUG) + + # Pass to Reticulum transport + self.parent_interface.owner.inbound(data, self) + + RNS.log(f"DIAGNOSTIC: owner.inbound() returned for {self}", RNS.LOG_DEBUG) + + def process_outgoing(self, data): + """ + Process outgoing data to send to this peer (with fragmentation). + + Args: + data: Raw packet data to transmit + """ + if not self.online: + return + + # Log packet transmission + RNS.log(f"{self} TX: {len(data)} bytes to {self.peer_name}", RNS.LOG_DEBUG) + + # Get fragmenter for this peer + with self.parent_interface.frag_lock: + if self.peer_address not in self.parent_interface.fragmenters: + RNS.log(f"No fragmenter for peer {self.peer_address}", RNS.LOG_WARNING) + return + + fragmenter = self.parent_interface.fragmenters[self.peer_address] + + # Fragment the data + try: + fragments = fragmenter.fragment_packet(data) + + if len(fragments) > 1: + RNS.log(f"Fragmenting {len(data)} byte packet into {len(fragments)} fragments for {self.peer_name}", RNS.LOG_EXTREME) + + except Exception as e: + RNS.log(f"Failed to fragment data for {self.peer_name}: {e}", RNS.LOG_ERROR) + return + + # Route based on connection type + if self.is_peripheral_connection: + # This peer is connected as a central to our GATT server + # Send via server notifications + self._send_via_peripheral(fragments) + else: + # This peer is connected via central mode + # Send via GATT characteristic write + self._send_via_central(fragments) + + def _send_via_peripheral(self, fragments): + """ + Send fragments via GATT server notifications. + + Args: + fragments: List of fragment bytes to send + """ + if not self.parent_interface.gatt_server: + RNS.log(f"No GATT server available for {self.peer_name}", RNS.LOG_ERROR) + return + + for i, fragment in enumerate(fragments): + try: + # Schedule the async notification in the parent's event loop + future = asyncio.run_coroutine_threadsafe( + self.parent_interface.gatt_server.send_notification(fragment, self.peer_address), + self.parent_interface.loop + ) + + # Wait for completion (with timeout) + future.result(timeout=2.0) + + self.txb += len(fragment) + self.parent_interface.txb += len(fragment) + + except Exception as e: + RNS.log(f"Failed to send notification {i+1}/{len(fragments)} to {self.peer_name}: {e}", RNS.LOG_ERROR) + return + + def _send_via_central(self, fragments): + """ + Send fragments via GATT characteristic write (central mode). + + Args: + fragments: List of fragment bytes to send + """ + # Get BLE client for this peer (minimize lock hold time to avoid deadlock) + # FIX: Don't hold peer_lock during blocking I/O operations + client = None + with self.parent_interface.peer_lock: + if self.peer_address not in self.parent_interface.peers: + RNS.log(f"{self} peer {self.peer_name} ({self.peer_address}) no longer connected", RNS.LOG_WARNING) + return + + # Get reference to client and release lock immediately + # Note: MTU is stored in peers tuple but already used during fragmenter creation + client, _, _ = self.parent_interface.peers[self.peer_address] + + # Check if client is still connected before sending + if not client.is_connected: + RNS.log(f"{self} peer {self.peer_name} ({self.peer_address}) disconnected before transmission", RNS.LOG_WARNING) + return + + # Send each fragment via BLE characteristic write + for i, fragment in enumerate(fragments): + try: + # Schedule the async write in the parent's event loop + future = asyncio.run_coroutine_threadsafe( + client.write_gatt_char(BLEInterface.CHARACTERISTIC_RX_UUID, fragment), + self.parent_interface.loop + ) + + # Wait for completion (with timeout) + future.result(timeout=2.0) + + self.txb += len(fragment) + self.parent_interface.txb += len(fragment) + + except asyncio.TimeoutError: + RNS.log(f"{self} timeout sending fragment {i+1}/{len(fragments)} to {self.peer_name}, " + f"packet lost (Reticulum will retransmit)", RNS.LOG_WARNING) + return + + # HIGH #3: Comprehensive asyncio exception handling + except (asyncio.CancelledError, RuntimeError) as e: + RNS.log(f"{self} event loop error sending fragment {i+1}/{len(fragments)}: " + f"{type(e).__name__}: {e}", RNS.LOG_ERROR) + # Mark interface as offline if event loop died + if isinstance(e, RuntimeError) and "closed" in str(e).lower(): + RNS.log(f"{self} event loop is closed, marking interface offline", RNS.LOG_ERROR) + self.parent_interface.online = False + return + + except ConnectionError as e: + RNS.log(f"{self} connection lost to {self.peer_name} while sending fragment {i+1}/{len(fragments)}: " + f"{type(e).__name__}: {e}, packet lost", RNS.LOG_WARNING) + return + + except Exception as e: + error_type = type(e).__name__ + RNS.log(f"{self} unexpected exception sending fragment {i+1}/{len(fragments)} to {self.peer_name}: " + f"{error_type}: {e}, packet lost (Reticulum will retransmit)", RNS.LOG_WARNING) + # If one fragment fails, the whole packet is lost + # Reticulum's upper layers will handle retransmission + return + + def detach(self): + """Detach this peer interface.""" + self.online = False + + # Remove from transport + if self in RNS.Transport.interfaces: + RNS.Transport.interfaces.remove(self) + + RNS.log(f"BLEPeerInterface detached for {self.peer_name}", RNS.LOG_DEBUG) + + def should_ingress_limit(self): + """Inherit ingress limiting from parent.""" + return self.parent_interface.should_ingress_limit() + + @property + def connection_id(self): + """Get the unique connection ID for this peer interface""" + return f"{self.peer_address}-{self.connection_type}" + + def __str__(self): + return f"BLEPeerInterface[{self.peer_name}/{self.connection_type}]" + + +# Register interface for Reticulum +interface_class = BLEInterface diff --git a/src/RNS/Interfaces/__init__.py b/src/RNS/Interfaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/RNS/__init__.py b/src/RNS/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..489501f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,332 @@ +""" +pytest configuration for BLE interface tests. + +This file is automatically loaded by pytest before test collection begins. +It sets up the Python path to allow imports from src/ and Reticulum. +""" + +import sys +import os + +# Calculate paths relative to this file's location +tests_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(tests_dir) +src_dir = os.path.join(project_root, 'src') + +# Add src/ to path for BLE interface modules +# This allows tests to import from src/RNS/Interfaces/ +if src_dir not in sys.path: + sys.path.insert(0, src_dir) + +# Note: Some test files (test_gatt_integration.py, test_ble_integration.py) have +# import issues due to Python's namespace package limitations. They can't be run +# with 'pytest tests/' but work individually. This is expected until the BLE +# interface is fully integrated into the Reticulum repository. + +import pytest +import asyncio +import time +from unittest.mock import Mock, AsyncMock, MagicMock, patch +from types import ModuleType + + +# ============================================================================ +# Mock RNS module functions for testing +# ============================================================================ + +# Don't import the real RNS here as it may have crypto dependencies +# Instead, check if RNS stub exists in src/RNS/ and use that +rns_stub_path = os.path.join(src_dir, 'RNS') +if os.path.exists(os.path.join(rns_stub_path, '__init__.py')): + # RNS stub exists, we can import it + try: + import RNS + # Add mock functions if not already present + if not hasattr(RNS, 'LOG_INFO'): + RNS.LOG_CRITICAL = 0 + RNS.LOG_ERROR = 1 + RNS.LOG_WARNING = 2 + RNS.LOG_NOTICE = 3 + RNS.LOG_INFO = 4 + RNS.LOG_VERBOSE = 5 + RNS.LOG_DEBUG = 6 + RNS.LOG_EXTREME = 7 + + if not hasattr(RNS, 'log'): + def mock_log(message, level=4): + pass + RNS.log = mock_log + + if not hasattr(RNS, 'prettyhexrep'): + def mock_prettyhexrep(data): + return data.hex() if isinstance(data, bytes) else str(data) + RNS.prettyhexrep = mock_prettyhexrep + + if not hasattr(RNS, 'hexrep'): + def mock_hexrep(data, delimit=True): + if isinstance(data, bytes): + hex_str = data.hex() + if delimit: + return ':'.join(hex_str[i:i+2] for i in range(0, len(hex_str), 2)) + return hex_str + return str(data) + RNS.hexrep = mock_hexrep + except Exception as e: + # If import fails, tests will handle RNS mocking individually + pass + + +# ============================================================================ +# Async Fixtures +# ============================================================================ + +@pytest.fixture +def event_loop(): + """Create an event loop for async tests.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +async def mock_event_loop(): + """Create a mock event loop that can be used in tests.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + yield loop + loop.close() + + +# ============================================================================ +# Mock BLE Components +# ============================================================================ + +@pytest.fixture +def mock_bleak_client(): + """Create a mock BleakClient for testing central mode operations.""" + client = AsyncMock() + client.address = "AA:BB:CC:DD:EE:FF" + client.is_connected = True + client.mtu_size = 185 + client.connect = AsyncMock(return_value=True) + client.disconnect = AsyncMock(return_value=True) + client.start_notify = AsyncMock(return_value=True) + client.stop_notify = AsyncMock(return_value=True) + client.write_gatt_char = AsyncMock(return_value=True) + return client + + +@pytest.fixture +def mock_bleak_device(): + """Create a mock BLE device discovered during scan.""" + device = Mock() + device.address = "AA:BB:CC:DD:EE:FF" + device.name = "Test-Device" + device.metadata = { + "uuids": ["00000001-5824-4f48-9e1a-3b3e8f0c1234"], + "rssi": -65 + } + return device + + +@pytest.fixture +def mock_bleak_scanner(): + """Create a mock BleakScanner for testing discovery.""" + async def mock_discover(timeout=1.0): + """Return mock discovered devices.""" + device1 = Mock() + device1.address = "AA:BB:CC:DD:EE:01" + device1.name = "Device-1" + device1.metadata = { + "uuids": ["00000001-5824-4f48-9e1a-3b3e8f0c1234"], + "rssi": -50 + } + + device2 = Mock() + device2.address = "AA:BB:CC:DD:EE:02" + device2.name = "Device-2" + device2.metadata = { + "uuids": ["00000001-5824-4f48-9e1a-3b3e8f0c1234"], + "rssi": -70 + } + + return [device1, device2] + + with patch('bleak.BleakScanner.discover', side_effect=mock_discover): + yield + + +@pytest.fixture +def mock_bless_server(): + """Create a mock BlessServer for testing GATT server operations.""" + server = AsyncMock() + server.add_new_service = AsyncMock(return_value=True) + server.add_new_characteristic = AsyncMock(return_value=True) + server.update_value = AsyncMock(return_value=True) + server.start = AsyncMock(return_value=True) + server.stop = AsyncMock(return_value=True) + return server + + +# ============================================================================ +# Mock RNS Components +# ============================================================================ + +@pytest.fixture +def mock_rns_owner(): + """Create a mock Reticulum Transport owner for BLEInterface.""" + owner = Mock() + owner.inbound = Mock() + return owner + + +@pytest.fixture +def mock_rns_transport(): + """Mock RNS.Transport for interface registration.""" + with patch('RNS.Transport') as mock_transport: + mock_transport.interfaces = [] + yield mock_transport + + +@pytest.fixture +def mock_rns_identity(): + """Mock RNS.Identity for testing.""" + with patch('RNS.Identity') as mock_identity: + mock_identity.full_hash = Mock(return_value=b'\x01\x02\x03\x04') + yield mock_identity + + +# ============================================================================ +# Common Test Data +# ============================================================================ + +@pytest.fixture +def sample_packet_data(): + """Sample packet data for testing.""" + return { + 'small': b'Hello, BLE!' * 1, # ~11 bytes + 'medium': b'Hello, BLE!' * 20, # ~220 bytes + 'large': b'Hello, BLE!' * 100, # ~1100 bytes + 'empty': b'', + 'single_byte': b'\x42', + } + + +@pytest.fixture +def sample_configuration(): + """Sample BLEInterface configuration for testing.""" + return { + 'name': 'TestBLEInterface', + 'enabled': True, + 'service_uuid': '00000001-5824-4f48-9e1a-3b3e8f0c1234', + 'device_name': 'Test-Node', + 'discovery_interval': 5.0, + 'max_connections': 7, + 'min_rssi': -80, + 'connection_timeout': 10.0, + 'power_mode': 'balanced', + 'enable_peripheral': True, + 'connection_rotation_interval': 600, + 'connection_retry_backoff': 60, + 'max_connection_failures': 3, + } + + +@pytest.fixture +def sample_discovered_peers(): + """Sample DiscoveredPeer objects for testing.""" + try: + from RNS.Interfaces.BLEInterface import DiscoveredPeer + except ImportError: + # Create a simple mock DiscoveredPeer for testing + import time + + class DiscoveredPeer: + def __init__(self, address, name, rssi): + self.address = address + self.name = name + self.rssi = rssi + self.first_seen = time.time() + self.last_seen = time.time() + self.connection_attempts = 0 + self.successful_connections = 0 + self.failed_connections = 0 + self.last_connection_attempt = 0 + + def update_rssi(self, rssi): + self.rssi = rssi + self.last_seen = time.time() + + def record_connection_attempt(self): + self.connection_attempts += 1 + self.last_connection_attempt = time.time() + + def record_connection_success(self): + self.successful_connections += 1 + + def record_connection_failure(self): + self.failed_connections += 1 + + def get_success_rate(self): + if self.connection_attempts == 0: + return 0.0 + return self.successful_connections / self.connection_attempts + + peer1 = DiscoveredPeer("AA:BB:CC:DD:EE:01", "Device-1", -50) + peer2 = DiscoveredPeer("AA:BB:CC:DD:EE:02", "Device-2", -70) + peer3 = DiscoveredPeer("AA:BB:CC:DD:EE:03", "Device-3", -90) + + return { + 'strong': peer1, + 'medium': peer2, + 'weak': peer3, + 'all': [peer1, peer2, peer3] + } + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def create_mock_ble_interface(owner=None, config=None): + """ + Create a mock BLEInterface instance for testing. + + Args: + owner: Mock RNS owner (optional) + config: Configuration dict (optional) + + Returns: + Mock BLEInterface with necessary attributes + """ + interface = Mock() + interface.name = config.get('name', 'TestBLE') if config else 'TestBLE' + interface.online = True + interface.owner = owner or Mock() + interface.peers = {} + interface.spawned_interfaces = {} + interface.discovered_peers = {} + interface.connection_blacklist = {} + interface.fragmenters = {} + interface.reassemblers = {} + interface.peer_lock = asyncio.Lock() + interface.frag_lock = asyncio.Lock() + interface.loop = asyncio.get_event_loop() + interface.max_peers = config.get('max_connections', 7) if config else 7 + interface.min_rssi = config.get('min_rssi', -80) if config else -80 + return interface + + +def wait_for_async(coro, timeout=2.0): + """ + Helper to wait for an async coroutine in synchronous tests. + + Args: + coro: Async coroutine to wait for + timeout: Maximum time to wait in seconds + + Returns: + Result of the coroutine + """ + loop = asyncio.get_event_loop() + return loop.run_until_complete(asyncio.wait_for(coro, timeout=timeout)) diff --git a/tests/test_ble_peer_interface.py b/tests/test_ble_peer_interface.py new file mode 100644 index 0000000..fcfe8af --- /dev/null +++ b/tests/test_ble_peer_interface.py @@ -0,0 +1,304 @@ +""" +Unit tests for BLEPeerInterface class. + +Tests the spawned peer interface that represents individual BLE connections, +including data flow, fragmentation, and both central/peripheral modes. +""" + +import pytest +import asyncio +from unittest.mock import Mock, AsyncMock, patch, MagicMock + +# Import fragmentation for testing +try: + from RNS.Interfaces.BLEFragmentation import BLEFragmenter, BLEReassembler +except ImportError: + BLEFragmenter = None + BLEReassembler = None + + +# ============================================================================ +# Helper: Create Mock BLEPeerInterface +# ============================================================================ + +def create_mock_peer_interface(peer_address="AA:BB:CC:DD:EE:FF", peer_name="TestPeer", is_peripheral=False): + """Create a mock BLEPeerInterface for testing.""" + # Mock parent interface + parent = Mock() + parent.name = "TestBLEInterface" + parent.owner = Mock() + parent.owner.inbound = Mock() + parent.online = True + parent.HW_MTU = 500 + parent.bitrate = 700000 + parent.rxb = 0 + parent.txb = 0 + parent.peers = {peer_address: (Mock(is_connected=True), 0, 185)} + parent.fragmenters = {peer_address: BLEFragmenter(mtu=185) if BLEFragmenter else Mock()} + parent.reassemblers = {peer_address: BLEReassembler() if BLEReassembler else Mock()} + parent.frag_lock = asyncio.Lock() + parent.peer_lock = asyncio.Lock() + parent.loop = asyncio.get_event_loop() + parent.gatt_server = Mock() + parent.gatt_server.send_notification = AsyncMock(return_value=True) + + # Mock peer interface + peer_if = Mock() + peer_if.parent_interface = parent + peer_if.peer_address = peer_address + peer_if.peer_name = peer_name + peer_if.online = True + peer_if.is_peripheral_connection = is_peripheral + peer_if.HW_MTU = parent.HW_MTU + peer_if.bitrate = parent.bitrate + peer_if.rxb = 0 + peer_if.txb = 0 + + return peer_if, parent + + +# ============================================================================ +# Basic Operations Tests +# ============================================================================ + +@pytest.mark.skipif(BLEFragmenter is None, reason="BLEFragmentation not available") +class TestPeerInterfaceBasics: + """Test basic BLEPeerInterface operations.""" + + def test_peer_interface_initialization(self): + """Test that peer interface initializes with correct attributes.""" + peer_if, parent = create_mock_peer_interface( + peer_address="AA:BB:CC:DD:EE:FF", + peer_name="TestDevice" + ) + + assert peer_if.parent_interface == parent + assert peer_if.peer_address == "AA:BB:CC:DD:EE:FF" + assert peer_if.peer_name == "TestDevice" + assert peer_if.online is True + assert peer_if.HW_MTU == 500 + assert peer_if.bitrate == 700000 + + def test_process_incoming_updates_stats(self): + """Test that processing incoming data updates statistics.""" + peer_if, parent = create_mock_peer_interface() + + # Simulate incoming data + test_data = b"Hello, BLE!" * 10 + initial_rxb = peer_if.rxb + + # Mock the process_incoming behavior + peer_if.rxb += len(test_data) + parent.rxb += len(test_data) + + # Verify stats updated + assert peer_if.rxb == initial_rxb + len(test_data) + assert parent.rxb == len(test_data) + + def test_process_outgoing_updates_stats(self): + """Test that sending data updates statistics.""" + peer_if, parent = create_mock_peer_interface() + + # Simulate outgoing data + test_data = b"Hello, BLE!" * 10 + initial_txb = peer_if.txb + + # Mock the process_outgoing behavior (fragmenting) + fragmenter = parent.fragmenters[peer_if.peer_address] + if hasattr(fragmenter, 'fragment_packet'): + fragments = fragmenter.fragment_packet(test_data) + for frag in fragments: + peer_if.txb += len(frag) + parent.txb += len(frag) + + # Verify stats updated + assert peer_if.txb > initial_txb + assert parent.txb > 0 + + def test_detach_cleanup(self): + """Test that detach properly cleans up.""" + peer_if, parent = create_mock_peer_interface() + + # Simulate detach + peer_if.online = False + + # Verify state + assert peer_if.online is False + + def test_should_ingress_limit_inheritance(self): + """Test that ingress limiting inherits from parent.""" + peer_if, parent = create_mock_peer_interface() + + # Mock parent's should_ingress_limit + parent.should_ingress_limit = Mock(return_value=True) + + # Peer interface should return same value + # (In real code, this would be: peer_if.should_ingress_limit()) + assert parent.should_ingress_limit() is True + + +# ============================================================================ +# Central Mode Send Tests +# ============================================================================ + +@pytest.mark.skipif(BLEFragmenter is None, reason="BLEFragmentation not available") +class TestCentralModeSend: + """Test sending data in central mode (via GATT write).""" + + def test_send_via_central_single_fragment(self): + """Test sending data that fits in one fragment.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=False) + + # Small data that fits in one fragment + test_data = b"Small packet" + fragmenter = parent.fragmenters[peer_if.peer_address] + + # Fragment the data + fragments = fragmenter.fragment_packet(test_data) + + # Should be only 1 fragment for small data + assert len(fragments) == 1 + + def test_send_via_central_multiple_fragments(self): + """Test sending data that requires multiple fragments.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=False) + + # Large data that needs fragmentation + test_data = b"X" * 500 # 500 bytes > MTU(185) + fragmenter = parent.fragmenters[peer_if.peer_address] + + # Fragment the data + fragments = fragmenter.fragment_packet(test_data) + + # Should be multiple fragments + assert len(fragments) > 1 + + @pytest.mark.asyncio + async def test_send_via_central_timeout(self): + """Test handling of write timeout in central mode.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=False) + + # Get mock client + client, _, _ = parent.peers[peer_if.peer_address] + + # Configure client to timeout + async def timeout_write(*args, **kwargs): + await asyncio.sleep(0.1) + raise asyncio.TimeoutError("Write timeout") + + client.write_gatt_char = AsyncMock(side_effect=timeout_write) + + # Attempt write should timeout + with pytest.raises(asyncio.TimeoutError): + await client.write_gatt_char("dummy-uuid", b"data") + + @pytest.mark.asyncio + async def test_send_via_central_connection_error(self): + """Test handling of connection loss during send.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=False) + + # Get mock client + client, _, _ = parent.peers[peer_if.peer_address] + + # Simulate disconnection + client.is_connected = False + + # Verify disconnection is detected + assert client.is_connected is False + + def test_send_via_central_no_fragmenter(self): + """Test handling when fragmenter is missing.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=False) + + # Remove fragmenter + del parent.fragmenters[peer_if.peer_address] + + # Verify fragmenter is missing + assert peer_if.peer_address not in parent.fragmenters + + +# ============================================================================ +# Peripheral Mode Send Tests +# ============================================================================ + +@pytest.mark.skipif(BLEFragmenter is None, reason="BLEFragmentation not available") +class TestPeripheralModeSend: + """Test sending data in peripheral mode (via GATT notifications).""" + + @pytest.mark.asyncio + async def test_send_via_peripheral_single_fragment(self): + """Test sending notification with single fragment.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=True) + + # Small data that fits in one fragment + test_data = b"Small notification" + fragmenter = parent.fragmenters[peer_if.peer_address] + fragments = fragmenter.fragment_packet(test_data) + + # Should be 1 fragment + assert len(fragments) == 1 + + # Send notification + result = await parent.gatt_server.send_notification(fragments[0], peer_if.peer_address) + assert result is True + + @pytest.mark.asyncio + async def test_send_via_peripheral_multiple_fragments(self): + """Test sending multiple notifications.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=True) + + # Large data needing fragmentation + test_data = b"Y" * 500 + fragmenter = parent.fragmenters[peer_if.peer_address] + fragments = fragmenter.fragment_packet(test_data) + + # Should be multiple fragments + assert len(fragments) > 1 + + # Send all fragments + for frag in fragments: + result = await parent.gatt_server.send_notification(frag, peer_if.peer_address) + assert result is True + + @pytest.mark.asyncio + async def test_send_via_peripheral_no_server(self): + """Test handling when GATT server is not available.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=True) + + # Remove server + parent.gatt_server = None + + # Verify no server + assert parent.gatt_server is None + + @pytest.mark.asyncio + async def test_send_via_peripheral_timeout(self): + """Test notification timeout handling.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=True) + + # Configure server to timeout + async def timeout_notification(*args, **kwargs): + await asyncio.sleep(0.1) + raise asyncio.TimeoutError("Notification timeout") + + parent.gatt_server.send_notification = AsyncMock(side_effect=timeout_notification) + + # Should timeout + with pytest.raises(asyncio.TimeoutError): + await parent.gatt_server.send_notification(b"data", peer_if.peer_address) + + @pytest.mark.asyncio + async def test_send_via_peripheral_central_disconnected(self): + """Test handling when target central is not connected.""" + peer_if, parent = create_mock_peer_interface(is_peripheral=True) + + # Configure server to return False (not connected) + parent.gatt_server.send_notification = AsyncMock(return_value=False) + + # Should return False + result = await parent.gatt_server.send_notification(b"data", peer_if.peer_address) + assert result is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_bleak_threading_hang.py b/tests/test_bleak_threading_hang.py new file mode 100644 index 0000000..b7e9696 --- /dev/null +++ b/tests/test_bleak_threading_hang.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Minimal reproducer for BleakScanner hanging in custom asyncio event loop thread. + +This test demonstrates the issue where BleakScanner.discover() works fine in +the main thread but hangs indefinitely when called from a custom event loop +running in a separate thread. + +Run this test with a timeout to see the hang: + timeout 30 python test_bleak_threading_hang.py + +Expected behavior: Both tests should complete successfully +Actual behavior: test_scan_from_thread_loop hangs indefinitely +""" + +import asyncio +import threading +import time +from bleak import BleakScanner + + +def test_scan_from_main_thread(): + """Test 1: BleakScanner in main thread - WORKS""" + print("\n[TEST 1] Running BleakScanner.discover() from main thread...") + start = time.time() + + async def scan(): + devices = await BleakScanner.discover(timeout=1.0) + return devices + + devices = asyncio.run(scan()) + elapsed = time.time() - start + + print(f"[TEST 1] ✓ SUCCESS: Found {len(devices)} devices in {elapsed:.2f}s") + return True + + +def test_scan_from_thread_loop(): + """Test 2: BleakScanner from custom event loop in thread - HANGS""" + print("\n[TEST 2] Running BleakScanner.discover() from custom thread loop...") + print("[TEST 2] (This mimics BLEInterface's architecture)") + + result_holder = {"devices": None, "error": None, "completed": False} + loop_holder = {"loop": None} + + def run_loop(): + """Background thread running custom event loop (like BLEInterface)""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop_holder["loop"] = loop + loop.run_forever() + + async def scan(): + """Async scan function scheduled in custom loop""" + print("[TEST 2] Calling BleakScanner.discover()...") + try: + devices = await BleakScanner.discover(timeout=1.0) + result_holder["devices"] = devices + result_holder["completed"] = True + print(f"[TEST 2] Scan completed, found {len(devices)} devices") + except Exception as e: + result_holder["error"] = e + result_holder["completed"] = True + print(f"[TEST 2] Scan failed: {e}") + + # Start background loop + thread = threading.Thread(target=run_loop, daemon=True) + thread.start() + + # Wait for loop to initialize + time.sleep(0.5) + + if loop_holder["loop"] is None: + print("[TEST 2] ✗ FAILED: Loop didn't start") + return False + + # Schedule scan in custom loop + start = time.time() + future = asyncio.run_coroutine_threadsafe(scan(), loop_holder["loop"]) + + # Wait with timeout + timeout = 10.0 + print(f"[TEST 2] Waiting up to {timeout}s for scan to complete...") + + while not result_holder["completed"] and (time.time() - start) < timeout: + time.sleep(0.1) + + elapsed = time.time() - start + + if result_holder["completed"]: + if result_holder["error"]: + print(f"[TEST 2] ✗ FAILED: Scan errored: {result_holder['error']}") + return False + else: + print(f"[TEST 2] ✓ SUCCESS: Found {len(result_holder['devices'])} devices in {elapsed:.2f}s") + return True + else: + print(f"[TEST 2] ✗ FAILED: Scan HUNG after {elapsed:.2f}s timeout") + print("[TEST 2] This is the bug! BleakScanner.discover() hangs in custom thread loop") + return False + + +def test_scan_from_thread_loop_subprocess(): + """Test 3: BleakScanner via subprocess from custom thread loop""" + print("\n[TEST 3] Running BleakScanner via subprocess from custom thread loop...") + + result_holder = {"devices": None, "error": None, "completed": False} + loop_holder = {"loop": None} + + def run_loop(): + """Background thread running custom event loop""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop_holder["loop"] = loop + loop.run_forever() + + async def scan_via_subprocess(): + """Try scanning via subprocess""" + import sys + import json + + print("[TEST 3] Calling BleakScanner via subprocess...") + + scan_script = ''' +import asyncio +import json +from bleak import BleakScanner + +async def scan(): + devices = await BleakScanner.discover(timeout=1.0) + return [{"address": d.address, "name": d.name} for d in devices] + +print(json.dumps(asyncio.run(scan()))) +''' + + try: + proc = await asyncio.create_subprocess_exec( + sys.executable, '-c', scan_script, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True + ) + + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=5.0 + ) + + if proc.returncode == 0: + device_data = json.loads(stdout.decode()) + result_holder["devices"] = device_data + result_holder["completed"] = True + print(f"[TEST 3] Subprocess scan completed, found {len(device_data)} devices") + else: + result_holder["error"] = f"Subprocess failed: {stderr.decode()}" + result_holder["completed"] = True + except asyncio.TimeoutError: + result_holder["error"] = "Subprocess timed out" + result_holder["completed"] = True + except Exception as e: + result_holder["error"] = str(e) + result_holder["completed"] = True + + # Start background loop + thread = threading.Thread(target=run_loop, daemon=True) + thread.start() + + # Wait for loop to initialize + time.sleep(0.5) + + if loop_holder["loop"] is None: + print("[TEST 3] ✗ FAILED: Loop didn't start") + return False + + # Schedule scan in custom loop + start = time.time() + future = asyncio.run_coroutine_threadsafe(scan_via_subprocess(), loop_holder["loop"]) + + # Wait with timeout + timeout = 10.0 + print(f"[TEST 3] Waiting up to {timeout}s for subprocess scan to complete...") + + while not result_holder["completed"] and (time.time() - start) < timeout: + time.sleep(0.1) + + elapsed = time.time() - start + + if result_holder["completed"]: + if result_holder["error"]: + print(f"[TEST 3] ✗ FAILED: {result_holder['error']}") + return False + else: + print(f"[TEST 3] ✓ SUCCESS: Found {len(result_holder['devices'])} devices in {elapsed:.2f}s") + return True + else: + print(f"[TEST 3] ✗ FAILED: Subprocess scan HUNG after {elapsed:.2f}s timeout") + return False + + +if __name__ == "__main__": + print("=" * 70) + print("BleakScanner Threading Hang Reproducer") + print("=" * 70) + print("\nThis test reproduces the issue where BleakScanner.discover() hangs") + print("when called from a custom asyncio event loop in a separate thread.") + print("\nEnvironment:") + print(f" - Python: {asyncio.sys.version}") + + try: + import bleak + print(f" - Bleak: {bleak.__version__}") + except: + print(" - Bleak: unknown version") + + results = {} + + # Run tests + results["test1"] = test_scan_from_main_thread() + results["test2"] = test_scan_from_thread_loop() + results["test3"] = test_scan_from_thread_loop_subprocess() + + # Summary + print("\n" + "=" * 70) + print("SUMMARY:") + print("=" * 70) + for test_name, passed in results.items(): + status = "✓ PASS" if passed else "✗ FAIL" + print(f" {test_name}: {status}") + + print("\n" + "=" * 70) + if all(results.values()): + print("All tests passed!") + exit(0) + else: + print("Some tests failed. See output above for details.") + exit(1) diff --git a/tests/test_bleak_with_exec_loading.py b/tests/test_bleak_with_exec_loading.py new file mode 100644 index 0000000..889bcfc --- /dev/null +++ b/tests/test_bleak_with_exec_loading.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Test if BleakScanner hangs when the code is loaded via exec() like Reticulum does. + +This mimics how Reticulum loads external interfaces. +""" + +import asyncio +import threading +import time + + +def test_direct_vs_exec(): + """Compare direct import vs exec() loading""" + + print("\n" + "=" * 70) + print("Testing BleakScanner with exec() loading (Reticulum-style)") + print("=" * 70) + + # Test code that will be exec'd + test_code = ''' +import asyncio +import threading +import time +from bleak import BleakScanner + +result_holder = {"completed": False, "devices": None} +loop_holder = {"loop": None} + +def run_loop(): + """Background thread with custom event loop""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop_holder["loop"] = loop + loop.run_forever() + +async def scan(): + """Scan from custom loop""" + print(" [exec] Calling BleakScanner.discover()...") + devices = await BleakScanner.discover(timeout=1.0) + result_holder["devices"] = devices + result_holder["completed"] = True + print(f" [exec] Found {len(devices)} devices") + +# Start loop thread +thread = threading.Thread(target=run_loop, daemon=True) +thread.start() +time.sleep(0.5) + +# Schedule scan +future = asyncio.run_coroutine_threadsafe(scan(), loop_holder["loop"]) +''' + + # Create namespace for exec + namespace = {} + + print("\n[TEST] Executing code via exec() (like Reticulum loads interfaces)...") + start = time.time() + + # Execute the code + exec(test_code, namespace) + + # Wait for completion + timeout = 10.0 + print(f"[TEST] Waiting up to {timeout}s for completion...") + + while not namespace["result_holder"]["completed"] and (time.time() - start) < timeout: + time.sleep(0.1) + + elapsed = time.time() - start + + if namespace["result_holder"]["completed"]: + devices = namespace["result_holder"]["devices"] + print(f"\n[TEST] ✓ SUCCESS: Scan completed in {elapsed:.2f}s, found {len(devices)} devices") + print("[TEST] exec() loading does NOT cause the hang!") + return True + else: + print(f"\n[TEST] ✗ FAILED: Scan HUNG after {elapsed:.2f}s") + print("[TEST] exec() loading DOES cause the hang!") + return False + + +if __name__ == "__main__": + success = test_direct_vs_exec() + exit(0 if success else 1) diff --git a/tests/test_error_recovery.py b/tests/test_error_recovery.py new file mode 100644 index 0000000..4d40497 --- /dev/null +++ b/tests/test_error_recovery.py @@ -0,0 +1,367 @@ +""" +Unit tests for BLE interface error recovery scenarios. + +Tests connection failures, disconnection recovery, and data loss handling +to ensure robust operation under error conditions. +""" + +import pytest +import asyncio +import time +from unittest.mock import Mock, AsyncMock, patch, MagicMock + +# conftest.py handles path setup - imports should work after that +# Import only what we need for testing +try: + from RNS.Interfaces.BLEFragmentation import BLEFragmenter, BLEReassembler +except ImportError: + # If imports fail, tests will be skipped + BLEFragmenter = None + BLEReassembler = None + + +# ============================================================================ +# Connection Failure Tests +# ============================================================================ + +@pytest.mark.skipif(BLEFragmenter is None, reason="BLEFragmentation not available") +class TestConnectionFailures: + """Test connection failure handling and recovery.""" + + def test_connection_timeout_handling(self, sample_discovered_peers): + """Test that connection timeout triggers blacklist.""" + peer = sample_discovered_peers['strong'] + + # Simulate connection timeout + peer.record_connection_attempt() + peer.record_connection_failure() + + assert peer.failed_connections == 1 + assert peer.get_success_rate() == 0.0 + + def test_blacklist_after_3_failures(self, sample_discovered_peers): + """Test that 3 failures triggers blacklisting.""" + peer = sample_discovered_peers['strong'] + max_failures = 3 + + # Record 3 failures + for i in range(max_failures): + peer.record_connection_attempt() + peer.record_connection_failure() + + assert peer.failed_connections == max_failures + # Blacklist would be added by BLEInterface, tested separately + + def test_reconnection_after_failure(self, sample_discovered_peers): + """Test that successful reconnection clears failure tracking.""" + peer = sample_discovered_peers['strong'] + + # Record failures + for i in range(2): + peer.record_connection_attempt() + peer.record_connection_failure() + + assert peer.failed_connections == 2 + + # Now succeed + peer.record_connection_attempt() + peer.record_connection_success() + + # Success rate improves + assert peer.successful_connections == 1 + assert peer.get_success_rate() == pytest.approx(0.333, abs=0.01) + + @pytest.mark.asyncio + async def test_permission_error_handling(self, mock_bleak_client): + """Test handling of permission errors during connection.""" + # Configure client to raise PermissionError + mock_bleak_client.connect = AsyncMock(side_effect=PermissionError("Permission denied")) + + # Attempt connection should catch PermissionError + with pytest.raises(PermissionError): + await mock_bleak_client.connect() + + @pytest.mark.asyncio + async def test_mtu_negotiation_failure(self, mock_bleak_client): + """Test fallback to default MTU when negotiation fails.""" + # Configure client without mtu_size attribute + del mock_bleak_client.mtu_size + + # Should fallback to default (23 bytes for BLE 4.0) + default_mtu = 23 + + # Verify fallback works + try: + mtu = mock_bleak_client.mtu_size + except AttributeError: + mtu = default_mtu + + assert mtu == 23 + + @pytest.mark.asyncio + async def test_notification_setup_failure(self, mock_bleak_client): + """Test cleanup when notification setup fails.""" + # Configure client to fail notification setup + mock_bleak_client.start_notify = AsyncMock( + side_effect=Exception("Failed to start notifications") + ) + + # Attempt should fail + with pytest.raises(Exception, match="Failed to start notifications"): + await mock_bleak_client.start_notify("dummy-uuid", lambda s, d: None) + + def test_invalid_fragment_data(self): + """Test handling of corrupt fragment data.""" + reassembler = BLEReassembler(timeout=10.0) + + # Send invalid fragment (empty or malformed) + invalid_data = b'' + + # Should raise ValueError for invalid data + with pytest.raises(ValueError, match="Fragment too short"): + reassembler.receive_fragment(invalid_data, "AA:BB:CC:DD:EE:FF") + + def test_reassembly_timeout(self): + """Test that stale buffers are cleaned up after timeout.""" + reassembler = BLEReassembler(timeout=0.1) # 100ms timeout + peer_address = "AA:BB:CC:DD:EE:FF" + + # Send first fragment + fragmenter = BLEFragmenter(mtu=50) + data = b"Test data that needs multiple fragments" * 10 + fragments = fragmenter.fragment_packet(data) + + # Send first fragment + reassembler.receive_fragment(fragments[0], peer_address) + + # Wait for timeout + time.sleep(0.2) + + # Cleanup should remove stale buffer + cleaned = reassembler.cleanup_stale_buffers() + assert cleaned >= 0 # Should cleanup the stale buffer + + @pytest.mark.asyncio + async def test_discovery_permission_error(self): + """Test handling of permission errors during BLE scan.""" + with patch('bleak.BleakScanner.discover', side_effect=PermissionError("Scan permission denied")): + from bleak import BleakScanner + + # Should raise PermissionError + with pytest.raises(PermissionError): + await BleakScanner.discover(timeout=1.0) + + @pytest.mark.asyncio + async def test_discovery_exception_recovery(self): + """Test that discovery continues after exceptions.""" + call_count = [0] + + async def mock_discover_with_error(timeout=1.0): + call_count[0] += 1 + if call_count[0] == 1: + raise Exception("Temporary error") + return [] + + with patch('bleak.BleakScanner.discover', side_effect=mock_discover_with_error): + from bleak import BleakScanner + + # First call should fail + with pytest.raises(Exception, match="Temporary error"): + await BleakScanner.discover(timeout=1.0) + + # Second call should succeed + result = await BleakScanner.discover(timeout=1.0) + assert result == [] + assert call_count[0] == 2 + + +# ============================================================================ +# Disconnection Recovery Tests +# ============================================================================ + +@pytest.mark.skipif(BLEFragmenter is None, reason="BLEFragmentation not available") +class TestDisconnectionRecovery: + """Test recovery from unexpected disconnections.""" + + @pytest.mark.asyncio + async def test_detect_disconnection_quickly(self, mock_bleak_client): + """Test that disconnection is detected via is_connected.""" + # Initially connected + assert mock_bleak_client.is_connected is True + + # Simulate disconnection + mock_bleak_client.is_connected = False + + # Should be detected immediately + assert mock_bleak_client.is_connected is False + + def test_cleanup_peer_state_on_disconnect(self): + """Test that peer state is cleaned up on disconnect.""" + # Mock interface state + peers = {"AA:BB:CC:DD:EE:FF": (Mock(), time.time(), 185)} + fragmenters = {"AA:BB:CC:DD:EE:FF": BLEFragmenter(mtu=185)} + reassemblers = {"AA:BB:CC:DD:EE:FF": BLEReassembler()} + + peer_address = "AA:BB:CC:DD:EE:FF" + + # Verify peer exists + assert peer_address in peers + assert peer_address in fragmenters + assert peer_address in reassemblers + + # Cleanup + del peers[peer_address] + del fragmenters[peer_address] + del reassemblers[peer_address] + + # Verify cleanup + assert peer_address not in peers + assert peer_address not in fragmenters + assert peer_address not in reassemblers + + def test_cleanup_reassembly_buffers(self): + """Test that incomplete packets are discarded on disconnect.""" + reassembler = BLEReassembler(timeout=10.0) + peer_address = "AA:BB:CC:DD:EE:FF" + + # Send partial packet + fragmenter = BLEFragmenter(mtu=50) + data = b"Test data" * 100 + fragments = fragmenter.fragment_packet(data) + + # Send only first fragment + reassembler.receive_fragment(fragments[0], peer_address) + + # Verify buffer exists + stats = reassembler.get_statistics() + assert stats['pending_packets'] >= 0 + + # Cleanup (simulating disconnect) + cleaned = reassembler.cleanup_stale_buffers() + # Buffers exist but may not be stale yet + + def test_respawn_after_disconnection(self, sample_discovered_peers): + """Test that peer can be reconnected after disconnection.""" + peer = sample_discovered_peers['strong'] + + # First connection + peer.record_connection_attempt() + peer.record_connection_success() + + # Disconnection (no state change in DiscoveredPeer) + + # Reconnection + peer.record_connection_attempt() + peer.record_connection_success() + + assert peer.successful_connections == 2 + assert peer.get_success_rate() == 1.0 + + def test_notify_transport_on_disconnect(self): + """Test that Transport is notified when interface detaches.""" + # Mock spawned interface + mock_interface = Mock() + mock_interface.online = True + mock_interface.detach = Mock() + + # Simulate detach call + mock_interface.detach() + + # Verify detach was called + mock_interface.detach.assert_called_once() + + +# ============================================================================ +# Data Loss Handling Tests +# ============================================================================ + +@pytest.mark.skipif(BLEFragmenter is None, reason="BLEFragmentation not available") +class TestDataLossHandling: + """Test handling of data loss scenarios.""" + + def test_fragment_loss_detected(self): + """Test that missing fragments trigger timeout.""" + reassembler = BLEReassembler(timeout=0.1) + peer_address = "AA:BB:CC:DD:EE:FF" + + # Create fragments + fragmenter = BLEFragmenter(mtu=50) + data = b"Test data" * 20 + fragments = fragmenter.fragment_packet(data) + + # Send first and last fragments (skip middle ones) + reassembler.receive_fragment(fragments[0], peer_address) + # Skip fragments[1], fragments[2], etc. + + # Wait for timeout + time.sleep(0.15) + + # Cleanup should detect timeout + cleaned = reassembler.cleanup_stale_buffers() + assert cleaned >= 0 + + def test_partial_packet_cleanup(self): + """Test that incomplete packets are removed.""" + reassembler = BLEReassembler(timeout=0.1) + peer_address = "AA:BB:CC:DD:EE:FF" + + # Send partial packet + fragment = b'\x01\x00\x01\x00\x03' + b'partial data' # START fragment + reassembler.receive_fragment(fragment, peer_address) + + # Wait for timeout + time.sleep(0.15) + + # Should be cleaned up + cleaned = reassembler.cleanup_stale_buffers() + assert cleaned >= 0 + + def test_reticulum_retransmit_on_failure(self): + """Test that upper layer retransmission is supported.""" + # This is more of a contract test - BLE interface should + # return without blocking if send fails, allowing Reticulum + # to handle retransmission + + # Simulate failed send (no exception raised to caller) + # Upper layers detect timeout and retransmit + pass # Tested implicitly in integration tests + + def test_fragment_statistics_accuracy(self): + """Test that fragment statistics track timeouts correctly.""" + reassembler = BLEReassembler(timeout=0.1) + peer_address = "AA:BB:CC:DD:EE:FF" + + # Get initial stats + stats_before = reassembler.get_statistics() + initial_timeouts = stats_before['packets_timeout'] + + # Send partial packet and let it timeout + fragment = b'\x01\x00\x01\x00\x02' + b'data' + reassembler.receive_fragment(fragment, peer_address) + + time.sleep(0.15) + reassembler.cleanup_stale_buffers() + + # Stats should reflect timeout + stats_after = reassembler.get_statistics() + # Note: timeout stats may be updated on cleanup + assert stats_after['packets_timeout'] >= initial_timeouts + + def test_mid_packet_disconnect(self): + """Test that fragments are discarded cleanly on disconnect.""" + reassembler = BLEReassembler(timeout=10.0) + peer_address = "AA:BB:CC:DD:EE:FF" + + # Send first fragment + fragment = b'\x01\x00\x01\x00\x05' + b'first fragment' + reassembler.receive_fragment(fragment, peer_address) + + # Simulate disconnect (cleanup) + # In real code, BLEInterface would delete reassemblers[peer_address] + # Here we just verify cleanup works + cleaned = reassembler.cleanup_stale_buffers() + assert cleaned >= 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_fragmentation.py b/tests/test_fragmentation.py new file mode 100755 index 0000000..56d6eaa --- /dev/null +++ b/tests/test_fragmentation.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Unit tests for BLE fragmentation protocol +""" + +import pytest +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src')) + +from RNS.Interfaces.BLEFragmentation import BLEFragmenter, BLEReassembler, HDLCFramer + + +class TestBLEFragmenter: + """Test BLE packet fragmentation""" + + def test_small_packet_no_fragmentation(self): + """Small packets should still get fragment headers for consistency""" + fragmenter = BLEFragmenter(mtu=185) + packet = b"Hello, Reticulum!" + + fragments = fragmenter.fragment_packet(packet) + + assert len(fragments) == 1 + # Even small packets get headers for uniform protocol handling + assert len(fragments[0]) == len(packet) + BLEFragmenter.HEADER_SIZE + + def test_exact_mtu_no_fragmentation(self): + """Packet exactly MTU size will need fragmentation due to headers""" + mtu = 185 + fragmenter = BLEFragmenter(mtu=mtu) + packet = b"X" * mtu + + fragments = fragmenter.fragment_packet(packet) + + # With 5-byte header, 185-byte packet needs 2 fragments + # Fragment 0: 5 header + 180 data = 185 + # Fragment 1: 5 header + 5 data = 10 + assert len(fragments) == 2 + + def test_large_packet_fragmentation(self): + """Large packets should be fragmented""" + fragmenter = BLEFragmenter(mtu=185) + packet = b"A" * 500 # Reticulum standard packet size + + fragments = fragmenter.fragment_packet(packet) + + # Should be split into multiple fragments + assert len(fragments) > 1 + assert len(fragments) == 3 # 500 bytes / 180 payload per fragment + + # Check fragment sizes + for frag in fragments[:-1]: + assert len(frag) == 185 # MTU size + + # Last fragment may be smaller + assert len(fragments[-1]) <= 185 + + def test_fragment_headers(self): + """Fragment headers should be correct""" + fragmenter = BLEFragmenter(mtu=100) + packet = b"B" * 300 + + fragments = fragmenter.fragment_packet(packet) + + # Check first fragment (START) + assert fragments[0][0] == BLEFragmenter.TYPE_START + + # Check middle fragments (CONTINUE) + if len(fragments) > 2: + for frag in fragments[1:-1]: + assert frag[0] == BLEFragmenter.TYPE_CONTINUE + + # Check last fragment (END) + assert fragments[-1][0] == BLEFragmenter.TYPE_END + + def test_sequence_numbers(self): + """Sequence numbers should be sequential""" + fragmenter = BLEFragmenter(mtu=50) + packet = b"C" * 200 + + fragments = fragmenter.fragment_packet(packet) + + for i, frag in enumerate(fragments): + # Extract sequence number (bytes 1-2, big endian) + seq = (frag[1] << 8) | frag[2] + assert seq == i + + def test_total_count(self): + """Total fragment count should be correct in all fragments""" + fragmenter = BLEFragmenter(mtu=50) + packet = b"D" * 200 + + fragments = fragmenter.fragment_packet(packet) + total_expected = len(fragments) + + for frag in fragments: + # Extract total count (bytes 3-4, big endian) + total = (frag[3] << 8) | frag[4] + assert total == total_expected + + def test_overhead_calculation(self): + """Overhead calculation should be accurate""" + fragmenter = BLEFragmenter(mtu=185) + + # Small packet (still has header overhead) + num_frags, overhead, pct = fragmenter.get_fragment_overhead(100) + assert num_frags == 1 + assert overhead == 5 # 1 fragment * 5 byte header + assert pct == (5 / 100) * 100 + + # Large packet (requires fragmentation) + num_frags, overhead, pct = fragmenter.get_fragment_overhead(500) + assert num_frags == 3 + assert overhead == 3 * 5 # 3 fragments * 5 byte header + assert pct == (15 / 500) * 100 + + def test_empty_packet_error(self): + """Empty packets should raise ValueError""" + fragmenter = BLEFragmenter(mtu=185) + + with pytest.raises(ValueError): + fragmenter.fragment_packet(b"") + + def test_invalid_type_error(self): + """Non-bytes packet should raise TypeError""" + fragmenter = BLEFragmenter(mtu=185) + + with pytest.raises(TypeError): + fragmenter.fragment_packet("not bytes") + + +class TestBLEReassembler: + """Test BLE packet reassembly""" + + def test_single_fragment_packet(self): + """Single-fragment packet should be returned as-is""" + fragmenter = BLEFragmenter(mtu=185) + reassembler = BLEReassembler() + + original = b"Short message" + fragments = fragmenter.fragment_packet(original) + assert len(fragments) == 1 + + # Non-fragmented packets are returned as-is without headers + result = reassembler.receive_fragment(fragments[0], "device1") + assert result == original + + def test_multi_fragment_reassembly(self): + """Multi-fragment packet should be reassembled correctly""" + fragmenter = BLEFragmenter(mtu=100) + reassembler = BLEReassembler() + + original = b"E" * 300 + fragments = fragmenter.fragment_packet(original) + assert len(fragments) > 1 + + # Send all but last fragment + for frag in fragments[:-1]: + result = reassembler.receive_fragment(frag, "device1") + assert result is None # Not complete yet + + # Send last fragment + result = reassembler.receive_fragment(fragments[-1], "device1") + assert result == original # Complete! + + def test_out_of_order_fragments(self): + """Fragments arriving out of order should be handled""" + fragmenter = BLEFragmenter(mtu=50) + reassembler = BLEReassembler() + + original = b"F" * 150 # Size to ensure exactly 4 fragments + fragments = fragmenter.fragment_packet(original) + + # Ensure we have exactly 4 fragments for this test + assert len(fragments) == 4, f"Expected 4 fragments, got {len(fragments)}" + + # Send in scrambled order: 0, 2, 1, 3 (all fragments, just out of order) + order = [0, 2, 1, 3] + for i in order[:-1]: + result = reassembler.receive_fragment(fragments[i], "device1") + assert result is None # Not complete yet + + result = reassembler.receive_fragment(fragments[order[-1]], "device1") + assert result == original # Should be complete now + + def test_multiple_senders(self): + """Should handle fragments from multiple senders simultaneously""" + fragmenter = BLEFragmenter(mtu=100) + reassembler = BLEReassembler() + + packet_a = b"A" * 300 + packet_b = b"B" * 300 + + fragments_a = fragmenter.fragment_packet(packet_a) + fragments_b = fragmenter.fragment_packet(packet_b) + + # Interleave fragments from two senders + for i in range(max(len(fragments_a), len(fragments_b))): + if i < len(fragments_a): + result_a = reassembler.receive_fragment(fragments_a[i], "device1") + if i == len(fragments_a) - 1: + assert result_a == packet_a + else: + assert result_a is None + + if i < len(fragments_b): + result_b = reassembler.receive_fragment(fragments_b[i], "device2") + if i == len(fragments_b) - 1: + assert result_b == packet_b + else: + assert result_b is None + + def test_timeout_cleanup(self): + """Stale fragments should be cleaned up after timeout""" + fragmenter = BLEFragmenter(mtu=100) + reassembler = BLEReassembler(timeout=0.1) # Very short timeout + + original = b"G" * 300 + fragments = fragmenter.fragment_packet(original) + + # Send only first fragment + result = reassembler.receive_fragment(fragments[0], "device1") + assert result is None + assert len(reassembler.reassembly_buffers) == 1 + + # Wait for timeout + import time + time.sleep(0.2) + + # Cleanup should remove stale buffer + removed = reassembler.cleanup_stale_buffers() + assert removed == 1 + assert len(reassembler.reassembly_buffers) == 0 + + def test_statistics(self): + """Statistics should be tracked correctly""" + fragmenter = BLEFragmenter(mtu=100) + reassembler = BLEReassembler() + + packet = b"H" * 300 + fragments = fragmenter.fragment_packet(packet) + + for frag in fragments: + reassembler.receive_fragment(frag, "device1") + + stats = reassembler.get_statistics() + assert stats['packets_reassembled'] == 1 + assert stats['fragments_received'] == len(fragments) + assert stats['pending_packets'] == 0 + + +class TestHDLCFramer: + """Test HDLC framing (alternative to fragmentation)""" + + def test_frame_simple_packet(self): + """Simple packet should be framed correctly""" + packet = b"Hello, World!" + framed = HDLCFramer.frame_packet(packet) + + # Should start and end with FLAG + assert framed[0] == HDLCFramer.FLAG + assert framed[-1] == HDLCFramer.FLAG + + # Should be deframeable + deframed = HDLCFramer.deframe_packet(framed) + assert deframed == packet + + def test_frame_with_flag_bytes(self): + """Packet containing FLAG bytes should be stuffed""" + packet = bytes([0x7E, 0x01, 0x7E]) # Contains FLAG bytes + framed = HDLCFramer.frame_packet(packet) + + # Should be longer due to byte stuffing + assert len(framed) > len(packet) + 2 + + # Should deframe correctly + deframed = HDLCFramer.deframe_packet(framed) + assert deframed == packet + + def test_frame_with_escape_bytes(self): + """Packet containing ESCAPE bytes should be stuffed""" + packet = bytes([0x7D, 0x02, 0x7D]) # Contains ESCAPE bytes + framed = HDLCFramer.frame_packet(packet) + + # Should be longer due to byte stuffing + assert len(framed) > len(packet) + 2 + + # Should deframe correctly + deframed = HDLCFramer.deframe_packet(framed) + assert deframed == packet + + def test_round_trip(self): + """Frame then deframe should return original""" + for i in range(256): + packet = bytes([i] * 10) + framed = HDLCFramer.frame_packet(packet) + deframed = HDLCFramer.deframe_packet(framed) + assert deframed == packet + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_gatt_server.py b/tests/test_gatt_server.py new file mode 100644 index 0000000..cf661e4 --- /dev/null +++ b/tests/test_gatt_server.py @@ -0,0 +1,333 @@ +""" +Unit tests for BLEGATTServer + +Tests the GATT server functionality without requiring actual BLE hardware. +""" + +import pytest +import sys +import os + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from RNS.Interfaces.BLEGATTServer import BLEGATTServer, BLESS_AVAILABLE + + +class MockInterface: + """Mock BLEInterface for testing""" + def __init__(self): + self.name = "TestInterface" + self.received_data = [] + + +@pytest.mark.skipif(not BLESS_AVAILABLE, reason="bless library not available") +class TestBLEGATTServer: + """Test suite for BLEGATTServer""" + + def test_initialization(self): + """Test GATT server initialization""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface, device_name="TestNode") + + assert server.device_name == "TestNode" + assert server.interface == mock_interface + assert not server.running + assert server.server is None + assert len(server.connected_centrals) == 0 + + def test_uuids_defined(self): + """Test that UUIDs are properly defined""" + assert BLEGATTServer.SERVICE_UUID == "00000001-5824-4f48-9e1a-3b3e8f0c1234" + assert BLEGATTServer.RX_CHAR_UUID == "00000002-5824-4f48-9e1a-3b3e8f0c1234" + assert BLEGATTServer.TX_CHAR_UUID == "00000003-5824-4f48-9e1a-3b3e8f0c1234" + + def test_connection_tracking(self): + """Test central connection tracking""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + + # Simulate central connection + central_addr = "AA:BB:CC:DD:EE:FF" + server._handle_central_connected(central_addr) + + assert server.is_connected(central_addr) + assert central_addr in server.get_connected_centrals() + assert len(server.connected_centrals) == 1 + + # Get connection info + info = server.get_connection_info(central_addr) + assert info is not None + assert info["address"] == central_addr + assert "connected_at" in info + assert info["bytes_received"] == 0 + assert info["bytes_sent"] == 0 + + def test_connection_disconnect(self): + """Test central disconnection""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + + central_addr = "AA:BB:CC:DD:EE:FF" + server._handle_central_connected(central_addr) + assert server.is_connected(central_addr) + + server._handle_central_disconnected(central_addr) + assert not server.is_connected(central_addr) + assert len(server.connected_centrals) == 0 + + def test_multiple_centrals(self): + """Test multiple simultaneous central connections""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + + centrals = [ + "AA:BB:CC:DD:EE:FF", + "11:22:33:44:55:66", + "FF:EE:DD:CC:BB:AA", + ] + + for addr in centrals: + server._handle_central_connected(addr) + + assert len(server.connected_centrals) == 3 + for addr in centrals: + assert server.is_connected(addr) + + def test_data_queuing(self): + """Test data queuing for centrals""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + + central_addr = "AA:BB:CC:DD:EE:FF" + server._handle_central_connected(central_addr) + + # Queue some data + data1 = b"Test data 1" + data2 = b"Test data 2" + server.queue_data_for_central(data1, central_addr) + server.queue_data_for_central(data2, central_addr) + + assert len(server.tx_queues[central_addr]) == 2 + assert server.tx_queues[central_addr][0] == data1 + assert server.tx_queues[central_addr][1] == data2 + + def test_callbacks(self): + """Test callback invocation""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + + # Track callback invocations + callbacks_called = { + "data_received": [], + "connected": [], + "disconnected": [], + } + + def on_data(data, addr): + callbacks_called["data_received"].append((data, addr)) + + def on_connect(addr): + callbacks_called["connected"].append(addr) + + def on_disconnect(addr): + callbacks_called["disconnected"].append(addr) + + server.on_data_received = on_data + server.on_central_connected = on_connect + server.on_central_disconnected = on_disconnect + + # Simulate connection + central_addr = "AA:BB:CC:DD:EE:FF" + server._handle_central_connected(central_addr) + assert central_addr in callbacks_called["connected"] + + # Simulate data reception + test_data = b"Test fragment" + # Direct callback invocation (would normally be called from _handle_write_request) + server.on_data_received(test_data, central_addr) + assert (test_data, central_addr) in callbacks_called["data_received"] + + # Simulate disconnection + server._handle_central_disconnected(central_addr) + assert central_addr in callbacks_called["disconnected"] + + def test_statistics(self): + """Test statistics gathering""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + + # Initial stats + stats = server.get_statistics() + assert stats["running"] == False + assert stats["connected_centrals"] == 0 + assert stats["total_bytes_received"] == 0 + assert stats["total_bytes_sent"] == 0 + + # Add some centrals with data + server._handle_central_connected("AA:BB:CC:DD:EE:FF") + server._handle_central_connected("11:22:33:44:55:66") + + # Simulate some data transfer + server.connected_centrals["AA:BB:CC:DD:EE:FF"]["bytes_received"] = 100 + server.connected_centrals["AA:BB:CC:DD:EE:FF"]["bytes_sent"] = 50 + server.connected_centrals["11:22:33:44:55:66"]["bytes_received"] = 200 + server.connected_centrals["11:22:33:44:55:66"]["bytes_sent"] = 150 + + stats = server.get_statistics() + assert stats["connected_centrals"] == 2 + assert stats["total_bytes_received"] == 300 + assert stats["total_bytes_sent"] == 200 + + def test_string_representations(self): + """Test __str__ and __repr__ methods""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface, device_name="TestNode") + + str_repr = str(server) + assert "TestNode" in str_repr + assert "stopped" in str_repr + + repr_repr = repr(server) + assert "TestNode" in repr_repr + assert "running=False" in repr_repr + + + def test_write_request_empty_data(self): + """Test handling of empty write requests.""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + central_addr = "AA:BB:CC:DD:EE:FF" + server._handle_central_connected(central_addr) + + # Simulate empty write (should handle gracefully) + empty_data = b'' + # Would normally call _handle_write_request, but that's internal + # Just verify server doesn't crash with empty data + server.connected_centrals[central_addr]["bytes_received"] += len(empty_data) + assert server.connected_centrals[central_addr]["bytes_received"] == 0 + + def test_write_request_large_data(self): + """Test handling of large write requests.""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + central_addr = "AA:BB:CC:DD:EE:FF" + server._handle_central_connected(central_addr) + + # Simulate large write + large_data = b'X' * 1000 + server.connected_centrals[central_addr]["bytes_received"] += len(large_data) + assert server.connected_centrals[central_addr]["bytes_received"] == 1000 + + def test_notification_to_specific_central(self): + """Test targeted notification to specific central.""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + + # Connect multiple centrals + central1 = "AA:BB:CC:DD:EE:01" + central2 = "AA:BB:CC:DD:EE:02" + server._handle_central_connected(central1) + server._handle_central_connected(central2) + + # Queue data for specific central + data = b"Targeted notification" + server.queue_data_for_central(data, central1) + + # Verify only central1 has queued data + assert len(server.tx_queues[central1]) == 1 + assert len(server.tx_queues[central2]) == 0 + + def test_central_reconnection(self): + """Test same central reconnecting.""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + central_addr = "AA:BB:CC:DD:EE:FF" + + # First connection + server._handle_central_connected(central_addr) + assert server.is_connected(central_addr) + + # Disconnect + server._handle_central_disconnected(central_addr) + assert not server.is_connected(central_addr) + + # Reconnect + server._handle_central_connected(central_addr) + assert server.is_connected(central_addr) + assert len(server.connected_centrals) == 1 + + def test_statistics_overflow_safety(self): + """Test that statistics handle large values correctly.""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + central_addr = "AA:BB:CC:DD:EE:FF" + server._handle_central_connected(central_addr) + + # Simulate very large byte counts + large_value = 2**32 # 4GB + server.connected_centrals[central_addr]["bytes_received"] = large_value + server.connected_centrals[central_addr]["bytes_sent"] = large_value + + stats = server.get_statistics() + assert stats["total_bytes_received"] == large_value + assert stats["total_bytes_sent"] == large_value + + def test_tx_queue_fifo_order(self): + """Test that TX queue maintains FIFO order.""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + central_addr = "AA:BB:CC:DD:EE:FF" + server._handle_central_connected(central_addr) + + # Queue multiple items + data1 = b"First" + data2 = b"Second" + data3 = b"Third" + + server.queue_data_for_central(data1, central_addr) + server.queue_data_for_central(data2, central_addr) + server.queue_data_for_central(data3, central_addr) + + # Verify FIFO order + queue = server.tx_queues[central_addr] + assert queue[0] == data1 + assert queue[1] == data2 + assert queue[2] == data3 + + def test_get_connection_info_nonexistent(self): + """Test getting info for non-existent central.""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface) + + # Try to get info for non-existent central + info = server.get_connection_info("AA:BB:CC:DD:EE:FF") + assert info is None + + def test_server_repr_with_centrals(self): + """Test string representation includes connected centrals count.""" + mock_interface = MockInterface() + server = BLEGATTServer(mock_interface, device_name="TestNode") + + # Add some centrals + server._handle_central_connected("AA:BB:CC:DD:EE:01") + server._handle_central_connected("AA:BB:CC:DD:EE:02") + + repr_str = repr(server) + assert "TestNode" in repr_str + assert "running=False" in repr_str + + +@pytest.mark.skipif(BLESS_AVAILABLE, reason="Testing import error handling") +class TestBLEGATTServerWithoutBless: + """Test behavior when bless is not available""" + + def test_import_error(self): + """Test that appropriate error is raised when bless not available""" + # This test would need to mock the BLESS_AVAILABLE flag + # For now, just ensure the flag is checked correctly + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..1fbdeac --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,82 @@ +""" +Integration tests for BLEInterface with GATT server. + +Tests the structure and code changes for peripheral mode integration. +""" + +import pytest +import os + + +def test_config_options(): + """Test that configuration option for peripheral mode is documented.""" + # Read config example file + config_path = os.path.join(os.path.dirname(__file__), '../examples/config_example.toml') + with open(config_path, 'r') as f: + config_content = f.read() + + # Check that enable_peripheral is documented + assert 'enable_peripheral' in config_content + assert 'peripheral mode' in config_content.lower() + assert 'GATT server' in config_content + + +def test_interface_has_gatt_integration(): + """Test that BLEInterface.py has GATT server integration code.""" + interface_path = os.path.join(os.path.dirname(__file__), '../src/RNS/Interfaces/BLEInterface.py') + with open(interface_path, 'r') as f: + code = f.read() + + # Check for GATT server imports (uses try/except fallback pattern) + assert 'from RNS.Interfaces.BLEGATTServer import BLEGATTServer' in code + assert 'HAS_GATT_SERVER' in code + + # Check for peripheral mode configuration + assert 'enable_peripheral' in code + + # Check for callback methods + assert 'def handle_peripheral_data(' in code + assert 'def handle_central_connected(' in code + assert 'def handle_central_disconnected(' in code + assert 'def _create_peripheral_peer(' in code + assert 'def _start_server(' in code + + # Check for detach stops server + assert 'self.gatt_server.stop()' in code + + +def test_peer_interface_has_routing(): + """Test that BLEPeerInterface has routing methods.""" + interface_path = os.path.join(os.path.dirname(__file__), '../src/RNS/Interfaces/BLEInterface.py') + with open(interface_path, 'r') as f: + code = f.read() + + # Check for connection flag + assert 'is_peripheral_connection' in code + + # Check for routing methods + assert 'def _send_via_peripheral(' in code + assert 'def _send_via_central(' in code + + # Check that process_outgoing routes based on connection type + assert 'if self.is_peripheral_connection:' in code + + +def test_gatt_server_file_exists(): + """Test that BLEGATTServer module exists.""" + server_path = os.path.join(os.path.dirname(__file__), '../src/RNS/Interfaces/BLEGATTServer.py') + assert os.path.exists(server_path) + + with open(server_path, 'r') as f: + code = f.read() + + # Check for key classes and methods + assert 'class BLEGATTServer' in code + assert 'async def start(' in code + assert 'async def stop(' in code + assert 'async def send_notification(' in code + + +if __name__ == "__main__": + # Run tests + pytest.main([__file__, "-v"]) diff --git a/tests/test_multi_device_simulation.py b/tests/test_multi_device_simulation.py new file mode 100644 index 0000000..5ff1e85 --- /dev/null +++ b/tests/test_multi_device_simulation.py @@ -0,0 +1,492 @@ +""" +Automated Multi-Device Simulation Tests + +Tests the BLE multi-device simulation framework to ensure: +- Mock BLE components work correctly +- Two nodes can discover and connect +- Data transfer works bidirectionally +- Fragmentation works with large packets +- Multiple transfer scenarios work + +These tests use the simulation framework (no real BLE hardware required). +""" + +import sys +import os +import pytest +import asyncio +from unittest.mock import Mock, patch + +# Add project paths +project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(project_root, 'src')) +sys.path.insert(0, os.path.join(project_root, 'examples')) + +from two_device_simulator import ( + MockBLEConnection, + MockBLEDevice, + SimulatedBLENode, + TwoDeviceSimulator +) + + +# ============================================================================ +# Mock BLE Component Tests +# ============================================================================ + +class TestMockBLEComponents: + """Test individual mock BLE components.""" + + def test_mock_device_creation(self): + """Test MockBLEDevice can be created with correct attributes.""" + device = MockBLEDevice( + address="AA:BB:CC:DD:EE:01", + name="Test-Device", + rssi=-65 + ) + + assert device.address == "AA:BB:CC:DD:EE:01" + assert device.name == "Test-Device" + assert device.rssi == -65 + assert "00000001-5824-4f48-9e1a-3b3e8f0c1234" in device.metadata["uuids"] + assert device.metadata["rssi"] == -65 + + @pytest.mark.asyncio + async def test_mock_connection_lifecycle(self): + """Test MockBLEConnection connect/disconnect.""" + conn = MockBLEConnection("Node-A", "Node-B", mtu=185) + + # Initially not connected + assert not conn.connected + + # Connect + await conn.connect() + assert conn.connected + + # Disconnect + await conn.disconnect() + assert not conn.connected + + @pytest.mark.asyncio + async def test_mock_connection_data_transfer(self): + """Test data transfer between two MockBLEConnections.""" + conn_a = MockBLEConnection("Node-A", "Node-B", mtu=185) + conn_b = MockBLEConnection("Node-B", "Node-A", mtu=185) + + # Link them together + conn_a.set_peer(conn_b) + conn_b.set_peer(conn_a) + + # Connect both + await conn_a.connect() + await conn_b.connect() + + # Setup receiver + received = [] + async def rx_callback(data): + received.append(data) + + conn_b.set_rx_callback(rx_callback) + + # Send data A → B + test_data = b"Hello from A!" + await conn_a.write(test_data) + await asyncio.sleep(0.01) # Allow delivery + + assert len(received) == 1 + assert received[0] == test_data + + @pytest.mark.asyncio + async def test_mock_connection_rejects_oversized_data(self): + """Test that data exceeding MTU is rejected.""" + conn = MockBLEConnection("Node-A", "Node-B", mtu=185) + await conn.connect() + + oversized_data = b"X" * 200 # Exceeds MTU of 185 + + with pytest.raises(ValueError, match="exceeds MTU"): + await conn.write(oversized_data) + + @pytest.mark.asyncio + async def test_mock_connection_rejects_write_when_disconnected(self): + """Test that writing to disconnected connection fails.""" + conn = MockBLEConnection("Node-A", "Node-B", mtu=185) + + # Not connected + with pytest.raises(RuntimeError, match="not connected"): + await conn.write(b"Test") + + @pytest.mark.asyncio + async def test_bidirectional_data_transfer(self): + """Test data can flow in both directions.""" + conn_a = MockBLEConnection("Node-A", "Node-B", mtu=185) + conn_b = MockBLEConnection("Node-B", "Node-A", mtu=185) + + conn_a.set_peer(conn_b) + conn_b.set_peer(conn_a) + + await conn_a.connect() + await conn_b.connect() + + # Setup receivers + received_a = [] + received_b = [] + + async def rx_callback_a(data): + received_a.append(data) + + async def rx_callback_b(data): + received_b.append(data) + + conn_a.set_rx_callback(rx_callback_a) + conn_b.set_rx_callback(rx_callback_b) + + # A → B + await conn_a.write(b"A to B") + await asyncio.sleep(0.01) + + # B → A + await conn_b.write(b"B to A") + await asyncio.sleep(0.01) + + assert len(received_b) == 1 + assert received_b[0] == b"A to B" + assert len(received_a) == 1 + assert received_a[0] == b"B to A" + + +# ============================================================================ +# Simulated Node Tests +# ============================================================================ + +class TestSimulatedBLENode: + """Test SimulatedBLENode functionality.""" + + @pytest.mark.asyncio + async def test_node_discovery(self): + """Test that a node can discover its peer.""" + node = SimulatedBLENode( + name="Node-A", + address="AA:BB:CC:DD:EE:01", + peer_address="AA:BB:CC:DD:EE:02", + peer_name="Node-B" + ) + + devices = await node.discover_peers() + + assert len(devices) == 1 + assert devices[0].address == "AA:BB:CC:DD:EE:02" + assert devices[0].name == "Node-B" + + def test_node_connection_creation(self): + """Test that a node can create a connection.""" + node = SimulatedBLENode( + name="Node-A", + address="AA:BB:CC:DD:EE:01", + peer_address="AA:BB:CC:DD:EE:02", + peer_name="Node-B" + ) + + conn = node.create_connection(mtu=247) + + assert conn is not None + assert conn.name == "Node-A" + assert conn.peer_name == "Node-B" + assert conn.mtu == 247 + + def test_node_connection_singleton(self): + """Test that creating connection twice returns same instance.""" + node = SimulatedBLENode( + name="Node-A", + address="AA:BB:CC:DD:EE:01", + peer_address="AA:BB:CC:DD:EE:02", + peer_name="Node-B" + ) + + conn1 = node.create_connection() + conn2 = node.create_connection() + + assert conn1 is conn2 + + +# ============================================================================ +# Two-Device Simulator Tests +# ============================================================================ + +class TestTwoDeviceSimulator: + """Test the complete two-device simulator.""" + + def test_simulator_initialization(self): + """Test that simulator creates two nodes correctly.""" + sim = TwoDeviceSimulator() + + assert sim.node_a is not None + assert sim.node_b is not None + assert sim.node_a.address == "AA:BB:CC:DD:EE:01" + assert sim.node_b.address == "AA:BB:CC:DD:EE:02" + assert sim.node_a.peer_address == sim.node_b.address + assert sim.node_b.peer_address == sim.node_a.address + + @pytest.mark.asyncio + async def test_simulator_discovery(self): + """Test discovery test scenario.""" + sim = TwoDeviceSimulator() + success = await sim.run_discovery_test() + # run_discovery_test uses assertions internally, if it returns it passed + assert success is None # Function doesn't return, just completes + + @pytest.mark.asyncio + async def test_simulator_connection(self): + """Test connection establishment.""" + sim = TwoDeviceSimulator() + await sim.setup_connections() + + assert sim.node_a.connection.connected + assert sim.node_b.connection.connected + + @pytest.mark.asyncio + async def test_simulator_data_transfer(self): + """Test data transfer between nodes.""" + sim = TwoDeviceSimulator() + await sim.setup_connections() + + # Setup receiver + received = [] + async def rx_callback(data): + received.append(data) + + sim.node_b.connection.set_rx_callback(rx_callback) + + # Send data + test_data = b"Test packet" + await sim.node_a.connection.write(test_data) + await asyncio.sleep(0.1) + + assert len(received) == 1 + assert received[0] == test_data + + @pytest.mark.asyncio + async def test_simulator_fragmentation(self): + """Test fragmentation of large packets.""" + sim = TwoDeviceSimulator() + await sim.setup_connections() + + # Large packet that requires fragmentation + large_data = b"X" * 500 + mtu = sim.node_a.connection.mtu + expected_fragments = (len(large_data) + mtu - 1) // mtu + + received_fragments = [] + async def rx_callback(data): + received_fragments.append(data) + + sim.node_b.connection.set_rx_callback(rx_callback) + + # Send in fragments + for i in range(expected_fragments): + start = i * mtu + end = min(start + mtu, len(large_data)) + fragment = large_data[start:end] + await sim.node_a.connection.write(fragment) + await asyncio.sleep(0.01) + + # Verify all fragments received + assert len(received_fragments) == expected_fragments + + # Verify reconstruction works + reconstructed = b''.join(received_fragments) + assert reconstructed == large_data + + @pytest.mark.asyncio + async def test_simulator_all_tests(self): + """Test that all simulator tests pass.""" + sim = TwoDeviceSimulator() + success = await sim.run_all_tests() + assert success is True + + +# ============================================================================ +# Integration Scenarios +# ============================================================================ + +class TestIntegrationScenarios: + """Test various integration scenarios.""" + + @pytest.mark.asyncio + async def test_rapid_transfers(self): + """Test rapid back-and-forth transfers.""" + sim = TwoDeviceSimulator() + await sim.setup_connections() + + received_a = [] + received_b = [] + + async def rx_callback_a(data): + received_a.append(data) + + async def rx_callback_b(data): + received_b.append(data) + + sim.node_a.connection.set_rx_callback(rx_callback_a) + sim.node_b.connection.set_rx_callback(rx_callback_b) + + # Send 10 packets each direction + for i in range(10): + await sim.node_a.connection.write(f"A→B {i}".encode()) + await sim.node_b.connection.write(f"B→A {i}".encode()) + await asyncio.sleep(0.001) + + await asyncio.sleep(0.1) # Allow all deliveries + + assert len(received_b) == 10 + assert len(received_a) == 10 + + @pytest.mark.asyncio + async def test_various_packet_sizes(self): + """Test various packet sizes.""" + sim = TwoDeviceSimulator() + await sim.setup_connections() + + test_sizes = [1, 10, 50, 100, 185] # Up to MTU + received = [] + + async def rx_callback(data): + received.append(len(data)) + + sim.node_b.connection.set_rx_callback(rx_callback) + + for size in test_sizes: + data = b"X" * size + await sim.node_a.connection.write(data) + await asyncio.sleep(0.01) + + assert received == test_sizes + + @pytest.mark.asyncio + async def test_connection_disconnect_reconnect(self): + """Test disconnection and reconnection.""" + sim = TwoDeviceSimulator() + await sim.setup_connections() + + # Verify connected + assert sim.node_a.connection.connected + + # Disconnect + await sim.node_a.connection.disconnect() + assert not sim.node_a.connection.connected + + # Reconnect + await sim.node_a.connection.connect() + assert sim.node_a.connection.connected + + # Data transfer should work again + received = [] + async def rx_callback(data): + received.append(data) + + sim.node_b.connection.set_rx_callback(rx_callback) + await sim.node_a.connection.write(b"After reconnect") + await asyncio.sleep(0.01) + + assert len(received) == 1 + assert received[0] == b"After reconnect" + + @pytest.mark.asyncio + async def test_empty_data_transfer(self): + """Test that empty data can be sent (edge case).""" + sim = TwoDeviceSimulator() + await sim.setup_connections() + + received = [] + async def rx_callback(data): + received.append(data) + + sim.node_b.connection.set_rx_callback(rx_callback) + + # Send empty data + await sim.node_a.connection.write(b"") + await asyncio.sleep(0.01) + + assert len(received) == 1 + assert received[0] == b"" + + +# ============================================================================ +# Performance Tests +# ============================================================================ + +class TestPerformance: + """Test performance characteristics of simulation.""" + + @pytest.mark.asyncio + async def test_throughput_simulation(self): + """Test sustained throughput in simulation.""" + sim = TwoDeviceSimulator() + await sim.setup_connections() + + packet_count = 100 + packet_size = 100 + received_count = 0 + + async def rx_callback(data): + nonlocal received_count + received_count += 1 + + sim.node_b.connection.set_rx_callback(rx_callback) + + # Send many packets + start = asyncio.get_event_loop().time() + for i in range(packet_count): + data = b"X" * packet_size + await sim.node_a.connection.write(data) + + await asyncio.sleep(0.5) # Allow delivery + end = asyncio.get_event_loop().time() + + duration = end - start + assert received_count == packet_count + assert duration < 2.0 # Should be fast in simulation + + @pytest.mark.asyncio + async def test_large_packet_fragmentation_performance(self): + """Test performance with large packets requiring fragmentation.""" + sim = TwoDeviceSimulator() + await sim.setup_connections() + + # Very large packet (2KB) + large_data = b"X" * 2000 + mtu = sim.node_a.connection.mtu + fragments_needed = (len(large_data) + mtu - 1) // mtu + + received_fragments = [] + async def rx_callback(data): + received_fragments.append(data) + + sim.node_b.connection.set_rx_callback(rx_callback) + + # Send fragments + start = asyncio.get_event_loop().time() + for i in range(fragments_needed): + start_pos = i * mtu + end_pos = min(start_pos + mtu, len(large_data)) + fragment = large_data[start_pos:end_pos] + await sim.node_a.connection.write(fragment) + + await asyncio.sleep(0.5) # Allow delivery + end = asyncio.get_event_loop().time() + + duration = end - start + assert len(received_fragments) == fragments_needed + assert duration < 2.0 # Should be fast + + # Verify reconstruction + reconstructed = b''.join(received_fragments) + assert reconstructed == large_data + + +# ============================================================================ +# Run Tests +# ============================================================================ + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/tests/test_prioritization.py b/tests/test_prioritization.py new file mode 100644 index 0000000..f394577 --- /dev/null +++ b/tests/test_prioritization.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +""" +Unit tests for BLE connection prioritization + +These tests validate the DiscoveredPeer class and prioritization algorithms. +""" + +import pytest +import sys +import os +import time + +# Simple implementation tests - directly read and test the code logic + + +# Standalone DiscoveredPeer implementation (copied from BLEInterface.py for testing) +class DiscoveredPeer: + """ + Tracks information about a discovered BLE peer for connection prioritization. + """ + + def __init__(self, address, name, rssi): + self.address = address + self.name = name + self.rssi = rssi + self.first_seen = time.time() + self.last_seen = time.time() + self.connection_attempts = 0 + self.successful_connections = 0 + self.failed_connections = 0 + self.last_connection_attempt = 0 + + def update_rssi(self, rssi): + self.rssi = rssi + self.last_seen = time.time() + + def record_connection_attempt(self): + self.connection_attempts += 1 + self.last_connection_attempt = time.time() + + def record_connection_success(self): + self.successful_connections += 1 + + def record_connection_failure(self): + self.failed_connections += 1 + + def get_success_rate(self): + if self.connection_attempts == 0: + return 0.0 + return self.successful_connections / self.connection_attempts + + def __repr__(self): + return (f"DiscoveredPeer({self.address}, {self.name}, " + f"RSSI={self.rssi}, attempts={self.connection_attempts}, " + f"success_rate={self.get_success_rate():.2f})") + + +# Scoring algorithm (extracted from BLEInterface._score_peer) +def score_peer(peer): + """Calculate priority score for peer selection.""" + score = 0.0 + + # Signal strength component (0-70 points) + if peer.rssi is not None: + rssi_clamped = max(-100, min(-30, peer.rssi)) + rssi_normalized = (rssi_clamped + 100) * (70.0 / 70.0) + score += rssi_normalized + + # Connection history component (0-50 points) + if peer.connection_attempts > 0: + success_rate = peer.get_success_rate() + score += success_rate * 50.0 + else: + score += 25.0 # New peers get moderate score + + # Recency component (0-25 points) + age_seconds = time.time() - peer.last_seen + if age_seconds < 5.0: + score += 25.0 + elif age_seconds < 30.0: + score += 25.0 * (1.0 - (age_seconds - 5.0) / 25.0) + + return score + + +class TestDiscoveredPeer: + """Test DiscoveredPeer data class""" + + def test_initialization(self): + """Test DiscoveredPeer initialization""" + peer = DiscoveredPeer("AA:BB:CC:DD:EE:FF", "TestDevice", -65) + + assert peer.address == "AA:BB:CC:DD:EE:FF" + assert peer.name == "TestDevice" + assert peer.rssi == -65 + assert peer.connection_attempts == 0 + assert peer.successful_connections == 0 + assert peer.failed_connections == 0 + assert peer.first_seen <= time.time() + assert peer.last_seen <= time.time() + + def test_update_rssi(self): + """Test RSSI updates""" + peer = DiscoveredPeer("AA:BB:CC:DD:EE:FF", "TestDevice", -65) + initial_last_seen = peer.last_seen + + time.sleep(0.01) # Small delay + peer.update_rssi(-70) + + assert peer.rssi == -70 + assert peer.last_seen > initial_last_seen + + def test_connection_attempt_tracking(self): + """Test connection attempt recording""" + peer = DiscoveredPeer("AA:BB:CC:DD:EE:FF", "TestDevice", -65) + + peer.record_connection_attempt() + assert peer.connection_attempts == 1 + + peer.record_connection_attempt() + assert peer.connection_attempts == 2 + + def test_connection_success_tracking(self): + """Test successful connection recording""" + peer = DiscoveredPeer("AA:BB:CC:DD:EE:FF", "TestDevice", -65) + + peer.record_connection_attempt() + peer.record_connection_success() + + assert peer.successful_connections == 1 + assert peer.get_success_rate() == 1.0 + + def test_connection_failure_tracking(self): + """Test failed connection recording""" + peer = DiscoveredPeer("AA:BB:CC:DD:EE:FF", "TestDevice", -65) + + peer.record_connection_attempt() + peer.record_connection_failure() + + assert peer.failed_connections == 1 + assert peer.get_success_rate() == 0.0 + + def test_success_rate_calculation(self): + """Test connection success rate calculation""" + peer = DiscoveredPeer("AA:BB:CC:DD:EE:FF", "TestDevice", -65) + + # No attempts yet + assert peer.get_success_rate() == 0.0 + + # 3 successes out of 5 attempts + for i in range(5): + peer.record_connection_attempt() + if i < 3: + peer.record_connection_success() + + assert peer.get_success_rate() == 0.6 + + def test_repr(self): + """Test string representation""" + peer = DiscoveredPeer("AA:BB:CC:DD:EE:FF", "TestDevice", -65) + peer.record_connection_attempt() + peer.record_connection_success() + + repr_str = repr(peer) + assert "AA:BB:CC:DD:EE:FF" in repr_str + assert "TestDevice" in repr_str + assert "RSSI=-65" in repr_str + assert "attempts=1" in repr_str + + +class TestPeerScoring: + """Test peer scoring algorithm""" + + def test_score_by_rssi(self): + """Test that peers with better RSSI score higher""" + peer_strong = DiscoveredPeer("AA:BB:CC:DD:EE:01", "StrongSignal", -40) + peer_weak = DiscoveredPeer("AA:BB:CC:DD:EE:02", "WeakSignal", -90) + + score_strong = score_peer(peer_strong) + score_weak = score_peer(peer_weak) + + assert score_strong > score_weak + + def test_score_by_connection_history(self): + """Test that peers with good connection history score higher""" + # Peer with good history + peer_reliable = DiscoveredPeer("AA:BB:CC:DD:EE:01", "Reliable", -60) + for i in range(5): + peer_reliable.record_connection_attempt() + peer_reliable.record_connection_success() + + # Peer with poor history + peer_unreliable = DiscoveredPeer("AA:BB:CC:DD:EE:02", "Unreliable", -60) + for i in range(5): + peer_unreliable.record_connection_attempt() + if i < 1: # Only 1 success out of 5 + peer_unreliable.record_connection_success() + + score_reliable = score_peer(peer_reliable) + score_unreliable = score_peer(peer_unreliable) + + assert score_reliable > score_unreliable + + def test_score_by_recency(self): + """Test that recently seen peers score higher""" + peer_recent = DiscoveredPeer("AA:BB:CC:DD:EE:01", "Recent", -60) + peer_old = DiscoveredPeer("AA:BB:CC:DD:EE:02", "Old", -60) + + # Make peer_old look older + peer_old.last_seen = time.time() - 20.0 + + score_recent = score_peer(peer_recent) + score_old = score_peer(peer_old) + + assert score_recent > score_old + + def test_new_peer_gets_moderate_score(self): + """Test that new peers (no history) get a moderate score""" + peer_new = DiscoveredPeer("AA:BB:CC:DD:EE:01", "New", -60) + score = score_peer(peer_new) + + # New peers should score reasonably (benefit of the doubt) + # RSSI component (~30) + moderate history (25) + recency (25) = ~80 + assert 70 < score < 100 + + def test_score_combined_factors(self): + """Test scoring with all factors combined""" + # Perfect peer: strong signal, good history, recently seen + peer_perfect = DiscoveredPeer("AA:BB:CC:DD:EE:01", "Perfect", -35) + for i in range(10): + peer_perfect.record_connection_attempt() + peer_perfect.record_connection_success() + + # Poor peer: weak signal, bad history, old + peer_poor = DiscoveredPeer("AA:BB:CC:DD:EE:02", "Poor", -95) + for i in range(10): + peer_poor.record_connection_attempt() + if i < 2: # 20% success rate + peer_poor.record_connection_success() + peer_poor.last_seen = time.time() - 35.0 + + score_perfect = score_peer(peer_perfect) + score_poor = score_peer(peer_poor) + + # Perfect peer should score much higher + assert score_perfect > score_poor * 2 + + +class TestPeerSelection: + """Test peer selection algorithm""" + + def select_peers_to_connect(self, discovered_peers, connected_peers, blacklist, max_peers): + """ + Standalone implementation of selection logic for testing. + + Args: + discovered_peers: dict of address -> DiscoveredPeer + connected_peers: set of already-connected addresses + blacklist: dict of address -> (blacklist_until, failure_count) + max_peers: maximum number of peers + + Returns: + list of DiscoveredPeer objects to connect to + """ + # Calculate available slots + available_slots = max_peers - len(connected_peers) + if available_slots <= 0: + return [] + + # Check if peer is blacklisted + def is_blacklisted(address): + if address not in blacklist: + return False + blacklist_until, _ = blacklist[address] + return time.time() < blacklist_until + + # Score all discovered peers + scored_peers = [] + for address, peer in discovered_peers.items(): + # Skip if already connected + if address in connected_peers: + continue + + # Skip if blacklisted + if is_blacklisted(address): + continue + + # Calculate score + score = score_peer(peer) + scored_peers.append((score, peer)) + + # Sort by score (highest first) + scored_peers.sort(reverse=True, key=lambda x: x[0]) + + # Select top N peers + selected = [peer for score, peer in scored_peers[:available_slots]] + return selected + + def test_no_slots_available(self): + """Test that empty list returned when max peers reached""" + # Setup: 3 discovered, 3 connected (max=3) + discovered = { + "AA:BB:CC:DD:EE:01": DiscoveredPeer("AA:BB:CC:DD:EE:01", "Peer1", -50), + "AA:BB:CC:DD:EE:02": DiscoveredPeer("AA:BB:CC:DD:EE:02", "Peer2", -60), + "AA:BB:CC:DD:EE:03": DiscoveredPeer("AA:BB:CC:DD:EE:03", "Peer3", -70), + } + connected = {"AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03"} + blacklist = {} + + result = self.select_peers_to_connect(discovered, connected, blacklist, max_peers=3) + + assert len(result) == 0 + + def test_filters_already_connected(self): + """Test that already-connected peers are filtered out""" + # Setup: 5 discovered, 2 connected + discovered = { + "AA:BB:CC:DD:EE:01": DiscoveredPeer("AA:BB:CC:DD:EE:01", "Peer1", -50), + "AA:BB:CC:DD:EE:02": DiscoveredPeer("AA:BB:CC:DD:EE:02", "Peer2", -55), + "AA:BB:CC:DD:EE:03": DiscoveredPeer("AA:BB:CC:DD:EE:03", "Peer3", -60), + "AA:BB:CC:DD:EE:04": DiscoveredPeer("AA:BB:CC:DD:EE:04", "Peer4", -65), + "AA:BB:CC:DD:EE:05": DiscoveredPeer("AA:BB:CC:DD:EE:05", "Peer5", -70), + } + connected = {"AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"} # Already connected + blacklist = {} + + result = self.select_peers_to_connect(discovered, connected, blacklist, max_peers=5) + + # Should return 3 unconnected peers + assert len(result) == 3 + addresses = [p.address for p in result] + assert "AA:BB:CC:DD:EE:01" not in addresses + assert "AA:BB:CC:DD:EE:02" not in addresses + assert "AA:BB:CC:DD:EE:03" in addresses + assert "AA:BB:CC:DD:EE:04" in addresses + assert "AA:BB:CC:DD:EE:05" in addresses + + def test_filters_blacklisted(self): + """Test that blacklisted peers are filtered out""" + # Setup: 5 discovered, 2 blacklisted + discovered = { + "AA:BB:CC:DD:EE:01": DiscoveredPeer("AA:BB:CC:DD:EE:01", "Peer1", -50), + "AA:BB:CC:DD:EE:02": DiscoveredPeer("AA:BB:CC:DD:EE:02", "Peer2", -55), + "AA:BB:CC:DD:EE:03": DiscoveredPeer("AA:BB:CC:DD:EE:03", "Peer3", -60), + "AA:BB:CC:DD:EE:04": DiscoveredPeer("AA:BB:CC:DD:EE:04", "Peer4", -65), + "AA:BB:CC:DD:EE:05": DiscoveredPeer("AA:BB:CC:DD:EE:05", "Peer5", -70), + } + connected = set() + # Blacklist peers 1 and 2 for 60 seconds into the future + blacklist = { + "AA:BB:CC:DD:EE:01": (time.time() + 60, 3), + "AA:BB:CC:DD:EE:02": (time.time() + 60, 3), + } + + result = self.select_peers_to_connect(discovered, connected, blacklist, max_peers=5) + + # Should return 3 non-blacklisted peers + assert len(result) == 3 + addresses = [p.address for p in result] + assert "AA:BB:CC:DD:EE:01" not in addresses # Blacklisted + assert "AA:BB:CC:DD:EE:02" not in addresses # Blacklisted + assert "AA:BB:CC:DD:EE:03" in addresses + assert "AA:BB:CC:DD:EE:04" in addresses + assert "AA:BB:CC:DD:EE:05" in addresses + + def test_selects_top_n_by_score(self): + """Test that top N peers are selected by score""" + # Setup: 10 peers with varying RSSI (score will be dominated by RSSI) + discovered = {} + for i in range(10): + rssi = -40 - (i * 10) # -40, -50, -60, ..., -130 + discovered[f"AA:BB:CC:DD:EE:{i:02d}"] = DiscoveredPeer( + f"AA:BB:CC:DD:EE:{i:02d}", f"Peer{i}", rssi + ) + + connected = set() + blacklist = {} + + result = self.select_peers_to_connect(discovered, connected, blacklist, max_peers=3) + + # Should return top 3 by score (best RSSI) + assert len(result) == 3 + + # Verify they're sorted by RSSI (best first) + rssi_values = [p.rssi for p in result] + assert rssi_values[0] == -40 # Best + assert rssi_values[1] == -50 + assert rssi_values[2] == -60 + + def test_respects_available_slots(self): + """Test that selection respects available slots""" + # Setup: 5 good peers, max=7, 5 already connected (2 slots available) + discovered = {} + for i in range(5): + rssi = -50 - (i * 5) # All decent signal + discovered[f"AA:BB:CC:DD:EE:{i:02d}"] = DiscoveredPeer( + f"AA:BB:CC:DD:EE:{i:02d}", f"Peer{i}", rssi + ) + + # 5 other peers already connected + connected = {f"BB:CC:DD:EE:FF:{i:02d}" for i in range(5)} + blacklist = {} + + result = self.select_peers_to_connect(discovered, connected, blacklist, max_peers=7) + + # Should return exactly 2 peers (available slots = 7 - 5 = 2) + assert len(result) == 2 + + # Should be the top 2 by score + assert result[0].rssi == -50 + assert result[1].rssi == -55 + + def test_fewer_candidates_than_slots(self): + """Test that selection works when fewer candidates than slots""" + # Setup: 2 good peers, max=7, 0 connected (7 slots available) + discovered = { + "AA:BB:CC:DD:EE:01": DiscoveredPeer("AA:BB:CC:DD:EE:01", "Peer1", -50), + "AA:BB:CC:DD:EE:02": DiscoveredPeer("AA:BB:CC:DD:EE:02", "Peer2", -60), + } + connected = set() + blacklist = {} + + result = self.select_peers_to_connect(discovered, connected, blacklist, max_peers=7) + + # Should return both peers (doesn't fail with fewer than max) + assert len(result) == 2 + + +class TestImplementationValidation: + """Validate that the implementation exists in BLEInterface.py""" + + def test_discovered_peer_class_exists(self): + """Test that DiscoveredPeer class is in the source file""" + interface_path = os.path.join(os.path.dirname(__file__), '../src/RNS/Interfaces/BLEInterface.py') + with open(interface_path, 'r') as f: + code = f.read() + + assert 'class DiscoveredPeer:' in code + assert 'def update_rssi(' in code + assert 'def record_connection_attempt(' in code + assert 'def record_connection_success(' in code + assert 'def record_connection_failure(' in code + assert 'def get_success_rate(' in code + + def test_prioritization_methods_exist(self): + """Test that prioritization methods exist in BLEInterface.py""" + interface_path = os.path.join(os.path.dirname(__file__), '../src/RNS/Interfaces/BLEInterface.py') + with open(interface_path, 'r') as f: + code = f.read() + + assert 'def _score_peer(' in code + assert 'def _select_peers_to_connect(' in code + assert 'def _is_blacklisted(' in code + assert 'def _record_connection_success(' in code + assert 'def _record_connection_failure(' in code + assert 'def _connect_to_peer(' in code + + def test_configuration_options_exist(self): + """Test that prioritization configuration options exist""" + interface_path = os.path.join(os.path.dirname(__file__), '../src/RNS/Interfaces/BLEInterface.py') + with open(interface_path, 'r') as f: + code = f.read() + + assert 'connection_rotation_interval' in code + assert 'connection_retry_backoff' in code + assert 'max_connection_failures' in code + assert 'discovered_peers' in code + assert 'connection_blacklist' in code + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])