Skip to content
Merged
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
37 changes: 37 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: PR Checks

on:
pull_request:
workflow_dispatch:

concurrency:
group: pr-checks-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
pre-commit:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: |
pyproject.toml
.pre-commit-config.yaml

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install pre-commit
python -m pip install -e .[dev]

- name: Run pre-commit
run: pre-commit run --all-files
63 changes: 38 additions & 25 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,51 @@
# Setup: pre-commit install
# Run manually: pre-commit run --all-files

default_language_version:
python: python3

repos:
# Generic file hygiene checks
# Python-focused safety checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-ast
files: ^.*\.py$
- id: debug-statements
files: ^.*\.py$
- id: check-docstring-first
files: ^.*\.py$
- id: check-builtin-literals
files: ^.*\.py$

# Python formatting (Black) - apply to all Python files
- repo: https://github.com/psf/black
rev: 24.4.2
# Modern Python linting + import sorting + formatting
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
hooks:
- id: black
language_version: python3
args: ["--line-length=100"]
- id: ruff-check
args: ["--fix"]
files: ^.*\.py$
- id: ruff-format
args: ["--check"]
files: ^.*\.py$

# Python import sorting (isort) - apply to all Python files
- repo: https://github.com/pycqa/isort
rev: 5.13.2
# Security-focused static analysis
- repo: https://github.com/PyCQA/bandit
rev: 1.7.9
hooks:
- id: isort
args: ["--profile", "black", "--line-length=100"]
- id: bandit
args: ["-q", "-l", "-i"]
files: ^.*\.py$
exclude: ^tests/

# Python linting (flake8) - strict settings for code quality
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
# Test suite gate
- repo: local
hooks:
- id: flake8
# Strict but reasonable settings
args: [
"--max-line-length=100",
"--extend-ignore=E203,W503"
]
- id: pytest
name: pytest
entry: ./scripts/precommit-pytest.sh
language: system
pass_filenames: false
always_run: true
types: [python]
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ I welcome contributions! To contribute to pyMC_repeater:
### Development Setup

