Initial commit: BLE Reticulum interface

This commit is contained in:
torlando-tech 2025-10-26 19:02:39 -04:00
commit 486f210ae4
29 changed files with 8644 additions and 0 deletions

133
.github/workflows/README.md vendored Normal file
View file

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

119
.github/workflows/test.yml vendored Normal file
View file

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

62
.gitignore vendored Normal file
View file

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

274
CONTRIBUTING.md Normal file
View file

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

21
LICENSE Normal file
View file

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

252
README.md Normal file
View file

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

470
TESTING.md Normal file
View file

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

182
examples/ble_minimal_test.py Executable file
View file

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

View file

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

244
install.sh Executable file
View file

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

53
pytest.ini Normal file
View file

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

10
requirements-dev.txt Normal file
View file

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

20
requirements.txt Normal file
View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

0
src/RNS/__init__.py Normal file
View file

332
tests/conftest.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

305
tests/test_fragmentation.py Executable file
View file

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

333
tests/test_gatt_server.py Normal file
View file

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

82
tests/test_integration.py Normal file
View file

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

View file

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

View file

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