Initial commit: BLE Reticulum interface
This commit is contained in:
commit
486f210ae4
29 changed files with 8644 additions and 0 deletions
133
.github/workflows/README.md
vendored
Normal file
133
.github/workflows/README.md
vendored
Normal 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
119
.github/workflows/test.yml
vendored
Normal 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
62
.gitignore
vendored
Normal 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
274
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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
252
README.md
Normal 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
470
TESTING.md
Normal 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
182
examples/ble_minimal_test.py
Executable 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()
|
||||
275
examples/config_example.toml
Normal file
275
examples/config_example.toml
Normal 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
244
install.sh
Executable 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
53
pytest.ini
Normal 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
10
requirements-dev.txt
Normal 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
20
requirements.txt
Normal 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
|
||||
284
src/RNS/Interfaces/BLEAgent.py
Normal file
284
src/RNS/Interfaces/BLEAgent.py
Normal 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
|
||||
543
src/RNS/Interfaces/BLEFragmentation.py
Normal file
543
src/RNS/Interfaces/BLEFragmentation.py
Normal 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)
|
||||
628
src/RNS/Interfaces/BLEGATTServer.py
Normal file
628
src/RNS/Interfaces/BLEGATTServer.py
Normal 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)})"
|
||||
)
|
||||
2066
src/RNS/Interfaces/BLEInterface.py
Normal file
2066
src/RNS/Interfaces/BLEInterface.py
Normal file
File diff suppressed because it is too large
Load diff
0
src/RNS/Interfaces/__init__.py
Normal file
0
src/RNS/Interfaces/__init__.py
Normal file
0
src/RNS/__init__.py
Normal file
0
src/RNS/__init__.py
Normal file
332
tests/conftest.py
Normal file
332
tests/conftest.py
Normal 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))
|
||||
304
tests/test_ble_peer_interface.py
Normal file
304
tests/test_ble_peer_interface.py
Normal 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"])
|
||||
236
tests/test_bleak_threading_hang.py
Normal file
236
tests/test_bleak_threading_hang.py
Normal 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)
|
||||
85
tests/test_bleak_with_exec_loading.py
Normal file
85
tests/test_bleak_with_exec_loading.py
Normal 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)
|
||||
367
tests/test_error_recovery.py
Normal file
367
tests/test_error_recovery.py
Normal 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
305
tests/test_fragmentation.py
Executable 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
333
tests/test_gatt_server.py
Normal 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
82
tests/test_integration.py
Normal 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"])
|
||||
492
tests/test_multi_device_simulation.py
Normal file
492
tests/test_multi_device_simulation.py
Normal 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"])
|
||||
472
tests/test_prioritization.py
Normal file
472
tests/test_prioritization.py
Normal 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"])
|
||||
Loading…
Add table
Add a link
Reference in a new issue