* test: add comprehensive pytest test suite with 95% coverage Add full unit and integration test coverage for the meshcore-stats project: - 1020 tests covering all modules (db, charts, html, reports, client, etc.) - 95.95% code coverage with pytest-cov (95% threshold enforced) - GitHub Actions CI workflow for automated testing on push/PR - Proper mocking of external dependencies (meshcore, serial, filesystem) - SVG snapshot infrastructure for chart regression testing - Integration tests for collection and rendering pipelines Test organization: - tests/charts/: Chart rendering and statistics - tests/client/: MeshCore client and connection handling - tests/config/: Environment and configuration parsing - tests/database/: SQLite operations and migrations - tests/html/: HTML generation and Jinja templates - tests/reports/: Report generation and formatting - tests/retry/: Circuit breaker and retry logic - tests/unit/: Pure unit tests for utilities - tests/integration/: End-to-end pipeline tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add test-engineer agent configuration Add project-local test-engineer agent for pytest test development, coverage analysis, and test review tasks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: comprehensive test suite review with 956 tests analyzed Conducted thorough review of all 956 test cases across 47 test files: - Unit Tests: 338 tests (battery, metrics, log, telemetry, env, charts, html, reports, formatters) - Config Tests: 53 tests (env loading, config file parsing) - Database Tests: 115 tests (init, insert, queries, migrations, maintenance, validation) - Retry Tests: 59 tests (circuit breaker, async retries, factory) - Charts Tests: 76 tests (transforms, statistics, timeseries, rendering, I/O) - HTML Tests: 81 tests (site generation, Jinja2, metrics builders, reports index) - Reports Tests: 149 tests (location, JSON/TXT formatting, aggregation, counter totals) - Client Tests: 63 tests (contacts, connection, meshcore availability, commands) - Integration Tests: 22 tests (reports, collection, rendering pipelines) Results: - Overall Pass Rate: 99.7% (953/956) - 3 tests marked for improvement (empty test bodies in client tests) - 0 tests requiring fixes Key findings documented in test_review/tests.md including quality observations, F.I.R.S.T. principle adherence, and recommendations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: implement snapshot testing for charts and reports Add comprehensive snapshot testing infrastructure: SVG Chart Snapshots: - Deterministic fixtures with fixed timestamps (2024-01-15 12:00:00) - Tests for gauge/counter metrics in light/dark themes - Empty chart and single-point edge cases - Extended normalize_svg_for_snapshot_full() for reproducible comparisons TXT Report Snapshots: - Monthly/yearly report snapshots for repeater and companion - Empty report handling tests - Tests in tests/reports/test_snapshots.py Infrastructure: - tests/snapshots/conftest.py with shared fixtures - UPDATE_SNAPSHOTS=1 environment variable for regeneration - scripts/generate_snapshots.py for batch snapshot generation Run `UPDATE_SNAPSHOTS=1 pytest tests/charts/test_chart_render.py::TestSvgSnapshots` to generate initial snapshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: fix SVG normalization and generate initial snapshots Fix normalize_svg_for_snapshot() to handle: - clipPath IDs like id="p47c77a2a6e" - url(#p...) references - xlink:href="#p..." references - <dc:date> timestamps Generated initial snapshot files: - 7 SVG chart snapshots (gauge, counter, empty, single-point in light/dark) - 6 TXT report snapshots (monthly/yearly for repeater/companion + empty) All 13 snapshot tests now pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: fix SVG normalization to preserve axis rendering The SVG normalization was replacing all matplotlib-generated IDs with the same value, causing duplicate IDs that broke SVG rendering: - Font glyphs, clipPaths, and tick marks all got id="normalized" - References couldn't resolve to the correct elements - X and Y axes failed to render in normalized snapshots Fix uses type-specific prefixes with sequential numbering: - glyph_N for font glyphs (DejaVuSans-XX patterns) - clip_N for clipPath definitions (p[0-9a-f]{8,} patterns) - tick_N for tick marks (m[0-9a-f]{8,} patterns) This ensures all IDs remain unique while still being deterministic for snapshot comparison. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add coverage and pytest artifacts to gitignore Add .coverage, .coverage.*, htmlcov/, and .pytest_cache/ to prevent test artifacts from being committed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix all ruff lint errors across codebase - Sort and organize imports (I001) - Use modern type annotations (X | Y instead of Union, collections.abc) - Remove unused imports (F401) - Combine nested if statements (SIM102) - Use ternary operators where appropriate (SIM108) - Combine nested with statements (SIM117) - Use contextlib.suppress instead of try-except-pass (SIM105) - Add noqa comments for intentional SIM115 violations (file locks) - Add TYPE_CHECKING import for forward references - Fix exception chaining (B904) All 1033 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add TDD workflow and pre-commit requirements to CLAUDE.md - Add mandatory test-driven development workflow (write tests first) - Add pre-commit requirements (must run lint and tests before committing) - Document test organization and running commands - Document 95% coverage requirement 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: resolve mypy type checking errors with proper structural fixes - charts.py: Create PeriodConfig dataclass for type-safe period configuration, use mdates.date2num() for matplotlib datetime handling, fix x-axis limits for single-point charts - db.py: Add explicit int() conversion with None handling for SQLite returns - env.py: Add class-level type annotations to Config class - html.py: Add MetricDisplay TypedDict, fix import order, add proper type annotations for table data functions - meshcore_client.py: Add return type annotation Update tests to use new dataclass attribute access and regenerate SVG snapshots. Add mypy step to CLAUDE.md pre-commit requirements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: cast Jinja2 template.render() to str for mypy Jinja2's type stubs declare render() as returning Any, but it actually returns str. Wrap with str() to satisfy mypy's no-any-return check. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci: improve workflow security and reliability - test.yml: Pin all actions by SHA, add concurrency control to cancel in-progress runs on rapid pushes - release-please.yml: Pin action by SHA, add 10-minute timeout - conftest.py: Fix snapshot_base_time to use explicit UTC timezone for consistent behavior across CI and local environments Regenerate SVG snapshots with UTC-aware timestamps. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add mypy command to permissions in settings.local.json * test: add comprehensive script tests with coroutine warning fixes - Add tests/scripts/ with tests for collect_companion, collect_repeater, and render scripts (1135 tests total, 96% coverage) - Fix unawaited coroutine warnings by using AsyncMock properly for async functions and async_context_manager_factory fixture for context managers - Add --cov=scripts to CI workflow and pyproject.toml coverage config - Omit scripts/generate_snapshots.py from coverage (dev utility) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: migrate claude setup to codex skills * feat: migrate dependencies to uv (#31) * fix: run tests through uv * test: fix ruff lint issues in tests Consolidate patch context managers and clean unused imports/variables Use datetime.UTC in snapshot fixtures * test: avoid unawaited async mocks in entrypoint tests * ci: replace codecov with github coverage artifacts Add junit XML output and coverage summary in job output Upload HTML and XML coverage artifacts (3.12 only) on every run --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
10 KiB
MeshCore Stats
A monitoring system for MeshCore LoRa mesh networks. Collects metrics from companion and repeater nodes, stores them in SQLite, and generates a static dashboard with interactive charts.
Live demo: meshcore.jorijn.com
Quick Start
Linux only - macOS and Windows users see Platform Notes first.
# Clone and configure
git clone https://github.com/jorijn/meshcore-stats.git
cd meshcore-stats
cp meshcore.conf.example meshcore.conf
# Edit meshcore.conf with your repeater name and password
# Create data directories (container runs as UID 1000)
mkdir -p data/state out
sudo chown -R 1000:1000 data out
# Add your serial device
cat > docker-compose.override.yml << 'EOF'
services:
meshcore-stats:
devices:
- /dev/ttyACM0:/dev/ttyACM0
EOF
# Start
docker compose up -d
# Verify it's working. The various collection and render jobs will trigger after a few minutes.
docker compose ps
docker compose logs meshcore-stats | head -20
# View dashboard at http://localhost:8080
Features
- Data Collection - Metrics from local companion and remote repeater nodes
- Interactive Charts - SVG charts with day/week/month/year views and tooltips
- Statistics Reports - Monthly and yearly report generation
- Light/Dark Theme - Automatic theme switching based on system preference
Prerequisites
- Docker and Docker Compose V2
- MeshCore companion node connected via USB serial
- Remote repeater node reachable via LoRa from the companion
Resource requirements: ~100MB memory, ~100MB disk per year of data.
Installation
Docker (Recommended)
1. Clone the Repository
git clone https://github.com/jorijn/meshcore-stats.git
cd meshcore-stats
2. Configure
Copy the example configuration and edit it:
cp meshcore.conf.example meshcore.conf
Minimal required settings:
# Repeater identity (required)
REPEATER_NAME=Your Repeater Name
REPEATER_PASSWORD=your-admin-password
# Display names
REPEATER_DISPLAY_NAME=My Repeater
COMPANION_DISPLAY_NAME=My Companion
See meshcore.conf.example for all available options.
3. Create Data Directories
mkdir -p data/state out
sudo chown -R 1000:1000 data out
The container runs as UID 1000, so directories must be writable by this user. If sudo is not available, you can relaxed the permissions using chmod 777 data out, but this is less secure.
4. Configure Serial Device
Create docker-compose.override.yml to specify your serial device:
services:
meshcore-stats:
devices:
- /dev/ttyACM0:/dev/ttyACM0
Ensure your user has serial port access:
sudo usermod -aG dialout $USER
# Log out and back in for changes to take effect
5. Start the Containers
docker compose up -d
After the various collection and render jobs has run, the dashboard will be available at http://localhost:8080.
Verify Installation
# Check container status
docker compose ps
# View logs
docker compose logs -f meshcore-stats
Common Docker Commands
# View real-time logs
docker compose logs -f meshcore-stats
# Restart after configuration changes
docker compose restart meshcore-stats
# Update to latest version (database migrations are automatic)
docker compose pull && docker compose up -d
# Stop all containers
docker compose down
# Backup database
cp data/state/metrics.db data/state/metrics.db.backup
Note
:
docker compose downpreserves your data. Usedocker compose down -vonly if you want to delete everything.
Manual Installation (Alternative)
For environments where Docker is not available.
Requirements
- Python 3.10+
- SQLite3
- uv
Setup
cd meshcore-stats
uv venv
source .venv/bin/activate
uv sync
cp meshcore.conf.example meshcore.conf
# Edit meshcore.conf with your settings
Cron Setup
Add to your crontab (crontab -e):
MESHCORE=/path/to/meshcore-stats
# Companion: every minute
* * * * * cd $MESHCORE && .venv/bin/python scripts/collect_companion.py
# Repeater: every 15 minutes
1,16,31,46 * * * * cd $MESHCORE && .venv/bin/python scripts/collect_repeater.py
# Charts: every 5 minutes
*/5 * * * * cd $MESHCORE && .venv/bin/python scripts/render_charts.py
# Site: every 5 minutes
*/5 * * * * cd $MESHCORE && .venv/bin/python scripts/render_site.py
# Reports: daily at midnight
0 0 * * * cd $MESHCORE && .venv/bin/python scripts/render_reports.py
Serve the out/ directory with any web server.
Platform Notes
Linux
Docker can access USB serial devices directly. Add your device to docker-compose.override.yml:
services:
meshcore-stats:
devices:
- /dev/ttyACM0:/dev/ttyACM0
Common device paths:
/dev/ttyACM0- Arduino/native USB/dev/ttyUSB0- USB-to-serial adapters
macOS
Docker Desktop for macOS runs in a Linux VM and cannot directly access USB serial devices.
Option 1: TCP Bridge (Recommended)
Expose the serial port over TCP using socat:
# Install socat
brew install socat
# Bridge serial to TCP (run in background)
socat TCP-LISTEN:5000,fork,reuseaddr OPEN:/dev/cu.usbserial-0001,rawer,nonblock,ispeed=115200,ospeed=115200
Configure in meshcore.conf:
MESH_TRANSPORT=tcp
MESH_TCP_HOST=host.docker.internal
MESH_TCP_PORT=5000
Option 2: Native Installation
Use the manual installation method with cron instead of Docker.
Windows (WSL2)
WSL2 and Docker Desktop for Windows cannot directly access COM ports.
Use the TCP bridge approach (similar to macOS) or native installation.
Configuration Reference
| Variable | Default | Description |
|---|---|---|
| Repeater Identity | ||
REPEATER_NAME |
required | Advertised name to find in contacts |
REPEATER_PASSWORD |
required | Admin password for repeater |
REPEATER_KEY_PREFIX |
- | Alternative to REPEATER_NAME: hex prefix of public key |
| Connection | ||
MESH_TRANSPORT |
serial | Transport type: serial, tcp, or ble |
MESH_SERIAL_PORT |
auto | Serial port path |
MESH_TCP_HOST |
localhost | TCP host (for TCP transport) |
MESH_TCP_PORT |
5000 | TCP port (for TCP transport) |
| Display | ||
REPEATER_DISPLAY_NAME |
Repeater Node | Name shown in UI |
COMPANION_DISPLAY_NAME |
Companion Node | Name shown in UI |
REPEATER_HARDWARE |
LoRa Repeater | Hardware model for sidebar |
COMPANION_HARDWARE |
LoRa Node | Hardware model for sidebar |
| Location | ||
REPORT_LOCATION_NAME |
Your Location | Full location for reports |
REPORT_LAT |
0.0 | Latitude |
REPORT_LON |
0.0 | Longitude |
REPORT_ELEV |
0.0 | Elevation |
| Radio (display only) | ||
RADIO_FREQUENCY |
869.618 MHz | Frequency shown in sidebar |
RADIO_BANDWIDTH |
62.5 kHz | Bandwidth |
RADIO_SPREAD_FACTOR |
SF8 | Spread factor |
See meshcore.conf.example for all options with regional radio presets.
Troubleshooting
| Symptom | Cause | Solution |
|---|---|---|
| "Permission denied" on serial port | User not in dialout group | sudo usermod -aG dialout $USER then re-login |
| Repeater shows "offline" status | No data or circuit breaker tripped | Check logs; delete data/state/repeater_circuit.json to reset |
| Empty charts | Not enough data collected | Wait for 2+ collection cycles |
| Container exits immediately | Missing or invalid configuration | Verify meshcore.conf exists and has required values |
| "No serial ports found" | Device not connected/detected | Check ls /dev/tty* and device permissions |
| Device path changed after reboot | USB enumeration order changed | Update path in docker-compose.override.yml or use udev rules |
| "database is locked" errors | Maintenance script running | Wait for completion; check if VACUUM is running |
Debug Logging
# Enable debug mode in meshcore.conf
MESH_DEBUG=1
# View detailed logs
docker compose logs -f meshcore-stats
Circuit Breaker
The repeater collector uses a circuit breaker to avoid spamming LoRa when the repeater is unreachable. After multiple failures, it enters a cooldown period (default: 1 hour).
To reset manually:
rm data/state/repeater_circuit.json
docker compose restart meshcore-stats
Architecture
┌─────────────────┐ LoRa ┌─────────────────┐
│ Companion │◄─────────────►│ Repeater │
│ (USB Serial) │ │ (Remote) │
└────────┬────────┘ └─────────────────┘
│
│ Serial/TCP
▼
┌─────────────────┐
│ Docker Host │
│ ┌───────────┐ │
│ │ meshcore- │ │ ┌─────────┐
│ │ stats │──┼────►│ nginx │──► :8080
│ └───────────┘ │ └─────────┘
│ │ │
│ ▼ │
│ SQLite + SVG │
└─────────────────┘
The system runs two containers:
- meshcore-stats: Collects data on schedule (Ofelia) and generates charts
- nginx: Serves the static dashboard
Documentation
- docs/firmware-responses.md - MeshCore firmware response formats
License
MIT
Public Instances
Public MeshCore Stats installations. Want to add yours? Open a pull request!
| URL | Hardware | Location |
|---|---|---|
| meshcore.jorijn.com | SenseCAP Solar Node P1 Pro + 6.5dBi Mikrotik antenna | Oosterhout, The Netherlands |

