Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
*.manifest
*.spec

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/

# Virtual environments
venv/
ENV/
env/
.venv/

# IDEs
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store

# Claude settings
.claude/*

# Firmware build artifacts
fw/build/
fw/Debug/
fw/Release/
*.out
*.hex
*.bin
*.elf
*.map

# Logs
*.log

# Temporary files
*.tmp
*.temp
.tmp/
.temp/

# OS files
Thumbs.db
.DS_Store
397 changes: 397 additions & 0 deletions python_cli/poetry.lock

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions python_cli/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
[tool.poetry]
name = "sniffle"
version = "1.9.0"
description = "Sniffer for Bluetooth 5 and 4.x (LE) using TI CC1352/CC26x2 hardware"
authors = ["Sniffle Developers"]
readme = "../README.md"
license = "GPL-2.0-or-later"
homepage = "https://github.com/nccgroup/Sniffle"
repository = "https://github.com/nccgroup/Sniffle"
keywords = ["bluetooth", "sniffer", "ble", "security"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering",
"Topic :: Security",
"Topic :: Software Development :: Libraries :: Python Modules",
]
packages = [{include = "sniffle"}]

[tool.poetry.dependencies]
python = "^3.9"
pyserial = "^3.5"

[tool.poetry.group.sdr]
optional = true

[tool.poetry.group.sdr.dependencies]
numpy = "^1.24.0"
scipy = "^1.10.0"
# soapysdr = "^0.8.0" # Note: Install manually with system package manager if needed

[tool.poetry.group.dev.dependencies]
pytest = "^8.0.0"
pytest-cov = "^5.0.0"
pytest-mock = "^3.14.0"

[tool.poetry.scripts]
sniffle = "sniffle.sniffle_hw:main"
sniffle_sdr = "sniffle.sniffle_sdr:main"
scanner = "sniffle.scanner:main"
adv_stats = "sniffle.adv_stats:main"
test = "pytest:main"
tests = "pytest:main"

[tool.pytest.ini_options]
minversion = "6.0"
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
"--cov=sniffle",
"--cov-branch",
"--cov-report=term-missing:skip-covered",
"--cov-report=html:htmlcov",
"--cov-report=xml:coverage.xml",
# "--cov-fail-under=80", # Uncomment when actual tests are written
]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
markers = [
"unit: Unit tests",
"integration: Integration tests",
"slow: Tests that take a long time to run",
]

[tool.coverage.run]
source = ["sniffle"]
omit = [
"*/tests/*",
"*/test_*",
"*/__pycache__/*",
"*/site-packages/*",
]

[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if __name__ == .__main__.:",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if False:",
"pass",
]
show_missing = true
precision = 2

[tool.coverage.html]
directory = "htmlcov"

[tool.coverage.xml]
output = "coverage.xml"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Empty file added python_cli/tests/__init__.py
Empty file.
158 changes: 158 additions & 0 deletions python_cli/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Shared pytest fixtures and configuration for Sniffle tests."""

import os
import tempfile
import shutil
from pathlib import Path
from unittest.mock import Mock, MagicMock
import pytest


@pytest.fixture
def temp_dir():
"""Create a temporary directory for test files."""
temp_path = tempfile.mkdtemp()
yield Path(temp_path)
shutil.rmtree(temp_path, ignore_errors=True)


@pytest.fixture
def mock_serial_port():
"""Mock serial port for hardware interface tests."""
port = MagicMock()
port.read.return_value = b''
port.write.return_value = 1
port.in_waiting = 0
port.is_open = True
return port


@pytest.fixture
def mock_pcap_writer(temp_dir):
"""Mock PCAP writer for packet capture tests."""
pcap_file = temp_dir / "test_capture.pcap"
writer = Mock()
writer.filename = str(pcap_file)
writer.write = Mock()
writer.close = Mock()
return writer


@pytest.fixture
def sample_ble_packet():
"""Sample BLE packet data for testing."""
return {
'aa': 0x8E89BED6,
'chan': 37,
'rssi': -65,
'timestamp': 1234567890,
'phy': 1, # 1M PHY
'data': bytes.fromhex('40240A0011223344556677889900AABBCCDDEE'),
'crc_ok': True,
'direction': 0, # Master to Slave
}


@pytest.fixture
def sample_adv_packet():
"""Sample BLE advertising packet."""
return {
'aa': 0x8E89BED6,
'chan': 37,
'rssi': -70,
'timestamp': 1234567890,
'phy': 1,
'data': bytes.fromhex('401E0201061AFF4C000215FDA50693A4E24FB1AFCFC6EB0764782527C5'),
'crc_ok': True,
'adv_type': 0, # ADV_IND
'adv_addr': bytes.fromhex('112233445566'),
}


@pytest.fixture
def mock_decoder():
"""Mock packet decoder for testing packet processing."""
decoder = Mock()
decoder.decode = Mock(return_value={
'type': 'adv',
'addr': '11:22:33:44:55:66',
'data': b'test_data',
})
return decoder


@pytest.fixture
def mock_ble_config():
"""Mock BLE configuration."""
return {
'hop_interval': 1250, # 1.25ms in microseconds
'channels': list(range(37)),
'phy': 1, # 1M PHY
'access_address': 0x8E89BED6,
'crc_init': 0x555555,
'window_size': 2500, # 2.5ms
'window_offset': 0,
'conn_interval': 7500, # 7.5ms
}


@pytest.fixture
def test_firmware_file(temp_dir):
"""Create a test firmware file."""
fw_file = temp_dir / "test_firmware.bin"
fw_file.write_bytes(b'\xFF' * 1024) # 1KB of 0xFF
return fw_file


@pytest.fixture
def mock_sniffle_hw():
"""Mock SniffleHW instance for hardware interface tests."""
hw = Mock()
hw.ser = MagicMock()
hw.decoder_state = Mock()
hw.recv_msg = Mock(return_value=None)
hw.send_cmd = Mock()
hw.setup_sniffer = Mock()
hw.mark_and_flush = Mock()
return hw


@pytest.fixture(autouse=True)
def reset_environment():
"""Reset environment variables before each test."""
env_backup = os.environ.copy()
yield
os.environ.clear()
os.environ.update(env_backup)


@pytest.fixture
def capture_output(monkeypatch):
"""Capture stdout and stderr output."""
import sys
from io import StringIO

stdout = StringIO()
stderr = StringIO()

monkeypatch.setattr(sys, 'stdout', stdout)
monkeypatch.setattr(sys, 'stderr', stderr)

yield {'stdout': stdout, 'stderr': stderr}


@pytest.fixture
def mock_time(monkeypatch):
"""Mock time functions for deterministic tests."""
current_time = [0.0]

def mock_time_func():
return current_time[0]

def advance_time(seconds):
current_time[0] += seconds

monkeypatch.setattr('time.time', mock_time_func)
monkeypatch.setattr('time.monotonic', mock_time_func)

return advance_time
Empty file.
Loading