```bash
# Install in development mode with dev tools (black, pytest, isort, mypy, etc)
# Install in development mode with dev tools (ruff, pytest, mypy, etc)
pip install -e ".[dev]"

# Setup pre-commit hooks for code quality
Expand All @@ -413,9 +413,8 @@ pre-commit run --all-files
**Note:** Hardware support (LoRa radio drivers) is included in the base installation automatically via `pymc_core[hardware]`.

Pre-commit hooks will automatically:
- Format code with Black
- Sort imports with isort
- Lint with flake8
- Lint and auto-fix Python issues with Ruff
- Validate formatting with Ruff formatter
- Fix trailing whitespace and other file issues

## Support
Expand Down
2 changes: 1 addition & 1 deletion debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Build-Depends: debhelper-compat (= 13),
git
Standards-Version: 4.6.2
Homepage: https://github.com/rightup/pyMC_Repeater
X-Python3-Version: >= 3.8
X-Python3-Version: >= 3.9

Package: pymc-repeater
Architecture: all
Expand Down
15 changes: 6 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@ authors = [
description = "PyMC Repeater Daemon"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.8"
requires-python = ">=3.9"
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
Expand Down Expand Up @@ -55,8 +54,7 @@ rrd = [
dev = [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
"black>=23.0.0",
"isort>=5.12.0",
"ruff>=0.15.14",
"mypy>=1.7.0",
]

Expand All @@ -78,13 +76,12 @@ repeater = [
"presets/*.yaml",
]

[tool.black]
[tool.ruff]
line-length = 100
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
target-version = "py39"

[tool.isort]
profile = "black"
line_length = 100
[tool.ruff.lint]
extend-ignore = ["E701"]

[tool.setuptools_scm]
version_scheme = "guess-next-dev"
Expand Down
4 changes: 2 additions & 2 deletions repeater/airtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def calculate_airtime(
Airtime in milliseconds
"""
sf = spreading_factor or self.spreading_factor
bw_hz = (bandwidth_hz or self.bandwidth)
bw_hz = bandwidth_hz or self.bandwidth
cr = coding_rate or self.coding_rate
preamble_len = preamble_len or self.preamble_length
crc = 1 if crc_enabled else 0
Expand All @@ -64,7 +64,7 @@ def calculate_airtime(
de = 1 if (sf >= 11 and bw_hz <= 125000) else 0

# Symbol time in milliseconds: T_sym = 2^SF / BW_kHz
t_sym = (2 ** sf) / (bw_hz / 1000)
t_sym = (2**sf) / (bw_hz / 1000)

# Preamble time: T_preamble = (n_preamble + 4.25) * T_sym
t_preamble = (preamble_len + 4.25) * t_sym
Expand Down
4 changes: 1 addition & 3 deletions repeater/companion/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,7 @@ def _save_prefs(self) -> None:
try:
prefs_dict = dataclasses.asdict(self.prefs)
prefs_safe = _to_json_safe(prefs_dict)
self._sqlite_handler.companion_save_prefs(
str(self._companion_hash), prefs_safe
)
self._sqlite_handler.companion_save_prefs(str(self._companion_hash), prefs_safe)
if self._on_prefs_saved:
try:
self._on_prefs_saved(self.prefs.node_name)
Expand Down
4 changes: 2 additions & 2 deletions repeater/companion/frame_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ def __init__(
bridge,
companion_hash: str,
port: int = 5000,
bind_address: str = "0.0.0.0",
client_idle_timeout_sec: Optional[int] = 8 * 60 * 60, # 8 hours
bind_address: str = "0.0.0.0", # nosec B104 - intentional default for LAN reachability
client_idle_timeout_sec: Optional[int] = 8 * 60 * 60, # 8 hours
sqlite_handler=None,
local_hash: Optional[int] = None,
stats_getter=None,
Expand Down
52 changes: 23 additions & 29 deletions repeater/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,7 @@ def resolve_storage_dir(
) -> Path:

storage_dir_cfg = (
config.get("storage", {}).get("storage_dir")
or config.get("storage_dir")
or default
config.get("storage", {}).get("storage_dir") or config.get("storage_dir") or default
)

storage_dir = Path(str(storage_dir_cfg)).expanduser()
Expand All @@ -70,10 +68,10 @@ def resolve_storage_dir(
def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract node name, radio configuration, and MQTT settings from config.

Args:
config: Configuration dictionary

Returns:
Dictionary with node_name, radio_config, and MQTT configuration
"""
Expand All @@ -87,10 +85,10 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
radio_freq_mhz = radio_freq / 1_000_000
radio_bw_khz = radio_bw / 1_000
radio_config_str = f"{radio_freq_mhz},{radio_bw_khz},{radio_sf},{radio_cr}"

# Handle getting the config from mqtt brokers, falling back to letsmesh if it doesn't exist
mqtt_config = config.get("mqtt_brokers", config.get("letsmesh", {}))

return {
"node_name": node_name,
"radio_config": radio_config_str,
Expand Down Expand Up @@ -143,7 +141,7 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
"inform_interval_seconds": 30,
"request_timeout_seconds": 10,
"verify_tls": True,
"api_token": "",
"api_token": None,
"cert_store_dir": "/etc/pymc_repeater/glass",
}

Expand Down Expand Up @@ -184,14 +182,14 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
if "security" not in config["repeater"]:
logger.warning(
"No 'security' section found under 'repeater' in config. "
"Adding defaults — please review and update passwords."
"Adding secure placeholders — complete setup wizard before login."
)
config["repeater"]["security"] = {
"max_clients": 1,
"admin_password": "admin123",
"guest_password": "guest123",
"admin_password": None,
"guest_password": None,
"allow_read_only": False,
"jwt_secret": "",
"jwt_secret": None,
"jwt_expiry_minutes": 60,
}

Expand All @@ -215,17 +213,17 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None) -> bool:
"""
Save configuration to YAML file.

Args:
config_data: Configuration dictionary to save
config_path: Path to config file (uses default if None)

Returns:
True if successful, False otherwise
"""
if config_path is None:
config_path = os.getenv("PYMC_REPEATER_CONFIG", "/etc/pymc_repeater/config.yaml")

try:
# Create backup of existing config
config_file = Path(config_path)
Expand All @@ -247,7 +245,7 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None)

logger.info(f"Saved configuration to {config_path}")
return True

except Exception as e:
logger.error(f"Failed to save configuration: {e}")
return False
Expand All @@ -256,29 +254,29 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None)
def update_unscoped_flood_policy(allow: bool, config_path: Optional[str] = None) -> bool:
"""
Update the unscoped flood policy in the configuration.

Args:
allow: True to allow unscoped flooding, False to deny
config_path: Path to config file (uses default if None)

Returns:
True if successful, False otherwise
"""
try:
# Load current config
config = load_config(config_path)

# Ensure mesh section exists
if "mesh" not in config:
config["mesh"] = {}

# Set global flood policy
config["mesh"]["global_flood_allow"] = allow
config["mesh"]["unscoped_flood_allow"] = allow

# Save updated config
return save_config(config, config_path)

except Exception as e:
logger.error(f"Failed to update unscoped flood policy: {e}")
return False
Expand Down Expand Up @@ -345,7 +343,7 @@ def _parse_int(value, *, default=None):
if isinstance(value, int):
return value
if isinstance(value, str):
return int(value.strip().rstrip(','), 0)
return int(value.strip().rstrip(","), 0)
raise ValueError(f"Invalid int value type: {type(value)}")

def _parse_int_list(value):
Expand Down Expand Up @@ -517,9 +515,7 @@ def _parse_int_list(value):

host = tcp_cfg.get("host")
if not host:
raise ValueError(
"Missing 'host' in 'pymc_tcp' section (modem hostname or LAN IP)"
)
raise ValueError("Missing 'host' in 'pymc_tcp' section (modem hostname or LAN IP)")

radio_cfg = board_config.get("radio") or {}
radio = TCPLoRaRadio(
Expand Down Expand Up @@ -563,9 +559,7 @@ def _parse_int_list(value):

port = usb_cfg.get("port")
if not port:
raise ValueError(
"Missing 'port' in 'pymc_usb' section (e.g. /dev/ttyACM0)"
)
raise ValueError("Missing 'port' in 'pymc_usb' section (e.g. /dev/ttyACM0)")

radio_cfg = board_config.get("radio") or {}
radio = USBLoRaRadio(
Expand Down
Loading
Loading